agentlinkops 0.5.1 → 0.6.0
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 +0 -0
- package/.codex-plugin/plugin.json +0 -0
- package/.mcp.json +0 -0
- package/CHANGELOG.md +0 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/README.md +0 -0
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +0 -0
- package/cli/agent-setup.js +0 -0
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/catalog.js +0 -0
- package/cli/check.js +0 -0
- package/cli/citation.js +105 -1
- package/cli/client.js +0 -0
- package/cli/commands.js +0 -0
- package/cli/config.js +0 -0
- package/cli/connection.js +0 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +0 -0
- package/cli/delivery.js +0 -0
- package/cli/doctor.js +0 -0
- package/cli/env.js +0 -0
- package/cli/fleet.js +0 -0
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +0 -0
- package/cli/ledger.js +0 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +0 -0
- package/cli/main.js +0 -0
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/platforms.js +0 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +0 -0
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +0 -0
- package/cli/state.js +0 -0
- package/cli/status.js +0 -0
- package/cli/sync.js +0 -0
- package/package.json +6 -3
- package/references/cli.md +0 -0
- package/references/disavow.md +0 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/report.py +0 -0
- package/shared/agent-clients.js +0 -0
- package/shared/observation-event.js +0 -0
- package/shared/response-contract.js +0 -0
- package/shared/search-connection-contract.js +0 -0
- package/skills/agentlinkops-assets/SKILL.md +0 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
- package/skills/agentlinkops-campaigns/SKILL.md +0 -0
- package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/SKILL.md +0 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +0 -0
- package/skills/agentlinkops-crm/SKILL.md +0 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
- package/skills/content-defingerprinting/SKILL.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
- package/skills/internal-linking-optimizer/SKILL.md +0 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
- package/skills/pre-publish-review/SKILL.md +0 -0
- package/skills/source-cited-content-builder/SKILL.md +0 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
- package/skills/source-cited-humanizer/SKILL.md +0 -0
- package/src/citations/adapter.js +173 -0
- package/src/citations/alert.js +53 -0
- package/src/citations/browser/engine.js +81 -10
- package/src/citations/browser/gen/core/providers/_shared/google.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/core/providers/types.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -0
- package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -0
- package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/getText.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +166 -0
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +0 -0
- package/src/citations/contract.js +10 -1
- package/src/citations/fixtures/dataforseo-aio.json +82 -0
- package/src/citations/match.js +0 -0
- package/src/citations/panel-builder.js +44 -0
- package/src/citations/report.js +165 -0
- package/src/citations/runner.js +25 -14
- package/src/citations/stats.js +63 -0
- package/src/citations/watch.js +85 -0
- package/src/competitors/contract.js +0 -0
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +0 -0
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +0 -0
- package/src/corpus/seeds.js +0 -0
- package/src/discovery/contract.js +0 -0
- package/src/discovery/import.js +0 -0
- package/src/tool-views.js +0 -0
- package/src/verifier/fetch.js +0 -0
- package/src/verifier/index.js +0 -0
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/parser.js +0 -0
- package/src/verifier/readiness.js +0 -0
- package/src/verifier/render-evidence.js +0 -0
- package/src/verifier/robots.js +0 -0
- package/src/verifier/transition.js +0 -0
- package/src/verifier/url.js +0 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
|
File without changes
|
|
File without changes
|
package/.mcp.json
CHANGED
|
File without changes
|
package/CHANGELOG.md
CHANGED
|
File without changes
|
package/LICENSE
CHANGED
|
File without changes
|
package/NOTICE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
File without changes
|
package/bin/agentlinkops.mjs
CHANGED
|
File without changes
|
package/bin/linktrail.mjs
CHANGED
|
File without changes
|
package/cli/adapters/csv.js
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/cli/agent-setup.js
CHANGED
|
File without changes
|
package/cli/agentlinkops.mjs
CHANGED
|
File without changes
|
package/cli/args.js
CHANGED
|
File without changes
|
package/cli/catalog.js
CHANGED
|
File without changes
|
package/cli/check.js
CHANGED
|
File without changes
|
package/cli/citation.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Exit codes follow the link commands: 0 nothing actionable (including
|
|
9
9
|
// insufficient_data — an unknown never fails), 1 a cell classified declined with
|
|
10
10
|
// complete evidence, 2 usage, configuration or budget-abort errors.
|
|
11
|
-
import { resolve } from 'node:path';
|
|
11
|
+
import { resolve, join } from 'node:path';
|
|
12
12
|
|
|
13
13
|
import { parseArgs } from './args.js';
|
|
14
14
|
import { loadPanel, runEpoch } from '../src/citations/runner.js';
|
|
@@ -25,6 +25,17 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
|
|
|
25
25
|
measure the consumer UI through Camoufox
|
|
26
26
|
(dedicated account, personal cadence; evidence
|
|
27
27
|
includes a screenshot of every answer)
|
|
28
|
+
agentlinkops citation report PANEL.json [--out FILE] [--dir DIR]
|
|
29
|
+
render the latest epoch as a self-contained
|
|
30
|
+
HTML report: trends, screenshots inline,
|
|
31
|
+
Wilson intervals, honest classifications
|
|
32
|
+
agentlinkops citation watch PANEL.json --cadence weekly|daily|hourly|monthly
|
|
33
|
+
register a cron entry that runs the epoch
|
|
34
|
+
and generates the report on schedule
|
|
35
|
+
agentlinkops citation watch --list list registered citation watches
|
|
36
|
+
agentlinkops citation watch --remove NAME remove a registered watch
|
|
37
|
+
agentlinkops citation alert PANEL.json check the latest epoch for declines;
|
|
38
|
+
logs an alert line if any cell declined
|
|
28
39
|
agentlinkops citation help this help
|
|
29
40
|
|
|
30
41
|
Panel (JSON): targets (domain or url, brand, aliases), prompts, engines
|
|
@@ -41,6 +52,81 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
|
|
|
41
52
|
const args = parseArgs(argv);
|
|
42
53
|
const command = args._[0] ?? 'help';
|
|
43
54
|
if (command === 'help' || args.help) { out(USAGE); return 0; }
|
|
55
|
+
if (command === 'alert') {
|
|
56
|
+
const panelPath = args._[1];
|
|
57
|
+
if (!panelPath) { err('citation alert needs a panel file'); return 2; }
|
|
58
|
+
const { readFile } = await import('node:fs/promises');
|
|
59
|
+
const dir = resolve(cwd, args.dir ?? '.agentlinkops');
|
|
60
|
+
let latestEpochId = null;
|
|
61
|
+
try {
|
|
62
|
+
const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
|
|
63
|
+
const rows = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
64
|
+
if (rows.length > 0) latestEpochId = rows[rows.length - 1].epoch_id;
|
|
65
|
+
} catch { /* no epochs */ }
|
|
66
|
+
if (!latestEpochId) { out('no epochs to check'); return 0; }
|
|
67
|
+
const { shouldAlert } = await import('../src/citations/alert.js');
|
|
68
|
+
const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
|
|
69
|
+
const cells = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l)).filter((r) => r.epoch_id === latestEpochId);
|
|
70
|
+
if (shouldAlert(cells)) {
|
|
71
|
+
const declined = cells.filter((c) => c.classification === 'declined');
|
|
72
|
+
err(`ALERT: ${declined.length} cell${declined.length === 1 ? '' : 's'} declined:`);
|
|
73
|
+
for (const c of declined) err(` ${c.cell_id}: rate ${c.rate.toFixed(2)} (was ${c.baseline?.rate?.toFixed(2) ?? 'n/a'})`);
|
|
74
|
+
return 1; // exit 1 = actionable, same convention as link checks
|
|
75
|
+
}
|
|
76
|
+
out('no declines detected');
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (command === 'watch') {
|
|
80
|
+
const { registerWatch, listWatches, removeWatch } = await import('../src/citations/watch.js');
|
|
81
|
+
if (args.list) {
|
|
82
|
+
const watches = await listWatches();
|
|
83
|
+
if (watches.length === 0) { out('no citation watches registered'); return 0; }
|
|
84
|
+
for (const w of watches) out(`${w.panel} ${w.cron}`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (args.remove) {
|
|
88
|
+
const result = await removeWatch(args.remove);
|
|
89
|
+
out(`removed watch: ${result.removed}`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
const panelPath = args._[1];
|
|
93
|
+
if (!panelPath) { err('citation watch needs a panel file (or --list / --remove)'); return 2; }
|
|
94
|
+
const cadence = args.cadence ?? 'weekly';
|
|
95
|
+
try {
|
|
96
|
+
const result = await registerWatch({ panelPath, cadence });
|
|
97
|
+
out(`citation watch registered: ${result.panelPath}`);
|
|
98
|
+
out(`cadence: ${result.cadence} (${result.next})`);
|
|
99
|
+
out(`cron: ${result.cronSpec}`);
|
|
100
|
+
return 0;
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
err(cause.message);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (command === 'report') {
|
|
107
|
+
const panelPath = args._[1];
|
|
108
|
+
if (!panelPath) { err('citation report needs a panel file'); return 2; }
|
|
109
|
+
const dir = resolve(cwd, args.dir ?? '.agentlinkops');
|
|
110
|
+
const { generateReport } = await import('../src/citations/report.js');
|
|
111
|
+
const { readFile, writeFile: wf } = await import('node:fs/promises');
|
|
112
|
+
// Find the latest epoch for this panel by reading the epochs file
|
|
113
|
+
let latestEpochId = null;
|
|
114
|
+
try {
|
|
115
|
+
const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
|
|
116
|
+
const rows = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
117
|
+
if (rows.length > 0) latestEpochId = rows[rows.length - 1].epoch_id;
|
|
118
|
+
} catch { /* no epochs yet */ }
|
|
119
|
+
if (!latestEpochId) { err('no epochs found — run an epoch first'); return 2; }
|
|
120
|
+
const panel = JSON.parse(await readFile(resolve(cwd, panelPath), 'utf8'));
|
|
121
|
+
const html = await generateReport({ dir, epochId: latestEpochId, panel });
|
|
122
|
+
const outPath = args.out ? resolve(cwd, args.out) : join(dir, 'citations', `report-${latestEpochId.slice(0, 15)}.html`);
|
|
123
|
+
const { dirname, join: j } = await import('node:path');
|
|
124
|
+
const { mkdir } = await import('node:fs/promises');
|
|
125
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
126
|
+
await wf(outPath, html, 'utf8');
|
|
127
|
+
out(`report written: ${outPath} (${Math.round(html.length / 1024)} KB, self-contained)`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
44
130
|
if (command !== 'run') { err(`unknown citation command: ${command}`); err(USAGE); return 2; }
|
|
45
131
|
|
|
46
132
|
const panelPath = args._[1];
|
|
@@ -83,6 +169,24 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
|
|
|
83
169
|
}
|
|
84
170
|
if (spec.engine === 'mock') {
|
|
85
171
|
engines.set(identity, createMockEngine({ fixtures: panel.mockFixtures ?? {} }));
|
|
172
|
+
} else if (spec.engine === 'google-aio') {
|
|
173
|
+
// Fixture-first: runs against the recorded response with no credentials.
|
|
174
|
+
// Activates live the moment DATAFORSEO_LOGIN + DATAFORSEO_PASSWORD exist.
|
|
175
|
+
const { createGoogleAioEngine } = await import('../src/citations/adapter.js');
|
|
176
|
+
const creds = env.DATAFORSEO_LOGIN && env.DATAFORSEO_PASSWORD
|
|
177
|
+
? { login: env.DATAFORSEO_LOGIN, password: env.DATAFORSEO_PASSWORD }
|
|
178
|
+
: null;
|
|
179
|
+
engines.set(identity, createGoogleAioEngine({ credentials: creds }));
|
|
180
|
+
if (!creds) err(`google-aio: fixture mode (set DATAFORSEO_LOGIN + DATAFORSEO_PASSWORD for live)`);
|
|
181
|
+
} else if (spec.engine === 'duck') {
|
|
182
|
+
// Self-hosted Duck2api on the VPS: the whole Claude family plus GPT-5.6
|
|
183
|
+
// anonymously through duck.ai. Localhost endpoint, no key.
|
|
184
|
+
const { createOpenAiCompatibleEngine } = await import('../src/citations/adapter.js');
|
|
185
|
+
engines.set(identity, createOpenAiCompatibleEngine({
|
|
186
|
+
baseUrl: env.DUCK2API_URL ?? 'http://127.0.0.1:8090/v1',
|
|
187
|
+
model: spec.model ?? 'claude-haiku-4-5',
|
|
188
|
+
providerLabel: 'duck-anon',
|
|
189
|
+
}));
|
|
86
190
|
} else if (spec.engine === 'perplexity') {
|
|
87
191
|
// Presence only, never the value — the same rule doctor applies to tokens.
|
|
88
192
|
if (!env.PERPLEXITY_API_KEY) {
|
package/cli/client.js
CHANGED
|
File without changes
|
package/cli/commands.js
CHANGED
|
File without changes
|
package/cli/config.js
CHANGED
|
File without changes
|
package/cli/connection.js
CHANGED
|
File without changes
|
package/cli/context-mcp.js
CHANGED
|
File without changes
|
package/cli/context.js
CHANGED
|
File without changes
|
package/cli/delivery.js
CHANGED
|
File without changes
|
package/cli/doctor.js
CHANGED
|
File without changes
|
package/cli/env.js
CHANGED
|
File without changes
|
package/cli/fleet.js
CHANGED
|
File without changes
|
package/cli/gsc-browser.js
CHANGED
|
File without changes
|
package/cli/import.js
CHANGED
|
File without changes
|
package/cli/ledger.js
CHANGED
|
File without changes
|
package/cli/linktrail.mjs
CHANGED
|
File without changes
|
package/cli/local-result.js
CHANGED
|
File without changes
|
package/cli/main.js
CHANGED
|
File without changes
|
package/cli/mirror.js
CHANGED
|
File without changes
|
package/cli/mix.js
CHANGED
|
File without changes
|
package/cli/platforms.js
CHANGED
|
File without changes
|
|
File without changes
|
package/cli/receipts.js
CHANGED
|
File without changes
|
package/cli/report.js
CHANGED
|
File without changes
|
package/cli/setup-plan.js
CHANGED
|
File without changes
|
package/cli/skill.js
CHANGED
|
File without changes
|
package/cli/state.js
CHANGED
|
File without changes
|
package/cli/status.js
CHANGED
|
File without changes
|
package/cli/sync.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlinkops",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A backlink ledger that lives in your repository: local link checks with dated evidence, supplier-export imports and optional sync to the hosted AgentLinkOps service.",
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
7
7
|
"type": "module",
|
|
@@ -46,9 +46,12 @@
|
|
|
46
46
|
"mcp",
|
|
47
47
|
"backlinks",
|
|
48
48
|
"seo",
|
|
49
|
+
"citation-monitoring",
|
|
50
|
+
"ai-citations",
|
|
49
51
|
"agent",
|
|
50
52
|
"cli",
|
|
51
53
|
"link-monitoring",
|
|
54
|
+
"citation-watch",
|
|
52
55
|
"ledger",
|
|
53
56
|
"jsonl"
|
|
54
57
|
],
|
|
@@ -64,4 +67,4 @@
|
|
|
64
67
|
"optionalDependencies": {
|
|
65
68
|
"playwright-core": "^1.63.0"
|
|
66
69
|
}
|
|
67
|
-
}
|
|
70
|
+
}
|
package/references/cli.md
CHANGED
|
File without changes
|
package/references/disavow.md
CHANGED
|
File without changes
|
package/references/reports.md
CHANGED
|
File without changes
|
package/scripts/agentlinkops.py
CHANGED
|
File without changes
|
package/scripts/disavow.py
CHANGED
|
File without changes
|
package/scripts/linktrail.py
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/scripts/report.py
CHANGED
|
File without changes
|
package/shared/agent-clients.js
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/citations/adapter.js
CHANGED
|
@@ -139,3 +139,176 @@ export function createEngine(spec, options = {}) {
|
|
|
139
139
|
if (spec.engine === 'perplexity') return createPerplexityEngine({ model: spec.model ?? 'sonar', ...(options.perplexity ?? {}) });
|
|
140
140
|
throw new EngineError(`unknown engine: ${spec.engine}`);
|
|
141
141
|
}
|
|
142
|
+
|
|
143
|
+
// duck.ai anonymous engine: DuckDuckGo's AI chat serves Claude Haiku 4.5
|
|
144
|
+
// without an account. The VQD handshake follows Duck2api (MIT): the status
|
|
145
|
+
// call returns a token; when it returns a JS hash challenge instead, the
|
|
146
|
+
// documented fallback payload is accepted. Pure HTTP through the proxy —
|
|
147
|
+
// cheaper than a browser, and the surface has no bot wall at personal cadence.
|
|
148
|
+
const DUCK_BASE = 'https://duck.ai';
|
|
149
|
+
const DUCK_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36';
|
|
150
|
+
const DUCK_ORIGIN = 'https://duck.ai';
|
|
151
|
+
const DUCK_STACK = 'Error\n at l (https://duck.ai/dist/duckai-dist/entry.duckai.c0a8c794abcbc8ee2d3c.js:2:1446307)\n at async https://duck.ai/dist/duckai-dist/entry.duckai.c0a8c794abcbc8ee2d3c.js:2:1294181';
|
|
152
|
+
|
|
153
|
+
async function duckDispatcher(proxyUrl) {
|
|
154
|
+
if (!proxyUrl) return undefined;
|
|
155
|
+
const { ProxyAgent } = await import('undici');
|
|
156
|
+
return new ProxyAgent(proxyUrl);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function createDuckAiEngine({ model = 'claude-haiku-4-5', proxyUrl } = {}) {
|
|
160
|
+
let dispatcher = null;
|
|
161
|
+
const getDispatcher = async () => dispatcher ?? (dispatcher = await duckDispatcher(proxyUrl));
|
|
162
|
+
|
|
163
|
+
const duckFetch = async (path, init = {}) => {
|
|
164
|
+
const d = await getDispatcher();
|
|
165
|
+
return fetch(`${DUCK_BASE}${path}`, {
|
|
166
|
+
...init,
|
|
167
|
+
headers: { 'user-agent': DUCK_UA, origin: DUCK_ORIGIN, referer: `${DUCK_BASE}/`, ...init.headers },
|
|
168
|
+
...(d ? { dispatcher: d } : {}),
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const vqdFallback = (challengeB64) => {
|
|
173
|
+
const decoded = Buffer.from(challengeB64, 'base64').toString('utf8');
|
|
174
|
+
const payload = `${decoded}::no-exec::${DUCK_STACK}::${DUCK_ORIGIN}`;
|
|
175
|
+
return Buffer.from(payload).toString('base64');
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
identity: { engine: 'duckai', provider: 'anon-api', model },
|
|
180
|
+
estimateCostUsd: () => 0.0002,
|
|
181
|
+
async run({ prompt }) {
|
|
182
|
+
const status = await duckFetch('/duckchat/v1/status', { headers: { accept: '*/*' } });
|
|
183
|
+
if (!status.ok) throw new EngineError(`duckai status HTTP ${status.status}`, { retriable: true });
|
|
184
|
+
let vqd = status.headers.get('x-vqd-4') ?? status.headers.get('x-vqd');
|
|
185
|
+
const hashChallenge = status.headers.get('x-vqd-hash-1');
|
|
186
|
+
const headers = { accept: 'text/event-stream', 'content-type': 'application/json' };
|
|
187
|
+
if (!vqd && hashChallenge) {
|
|
188
|
+
vqd = vqdFallback(hashChallenge);
|
|
189
|
+
headers['x-vqd-hash-1'] = vqd;
|
|
190
|
+
}
|
|
191
|
+
if (!vqd) throw new EngineError('duckai: no vqd token or challenge in status response', { retriable: true });
|
|
192
|
+
headers['x-vqd-4'] = vqd;
|
|
193
|
+
if (vqd !== headers['x-vqd-hash-1']) headers['x-vqd'] = vqd;
|
|
194
|
+
|
|
195
|
+
const chat = await duckFetch('/duckchat/v1/chat', {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers,
|
|
198
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], stream: true }),
|
|
199
|
+
});
|
|
200
|
+
if (!chat.ok) throw new EngineError(`duckai chat HTTP ${chat.status}`, { retriable: true });
|
|
201
|
+
const body = await chat.text();
|
|
202
|
+
let message = '';
|
|
203
|
+
for (const line of body.split('\n')) {
|
|
204
|
+
const t = line.trim();
|
|
205
|
+
if (!t.startsWith('data:') || t.includes('[DONE]')) continue;
|
|
206
|
+
try { message += (JSON.parse(t.slice(5).trim()).message ?? ''); } catch { /* chunk */ }
|
|
207
|
+
}
|
|
208
|
+
if (message.trim().length < 20) throw new EngineError('duckai: empty chat message', { retriable: true });
|
|
209
|
+
return {
|
|
210
|
+
engine: 'duckai', provider: 'anon-api', model,
|
|
211
|
+
providerModelVersion: `duck:${model}`,
|
|
212
|
+
answer: message, citations: [], fanOut: [],
|
|
213
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
214
|
+
costEstimateUsd: 0.0002,
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// OpenAI-compatible engine: any endpoint speaking /v1/chat/completions —
|
|
221
|
+
// including self-hosted Duck2api on the VPS, which serves the whole Claude
|
|
222
|
+
// family (haiku/sonnet/opus) plus GPT-5.6 anonymously through duck.ai.
|
|
223
|
+
// The engine identity names the model explicitly; never averaged with others.
|
|
224
|
+
export function createOpenAiCompatibleEngine({ baseUrl, model, apiKey, fetchImpl, costEstimateUsd = 0.0002, providerLabel = 'openai-compatible' } = {}) {
|
|
225
|
+
if (!baseUrl || !model) throw new EngineError('openai-compatible engine needs baseUrl and model');
|
|
226
|
+
const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
227
|
+
return {
|
|
228
|
+
identity: { engine: model.split(':')[0] ?? model, provider: providerLabel, model },
|
|
229
|
+
estimateCostUsd: () => costEstimateUsd,
|
|
230
|
+
async run({ prompt }) {
|
|
231
|
+
const headers = { 'content-type': 'application/json' };
|
|
232
|
+
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
233
|
+
const response = await doFetch(`${baseUrl}/chat/completions`, {
|
|
234
|
+
method: 'POST', headers,
|
|
235
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }] }),
|
|
236
|
+
signal: AbortSignal.timeout(CITATION_LIMITS.timeoutMs * 2),
|
|
237
|
+
}).catch((cause) => { throw new EngineError(`openai-compatible request failed: ${cause.message}`, { retriable: true, cause }); });
|
|
238
|
+
if (!response.ok) throw new EngineError(`openai-compatible responded ${response.status}`, { retriable: response.status >= 500 || [429, 418, 408, 425].includes(response.status) });
|
|
239
|
+
const body = await response.json().catch(() => { throw new EngineError('openai-compatible response was not JSON'); });
|
|
240
|
+
const answer = body?.choices?.[0]?.message?.content ?? '';
|
|
241
|
+
if (answer.trim().length < 20) throw new EngineError('openai-compatible returned an empty answer', { retriable: true });
|
|
242
|
+
return {
|
|
243
|
+
engine: model.split(':')[0] ?? model, provider: providerLabel, model,
|
|
244
|
+
providerModelVersion: `${providerLabel}:${model}`,
|
|
245
|
+
answer, citations: [], fanOut: [],
|
|
246
|
+
usage: { input_tokens: body?.usage?.prompt_tokens ?? 0, output_tokens: body?.usage?.completion_tokens ?? 0 },
|
|
247
|
+
costEstimateUsd,
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Google AI Overview engine via DataForSEO SERP API (fixture-first, DP-0044).
|
|
254
|
+
// AIO is a SERP feature, not a chat: the engine sends a search query and reads
|
|
255
|
+
// the ai_overview block from the returned items. Cited sources are domains the
|
|
256
|
+
// overview linked to — the citation equivalent of a chat engine's source list.
|
|
257
|
+
// The fixture below proves the parser against the documented response shape;
|
|
258
|
+
// the live adapter activates with credentials in fleet, never before.
|
|
259
|
+
export function createGoogleAioEngine({ fetchImpl, credentials, costEstimateUsd = 0.0012 } = {}) {
|
|
260
|
+
const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
261
|
+
const hasCredentials = Boolean(credentials?.login && credentials?.password);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
identity: { engine: 'google-aio', provider: hasCredentials ? 'dataforseo-serp' : 'fixture', model: 'ai-overview' },
|
|
265
|
+
estimateCostUsd: () => costEstimateUsd,
|
|
266
|
+
|
|
267
|
+
async run({ prompt }) {
|
|
268
|
+
if (!hasCredentials) {
|
|
269
|
+
// Fixture mode: prove the parser against the recorded response.
|
|
270
|
+
const fixture = await import('./fixtures/dataforseo-aio.json', { with: { type: 'json' } });
|
|
271
|
+
return parseAioResponse(fixture.default, prompt);
|
|
272
|
+
}
|
|
273
|
+
// Live mode: DataForSEO live/advanced POST.
|
|
274
|
+
const auth = Buffer.from(`${credentials.login}:${credentials.password}`).toString('base64');
|
|
275
|
+
const response = await doFetch('https://api.dataforseo.com/v3/serp/google/organic/live/advanced', {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { 'authorization': `Basic ${auth}`, 'content-type': 'application/json' },
|
|
278
|
+
body: JSON.stringify([{ keyword: prompt, location_code: 2840, language_code: 'en' }]),
|
|
279
|
+
signal: AbortSignal.timeout(120_000),
|
|
280
|
+
}).catch((cause) => { throw new EngineError(`dataforseo request failed: ${cause.message}`, { retriable: true, cause }); });
|
|
281
|
+
if (!response.ok) throw new EngineError(`dataforseo responded ${response.status}`, { retriable: response.status >= 500 });
|
|
282
|
+
const body = await response.json().catch(() => { throw new EngineError('dataforseo response was not JSON'); });
|
|
283
|
+
return parseAioResponse(body, prompt);
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function parseAioResponse(body, prompt) {
|
|
289
|
+
const result = body?.tasks?.[0]?.result?.[0];
|
|
290
|
+
if (!result) throw new EngineError('google-aio: no result in response', { retriable: true });
|
|
291
|
+
const aioBlock = (result.items ?? []).find((item) => item.type === 'ai_overview');
|
|
292
|
+
if (!aioBlock) {
|
|
293
|
+
// An honest absence: the SERP rendered without an AI Overview block.
|
|
294
|
+
throw new EngineError('google-aio: no AI Overview block rendered for this query', { retriable: true });
|
|
295
|
+
}
|
|
296
|
+
const answer = aioBlock.text ?? '';
|
|
297
|
+
if (answer.trim().length < 40) {
|
|
298
|
+
throw new EngineError('google-aio: AI Overview text too short to interpret', { retriable: true });
|
|
299
|
+
}
|
|
300
|
+
const citations = (aioBlock.items ?? [])
|
|
301
|
+
.filter((src) => typeof src.url === 'string' && src.url.length > 0)
|
|
302
|
+
.map((src) => ({ url: src.url, title: src.title ?? null }));
|
|
303
|
+
return {
|
|
304
|
+
engine: 'google-aio',
|
|
305
|
+
provider: 'dataforseo-serp',
|
|
306
|
+
model: 'ai-overview',
|
|
307
|
+
providerModelVersion: 'google-aio-serp-v1',
|
|
308
|
+
answer,
|
|
309
|
+
citations,
|
|
310
|
+
fanOut: [],
|
|
311
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
312
|
+
costEstimateUsd: body?.cost ?? 0.0012,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Citation alert: deliver the report when a cell classifies as declined.
|
|
2
|
+
// Uses the same SMTP pattern as the send-email skill (zero-dependency Python
|
|
3
|
+
// script). The alert only fires on complete evidence — insufficient_data
|
|
4
|
+
// and first_epoch never trigger, exactly as the contract requires.
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export function shouldAlert(cells) {
|
|
10
|
+
return cells.some((c) => c.classification === 'declined');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function sendAlertEmail({ to, subject, htmlPath, smtp = {} }) {
|
|
14
|
+
if (!to || !htmlPath) throw new Error('sendAlertEmail needs to and htmlPath');
|
|
15
|
+
const { host, port, user, pass, from } = smtp;
|
|
16
|
+
if (!host || !user || !pass) {
|
|
17
|
+
// No SMTP configured — log the alert locally; the report file IS the alert.
|
|
18
|
+
return { delivered: false, reason: 'no smtp configured', htmlPath };
|
|
19
|
+
}
|
|
20
|
+
const html = await readFile(htmlPath, 'utf8');
|
|
21
|
+
// Strip the outer HTML tags for the plain-text body (the HTML stays as an
|
|
22
|
+
// attachment in a real deployment; for v1 the file path is the artifact).
|
|
23
|
+
const textBody = html
|
|
24
|
+
.replace(/<style[^>]*>.*?<\/style>/gs, '')
|
|
25
|
+
.replace(/<[^>]+>/g, ' ')
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim()
|
|
28
|
+
.slice(0, 2000);
|
|
29
|
+
|
|
30
|
+
const python = `
|
|
31
|
+
import smtplib
|
|
32
|
+
from email.mime.multipart import MIMEMultipart
|
|
33
|
+
from email.mime.text import MIMEText
|
|
34
|
+
msg = MIMEMultipart('alternative')
|
|
35
|
+
msg['Subject'] = ${JSON.stringify(subject)}
|
|
36
|
+
msg['From'] = ${JSON.stringify(from ?? user)}
|
|
37
|
+
msg['To'] = ${JSON.stringify(to)}
|
|
38
|
+
msg.attach(MIMEText(${JSON.stringify(textBody)}, 'plain'))
|
|
39
|
+
with open(${JSON.stringify(htmlPath)}, 'r') as f:
|
|
40
|
+
msg.attach(MIMEText(f.read(), 'html'))
|
|
41
|
+
with smtplib.SMTP(${JSON.stringify(host)}, ${parseInt(port ?? 587, 10)}) as s:
|
|
42
|
+
s.starttls()
|
|
43
|
+
s.login(${JSON.stringify(user)}, ${JSON.stringify(pass)})
|
|
44
|
+
s.send_message(msg)
|
|
45
|
+
print('sent')
|
|
46
|
+
`;
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
execFile('python3', ['-c', python], (err, stdout) => {
|
|
49
|
+
if (err) reject(new Error(`smtp send failed: ${err.message}`));
|
|
50
|
+
else resolve({ delivered: true, htmlPath });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|