agentlinkops 0.5.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.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +24 -0
  3. package/CHANGELOG.md +52 -0
  4. package/LICENSE +190 -0
  5. package/NOTICE +17 -0
  6. package/README.md +202 -0
  7. package/bin/agentlinkops.mjs +4 -0
  8. package/bin/linktrail.mjs +6 -0
  9. package/cli/adapters/csv.js +68 -0
  10. package/cli/adapters/sqlite-crm.js +79 -0
  11. package/cli/adapters/suppliers.js +180 -0
  12. package/cli/agent-setup.js +203 -0
  13. package/cli/agentlinkops.mjs +4 -0
  14. package/cli/args.js +18 -0
  15. package/cli/catalog.js +7483 -0
  16. package/cli/check.js +119 -0
  17. package/cli/citation.js +109 -0
  18. package/cli/client.js +60 -0
  19. package/cli/commands.js +183 -0
  20. package/cli/config.js +104 -0
  21. package/cli/connection.js +90 -0
  22. package/cli/context-mcp.js +193 -0
  23. package/cli/context.js +340 -0
  24. package/cli/delivery.js +67 -0
  25. package/cli/doctor.js +283 -0
  26. package/cli/env.js +66 -0
  27. package/cli/fleet.js +82 -0
  28. package/cli/gsc-browser.js +239 -0
  29. package/cli/import.js +108 -0
  30. package/cli/ledger.js +232 -0
  31. package/cli/linktrail.mjs +6 -0
  32. package/cli/local-result.js +161 -0
  33. package/cli/main.js +523 -0
  34. package/cli/mirror.js +126 -0
  35. package/cli/mix.js +123 -0
  36. package/cli/platforms.js +161 -0
  37. package/cli/receipt-performance.js +64 -0
  38. package/cli/receipts.js +122 -0
  39. package/cli/report.js +158 -0
  40. package/cli/setup-plan.js +211 -0
  41. package/cli/skill.js +39 -0
  42. package/cli/state.js +106 -0
  43. package/cli/status.js +51 -0
  44. package/cli/sync.js +179 -0
  45. package/package.json +59 -0
  46. package/references/cli.md +198 -0
  47. package/references/disavow.md +73 -0
  48. package/references/reports.md +53 -0
  49. package/scripts/agentlinkops.py +651 -0
  50. package/scripts/disavow.py +207 -0
  51. package/scripts/linktrail.py +10 -0
  52. package/scripts/migrations/001_crm.sql +45 -0
  53. package/scripts/migrations/002_cloud_events.sql +13 -0
  54. package/scripts/migrations/003_disavow.sql +20 -0
  55. package/scripts/migrations/004_placement_costs.sql +2 -0
  56. package/scripts/report.py +113 -0
  57. package/shared/agent-clients.js +49 -0
  58. package/shared/observation-event.js +43 -0
  59. package/shared/response-contract.js +59 -0
  60. package/shared/search-connection-contract.js +18 -0
  61. package/skills/agentlinkops-assets/SKILL.md +13 -0
  62. package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
  63. package/skills/agentlinkops-campaigns/SKILL.md +17 -0
  64. package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
  65. package/skills/agentlinkops-connect/SKILL.md +33 -0
  66. package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
  67. package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
  68. package/skills/agentlinkops-crm/SKILL.md +27 -0
  69. package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
  70. package/skills/content-defingerprinting/SKILL.md +29 -0
  71. package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
  82. package/skills/internal-linking-optimizer/SKILL.md +56 -0
  83. package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
  84. package/skills/pre-publish-review/SKILL.md +179 -0
  85. package/skills/source-cited-content-builder/SKILL.md +56 -0
  86. package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
  87. package/skills/source-cited-humanizer/SKILL.md +20 -0
  88. package/src/citations/adapter.js +141 -0
  89. package/src/citations/contract.js +171 -0
  90. package/src/citations/match.js +120 -0
  91. package/src/citations/runner.js +151 -0
  92. package/src/citations/stats.js +74 -0
  93. package/src/competitors/contract.js +157 -0
  94. package/src/competitors/domain-mix.js +199 -0
  95. package/src/context/ga4.js +652 -0
  96. package/src/context/gsc.js +925 -0
  97. package/src/context/site-profile.js +445 -0
  98. package/src/context/util.js +71 -0
  99. package/src/context-tool-registry.js +27 -0
  100. package/src/corpus/acquire.js +135 -0
  101. package/src/corpus/seeds.js +182 -0
  102. package/src/discovery/contract.js +237 -0
  103. package/src/discovery/import.js +156 -0
  104. package/src/tool-views.js +117 -0
  105. package/src/verifier/fetch.js +303 -0
  106. package/src/verifier/index.js +311 -0
  107. package/src/verifier/observation-kind.js +91 -0
  108. package/src/verifier/parser.js +241 -0
  109. package/src/verifier/readiness.js +90 -0
  110. package/src/verifier/render-evidence.js +57 -0
  111. package/src/verifier/robots.js +85 -0
  112. package/src/verifier/transition.js +129 -0
  113. package/src/verifier/url.js +56 -0
  114. package/templates/SITE.template.md +59 -0
  115. package/templates/campaigns.v1.json +65 -0
