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.
Files changed (201) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.mcp.json +0 -0
  4. package/CHANGELOG.md +17 -0
  5. package/LICENSE +0 -0
  6. package/NOTICE +0 -0
  7. package/README.md +0 -0
  8. package/bin/agentlinkops.mjs +0 -0
  9. package/bin/linktrail.mjs +0 -0
  10. package/cli/adapters/csv.js +0 -0
  11. package/cli/adapters/sqlite-crm.js +0 -0
  12. package/cli/adapters/suppliers.js +0 -0
  13. package/cli/admission.js +130 -0
  14. package/cli/agent-setup.js +1014 -101
  15. package/cli/agentlinkops.mjs +0 -0
  16. package/cli/args.js +0 -0
  17. package/cli/catalog.js +12837 -4694
  18. package/cli/check.js +0 -0
  19. package/cli/citation.js +181 -32
  20. package/cli/client.js +1 -1
  21. package/cli/commands.js +3 -3
  22. package/cli/config.js +2 -1
  23. package/cli/connection.js +0 -0
  24. package/cli/context-mcp.js +0 -0
  25. package/cli/context.js +0 -0
  26. package/cli/delivery.js +0 -0
  27. package/cli/disavow.js +54 -0
  28. package/cli/doctor.js +7 -8
  29. package/cli/env.js +0 -0
  30. package/cli/fleet.js +0 -0
  31. package/cli/gsc-browser.js +0 -0
  32. package/cli/import.js +0 -0
  33. package/cli/index-observations.js +89 -0
  34. package/cli/ledger.js +7 -1
  35. package/cli/lifecycle.js +40 -0
  36. package/cli/linktrail.mjs +0 -0
  37. package/cli/local-result.js +0 -0
  38. package/cli/main.js +99 -7
  39. package/cli/mirror.js +0 -0
  40. package/cli/mix.js +0 -0
  41. package/cli/platforms.js +0 -0
  42. package/cli/receipt-performance.js +0 -0
  43. package/cli/receipts.js +0 -0
  44. package/cli/report.js +79 -6
  45. package/cli/setup-plan.js +0 -0
  46. package/cli/skill.js +41 -4
  47. package/cli/state.js +0 -0
  48. package/cli/status.js +0 -0
  49. package/cli/sync.js +23 -1
  50. package/package.json +3 -2
  51. package/references/citation-panels.md +26 -0
  52. package/references/cli.md +0 -0
  53. package/references/disavow.md +0 -0
  54. package/references/discovery-handoff.md +32 -0
  55. package/references/recipes/catalog.json +148 -0
  56. package/references/recipes/command-notes.md +9 -0
  57. package/references/recipes/placement-reconciliation.md +41 -0
  58. package/references/recipes/qualified-campaign-handoff.md +56 -0
  59. package/references/recipes/site-context-brief.md +25 -0
  60. package/references/recipes/sourced-linkable-asset.md +25 -0
  61. package/references/reports.md +0 -0
  62. package/scripts/agentlinkops.py +0 -0
  63. package/scripts/disavow.py +0 -0
  64. package/scripts/linktrail.py +0 -0
  65. package/scripts/migrations/001_crm.sql +0 -0
  66. package/scripts/migrations/002_cloud_events.sql +0 -0
  67. package/scripts/migrations/003_disavow.sql +0 -0
  68. package/scripts/migrations/004_placement_costs.sql +0 -0
  69. package/scripts/report.py +0 -0
  70. package/shared/admission-contract.js +16 -0
  71. package/shared/admission-report-contract.js +8 -0
  72. package/shared/agent-clients.js +0 -0
  73. package/shared/citation-evidence-contract.js +27 -0
  74. package/shared/disavow-contract.js +18 -0
  75. package/shared/index-observation-contract.js +14 -0
  76. package/shared/lifecycle-contract.js +22 -0
  77. package/shared/lifecycle-money.js +24 -0
  78. package/shared/observation-event.js +12 -1
  79. package/shared/opportunity-record.js +259 -0
  80. package/shared/response-contract.js +2 -0
  81. package/shared/search-connection-contract.js +0 -0
  82. package/skills/agentlinkops-assets/SKILL.md +2 -0
  83. package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
  84. package/skills/agentlinkops-campaigns/SKILL.md +5 -1
  85. package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
  86. package/skills/agentlinkops-connect/SKILL.md +8 -0
  87. package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
  88. package/skills/agentlinkops-connect/references/agentlinkops.md +9 -5
  89. package/skills/agentlinkops-crm/SKILL.md +2 -0
  90. package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
  91. package/skills/agentlinkops-discovery/SKILL.md +17 -0
  92. package/skills/content-defingerprinting/SKILL.md +0 -0
  93. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
  94. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
  95. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
  96. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
  97. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
  98. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
  99. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
  100. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
  101. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
  102. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
  103. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
  104. package/skills/internal-linking-optimizer/SKILL.md +0 -0
  105. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
  106. package/skills/pre-publish-review/SKILL.md +0 -0
  107. package/skills/source-cited-content-builder/SKILL.md +0 -0
  108. package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
  109. package/skills/source-cited-humanizer/SKILL.md +0 -0
  110. package/src/citations/adapter.js +102 -21
  111. package/src/citations/alert.js +38 -13
  112. package/src/citations/browser/engine.js +145 -50
  113. package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
  114. package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
  115. package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
  116. package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
  117. package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
  118. package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
  119. package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
  120. package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
  121. package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
  122. package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
  123. package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
  124. package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
  125. package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
  126. package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
  127. package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
  128. package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
  129. package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
  130. package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
  131. package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
  132. package/src/citations/browser/gen/core/providers/index.js +0 -0
  133. package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
  134. package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
  135. package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
  136. package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
  137. package/src/citations/browser/gen/lib/browser/display.js +0 -0
  138. package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
  139. package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
  140. package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
  141. package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
  142. package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
  143. package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
  144. package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
  145. package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
  146. package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
  147. package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
  148. package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
  149. package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
  150. package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
  151. package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
  152. package/src/citations/browser/gen/utils/agent-constants.js +0 -0
  153. package/src/citations/browser/providers.js +3 -1
  154. package/src/citations/browser/shims/errors.js +0 -0
  155. package/src/citations/browser/shims/types.js +0 -0
  156. package/src/citations/browser/shims/utils.js +1 -0
  157. package/src/citations/contract.js +55 -9
  158. package/src/citations/inventory.js +106 -0
  159. package/src/citations/login.js +122 -0
  160. package/src/citations/match.js +12 -0
  161. package/src/citations/panel-builder.js +44 -39
  162. package/src/citations/report.js +88 -21
  163. package/src/citations/runner.js +59 -31
  164. package/src/citations/stats.js +133 -61
  165. package/src/citations/sync.js +131 -0
  166. package/src/citations/watch.js +57 -77
  167. package/src/competitors/contract.js +0 -0
  168. package/src/competitors/domain-mix.js +0 -0
  169. package/src/context/ga4.js +0 -0
  170. package/src/context/gsc.js +0 -0
  171. package/src/context/site-profile.js +0 -0
  172. package/src/context/util.js +0 -0
  173. package/src/context-tool-registry.js +0 -0
  174. package/src/corpus/acquire.js +47 -8
  175. package/src/corpus/seeds.js +132 -19
  176. package/src/discovery/contract.js +0 -0
  177. package/src/discovery/import.js +0 -0
  178. package/src/evidence-reference.js +112 -0
  179. package/src/index-observation.js +161 -0
  180. package/src/locator.js +112 -0
  181. package/src/tool-views.js +6 -2
  182. package/src/verifier/fetch.js +42 -6
  183. package/src/verifier/index.js +0 -0
  184. package/src/verifier/observation-kind.js +0 -0
  185. package/src/verifier/parser.js +0 -0
  186. package/src/verifier/readiness.js +0 -0
  187. package/src/verifier/render-evidence.js +0 -0
  188. package/src/verifier/robots.js +0 -0
  189. package/src/verifier/transition.js +0 -0
  190. package/src/verifier/url.js +0 -0
  191. package/templates/SITE.template.md +0 -0
  192. package/templates/campaigns.v1.json +0 -0
  193. package/src/citations/browser/gen/core/providers/_shared/google.js +0 -4
  194. package/src/citations/browser/gen/core/providers/types.js +0 -0
  195. package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -321
  196. package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -251
  197. package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
  198. package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -54
  199. package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -21
  200. package/src/citations/browser/gen/lib/input/response/getText.js +0 -10
  201. 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 const citationPanelSchema = z.object({
85
- schema_version: z.literal(CITATIONS_VERSION),
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().min(2).max(10).default('en-US'),
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
+ }
@@ -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
- // Guided panel authoring: turn a domain + optional brand into a ready-to-run
2
- // panel with sensible defaults. The buyer who doesn't want to write JSON
3
- // gets a starting point they can edit.
4
- export function buildStarterPanel({ domain, brand, aliases = [], engines = [
5
- { engine: 'chatgpt', via: 'browser' },
6
- { engine: 'duck', model: 'claude-haiku-4-5' },
7
- { engine: 'google-aio' },
8
- ], samples = 10, maxUsd = 1.0, competitors = [] } = {}) {
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 prompts = defaultPrompts(domain);
11
- const targets = [
12
- { domain, scope: 'domain', brand, aliases },
13
- ...competitors.map((c) => ({ domain: c.domain, scope: 'domain', brand: c.brand, aliases: c.aliases ?? [] })),
14
- ];
15
- return {
16
- schema_version: 1,
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: 'category', text: `what is ${base}` },
34
- { id: 'best-tools', text: `best ${base} tools` },
35
- { id: 'how-to', text: `how to use ${base}` },
36
- { id: 'comparison', text: `${base} vs alternatives` },
37
- { id: 'open-source', text: `open source ${base}` },
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
+ }