agentlinkops 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.json +0 -0
- package/CHANGELOG.md +17 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/README.md +0 -0
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +0 -0
- package/cli/admission.js +130 -0
- package/cli/agent-setup.js +1014 -101
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/catalog.js +12837 -4694
- package/cli/check.js +0 -0
- package/cli/citation.js +181 -32
- package/cli/client.js +1 -1
- package/cli/commands.js +3 -3
- package/cli/config.js +2 -1
- package/cli/connection.js +0 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +0 -0
- package/cli/delivery.js +0 -0
- package/cli/disavow.js +54 -0
- package/cli/doctor.js +7 -8
- package/cli/env.js +0 -0
- package/cli/fleet.js +0 -0
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +0 -0
- package/cli/index-observations.js +89 -0
- package/cli/ledger.js +7 -1
- package/cli/lifecycle.js +40 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +0 -0
- package/cli/main.js +99 -7
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/platforms.js +0 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +79 -6
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +41 -4
- package/cli/state.js +0 -0
- package/cli/status.js +0 -0
- package/cli/sync.js +23 -1
- package/package.json +3 -2
- package/references/citation-panels.md +26 -0
- package/references/cli.md +0 -0
- package/references/disavow.md +0 -0
- package/references/discovery-handoff.md +32 -0
- package/references/recipes/catalog.json +148 -0
- package/references/recipes/command-notes.md +9 -0
- package/references/recipes/placement-reconciliation.md +41 -0
- package/references/recipes/qualified-campaign-handoff.md +56 -0
- package/references/recipes/site-context-brief.md +25 -0
- package/references/recipes/sourced-linkable-asset.md +25 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/report.py +0 -0
- package/shared/admission-contract.js +16 -0
- package/shared/admission-report-contract.js +8 -0
- package/shared/agent-clients.js +0 -0
- package/shared/citation-evidence-contract.js +27 -0
- package/shared/disavow-contract.js +18 -0
- package/shared/index-observation-contract.js +14 -0
- package/shared/lifecycle-contract.js +22 -0
- package/shared/lifecycle-money.js +24 -0
- package/shared/observation-event.js +12 -1
- package/shared/opportunity-record.js +259 -0
- package/shared/response-contract.js +2 -0
- package/shared/search-connection-contract.js +0 -0
- package/skills/agentlinkops-assets/SKILL.md +2 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
- package/skills/agentlinkops-campaigns/SKILL.md +5 -1
- package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/SKILL.md +8 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +9 -5
- package/skills/agentlinkops-crm/SKILL.md +2 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
- package/skills/agentlinkops-discovery/SKILL.md +17 -0
- package/skills/content-defingerprinting/SKILL.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
- package/skills/internal-linking-optimizer/SKILL.md +0 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
- package/skills/pre-publish-review/SKILL.md +0 -0
- package/skills/source-cited-content-builder/SKILL.md +0 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
- package/skills/source-cited-humanizer/SKILL.md +0 -0
- package/src/citations/adapter.js +102 -21
- package/src/citations/alert.js +38 -13
- package/src/citations/browser/engine.js +145 -50
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +3 -1
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +1 -0
- package/src/citations/contract.js +55 -9
- package/src/citations/inventory.js +106 -0
- package/src/citations/login.js +122 -0
- package/src/citations/match.js +12 -0
- package/src/citations/panel-builder.js +44 -39
- package/src/citations/report.js +88 -21
- package/src/citations/runner.js +59 -31
- package/src/citations/stats.js +133 -61
- package/src/citations/sync.js +131 -0
- package/src/citations/watch.js +57 -77
- package/src/competitors/contract.js +0 -0
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +0 -0
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +47 -8
- package/src/corpus/seeds.js +132 -19
- package/src/discovery/contract.js +0 -0
- package/src/discovery/import.js +0 -0
- package/src/evidence-reference.js +112 -0
- package/src/index-observation.js +161 -0
- package/src/locator.js +112 -0
- package/src/tool-views.js +6 -2
- package/src/verifier/fetch.js +42 -6
- package/src/verifier/index.js +0 -0
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/parser.js +0 -0
- package/src/verifier/readiness.js +0 -0
- package/src/verifier/render-evidence.js +0 -0
- package/src/verifier/robots.js +0 -0
- package/src/verifier/transition.js +0 -0
- package/src/verifier/url.js +0 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
- package/src/citations/browser/gen/core/providers/_shared/google.js +0 -4
- package/src/citations/browser/gen/core/providers/types.js +0 -0
- package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -321
- package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -251
- package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -54
- package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -21
- package/src/citations/browser/gen/lib/input/response/getText.js +0 -10
- package/src/citations/fixtures/dataforseo-aio.json +0 -82
|
@@ -13,6 +13,7 @@ import { z } from 'zod';
|
|
|
13
13
|
import { validatePublicUrl } from '../verifier/url.js';
|
|
14
14
|
|
|
15
15
|
export const CITATIONS_VERSION = 1;
|
|
16
|
+
export const CITATION_PANEL_VERSION = 2;
|
|
16
17
|
|
|
17
18
|
// Outcome tiers, weakest to strongest. `unknown` is NOT the bottom of a scale: it is
|
|
18
19
|
// excluded from every denominator, exactly as a blocked fetch is never a lost link.
|
|
@@ -55,11 +56,11 @@ export const CITATION_LIMITS = Object.freeze({
|
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
const httpsUrl = z.string().refine((value) => {
|
|
58
|
-
try { return validatePublicUrl(value); } catch { return false; }
|
|
59
|
+
try { return new URL(value).protocol === 'https:' && validatePublicUrl(value).valid; } catch { return false; }
|
|
59
60
|
}, { message: 'a public https URL is required' });
|
|
60
61
|
|
|
61
62
|
export const citationTargetSchema = z.object({
|
|
62
|
-
domain: z.string().min(3).optional(),
|
|
63
|
+
domain: z.string().min(3).refine(value => !/[/?#@:]/.test(value) && validatePublicUrl(`https://${value}/`).valid, { message: 'a public hostname is required' }).optional(),
|
|
63
64
|
url: httpsUrl.optional(),
|
|
64
65
|
scope: z.enum(CITATION_TARGET_SCOPES).default('domain'),
|
|
65
66
|
brand: z.string().min(1),
|
|
@@ -71,6 +72,9 @@ export const citationTargetSchema = z.object({
|
|
|
71
72
|
export const citationPromptSchema = z.object({
|
|
72
73
|
id: z.string().min(1).max(80).optional(),
|
|
73
74
|
text: z.string().min(1).max(2000),
|
|
75
|
+
intent_cluster: z.string().min(1).max(100).optional(),
|
|
76
|
+
branded: z.boolean().default(false),
|
|
77
|
+
provenance: z.object({ source: z.enum(['gsc', 'paa', 'autocomplete', 'manual']), reference: z.string().min(1).max(1000), observed_at: z.iso.datetime({ offset: true }), basis: z.literal('user_supplied') }).optional(),
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
export const citationEngineSpecSchema = z.object({
|
|
@@ -79,10 +83,23 @@ export const citationEngineSpecSchema = z.object({
|
|
|
79
83
|
engine: z.string().min(2).max(40),
|
|
80
84
|
via: z.enum(['api', 'browser']).default('api'),
|
|
81
85
|
model: z.string().min(1).max(80).optional(),
|
|
86
|
+
country: z.string().regex(/^[A-Z]{2}$/).optional(),
|
|
82
87
|
});
|
|
83
88
|
|
|
84
|
-
export
|
|
85
|
-
|
|
89
|
+
export function migrateCitationPanel(raw) {
|
|
90
|
+
if (!raw || typeof raw !== 'object' || ![1, 2].includes(raw.schema_version)) return raw;
|
|
91
|
+
const targets = Array.isArray(raw.targets) ? [...raw.targets] : raw.targets;
|
|
92
|
+
if (Array.isArray(targets) && Array.isArray(raw.competitors)) {
|
|
93
|
+
for (const competitor of raw.competitors) {
|
|
94
|
+
if (competitor && typeof competitor.domain === 'string' && !targets.some(target => target?.scope !== 'url' && target?.domain?.toLowerCase() === competitor.domain.toLowerCase())) {
|
|
95
|
+
targets.push({ ...competitor, scope: 'domain' });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { ...raw, schema_version: CITATION_PANEL_VERSION, targets, country: raw.country ?? (raw.locale ?? 'en-US').split('-').at(-1) };
|
|
100
|
+
}
|
|
101
|
+
export const citationPanelSchema = z.preprocess(migrateCitationPanel, z.object({
|
|
102
|
+
schema_version: z.literal(CITATION_PANEL_VERSION),
|
|
86
103
|
targets: z.array(citationTargetSchema).min(1).max(50),
|
|
87
104
|
prompts: z.array(citationPromptSchema).min(1).max(500),
|
|
88
105
|
engines: z.array(citationEngineSpecSchema).min(1).max(4),
|
|
@@ -100,9 +117,19 @@ export const citationPanelSchema = z.object({
|
|
|
100
117
|
aliases: z.array(z.string().min(1).max(100)).max(12).default([]),
|
|
101
118
|
})).max(10).default([]),
|
|
102
119
|
// Locale pinning: surfaces vary answers by geography. Default en-US.
|
|
103
|
-
locale: z.string().
|
|
120
|
+
locale: z.string().regex(/^[a-z]{2,3}-[A-Z]{2}$/).default('en-US'),
|
|
121
|
+
country: z.string().regex(/^[A-Z]{2}$/).default('US'),
|
|
104
122
|
note: z.string().max(500).optional(),
|
|
105
|
-
});
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
export function panelLocaleContext(panel, spec) {
|
|
126
|
+
return { locale: panel.locale ?? 'en-US', country: spec.country ?? panel.country ?? 'US',
|
|
127
|
+
mode: spec.engine === 'mock' ? 'fixture' : spec.engine === 'google-aio' ? 'supplier-location' : 'single-locale-unverified' };
|
|
128
|
+
}
|
|
129
|
+
export function localizedPromptId(promptId, context) {
|
|
130
|
+
// Preserve legacy en-US/US cell identities; other contexts get distinct baselines.
|
|
131
|
+
return context.locale === 'en-US' && context.country === 'US' ? promptId : `${promptId}@${context.locale}:${context.country}`;
|
|
132
|
+
}
|
|
106
133
|
|
|
107
134
|
// The engine identity names HOW a surface was reached, because the same engine two
|
|
108
135
|
// ways is two different measurements and is never averaged (limelitgeo/open's rule).
|
|
@@ -110,6 +137,7 @@ export function engineIdentity(spec) {
|
|
|
110
137
|
const provider = spec.via === 'browser' ? 'web-own-browser' : spec.engine === 'mock' ? 'builtin' : 'api';
|
|
111
138
|
const parts = [spec.engine, provider];
|
|
112
139
|
if (spec.model) parts.push(spec.model);
|
|
140
|
+
if (spec.country && spec.country !== 'US') parts.push(`country:${spec.country}`);
|
|
113
141
|
return parts.join(':');
|
|
114
142
|
}
|
|
115
143
|
|
|
@@ -135,7 +163,17 @@ export const evidenceEnvelopeSchema = z.object({
|
|
|
135
163
|
screenshot_file: z.string().max(120).optional(),
|
|
136
164
|
prompt: z.string().min(1),
|
|
137
165
|
engine_identity: z.string().min(1),
|
|
166
|
+
locale_context: z.object({ locale: z.string(), country: z.string(), mode: z.enum(['fixture', 'supplier-location', 'single-locale-unverified']) }).optional(),
|
|
138
167
|
provider_model_version: z.string().min(1),
|
|
168
|
+
browser_context: z.object({ surface: z.literal('web'), authentication: z.enum(['saved-session', 'anonymous']), account_tier: z.literal('unknown') }).optional(),
|
|
169
|
+
// Adapter provenance is request/response metadata, never supplier credentials.
|
|
170
|
+
provenance: z.object({
|
|
171
|
+
query: z.string().max(700), location_code: z.number().int().positive(), language_code: z.string().max(20),
|
|
172
|
+
device: z.enum(['desktop','mobile']), endpoint: z.string().url(), depth: z.number().int().positive(),
|
|
173
|
+
load_async_ai_overview: z.boolean(), task_id: z.string().nullable().optional(), checked_at: z.string().nullable().optional(),
|
|
174
|
+
check_url: z.string().nullable().optional(), provider_query: z.string().nullable().optional(), provider_device: z.string().nullable().optional(),
|
|
175
|
+
}).optional(),
|
|
176
|
+
failure: z.object({ code: z.string().max(100), message: z.string().max(2000) }).optional(),
|
|
139
177
|
answer: z.string(),
|
|
140
178
|
citations: z.array(z.object({ url: httpsUrl, title: z.string().max(500).optional() })).max(200),
|
|
141
179
|
fan_out: z.array(z.string().max(500)).max(100).default([]),
|
|
@@ -153,6 +191,8 @@ export const observationRowSchema = z.object({
|
|
|
153
191
|
cell_id: z.string().min(1),
|
|
154
192
|
run_index: z.number().int().nonnegative(),
|
|
155
193
|
outcome: z.enum(CITATION_OUTCOMES),
|
|
194
|
+
brand_mentioned: z.boolean().nullable().optional(),
|
|
195
|
+
target_cited: z.boolean().nullable().optional(),
|
|
156
196
|
cited_urls: z.array(z.string().max(2048)).max(50).default([]),
|
|
157
197
|
verify: z.enum(['unverified', 'reachable', 'unreachable', 'unknown']).default('unverified'),
|
|
158
198
|
evidence_sha256: z.string().length(64),
|
|
@@ -172,10 +212,16 @@ export const epochRowSchema = z.object({
|
|
|
172
212
|
n: z.number().int().nonnegative(),
|
|
173
213
|
unknowns: z.number().int().nonnegative(),
|
|
174
214
|
mentioned: z.number().int().nonnegative(),
|
|
175
|
-
rate: z.number().min(0).max(1),
|
|
176
|
-
ci_low: z.number().min(0).max(1),
|
|
177
|
-
ci_high: z.number().min(0).max(1),
|
|
215
|
+
rate: z.number().min(0).max(1).nullable(),
|
|
216
|
+
ci_low: z.number().min(0).max(1).nullable(),
|
|
217
|
+
ci_high: z.number().min(0).max(1).nullable(),
|
|
178
218
|
classification: z.enum(EPOCH_CLASSIFICATIONS),
|
|
219
|
+
statistics: z.object({
|
|
220
|
+
scope: z.literal('fixed-epoch-supplemental'),
|
|
221
|
+
confidence_sequence: z.record(z.string(), z.unknown()).nullable(),
|
|
222
|
+
epoch_comparison: z.record(z.string(), z.unknown()).nullable(),
|
|
223
|
+
changepoint: z.record(z.string(), z.unknown()),
|
|
224
|
+
}).optional(),
|
|
179
225
|
baseline: z.object({
|
|
180
226
|
epoch_id: z.string().min(1),
|
|
181
227
|
rate: z.number().min(0).max(1),
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFile, readdir, lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { citationPanelSchema, evidenceEnvelopeSchema, targetKey } from './contract.js';
|
|
5
|
+
import { panelEpochs } from './report.js';
|
|
6
|
+
import { analyzeRetainedAnswer } from './match.js';
|
|
7
|
+
import { parseOpportunityRecord, opportunityRecordId, ELIGIBILITY_GATES } from '../../shared/opportunity-record.js';
|
|
8
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
|
|
10
|
+
export async function freezeCitationInventory({ dir, panel: input, epochId }) {
|
|
11
|
+
const panel = citationPanelSchema.parse(input);
|
|
12
|
+
const epochs = await panelEpochs({ dir, panel });
|
|
13
|
+
const cells = epochId ? epochs.find(rows => rows[0]?.epoch_id === epochId) : epochs[0];
|
|
14
|
+
if (!cells) throw new Error('No matching panel epoch to freeze');
|
|
15
|
+
epochId = cells[0].epoch_id;
|
|
16
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(epochId) || ['.', '..'].includes(epochId)) throw new Error('Invalid epoch reference');
|
|
17
|
+
const root = await realpath(resolve(dir)); const evidenceDir = join(root, 'citations/evidence', epochId);
|
|
18
|
+
let files = [];
|
|
19
|
+
try {
|
|
20
|
+
if (!(await lstat(evidenceDir)).isDirectory() || !(await realpath(evidenceDir)).startsWith(root + '/')) throw new Error('Unsafe evidence directory');
|
|
21
|
+
files = await readdir(evidenceDir);
|
|
22
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
23
|
+
const observations = []; const identities = new Set(); let invalid = 0;
|
|
24
|
+
for (const file of files.sort()) {
|
|
25
|
+
if (!/^[a-f0-9]{64}\.json$/.test(file)) continue;
|
|
26
|
+
const path = join(evidenceDir, file);
|
|
27
|
+
if (!(await lstat(path)).isFile()) { invalid++; continue; }
|
|
28
|
+
let envelope, text;
|
|
29
|
+
try { text = await readFile(path, 'utf8'); envelope = evidenceEnvelopeSchema.parse(JSON.parse(text)); }
|
|
30
|
+
catch { invalid++; continue; }
|
|
31
|
+
if (envelope.epoch_id !== epochId || !cells.some(cell => cell.cell_id === envelope.cell_id)) continue;
|
|
32
|
+
const identity = `${envelope.cell_id}|${envelope.run_index}`;
|
|
33
|
+
if (identities.has(identity)) throw new Error('Duplicate observation identity in retained evidence'); identities.add(identity);
|
|
34
|
+
const [, promptId, key] = envelope.cell_id.split('|');
|
|
35
|
+
const target = panel.targets.find(target => targetKey(target) === key);
|
|
36
|
+
if (!target) continue;
|
|
37
|
+
const prompt = panel.prompts.find(prompt => { const id = prompt.id ?? hash(prompt.text).slice(0, 10); return id === promptId || promptId?.startsWith(`${id}@`); });
|
|
38
|
+
const analysis = envelope.engine_identity.startsWith('google-aio:') && !envelope.provenance ? { mentioned: null, cited: null, outcome: 'unknown' } : analyzeRetainedAnswer(target, envelope);
|
|
39
|
+
observations.push({ cell_id: envelope.cell_id, run_index: envelope.run_index, engine_identity: envelope.engine_identity,
|
|
40
|
+
prompt_id: promptId, prompt: envelope.prompt, headline_eligible: !prompt?.branded, prompt_provenance: prompt?.provenance ?? null,
|
|
41
|
+
intent_cluster: prompt?.intent_cluster ?? null, target: key, observed_at: envelope.at,
|
|
42
|
+
provider_model_version: envelope.provider_model_version, locale_context: envelope.locale_context ?? null,
|
|
43
|
+
mentioned: analysis.mentioned, cited: analysis.cited, outcome: analysis.outcome,
|
|
44
|
+
citations: analysis.outcome === 'unknown' ? [] : envelope.citations,
|
|
45
|
+
evidence_sha256: hash(text), fixture: envelope.engine_identity.startsWith('mock:') });
|
|
46
|
+
}
|
|
47
|
+
const expected = cells.reduce((count, cell) => count + (cell.n ?? 0) + (cell.unknowns ?? 0), 0);
|
|
48
|
+
const payload = { schema_version: 1, kind: 'frozen_citation_inventory', epoch_id: epochId, frozen_at: new Date().toISOString(),
|
|
49
|
+
scope: 'Only the retained observations in this fixed panel epoch; missing rows do not prove absence.',
|
|
50
|
+
absence_claim: 'not_supported', panel: { ...panel, mockFixtures: undefined }, cells,
|
|
51
|
+
spent_estimate_usd: Math.max(0, ...cells.map(cell => cell.spent_estimate_usd ?? 0)),
|
|
52
|
+
missing_envelopes: Math.max(0, expected - observations.length), invalid_envelopes: invalid, observations };
|
|
53
|
+
return { ...payload, inventory_sha256: hash(JSON.stringify(payload)) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateInventory(inventory) {
|
|
57
|
+
if (inventory?.kind !== 'frozen_citation_inventory') throw new Error('A frozen citation inventory is required');
|
|
58
|
+
const { inventory_sha256, ...payload } = inventory;
|
|
59
|
+
if (hash(JSON.stringify(payload)) !== inventory_sha256) throw new Error('Frozen inventory checksum does not match');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function citationGap(inventory) {
|
|
63
|
+
validateInventory(inventory);
|
|
64
|
+
return { inventory_sha256: inventory.inventory_sha256, absence_claim: 'not_supported',
|
|
65
|
+
scope: 'Citation and mention counts describe this dataset only, separately by engine, prompt and target.',
|
|
66
|
+
cells: inventory.cells.map(cell => {
|
|
67
|
+
const rows = inventory.observations.filter(row => row.cell_id === cell.cell_id);
|
|
68
|
+
const known = rows.filter(row => row.outcome !== 'unknown');
|
|
69
|
+
const cited = known.filter(row => row.cited).length;
|
|
70
|
+
return { cell_id: cell.cell_id, n: known.length, cited, mentioned: known.filter(row => row.mentioned).length,
|
|
71
|
+
unknowns: rows.length - known.length, missing_envelopes: Math.max(0, (cell.n ?? 0) + (cell.unknowns ?? 0) - rows.length),
|
|
72
|
+
headline_eligible: rows.length > 0 && rows.every(row => row.headline_eligible),
|
|
73
|
+
finding: !known.length ? 'unknown' : cited ? 'cited_in_this_dataset' : 'not_cited_in_these_known_samples' };
|
|
74
|
+
}),
|
|
75
|
+
mentioned_not_cited: inventory.observations.filter(row => row.mentioned === true && row.cited === false).map(row => ({ cell_id: row.cell_id, prompt: row.prompt, evidence_sha256: row.evidence_sha256 })) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function exportCitationSources(inventory, { niche, consentRef, excludedInputsAttested = false } = {}) {
|
|
79
|
+
if (!consentRef || !excludedInputsAttested) throw new Error('Library export requires a written consent reference and an explicit excluded-inputs attestation');
|
|
80
|
+
validateInventory(inventory);
|
|
81
|
+
const records = new Map();
|
|
82
|
+
for (const row of inventory.observations) {
|
|
83
|
+
if (row.outcome === 'unknown' || !row.headline_eligible) continue;
|
|
84
|
+
// Private named-brand questions are not public opportunity-library fields.
|
|
85
|
+
if (inventory.panel.targets.some(target => [target.brand, ...(target.aliases ?? []), target.domain].filter(Boolean).some(value => row.prompt.toLowerCase().includes(value.toLowerCase())))) continue;
|
|
86
|
+
for (const citation of row.citations) {
|
|
87
|
+
const host = new URL(citation.url).hostname; const subject = { host, page_url: citation.url, route_url: null, surface: 'ai_citation' };
|
|
88
|
+
const id = await opportunityRecordId(niche?.id, subject);
|
|
89
|
+
if (records.has(id)) continue;
|
|
90
|
+
const at = row.observed_at;
|
|
91
|
+
const record = { schema_version: 1, record_id: id, status: 'held', niche, opportunity_type: 'ai_citation_source', type_basis: 'observed', subject,
|
|
92
|
+
observed: { rel_tokens: null, indexing: 'unknown', cost_basis: 'unknown', audience_statement: null, cited_destinations: [],
|
|
93
|
+
ai_citation: { query: row.prompt, model: row.provider_model_version ?? 'unknown', observed_at: at } },
|
|
94
|
+
contact_route: { class: 'unknown', deliverability: 'not_checked', observed_at: null },
|
|
95
|
+
eligibility: { gates: ELIGIBILITY_GATES.map(gate => ({ gate, verdict: 'not_satisfied', because: row.fixture ? 'fixture_not_live_evidence' : 'public_page_and_rights_review_pending', observed_at: null })) },
|
|
96
|
+
verification: { route_state: 'unverified', listing_state: 'not_checked', last_verified_at: null, next_due_at: null, window_days: 90, unknown_streak: 0, reason: 'page_verification_pending' },
|
|
97
|
+
provenance: { source_class: 'consented_project_lane', run_id: inventory.epoch_id, seed_reason: row.fixture ? 'fixture_only' : 'retained_assistant_citation', consent_ref: consentRef, first_seen_at: at, last_seen_at: at },
|
|
98
|
+
rights: { basis: 'owned_observation_of_public_page', redistributable: false, excluded_inputs_attested: true, takedown_route: 'withdraw_on_request' },
|
|
99
|
+
evidence: [{ observed_at: at, observer: 'assistant_query', outcome: row.fixture ? 'unknown' : 'present', reason: row.fixture ? 'fixture_only' : null, page_url: citation.url, http_status: null, sha256: row.evidence_sha256,
|
|
100
|
+
complete: !row.fixture, run_id: inventory.epoch_id, checker_version: 'citation-inventory-v1', rel_tokens: null, anchor: null }],
|
|
101
|
+
dates: { created_at: at, updated_at: at, last_observed_at: at }, withdrawal: null };
|
|
102
|
+
records.set(id, parseOpportunityRecord(record));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...records.values()];
|
|
106
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// `agentlinkops citation login ENGINE` — one-time session persistence.
|
|
2
|
+
// Opens a visible browser window (or an Xvfb virtual display on headless
|
|
3
|
+
// Linux), navigates to the engine's URL, waits for the person to log in,
|
|
4
|
+
// then saves the storageState (cookies + localStorage) as a JSON file the
|
|
5
|
+
// engine adapter reads on every subsequent run.
|
|
6
|
+
//
|
|
7
|
+
// The saved session belongs to the person: it never leaves their machine,
|
|
8
|
+
// it's never synced to the hosted service, and `citation logout ENGINE`
|
|
9
|
+
// removes it. This is the CLI-own-browser posture — the user's account,
|
|
10
|
+
// their risk, their data.
|
|
11
|
+
import { mkdir, writeFile, rm, chmod } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
|
|
16
|
+
const SESSIONS_DIR = join(homedir(), '.agentlinkops', 'citations', 'sessions');
|
|
17
|
+
|
|
18
|
+
const ENGINE_URLS = Object.freeze({
|
|
19
|
+
chatgpt: 'https://chatgpt.com/',
|
|
20
|
+
grok: 'https://grok.com/',
|
|
21
|
+
copilot: 'https://copilot.microsoft.com/',
|
|
22
|
+
perplexity: 'https://www.perplexity.ai/',
|
|
23
|
+
gemini: 'https://gemini.google.com/',
|
|
24
|
+
claude: 'https://claude.ai/',
|
|
25
|
+
bing: 'https://www.bing.com/',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export async function loginEngine({ engineName, timeoutMs = 180_000, runtime = {} }) {
|
|
29
|
+
const url = ENGINE_URLS[engineName];
|
|
30
|
+
if (!url) {
|
|
31
|
+
throw new Error(`unknown engine for login: ${engineName} (known: ${Object.keys(ENGINE_URLS).join(', ')})`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Lazy import playwright-core so the CLI still works without browser tooling.
|
|
35
|
+
const { firefox } = runtime.playwright ?? await import('playwright-core');
|
|
36
|
+
const { resolveCamoufoxLaunchOptions } = runtime.camoufox ?? await import('./browser/gen/lib/browser/camoufox.js');
|
|
37
|
+
const { ensureDisplay, detectDisplay } = runtime.display ?? await import('./browser/gen/lib/browser/display.js');
|
|
38
|
+
|
|
39
|
+
// On headless Linux, create a virtual display so the browser renders visibly
|
|
40
|
+
// for X11 forwarding over ssh. On macOS, the browser opens directly.
|
|
41
|
+
let displayHandle = null;
|
|
42
|
+
let display;
|
|
43
|
+
let browser;
|
|
44
|
+
try {
|
|
45
|
+
if ((runtime.platform ?? process.platform) === 'linux' && !detectDisplay()) {
|
|
46
|
+
displayHandle = await ensureDisplay({ allowExistingDisplay: false });
|
|
47
|
+
display = displayHandle.display;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const options = await resolveCamoufoxLaunchOptions({
|
|
51
|
+
provider: engineName,
|
|
52
|
+
display,
|
|
53
|
+
headlessMode: 'headful', // login requires a visible browser
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
browser = await firefox.launch({ ...options, executablePath: options.executablePath });
|
|
57
|
+
const context = await browser.newContext({ locale: 'en-US', timezoneId: 'America/New_York' });
|
|
58
|
+
const page = await context.newPage();
|
|
59
|
+
|
|
60
|
+
(runtime.log ?? console.log)(`Opening ${url} — log in; the session saves after login is detected or the timeout expires.`);
|
|
61
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
62
|
+
|
|
63
|
+
// Wait for login: poll for a logged-in indicator or timeout.
|
|
64
|
+
const deadline = Date.now() + timeoutMs;
|
|
65
|
+
let loggedIn = false;
|
|
66
|
+
while (Date.now() < deadline && !loggedIn) {
|
|
67
|
+
await page.waitForTimeout(5000);
|
|
68
|
+
loggedIn = await page.evaluate(() => {
|
|
69
|
+
// Generic login indicators: cookie-based session markers
|
|
70
|
+
const cookies = document.cookie;
|
|
71
|
+
if (/session|auth|token|login/i.test(cookies)) return true;
|
|
72
|
+
// Surface-specific: the login button disappeared
|
|
73
|
+
const loginBtn = document.querySelector('button[data-testid*="login"], a[href*="login"], button[aria-label*="Log in"]');
|
|
74
|
+
if (!loginBtn) return document.querySelectorAll('textarea, [contenteditable="true"]').length > 0;
|
|
75
|
+
return false;
|
|
76
|
+
}).catch(() => false);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Save the storageState regardless: even a partial session may work
|
|
80
|
+
// (anonymous surfaces don't need login, and the adapter handles both).
|
|
81
|
+
const state = await context.storageState();
|
|
82
|
+
const sessionDir = runtime.sessionDir ?? SESSIONS_DIR;
|
|
83
|
+
await mkdir(sessionDir, { recursive: true, mode: 0o700 });
|
|
84
|
+
const sessionPath = join(sessionDir, `${engineName}.json`);
|
|
85
|
+
await writeFile(sessionPath, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
86
|
+
await chmod(sessionPath, 0o600);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
engine: engineName,
|
|
90
|
+
sessionPath,
|
|
91
|
+
loggedIn: loggedIn || 'saved-partial-state',
|
|
92
|
+
cookies: state.cookies?.length ?? 0,
|
|
93
|
+
origins: state.origins?.length ?? 0,
|
|
94
|
+
};
|
|
95
|
+
} finally {
|
|
96
|
+
let timer;
|
|
97
|
+
try { await Promise.race([browser?.close().catch(() => {}), new Promise(resolve => { timer = setTimeout(resolve, runtime.cleanupTimeoutMs ?? 5000); })]); }
|
|
98
|
+
finally { clearTimeout(timer); await displayHandle?.cleanup?.(); }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function logoutEngine({ engineName }) {
|
|
103
|
+
if (!Object.hasOwn(ENGINE_URLS, engineName)) throw new Error('unknown engine for logout');
|
|
104
|
+
const sessionPath = join(SESSIONS_DIR, `${engineName}.json`);
|
|
105
|
+
try {
|
|
106
|
+
await rm(sessionPath);
|
|
107
|
+
return { engine: engineName, removed: true };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error.code !== 'ENOENT') throw error;
|
|
110
|
+
return { engine: engineName, removed: false, note: 'no session file found' };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function listSessions() {
|
|
115
|
+
try {
|
|
116
|
+
const { readdir } = await import('node:fs/promises');
|
|
117
|
+
const files = await readdir(SESSIONS_DIR);
|
|
118
|
+
return files.filter((f) => f.endsWith('.json')).map((f) => f.replace('.json', ''));
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/citations/match.js
CHANGED
|
@@ -118,3 +118,15 @@ export function classifyOutcome(target, run, verifyFetch) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
export { CITATION_OUTCOMES, outcomeRank };
|
|
121
|
+
|
|
122
|
+
// Re-analyze retained answers without new provider requests or mutating evidence.
|
|
123
|
+
// Mention and citation are independent facts: an answer may satisfy both.
|
|
124
|
+
export function analyzeRetainedAnswer(target, evidence) {
|
|
125
|
+
if (!evidence || evidence.failure || evidence.unknown || typeof evidence.answer !== 'string') {
|
|
126
|
+
return { mentioned: null, cited: null, citedUrls: [], outcome: 'unknown' };
|
|
127
|
+
}
|
|
128
|
+
const citedUrls = matchCitations(target, evidence.citations ?? []);
|
|
129
|
+
const mentioned = matchMention(target, evidence.answer);
|
|
130
|
+
return { mentioned, cited: citedUrls.length > 0, citedUrls,
|
|
131
|
+
outcome: citedUrls.length ? 'cited' : mentioned ? 'mentioned' : 'not_cited' };
|
|
132
|
+
}
|
|
@@ -1,44 +1,49 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
// Suggested prompts are editable starting points, never measured search demand.
|
|
2
|
+
import { citationPanelSchema, CITATION_PANEL_VERSION } from './contract.js';
|
|
3
|
+
import { validatePublicUrl } from '../verifier/url.js';
|
|
4
|
+
|
|
5
|
+
function domainName(value) {
|
|
6
|
+
if (typeof value !== 'string' || /[\s/?#@:]/.test(value)) throw new Error('domain must be a hostname without a path');
|
|
7
|
+
const url = new URL(`https://${value}/`);
|
|
8
|
+
if (!validatePublicUrl(url.href).valid) throw new Error('domain must be a public hostname');
|
|
9
|
+
return url.hostname.toLowerCase().replace(/^www\./, '');
|
|
10
|
+
}
|
|
11
|
+
export function buildStarterPanel({ domain, brand, topic, aliases = [], engines = [{ engine: 'mock' }],
|
|
12
|
+
samples = 10, maxUsd = 1, competitors = [], prompts, locale = 'en-US', country } = {}) {
|
|
9
13
|
if (!domain || !brand) throw new Error('domain and brand are required');
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
note: `Citation watch for ${brand}`,
|
|
18
|
-
targets,
|
|
19
|
-
prompts,
|
|
20
|
-
engines,
|
|
21
|
-
samples,
|
|
22
|
-
maxUsd,
|
|
23
|
-
competitors: competitors.map((c) => ({ domain: c.domain, brand: c.brand, aliases: c.aliases ?? [] })),
|
|
24
|
-
locale: 'en-US',
|
|
25
|
-
};
|
|
14
|
+
const targets = [{ domain: domainName(domain), scope: 'domain', brand, aliases }];
|
|
15
|
+
const normalizedCompetitors = competitors.map(c => ({ ...c, domain: domainName(c.domain), aliases: c.aliases ?? [] }));
|
|
16
|
+
return citationPanelSchema.parse({
|
|
17
|
+
schema_version: CITATION_PANEL_VERSION, note: `Editable prompt suggestions for ${brand}; verify audience relevance before running.`,
|
|
18
|
+
targets, competitors: normalizedCompetitors, prompts: prompts ?? defaultPrompts(topic || brand),
|
|
19
|
+
engines, samples, maxUsd, locale, country: country ?? locale.split('-').at(-1),
|
|
20
|
+
});
|
|
26
21
|
}
|
|
27
|
-
|
|
28
|
-
// Ten sensible starting prompts: category, how-to, comparison, tool-seeking.
|
|
29
|
-
// Replace with GSC-derived panels (DP-0032) once search data is wired.
|
|
30
|
-
function defaultPrompts(domain) {
|
|
31
|
-
const base = domain.split('.')[0];
|
|
22
|
+
function defaultPrompts(topic) {
|
|
32
23
|
return [
|
|
33
|
-
{ id: '
|
|
34
|
-
{ id: '
|
|
35
|
-
{ id: '
|
|
36
|
-
{ id: '
|
|
37
|
-
{ id: '
|
|
38
|
-
{ id: 'pricing', text: `${base} pricing` },
|
|
39
|
-
{ id: 'reviews', text: `${base} reviews` },
|
|
40
|
-
{ id: 'tutorial', text: `${base} tutorial` },
|
|
41
|
-
{ id: 'alternatives', text: `${base} alternatives free` },
|
|
42
|
-
{ id: 'for-beginners', text: `${base} for beginners` },
|
|
24
|
+
{ id: 'definition', text: `What is ${topic}?` },
|
|
25
|
+
{ id: 'options', text: `Which ${topic} tools should I compare?` },
|
|
26
|
+
{ id: 'workflow', text: `How can I get started with ${topic}?` },
|
|
27
|
+
{ id: 'alternatives', text: `What alternatives to ${topic} should I consider?` },
|
|
28
|
+
{ id: 'evidence', text: `What evidence should I check when choosing ${topic}?` },
|
|
43
29
|
];
|
|
44
30
|
}
|
|
31
|
+
|
|
32
|
+
// The input is the user's retained research, never a claim that this command queried a source.
|
|
33
|
+
export function selectResearchPrompts(records, { limit = 50 } = {}) {
|
|
34
|
+
if (!Array.isArray(records) || !records.length || records.length > 5000) throw new Error('Research must contain 1–5000 prompt records');
|
|
35
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error('Prompt limit must be 1–500');
|
|
36
|
+
const seen = new Set(); const groups = new Map();
|
|
37
|
+
for (const record of records) {
|
|
38
|
+
if (!record || typeof record.text !== 'string' || !record.intent_cluster || !record.provenance) throw new Error('Each researched prompt needs text, intent_cluster and provenance');
|
|
39
|
+
const text = record.text.trim().replace(/\s+/g, ' '); const key = text.toLowerCase();
|
|
40
|
+
if (seen.has(key)) continue; seen.add(key);
|
|
41
|
+
const prompt = { text, intent_cluster: record.intent_cluster, branded: record.branded === true, provenance: { ...record.provenance, basis: 'user_supplied' } };
|
|
42
|
+
const group = groups.get(record.intent_cluster) ?? []; group.push(prompt); groups.set(record.intent_cluster, group);
|
|
43
|
+
}
|
|
44
|
+
const selected = [];
|
|
45
|
+
while (selected.length < limit && [...groups.values()].some(group => group.length)) {
|
|
46
|
+
for (const group of groups.values()) if (group.length && selected.length < limit) selected.push(group.shift());
|
|
47
|
+
}
|
|
48
|
+
return selected.map((prompt, index) => ({ id: `research-${index + 1}`, ...prompt }));
|
|
49
|
+
}
|