@@ -0,0 +1,141 @@
1
+ // Engine adapters: one run of one prompt against one engine identity.
2
+ //
3
+ // An adapter returns answer text, a normalized provider citation list, any search
4
+ // fan-out the API exposes, token usage, and a cost estimate for the budget cap. It
5
+ // never interprets the answer (matching does that) and never decides outcomes. The
6
+ // Perplexity field map follows LiteLLM's normalization (MIT; used as a spec, not
7
+ // vendored): citations live on the response object as URLs, sometimes objects.
8
+ import { ENGINE_RATE_CARD, CITATION_LIMITS } from './contract.js';
9
+
10
+ export class EngineError extends Error {
11
+ constructor(message, { retriable = false, cause } = {}) {
12
+ super(message);
13
+ this.name = 'EngineError';
14
+ this.retriable = retriable;
15
+ if (cause) this.cause = cause;
16
+ }
17
+ }
18
+
19
+ // Deterministic per (seed, prompt, runIndex): tests and dry-runs must cost $0 AND
20
+ // reproduce exactly, or the statistics tests would be testing the RNG, not the tool.
21
+ function mulberry32(a) {
22
+ return function next() {
23
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
24
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
25
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
26
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
27
+ };
28
+ }
29
+ const hashString = (text) => {
30
+ let h = 2166136261;
31
+ for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
32
+ return h >>> 0;
33
+ };
34
+
35
+ /**
36
+ * The mock engine. Fixtures map prompt text to candidate citations with inclusion
37
+ * weights, so a panel can simulate any citation behavior — including a collapse
38
+ * between epochs — without a network call or a cent.
39
+ */
40
+ export function createMockEngine({ seed = 'mock', fixtures = {}, costEstimateUsd = 0.002 } = {}) {
41
+ return {
42
+ identity: { engine: 'mock', provider: 'builtin', model: 'mock-1' },
43
+ // The nominal per-run estimate the budget cap is enforced against, checked
44
+ // BEFORE a call so the cap can be approached but never exceeded.
45
+ estimateCostUsd: () => costEstimateUsd,
46
+ async run({ prompt, runIndex = 0 }) {
47
+ const fixture = fixtures[prompt] ?? {};
48
+ const rng = mulberry32(hashString(`${seed}|${prompt}|${runIndex}`));
49
+ const candidates = fixture.citations ?? [];
50
+ const citations = candidates
51
+ .filter((c) => typeof c.weight !== 'number' || rng() < c.weight)
52
+ .map((c) => ({ url: c.url, title: c.title }));
53
+ let answer = fixture.answerText ?? `Mock answer for: ${prompt}`;
54
+ for (const c of citations) answer += `\nSource: ${c.url}`;
55
+ if (fixture.mentionText) answer += `\n${fixture.mentionText}`;
56
+ return {
57
+ engine: 'mock', provider: 'builtin', model: 'mock-1', providerModelVersion: 'mock-1.0',
58
+ answer, citations, fanOut: fixture.fanOut ?? [],
59
+ usage: { input_tokens: 40, output_tokens: 400 },
60
+ // The run reports its actual estimate; the engine-level value is the nominal
61
+ // one the budget pre-check uses, so both must come from the same dial.
62
+ costEstimateUsd: fixture.costEstimateUsd ?? costEstimateUsd,
63
+ };
64
+ },
65
+ };
66
+ }
67
+
68
+ function estimatePerplexityCost(usage) {
69
+ const rates = ENGINE_RATE_CARD.perplexity;
70
+ const input = ((usage.input_tokens ?? 0) / 1e6) * rates.inputPerMTokens;
71
+ const output = ((usage.output_tokens ?? 0) / 1e6) * rates.outputPerMTokens;
72
+ return Number((input + output + rates.perRequest).toFixed(6));
73
+ }
74
+
75
+ /**
76
+ * The Perplexity adapter. First live engine because Sonar's citations are native and
77
+ * its base tier is the cheapest per-answer surface (~$0.005-0.01). The key is read
78
+ * by the caller (env or fleet) and never stored or printed here.
79
+ */
80
+ export function createPerplexityEngine({
81
+ apiKey, model = 'sonar', baseUrl = 'https://api.perplexity.ai', fetchImpl,
82
+ } = {}) {
83
+ if (!apiKey || typeof apiKey !== 'string') throw new EngineError('a Perplexity API key is required');
84
+ const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
85
+ if (typeof doFetch !== 'function') throw new EngineError('no fetch implementation available');
86
+ return {
87
+ identity: { engine: 'perplexity', provider: 'api', model },
88
+ // Nominal per-run estimate for the pre-call cap check (search fee plus a
89
+ // conservative token assumption); the post-run estimate uses actual usage.
90
+ estimateCostUsd: () => Number((ENGINE_RATE_CARD.perplexity.perRequest
91
+ + (800 / 1e6) * ENGINE_RATE_CARD.perplexity.inputPerMTokens
92
+ + (600 / 1e6) * ENGINE_RATE_CARD.perplexity.outputPerMTokens).toFixed(6)),
93
+ async run({ prompt }) {
94
+ let response;
95
+ try {
96
+ response = await doFetch(`${baseUrl}/chat/completions`, {
97
+ method: 'POST',
98
+ headers: { 'authorization': `Bearer ${apiKey}`, 'content-type': 'application/json' },
99
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }] }),
100
+ signal: AbortSignal.timeout(CITATION_LIMITS.timeoutMs),
101
+ });
102
+ } catch (cause) {
103
+ throw new EngineError(`perplexity request failed: ${cause.message}`, { retriable: true, cause });
104
+ }
105
+ if (!response.ok) {
106
+ const retriable = response.status === 429 || response.status >= 500;
107
+ throw new EngineError(`perplexity responded ${response.status}`, { retriable });
108
+ }
109
+ let body;
110
+ try { body = await response.json(); }
111
+ catch (cause) { throw new EngineError('perplexity response was not JSON', { cause }); }
112
+ const message = body?.choices?.[0]?.message;
113
+ const answer = typeof message?.content === 'string' ? message.content : '';
114
+ if (answer.length === 0) throw new EngineError('perplexity returned an empty answer');
115
+ if (answer.length > CITATION_LIMITS.answerBytes) {
116
+ throw new EngineError('perplexity answer exceeded the size limit');
117
+ }
118
+ // LiteLLM's map: citations are URL strings, or objects carrying a url/link.
119
+ const citations = (body?.citations ?? message?.citations ?? [])
120
+ .map((c) => (typeof c === 'string' ? { url: c } : { url: c?.url ?? c?.link, title: c?.title }))
121
+ .filter((c) => typeof c.url === 'string' && c.url.length > 0);
122
+ const usage = {
123
+ input_tokens: body?.usage?.prompt_tokens ?? 0,
124
+ output_tokens: body?.usage?.completion_tokens ?? 0,
125
+ };
126
+ return {
127
+ engine: 'perplexity', provider: 'api', model,
128
+ providerModelVersion: body?.model ?? model,
129
+ answer, citations, fanOut: [],
130
+ usage,
131
+ costEstimateUsd: estimatePerplexityCost(usage),
132
+ };
133
+ },
134
+ };
135
+ }
136
+
137
+ export function createEngine(spec, options = {}) {
138
+ if (spec.engine === 'mock') return createMockEngine(options.mock ?? {});
139
+ if (spec.engine === 'perplexity') return createPerplexityEngine({ model: spec.model ?? 'sonar', ...(options.perplexity ?? {}) });
140
+ throw new EngineError(`unknown engine: ${spec.engine}`);
141
+ }
@@ -0,0 +1,171 @@
1
+ // The citation contract: one AI citation treated as one placement.
2
+ //
3
+ // A link is placed by an editor and stays until an editor removes it. A citation is
4
+ // re-decided by a model on every ask, so the observable is a rate, not a presence —
5
+ // and the 2026 measurement research (IQRush, arXiv 2603.08924) says single readings
6
+ // are mostly noise (run-to-run citation-set Jaccard 0.29-0.50). Every rule in this
7
+ // file exists to keep that fact structural: fixed-n epochs, honest denominators,
8
+ // intervals instead of scores, and an explicit refusal to interpret thin data.
9
+ //
10
+ // Internal v1 contract only. No route, queue or hosted surface is activated by this
11
+ // module; the hosted port (DP-0033) must not weaken anything stated here.
12
+ import { z } from 'zod';
13
+ import { validatePublicUrl } from '../verifier/url.js';
14
+
15
+ export const CITATIONS_VERSION = 1;
16
+
17
+ // Outcome tiers, weakest to strongest. `unknown` is NOT the bottom of a scale: it is
18
+ // excluded from every denominator, exactly as a blocked fetch is never a lost link.
19
+ // `verified` means we re-fetched the cited URL ourselves; we never take the engine's
20
+ // word for its citations (potato's rule, adopted 2026-09-16).
21
+ export const CITATION_OUTCOMES = Object.freeze(['verified', 'cited', 'mentioned', 'not_cited', 'unknown']);
22
+ const OUTCOME_RANK = Object.freeze({ verified: 4, cited: 3, mentioned: 2, not_cited: 1, unknown: 0 });
23
+ export const outcomeRank = (outcome) => OUTCOME_RANK[outcome] ?? 0;
24
+
25
+ // Domain scope is the default because URL-level matching is strictly more volatile
26
+ // (IQRush). URL scope is an explicit precision mode and its intervals are read with
27
+ // that caveat attached.
28
+ export const CITATION_TARGET_SCOPES = Object.freeze(['domain', 'url']);
29
+
30
+ // Engines this version knows. `mock` costs nothing, is deterministic per seed, and
31
+ // exists so every test and every dry-run proves the pipeline without spending money.
32
+ export const CITATION_ENGINES = Object.freeze(['mock', 'perplexity']);
33
+
34
+ // Token rates used ONLY for the pre-call budget estimate. These are not bills; they
35
+ // are the numbers the hard maxUsd cap is enforced against, so they are deliberately
36
+ // conservative (rounded up where uncertain). Update with a version bump when prices
37
+ // move.
38
+ export const ENGINE_RATE_CARD = Object.freeze({
39
+ mock: Object.freeze({ inputPerMTokens: 0, outputPerMTokens: 0, perRequest: 0, currency: 'USD' }),
40
+ perplexity: Object.freeze({ inputPerMTokens: 1.0, outputPerMTokens: 1.0, perRequest: 0.005, currency: 'USD' }),
41
+ });
42
+
43
+ export const CITATION_LIMITS = Object.freeze({
44
+ answerBytes: 256 * 1024,
45
+ timeoutMs: 45_000,
46
+ // Below this the tool refuses to interpret, whatever the numbers look like.
47
+ minSamplesForInterpretation: 10,
48
+ maxCiWidth: 0.5,
49
+ defaultSamples: 30,
50
+ shareGradeSamples: 100,
51
+ defaultMaxUsd: 5.0,
52
+ // Courtesy pacing between runs against the same engine identity. The hosted port
53
+ // owns real fairness; locally this only keeps one panel from hammering an API key.
54
+ delayMs: 250,
55
+ });
56
+
57
+ const httpsUrl = z.string().refine((value) => {
58
+ try { return validatePublicUrl(value); } catch { return false; }
59
+ }, { message: 'a public https URL is required' });
60
+
61
+ export const citationTargetSchema = z.object({
62
+ domain: z.string().min(3).optional(),
63
+ url: httpsUrl.optional(),
64
+ scope: z.enum(CITATION_TARGET_SCOPES).default('domain'),
65
+ brand: z.string().min(1),
66
+ aliases: z.array(z.string().min(1)).max(12).default([]),
67
+ }).refine((t) => (t.scope === 'domain' ? typeof t.domain === 'string' : typeof t.url === 'string'), {
68
+ message: 'domain scope needs a domain; url scope needs a url',
69
+ });
70
+
71
+ export const citationPromptSchema = z.object({
72
+ id: z.string().min(1).max(80).optional(),
73
+ text: z.string().min(1).max(2000),
74
+ });
75
+
76
+ export const citationEngineSpecSchema = z.object({
77
+ engine: z.enum(CITATION_ENGINES),
78
+ model: z.string().min(1).max(80).optional(),
79
+ });
80
+
81
+ export const citationPanelSchema = z.object({
82
+ schema_version: z.literal(CITATIONS_VERSION),
83
+ targets: z.array(citationTargetSchema).min(1).max(50),
84
+ prompts: z.array(citationPromptSchema).min(1).max(500),
85
+ engines: z.array(citationEngineSpecSchema).min(1).max(4),
86
+ samples: z.number().int().min(1).max(500).optional(),
87
+ maxUsd: z.number().positive().max(500).optional(),
88
+ // Mock-engine behavior keyed by prompt text: candidate citations with inclusion
89
+ // weights, mentionText, fanOut, costEstimateUsd. This is how a dry run simulates a
90
+ // real panel (and how a test simulates a collapse) without spending anything.
91
+ mockFixtures: z.record(z.string(), z.any()).optional(),
92
+ note: z.string().max(500).optional(),
93
+ });
94
+
95
+ // The engine identity names HOW a surface was reached, because the same engine two
96
+ // ways is two different measurements and is never averaged (limelitgeo/open's rule).
97
+ export function engineIdentity(spec) {
98
+ const parts = [spec.engine, spec.engine === 'mock' ? 'builtin' : 'api'];
99
+ if (spec.model) parts.push(spec.model);
100
+ return parts.join(':');
101
+ }
102
+
103
+ export function cellId(identity, promptId, targetKey) {
104
+ return `${identity}|${promptId}|${targetKey}`;
105
+ }
106
+
107
+ export function targetKey(target) {
108
+ return target.scope === 'domain' ? `d:${target.domain.toLowerCase()}` : `u:${target.url}`;
109
+ }
110
+
111
+ // What one run leaves behind, immutably, before anyone interprets it.
112
+ export const evidenceEnvelopeSchema = z.object({
113
+ schema_version: z.literal(CITATIONS_VERSION),
114
+ epoch_id: z.string().min(1),
115
+ cell_id: z.string().min(1),
116
+ // Two runs can land in the same millisecond; the run index is what makes their
117
+ // evidence snapshots distinct content-addressed objects rather than silent
118
+ // duplicates of each other.
119
+ run_index: z.number().int().nonnegative(),
120
+ prompt: z.string().min(1),
121
+ engine_identity: z.string().min(1),
122
+ provider_model_version: z.string().min(1),
123
+ answer: z.string(),
124
+ citations: z.array(z.object({ url: httpsUrl, title: z.string().max(500).optional() })).max(200),
125
+ fan_out: z.array(z.string().max(500)).max(100).default([]),
126
+ usage: z.object({
127
+ input_tokens: z.number().int().nonnegative().default(0),
128
+ output_tokens: z.number().int().nonnegative().default(0),
129
+ }),
130
+ cost_estimate_usd: z.number().nonnegative(),
131
+ at: z.string().min(1),
132
+ });
133
+
134
+ export const observationRowSchema = z.object({
135
+ schema_version: z.literal(CITATIONS_VERSION),
136
+ epoch_id: z.string().min(1),
137
+ cell_id: z.string().min(1),
138
+ run_index: z.number().int().nonnegative(),
139
+ outcome: z.enum(CITATION_OUTCOMES),
140
+ cited_urls: z.array(z.string().max(2048)).max(50).default([]),
141
+ verify: z.enum(['unverified', 'reachable', 'unreachable', 'unknown']).default('unverified'),
142
+ evidence_sha256: z.string().length(64),
143
+ cost_estimate_usd: z.number().nonnegative(),
144
+ at: z.string().min(1),
145
+ });
146
+
147
+ export const EPOCH_CLASSIFICATIONS = Object.freeze([
148
+ 'first_epoch', 'insufficient_data', 'declined', 'grown', 'not_distinguishable',
149
+ ]);
150
+
151
+ export const epochRowSchema = z.object({
152
+ schema_version: z.literal(CITATIONS_VERSION),
153
+ epoch_id: z.string().min(1),
154
+ cell_id: z.string().min(1),
155
+ k: z.number().int().nonnegative(),
156
+ n: z.number().int().nonnegative(),
157
+ unknowns: z.number().int().nonnegative(),
158
+ mentioned: z.number().int().nonnegative(),
159
+ rate: z.number().min(0).max(1),
160
+ ci_low: z.number().min(0).max(1),
161
+ ci_high: z.number().min(0).max(1),
162
+ classification: z.enum(EPOCH_CLASSIFICATIONS),
163
+ baseline: z.object({
164
+ epoch_id: z.string().min(1),
165
+ rate: z.number().min(0).max(1),
166
+ ci_low: z.number().min(0).max(1),
167
+ ci_high: z.number().min(0).max(1),
168
+ }).optional(),
169
+ spent_estimate_usd: z.number().nonnegative(),
170
+ at: z.string().min(1),
171
+ });
@@ -0,0 +1,120 @@
1
+ // Target matching: did THIS run's answer reference THIS target?
2
+ //
3
+ // Two doors, in cost order: a citation match against the provider's citation list
4
+ // (cheap, exact), then a word-boundary mention match against the answer text (for the
5
+ // mentioned tier). URL normalization is deliberately conservative — the pilot's own
6
+ // domains are simple — and carries a documented limitation: the registrable-domain
7
+ // table below is a common-case subset of the public suffix list, not the PSL. It is
8
+ // sufficient for owned-site panels and wrong for exotic ccTLD composites, which is
9
+ // why hardening it (tldts or a vendored PSL) is a named task before general release.
10
+ import { CITATION_OUTCOMES, outcomeRank } from './contract.js';
11
+
12
+ const MULTI_LABEL_SUFFIXES = new Set([
13
+ 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'net.uk',
14
+ 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au',
15
+ 'co.nz', 'net.nz', 'org.nz', 'govt.nz',
16
+ 'com.br', 'com.mx', 'com.ar', 'com.co', 'com.pe', 'com.uy',
17
+ 'co.jp', 'or.jp', 'ne.jp', 'ac.jp',
18
+ 'co.in', 'net.in', 'org.in', 'co.za', 'com.sg', 'com.my', 'com.tr',
19
+ 'com.cn', 'com.hk', 'com.tw', 'co.kr',
20
+ ]);
21
+
22
+ export function normalizeHost(rawHost) {
23
+ if (typeof rawHost !== 'string') return null;
24
+ let host = rawHost.trim().toLowerCase().replace(/\.$/, '');
25
+ if (host.startsWith('www.')) host = host.slice(4);
26
+ return host || null;
27
+ }
28
+
29
+ /** Best-effort registrable domain. See the module comment for the PSL caveat. */
30
+ export function registrableDomain(rawHost) {
31
+ const host = normalizeHost(rawHost);
32
+ if (!host || !host.includes('.')) return host ?? null;
33
+ const labels = host.split('.');
34
+ if (labels.length < 2) return host;
35
+ const lastTwo = labels.slice(-2).join('.');
36
+ if (MULTI_LABEL_SUFFIXES.has(lastTwo) && labels.length >= 3) return labels.slice(-3).join('.');
37
+ return lastTwo;
38
+ }
39
+
40
+ /** URL-scope comparison key: scheme- and query-agnostic, trailing-slash-insensitive. */
41
+ export function urlMatchKey(rawUrl) {
42
+ try {
43
+ const url = new URL(rawUrl);
44
+ const host = normalizeHost(url.hostname);
45
+ if (!host) return null;
46
+ let path = url.pathname.replace(/\/+$/, '');
47
+ if (path === '') path = '/';
48
+ return `${host}${path}`;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function hostOf(rawUrl) {
55
+ try { return normalizeHost(new URL(rawUrl).hostname); } catch { return null; }
56
+ }
57
+
58
+ /**
59
+ * Citation matching against a provider citation list.
60
+ * domain scope: registrable domains equal. url scope: normalized URL keys equal.
61
+ * Returns the matched citation URLs (possibly several).
62
+ */
63
+ export function matchCitations(target, citations) {
64
+ if (!Array.isArray(citations)) return [];
65
+ if (target.scope === 'url') {
66
+ const want = urlMatchKey(target.url);
67
+ if (!want) return [];
68
+ return citations.filter((c) => urlMatchKey(c.url) === want).map((c) => c.url);
69
+ }
70
+ const wantDomain = registrableDomain(target.domain);
71
+ if (!wantDomain) return [];
72
+ const hits = [];
73
+ for (const c of citations) {
74
+ const host = hostOf(c.url);
75
+ if (host && registrableDomain(host) === wantDomain) hits.push(c.url);
76
+ }
77
+ return hits;
78
+ }
79
+
80
+ const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
81
+
82
+ /**
83
+ * Word-boundary mention match. "acme" must not match "acmeshop" (mcp-geo's false
84
+ * positive). Unicode lookarounds instead of \b, because \b is ASCII-only and brand
85
+ * names are not.
86
+ */
87
+ export function matchMention(target, answerText) {
88
+ if (typeof answerText !== 'string' || answerText.length === 0) return false;
89
+ const names = [target.brand, ...(target.aliases ?? [])].filter((n) => typeof n === 'string' && n.trim().length > 0);
90
+ for (const name of names) {
91
+ const re = new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(name.trim())}(?![\\p{L}\\p{N}])`, 'iu');
92
+ if (re.test(answerText)) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ /**
98
+ * Tier one observation for one target. Citations outrank mentions; a successful
99
+ * re-fetch of the cited URL upgrades cited to verified. A failed re-fetch NEVER
100
+ * downgrades the citation: the engine cited it, and our fetch problems are ours
101
+ * (verify: 'unknown'), which is the link verifier's blocked-fetch rule transplanted.
102
+ */
103
+ export function classifyOutcome(target, run, verifyFetch) {
104
+ const citedUrls = matchCitations(target, run.citations ?? []);
105
+ if (citedUrls.length > 0) {
106
+ let outcome = 'cited';
107
+ let verify = 'unverified';
108
+ if (typeof verifyFetch === 'function') {
109
+ const reachable = verifyFetch(citedUrls[0]);
110
+ if (reachable === true) { outcome = 'verified'; verify = 'reachable'; }
111
+ else if (reachable === false) { verify = 'unreachable'; }
112
+ else { verify = 'unknown'; }
113
+ }
114
+ return { outcome, citedUrls, verify };
115
+ }
116
+ if (matchMention(target, run.answer ?? '')) return { outcome: 'mentioned', citedUrls: [], verify: 'unverified' };
117
+ return { outcome: 'not_cited', citedUrls: [], verify: 'unverified' };
118
+ }
119
+
120
+ export { CITATION_OUTCOMES, outcomeRank };
@@ -0,0 +1,151 @@
1
+ // The epoch runner: a fixed-n batch over a panel, budgeted, evidenced, summarized.
2
+ //
3
+ // Execution order per run: check the budget, run the engine, write the immutable
4
+ // evidence snapshot, tier the outcome, tally. The epoch size is committed before the
5
+ // first call and never extended mid-epoch. A budget abort leaves a partial-epoch
6
+ // receipt — never a silent overspend, never a silent shortfall — and partial tallies
7
+ // below the interpretation minimum classify as insufficient_data, which is the point.
8
+ import { createHash } from 'node:crypto';
9
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { citationPanelSchema, engineIdentity, cellId, targetKey,
12
+ CITATIONS_VERSION, CITATION_LIMITS,
13
+ evidenceEnvelopeSchema, observationRowSchema, epochRowSchema } from './contract.js';
14
+ import { classifyOutcome } from './match.js';
15
+ import { wilsonInterval, smoothedRate, classifyEpoch, tallyOutcomes } from './stats.js';
16
+ import { EngineError } from './adapter.js';
17
+
18
+ const sha256 = (text) => createHash('sha256').update(text).digest('hex');
19
+ const nowIso = () => new Date().toISOString();
20
+
21
+ export const CITATION_FILES = Object.freeze({
22
+ observations: 'citations-observations.jsonl',
23
+ epochs: 'citations-epochs.jsonl',
24
+ evidenceDir: 'citations/evidence',
25
+ });
26
+
27
+ export async function loadPanel(path) {
28
+ const raw = JSON.parse(await readFile(path, 'utf8'));
29
+ return citationPanelSchema.parse(raw);
30
+ }
31
+
32
+ async function readPriorEpochs(dir) {
33
+ let text = '';
34
+ try { text = await readFile(join(dir, CITATION_FILES.epochs), 'utf8'); } catch { return []; }
35
+ const rows = [];
36
+ for (const line of text.split('\n')) {
37
+ const trimmed = line.trim();
38
+ if (trimmed.length === 0) continue;
39
+ try { rows.push(JSON.parse(trimmed)); } catch { /* reported by the reader, not the runner */ }
40
+ }
41
+ return rows;
42
+ }
43
+
44
+ /**
45
+ * Run one epoch. Options:
46
+ * - engines: Map from engine identity to an adapter instance (tests pass mocks)
47
+ * - dir: the ledger directory (default `.agentlinkops`)
48
+ * - verifyFetch: async (url) => true | false | null — upgrades cited to verified
49
+ * - now: injectable clock, for tests
50
+ * The runner never retries: a failed run is one unknown observation, and retry
51
+ * policy is a hosted concern (DP-0033) where leases exist.
52
+ */
53
+ export async function runEpoch(panelInput, options = {}) {
54
+ const panel = citationPanelSchema.parse(panelInput);
55
+ const dir = options.dir ?? '.agentlinkops';
56
+ const now = options.now ?? new Date();
57
+ const samples = panel.samples ?? CITATION_LIMITS.defaultSamples;
58
+ const maxUsd = panel.maxUsd ?? CITATION_LIMITS.defaultMaxUsd;
59
+ const delayMs = options.delayMs ?? 0; // mock/tests: 0; live callers pass pacing
60
+
61
+ const epochId = `${now.toISOString().slice(0, 10)}-${sha256(JSON.stringify({ panel, at: now.toISOString() })).slice(0, 10)}`;
62
+ const evidenceRoot = join(dir, CITATION_FILES.evidenceDir, epochId);
63
+ await mkdir(evidenceRoot, { recursive: true });
64
+
65
+ const cells = [];
66
+ for (const engineSpec of panel.engines) {
67
+ const identity = engineIdentity(engineSpec);
68
+ const engine = options.engines?.get(identity);
69
+ if (!engine) throw new EngineError(`no engine instance provided for ${identity}`);
70
+ for (const prompt of panel.prompts) {
71
+ for (const target of panel.targets) {
72
+ cells.push({ identity, prompt, target, engine, id: cellId(identity, prompt.id ?? sha256(prompt.text).slice(0, 10), targetKey(target)) });
73
+ }
74
+ }
75
+ }
76
+
77
+ let spent = 0;
78
+ let aborted = null;
79
+ const observationRows = [];
80
+ const outcomesByCell = new Map(cells.map((c) => [c.id, []]));
81
+
82
+ outer:
83
+ for (const cell of cells) {
84
+ for (let runIndex = 0; runIndex < samples; runIndex += 1) {
85
+ // Budget first: the estimate is checked BEFORE the call, so the cap cannot be
86
+ // exceeded, only approached. The mock engine's estimate makes this testable.
87
+ const estimate = cell.engine.estimateCostUsd?.() ?? null;
88
+ if (estimate !== null && spent + estimate > maxUsd) {
89
+ aborted = { reason: 'budget', spent, nextEstimate: estimate, maxUsd };
90
+ break outer;
91
+ }
92
+ let run;
93
+ try {
94
+ run = await cell.engine.run({ prompt: cell.prompt.text, runIndex });
95
+ } catch (cause) {
96
+ if (cause instanceof EngineError && cause.retriable) {
97
+ outcomesByCell.get(cell.id).push('unknown');
98
+ continue;
99
+ }
100
+ throw cause;
101
+ }
102
+ spent += run.costEstimateUsd ?? 0;
103
+
104
+ const envelope = evidenceEnvelopeSchema.parse({
105
+ schema_version: CITATIONS_VERSION,
106
+ epoch_id: epochId, cell_id: cell.id, run_index: runIndex, prompt: cell.prompt.text,
107
+ engine_identity: cell.identity,
108
+ provider_model_version: run.providerModelVersion ?? `${run.engine}:${run.model}`,
109
+ answer: run.answer, citations: run.citations, fan_out: run.fanOut ?? [],
110
+ usage: run.usage ?? { input_tokens: 0, output_tokens: 0 },
111
+ cost_estimate_usd: run.costEstimateUsd ?? 0,
112
+ at: nowIso(),
113
+ });
114
+ const evidenceJson = JSON.stringify(envelope);
115
+ const digest = sha256(evidenceJson);
116
+ await writeFile(join(evidenceRoot, `${digest}.json`), evidenceJson, 'utf8');
117
+
118
+ const tiered = classifyOutcome(cell.target, run, options.verifyFetch ?? null);
119
+ outcomesByCell.get(cell.id).push(tiered.outcome);
120
+ observationRows.push(observationRowSchema.parse({
121
+ schema_version: CITATIONS_VERSION, epoch_id: epochId, cell_id: cell.id, run_index: runIndex,
122
+ outcome: tiered.outcome, cited_urls: tiered.citedUrls, verify: tiered.verify,
123
+ evidence_sha256: digest, cost_estimate_usd: run.costEstimateUsd ?? 0, at: nowIso(),
124
+ }));
125
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
126
+ }
127
+ }
128
+
129
+ const prior = await readPriorEpochs(dir);
130
+ const epochRows = [];
131
+ for (const cell of cells) {
132
+ const outcomes = outcomesByCell.get(cell.id);
133
+ const { k, n, unknowns, mentioned } = tallyOutcomes(outcomes);
134
+ const ci = wilsonInterval(k, n) ?? [0, 1];
135
+ const baselineRow = prior.filter((r) => r.cell_id === cell.id && r.n >= CITATION_LIMITS.minSamplesForInterpretation).at(-1);
136
+ const baseline = baselineRow ? { k: baselineRow.k, n: baselineRow.n } : null;
137
+ epochRows.push(epochRowSchema.parse({
138
+ schema_version: CITATIONS_VERSION, epoch_id: epochId, cell_id: cell.id,
139
+ k, n, unknowns, mentioned,
140
+ rate: smoothedRate(k, n) ?? 0, ci_low: ci[0], ci_high: ci[1],
141
+ classification: classifyEpoch({ k, n }, baseline, CITATION_LIMITS),
142
+ baseline: baselineRow ? { epoch_id: baselineRow.epoch_id, rate: baselineRow.rate, ci_low: baselineRow.ci_low, ci_high: baselineRow.ci_high } : undefined,
143
+ spent_estimate_usd: Number(spent.toFixed(6)), at: nowIso(),
144
+ }));
145
+ }
146
+
147
+ await writeFile(join(dir, CITATION_FILES.observations), observationRows.map((r) => JSON.stringify(r)).join('\n') + (observationRows.length ? '\n' : ''), { flag: 'a' });
148
+ await writeFile(join(dir, CITATION_FILES.epochs), epochRows.map((r) => JSON.stringify(r)).join('\n') + (epochRows.length ? '\n' : ''), { flag: 'a' });
149
+
150
+ return { epochId, cells: epochRows, spentEstimateUsd: Number(spent.toFixed(6)), aborted, samples, maxUsd };
151
+ }
@@ -0,0 +1,74 @@
1
+ // The statistics: closed-form and boring on purpose.
2
+ //
3
+ // Wilson intervals over fixed-n epochs. No bootstrap, no resampling, no early
4
+ // stopping — the IQRush finding that bootstrap CIs can be non-monotone in n is why
5
+ // the epoch size is committed before the epoch starts. Anything cleverer (confidence
6
+ // sequences, SPRT, changepoint dating) is a v2 design decision with its own gate,
7
+ // recorded in DP-0031's contract, and does not ship until it is validated against
8
+ // published data.
9
+ export const WILSON_Z = 1.959963985; // two-sided 95%
10
+
11
+ /** Wilson score interval for k successes in n rendered runs. null when n is 0. */
12
+ export function wilsonInterval(k, n, z = WILSON_Z) {
13
+ if (!Number.isFinite(k) || !Number.isFinite(n) || n <= 0 || k < 0 || k > n) return null;
14
+ const p = k / n;
15
+ const z2 = z * z;
16
+ const denom = 1 + z2 / n;
17
+ const center = (p + z2 / (2 * n)) / denom;
18
+ const half = (z * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n))) / denom;
19
+ return [Math.max(0, center - half), Math.min(1, center + half)];
20
+ }
21
+
22
+ /** Laplace-smoothed rate. (k+1)/(n+2) never reports 0 or 1 from finite data. */
23
+ export function smoothedRate(k, n) {
24
+ if (n <= 0) return null;
25
+ return (k + 1) / (n + 2);
26
+ }
27
+
28
+ /**
29
+ * Classify one epoch against the previous epoch's baseline.
30
+ *
31
+ * declined/grown require the intervals to separate entirely; overlap is reported as
32
+ * not_distinguishable — the honest label, because claiming "stable" would assert
33
+ * knowledge the noise forbids. Thin data is refused, not interpreted.
34
+ */
35
+ export function classifyEpoch(current, baseline, limits) {
36
+ const minN = limits?.minSamplesForInterpretation ?? 10;
37
+ const maxWidth = limits?.maxCiWidth ?? 0.5;
38
+ if (current.n < minN) return 'insufficient_data';
39
+ const ci = wilsonInterval(current.k, current.n);
40
+ if (!ci) return 'insufficient_data';
41
+ if (ci[1] - ci[0] > maxWidth) return 'insufficient_data';
42
+ if (!baseline || baseline.n < minN) return 'first_epoch';
43
+ const baseCi = wilsonInterval(baseline.k, baseline.n);
44
+ if (!baseCi) return 'first_epoch';
45
+ if (ci[1] < baseCi[0]) return 'declined';
46
+ if (ci[0] > baseCi[1]) return 'grown';
47
+ return 'not_distinguishable';
48
+ }
49
+
50
+ /**
51
+ * Tally rendered runs. Unknowns are excluded from the denominator and carried
52
+ * alongside, because an errored run is a missing observation, not a miss — the same
53
+ * honest-denominator rule the link verifier applies to blocked fetches.
54
+ */
55
+ export function tallyOutcomes(outcomes) {
56
+ let k = 0, n = 0, unknowns = 0, mentioned = 0;
57
+ for (const outcome of outcomes) {
58
+ if (outcome === 'unknown') { unknowns += 1; continue; }
59
+ n += 1;
60
+ if (outcome === 'cited' || outcome === 'verified') k += 1;
61
+ if (outcome === 'mentioned') mentioned += 1;
62
+ }
63
+ return { k, n, unknowns, mentioned };
64
+ }
65
+
66
+ /**
67
+ * The glossary, as a function. The tool never says a citation was "removed" or
68
+ * "lost"; it states both rates with their intervals and sample sizes.
69
+ */
70
+ export function describeChange(current, baseline) {
71
+ const fmt = (e) => `${e.rate.toFixed(2)} (${e.ci_low.toFixed(2)}–${e.ci_high.toFixed(2)}, n=${e.n})`;
72
+ if (!baseline) return `first epoch: citation rate ${fmt(current)}`;
73
+ return `citation rate moved from ${fmt(baseline)} to ${fmt(current)} between epochs`;
74
+ }