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
@@ -5,8 +5,28 @@
5
5
  // No external assets. No server. No JavaScript. Opens in any browser,
6
6
  // survives email forwarding, and carries the screenshots as base64 because
7
7
  // linked screenshots break the moment the evidence directory moves.
8
- import { readFile, readdir } from 'node:fs/promises';
9
- import { join, basename } from 'node:path';
8
+ import { readFile, readdir, lstat, realpath } from 'node:fs/promises';
9
+ import { join, basename, sep } from 'node:path';
10
+ import { createHash } from 'node:crypto';
11
+ import { wilsonInterval } from './stats.js';
12
+ import { citationPanelSchema, engineIdentity, cellId, targetKey, panelLocaleContext, localizedPromptId } from './contract.js';
13
+
14
+ export async function panelEpochs({ dir, panel }) {
15
+ const parsed = citationPanelSchema.parse(panel);
16
+ const ids = new Set(parsed.engines.flatMap(engine => parsed.prompts.flatMap(prompt => parsed.targets.map(target =>
17
+ cellId(engineIdentity(engine), localizedPromptId(prompt.id ?? createHash('sha256').update(prompt.text).digest('hex').slice(0, 10), panelLocaleContext(parsed, engine)), targetKey(target))))));
18
+ let text;
19
+ try { text = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8'); }
20
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
21
+ const rows = text.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
22
+ const groups = new Map();
23
+ for (const row of rows) { if (!groups.has(row.epoch_id)) groups.set(row.epoch_id, []); groups.get(row.epoch_id).push(row); }
24
+ return [...groups.values()].reverse().filter(cells => cells.length === ids.size && new Set(cells.map(cell => cell.cell_id)).size === ids.size && cells.every(cell => ids.has(cell.cell_id)));
25
+ }
26
+ export async function latestPanelEpoch(options) {
27
+ return (await panelEpochs(options))[0] ?? null;
28
+ }
29
+
10
30
 
11
31
  const esc = (text) => String(text ?? '')
12
32
  .replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
@@ -26,7 +46,20 @@ function classificationBadge(cls) {
26
46
  return `<span style="display:inline-block;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:600;color:${s.color};background:${s.bg};margin-left:8px">${esc(s.label)}</span>`;
27
47
  }
28
48
 
49
+ // The local epoch contract stores smoothed estimates. Display empirical fractions
50
+ // from the retained counts without changing those original evidence bytes.
51
+ function reportCell(row) {
52
+ if (!Number.isSafeInteger(row.k) || !Number.isSafeInteger(row.n) || row.k < 0 || row.n < 0 || row.k > row.n) {
53
+ throw new Error('Invalid retained citation counts');
54
+ }
55
+ const interval = wilsonInterval(row.k, row.n);
56
+ const rate = row.n ? row.k / row.n : null;
57
+ return { ...row, rate, ci_low: interval?.[0] ?? null, ci_high: interval?.[1] ?? null,
58
+ rate_recomputed: row.rate !== rate };
59
+ }
60
+
29
61
  function rateBar(rate, low, high) {
62
+ if (rate === null || low === null || high === null) return '<div>Unknown: no interpretable observations</div>';
30
63
  return `
31
64
  <div style="margin:8px 0 4px 0">
32
65
  <div style="font-size:22px;font-weight:700;color:#111827">${pct(rate)} <span style="font-size:13px;font-weight:400;color:#6b7280">cited (${pct(low)}–${pct(high)} Wilson 95%)</span></div>
@@ -37,6 +70,18 @@ function rateBar(rate, low, high) {
37
70
  </div>`;
38
71
  }
39
72
 
73
+ function supplementalStats(cell) {
74
+ if (!cell.statistics) return '';
75
+ const { confidence_sequence: sequence, epoch_comparison: comparison, changepoint } = cell.statistics;
76
+ const bounds = sequence?.interval ? `${pct(sequence.interval[0])}–${pct(sequence.interval[1])}` : 'not available';
77
+ return `<details style="margin-top:12px;font-size:12px;color:#6b7280"><summary>Supplemental statistics</summary>
78
+ <p>The classification above is descriptive. These bounds apply within this fixed epoch across its planned cells; they do not promise lifetime coverage.</p>
79
+ <p>Sequential bounds: ${esc(bounds)}. Method: ${esc(sequence?.method ?? 'insufficient data')}.</p>
80
+ <p>Corrected epoch comparison: ${esc(comparison?.verdict ?? 'no baseline')}. Change-point analysis is exploratory and does not establish a change date.</p>
81
+ ${changepoint?.candidate ? `<p>Within-epoch candidate at observation ${esc(changepoint.candidate.index + 1)}.</p>` : ''}
82
+ </details>`;
83
+ }
84
+
40
85
  function evidenceCard(cell, envelope, screenshotB64) {
41
86
  const [enginePart, promptPart, targetPart] = cell.cell_id.split('|');
42
87
  const citedDomains = [...new Set((envelope?.citations ?? []).map((c) => {
@@ -45,20 +90,23 @@ function evidenceCard(cell, envelope, screenshotB64) {
45
90
 
46
91
  return `
47
92
  <div style="border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:16px;background:#fff">
48
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
93
+ <div class="cell-heading" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
49
94
  <div>
50
95
  <span style="font-size:14px;font-weight:700;color:#111827;font-family:ui-monospace,monospace">${esc(enginePart)}</span>
51
96
  <span style="font-size:12px;color:#6b7280;margin-left:8px">${esc(promptPart)}</span>
97
+ <div style="font-size:12px;color:#6b7280;margin-top:4px">Target: ${esc(targetPart)}</div>
52
98
  </div>
53
99
  ${classificationBadge(cell.classification)}
54
100
  </div>
101
+ <div style="font-size:12px;color:#6b7280;margin-bottom:8px">${envelope?.locale_context ? `Requested locale: ${esc(envelope.locale_context.locale)}; country: ${esc(envelope.locale_context.country)}; basis: ${esc(envelope.locale_context.mode)}.` : 'Locale not retained for this observation.'}</div>
55
102
  ${rateBar(cell.rate, cell.ci_low, cell.ci_high)}
56
- <div style="font-size:11px;color:#9ca3af;margin-top:2px">n=${cell.n} rendered · ${cell.unknowns} unknown${cell.unknowns === 1 ? '' : 's'} excluded · ${cell.mentioned} mentioned without citation</div>
103
+ ${supplementalStats(cell)}
104
+ <div style="font-size:11px;color:#9ca3af;margin-top:2px">n=${esc(cell.n)} rendered · ${esc(cell.unknowns)} unknown${cell.unknowns === 1 ? '' : 's'} excluded · ${esc(cell.mentioned)} mentioned without citation</div>
57
105
  ${screenshotB64 ? `
58
106
  <details style="margin-top:12px">
59
107
  <summary style="font-size:12px;color:#3b82f6;cursor:pointer;font-weight:500">View answer screenshot</summary>
60
108
  <img src="data:image/png;base64,${screenshotB64}" style="max-width:100%;border-radius:8px;border:1px solid #e5e7eb;margin-top:8px" alt="Answer surface screenshot" />
61
- </details>` : ''}
109
+ </details>` : '<div style="font-size:12px;color:#6b7280;margin-top:12px">No screenshot retained for this observation.</div>'}
62
110
  ${envelope?.answer ? `
63
111
  <details style="margin-top:8px">
64
112
  <summary style="font-size:12px;color:#3b82f6;cursor:pointer;font-weight:500">View answer text</summary>
@@ -68,7 +116,7 @@ function evidenceCard(cell, envelope, screenshotB64) {
68
116
  <div style="margin-top:10px">
69
117
  <span style="font-size:11px;font-weight:600;color:#374151">Cited domains:</span>
70
118
  ${citedDomains.map((d) => `<span style="display:inline-block;padding:1px 8px;border-radius:8px;font-size:11px;background:#eff6ff;color:#1e40af;margin:2px 2px">${esc(d)}</span>`).join('')}
71
- </div>` : `<div style="margin-top:10px;font-size:11px;color:#9ca3af">No citations recorded this epoch</div>`}
119
+ </div>` : `<div style="margin-top:10px;font-size:11px;color:#9ca3af">No cited source URLs retained for this observation.</div>`}
72
120
  </div>`;
73
121
  }
74
122
 
@@ -81,39 +129,47 @@ function summaryParagraph(cells, panelName) {
81
129
  const unknowns = cells.reduce((sum, c) => sum + c.unknowns, 0);
82
130
 
83
131
  const parts = [];
84
- parts.push(`${totalK} of ${totalN} rendered observations cited the target`);
132
+ parts.push(totalN === 0 ? 'no observations could be interpreted' : `${totalK} of ${totalN} rendered observations cited the target`);
85
133
  if (unknowns > 0) parts.push(`${unknowns} unknown${unknowns === 1 ? ' was' : 's were'} excluded from the denominator`);
86
- if (declined > 0) parts.push(`${declined} cell${declined === 1 ? '' : 's'} declined with complete evidence`);
134
+ if (declined > 0) parts.push(`${declined} cell${declined === 1 ? '' : 's'} ${declined === 1 ? 'carries' : 'carry'} a recorded decline classification`);
87
135
  if (grown > 0) parts.push(`${grown} cell${grown === 1 ? '' : 's'} grew`);
88
136
  if (insufficient > 0) parts.push(`${insufficient} cell${insufficient === 1 ? '' : 's'} ${insufficient === 1 ? 'has' : 'have'} insufficient data to interpret`);
89
- if (declined === 0 && grown === 0 && insufficient === totalN === 0) parts.push('no actionable changes were detected');
137
+ if (declined === 0 && grown === 0 && insufficient === 0 && totalN > 0) parts.push('no change passed the comparison threshold');
90
138
 
91
139
  return `Across ${cells.length} monitored cell${cells.length === 1 ? '' : 's'}, ${parts.join('. ')}. `;
92
140
  }
93
141
 
94
- export async function generateReport({ dir, epochId, panel, now = new Date() }) {
142
+ export async function generateReport({ dir, epochId, panel, history = [], now = new Date() }) {
143
+ if (typeof epochId !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}$/.test(epochId)) throw new Error('Invalid citation epoch identifier');
95
144
  // Read the epoch rows for this epoch
96
145
  const epochsPath = join(dir, 'citations-epochs.jsonl');
97
146
  const epochsText = await readFile(epochsPath, 'utf8').catch(() => '');
98
147
  const allRows = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
99
- const cells = allRows.filter((r) => r.epoch_id === epochId);
148
+ const cells = allRows.filter((r) => r.epoch_id === epochId).map(reportCell);
149
+ history = history.map(rows => rows.map(reportCell));
100
150
  if (cells.length === 0) throw new Error(`no epoch rows found for ${epochId}`);
101
151
 
102
152
  // Read the evidence envelopes for this epoch (latest per cell)
103
153
  const evidenceRoot = join(dir, 'citations', 'evidence', epochId);
104
- const evidenceFiles = await readdir(evidenceRoot).catch(() => []);
154
+ const rootStat = await lstat(evidenceRoot).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
155
+ if (rootStat?.isSymbolicLink()) throw new Error('Citation evidence directory must not be a symlink');
156
+ if (rootStat && !(await realpath(evidenceRoot)).startsWith((await realpath(dir)) + sep)) throw new Error('Citation evidence must stay inside its ledger');
157
+ const evidenceFiles = rootStat ? await readdir(evidenceRoot) : [];
105
158
  const envelopes = new Map();
106
159
  const screenshots = new Map();
107
160
  for (const file of evidenceFiles) {
108
161
  if (!file.endsWith('.json')) continue;
162
+ if (!(await lstat(join(evidenceRoot, file))).isFile()) continue;
109
163
  try {
110
164
  const env = JSON.parse(await readFile(join(evidenceRoot, file), 'utf8'));
165
+ if (env.epoch_id !== epochId || !cells.some(cell => cell.cell_id === env.cell_id)) continue;
111
166
  const existing = envelopes.get(env.cell_id);
112
167
  if (!existing || env.run_index > existing.run_index) {
113
168
  envelopes.set(env.cell_id, env);
169
+ screenshots.delete(env.cell_id);
114
170
  // Look for the sibling screenshot
115
171
  const shotFile = file.replace('.json', '.screenshot.png');
116
- if (evidenceFiles.includes(shotFile)) {
172
+ if (evidenceFiles.includes(shotFile) && (await lstat(join(evidenceRoot, shotFile))).isFile()) {
117
173
  const shotBuf = await readFile(join(evidenceRoot, shotFile));
118
174
  screenshots.set(env.cell_id, shotBuf.toString('base64'));
119
175
  }
@@ -121,25 +177,34 @@ export async function generateReport({ dir, epochId, panel, now = new Date() })
121
177
  } catch { /* skip unreadable */ }
122
178
  }
123
179
 
124
- const totalSpent = cells.reduce((sum, c) => sum + (c.spent_estimate_usd ?? 0), 0);
180
+ const totalSpent = Math.max(0, ...cells.map(c => c.spent_estimate_usd ?? 0));
125
181
  const engineList = [...new Set(cells.map((c) => c.cell_id.split('|')[0]))];
126
182
 
127
183
  const cards = cells.map((cell) => evidenceCard(cell, envelopes.get(cell.cell_id), screenshots.get(cell.cell_id))).join('\n');
128
184
  const summary = summaryParagraph(cells, panel?.note ?? 'Citation panel');
185
+ const historyHtml = history.length > 1 ? `<section style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px 20px;margin-bottom:20px">
186
+ <h2 style="margin:0 0 12px;font-size:18px">Recent epochs</h2>
187
+ <p style="font-size:13px;color:#6b7280">Rates stay separate for each engine, prompt and target. Answer evidence below is from the latest epoch.</p>
188
+ ${history.map(rows => `<div style="margin-top:12px"><h3 style="font-size:14px;margin:0 0 8px">${esc(rows[0]?.epoch_id)}</h3><ul style="padding-left:18px;font-size:12px;line-height:1.6">${rows.map(row => `<li><span style="font-family:monospace">${esc(row.cell_id)}</span>: ${row.rate === null ? 'unknown' : `${pct(row.rate)} cited (${pct(row.ci_low)}–${pct(row.ci_high)} Wilson 95%)`}; n=${esc(row.n)}; ${esc(CLASSIFICATION_STYLE[row.classification]?.label ?? row.classification)}</li>`).join('')}</ul></div>`).join('')}
189
+ </section>` : '';
190
+
129
191
 
130
192
  return `<!DOCTYPE html>
131
193
  <html lang="en">
132
194
  <head>
195
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
196
+ <meta name="referrer" content="no-referrer">
133
197
  <meta charset="UTF-8">
134
198
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
135
- <title>Citation Report — ${esc(now.toISOString().slice(0, 10))}</title>
199
+ <title>Citation Report - ${esc(now.toISOString().slice(0, 10))}</title>
200
+ <style>*{box-sizing:border-box} body{overflow-wrap:anywhere} .cell-heading{flex-wrap:wrap;gap:8px} @media(max-width:480px){body{padding:12px!important}.report-header{padding:20px!important}}</style>
136
201
  </head>
137
202
  <body style="margin:0;padding:24px;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#111827">
138
203
  <div style="max-width:720px;margin:0 auto">
139
204
 
140
- <div style="background:#111827;border-radius:16px;padding:24px 28px;margin-bottom:20px">
205
+ <div class="report-header" style="background:#111827;border-radius:16px;padding:24px 28px;margin-bottom:20px">
141
206
  <div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px">AgentLinkOps Citation Report</div>
142
- <div style="font-size:24px;font-weight:700;color:#fff">${esc(panel?.note ?? 'Citation monitoring epoch')}</div>
207
+ <h1 style="margin:0;font-size:24px;font-weight:700;color:#fff">${esc(panel?.note ?? 'Citation monitoring epoch')}</h1>
143
208
  <div style="font-size:13px;color:#9ca3af;margin-top:4px">${esc(now.toISOString().slice(0, 10))} · ${engineList.length} engine${engineList.length === 1 ? '' : 's'} · ${cells.length} cell${cells.length === 1 ? '' : 's'} · estimated spend $${totalSpent.toFixed(4)}</div>
144
209
  </div>
145
210
 
@@ -148,14 +213,16 @@ export async function generateReport({ dir, epochId, panel, now = new Date() })
148
213
  <div style="font-size:14px;line-height:1.6;color:#374151">${esc(summary)}</div>
149
214
  </div>
150
215
 
216
+ ${cells.some(cell => cell.rate_recomputed) || history.some(rows => rows.some(row => row.rate_recomputed)) ? '<p style="font-size:12px;color:#6b7280">Display rates use retained citation counts (k/n). Stored estimates differ; source records have not been changed. Cells with no interpretable observations remain unknown.</p>' : ''}
217
+ ${historyHtml}
151
218
  ${cards}
152
219
 
153
220
  <div style="margin-top:24px;padding:16px;background:#f9fafb;border-radius:12px;border:1px solid #e5e7eb">
154
221
  <div style="font-size:11px;color:#6b7280;line-height:1.5">
155
- <strong>Evidence integrity:</strong> Every observation carries a content-addressed evidence snapshot
156
- and screenshot. Observations with unknown outcomes are excluded from denominators. Citation rates
157
- are reported with Wilson 95% intervals and their sample sizes. Cells with insufficient data are
158
- labeled as such — never interpreted. This report was generated by AgentLinkOps.
222
+ <strong>Evidence integrity:</strong> This report includes retained answer text and screenshots where available.
223
+ Unknown outcomes do not count toward citation rates. Each rate includes its sample size and Wilson 95% interval.
224
+ Cells with too little data carry an insufficient-data label. Answer text and screenshots may contain private information;
225
+ review them before sharing this file. AgentLinkOps generated this report.
159
226
  </div>
160
227
  </div>
161
228
 
@@ -8,11 +8,11 @@
8
8
  import { createHash } from 'node:crypto';
9
9
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
- import { citationPanelSchema, engineIdentity, cellId, targetKey,
11
+ import { citationPanelSchema, engineIdentity, cellId, targetKey, panelLocaleContext, localizedPromptId,
12
12
  CITATIONS_VERSION, CITATION_LIMITS,
13
13
  evidenceEnvelopeSchema, observationRowSchema, epochRowSchema } from './contract.js';
14
- import { classifyOutcome } from './match.js';
15
- import { wilsonInterval, smoothedRate, classifyEpoch, tallyOutcomes } from './stats.js';
14
+ import { classifyOutcome, analyzeRetainedAnswer } from './match.js';
15
+ import { wilsonInterval, smoothedRate, classifyEpoch, tallyOutcomes, confidenceSequence, compareEpochs, changepointPosterior } from './stats.js';
16
16
  import { EngineError } from './adapter.js';
17
17
 
18
18
  const sha256 = (text) => createHash('sha256').update(text).digest('hex');
@@ -47,8 +47,7 @@ async function readPriorEpochs(dir) {
47
47
  * - dir: the ledger directory (default `.agentlinkops`)
48
48
  * - verifyFetch: async (url) => true | false | null — upgrades cited to verified
49
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.
50
+ * Retriable failures receive one retry; exhausted attempts remain unknown.
52
51
  */
53
52
  export async function runEpoch(panelInput, options = {}) {
54
53
  const panel = citationPanelSchema.parse(panelInput);
@@ -66,10 +65,12 @@ export async function runEpoch(panelInput, options = {}) {
66
65
  for (const engineSpec of panel.engines) {
67
66
  const identity = engineIdentity(engineSpec);
68
67
  const engine = options.engines?.get(identity);
68
+ const context = panelLocaleContext(panel, engineSpec);
69
+ if ((context.locale !== 'en-US' || context.country !== 'US') && !['mock', 'google-aio'].includes(engineSpec.engine)) throw new EngineError('this engine has only an unverified en-US/US context; use mock or a configured supplier for locale variants');
69
70
  if (!engine) throw new EngineError(`no engine instance provided for ${identity}`);
70
71
  for (const prompt of panel.prompts) {
71
72
  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
+ cells.push({ identity, prompt, target, engine, context, id: cellId(identity, localizedPromptId(prompt.id ?? sha256(prompt.text).slice(0, 10), context), targetKey(target)) });
73
74
  }
74
75
  }
75
76
  }
@@ -82,45 +83,59 @@ export async function runEpoch(panelInput, options = {}) {
82
83
  outer:
83
84
  for (const cell of cells) {
84
85
  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 = null;
93
- let lastCause = null;
94
- // One retry per observation: with exit-rotating engines the retry lands
95
- // on a fresh exit, which is exactly what a transient 418 or dead exit
96
- // needs. The second failure is an honest, non-silent unknown.
86
+ const estimate = cell.engine.estimateCostUsd?.() ?? 0;
87
+ if (!Number.isFinite(estimate) || estimate < 0) throw new EngineError('engine cost estimate must be a finite nonnegative number');
88
+ let run = null, lastCause = null, attemptCost = 0;
97
89
  for (let attempt = 0; attempt < 2 && !run; attempt += 1) {
90
+ // Reserve each attempt, including retries, before calling a supplier.
91
+ if (spent + estimate > maxUsd) {
92
+ aborted = { reason: 'budget', spent, nextEstimate: estimate, maxUsd };
93
+ break;
94
+ }
95
+ spent += estimate;
96
+ attemptCost += estimate;
98
97
  try {
99
- run = await cell.engine.run({ prompt: cell.prompt.text, runIndex });
98
+ run = await cell.engine.run({ prompt: cell.prompt.text, runIndex, localeContext: cell.context, maxCostUsd: estimate || maxUsd - spent });
99
+ const actual = run.costEstimateUsd ?? estimate;
100
+ if (!Number.isFinite(actual) || actual < 0) throw new EngineError('engine returned an invalid cost');
101
+ spent += actual - estimate;
102
+ attemptCost += actual - estimate;
103
+ if (spent > maxUsd) aborted = { reason: 'supplier_cost_exceeded', spent, maxUsd };
100
104
  } catch (cause) {
105
+ run = null;
101
106
  lastCause = cause;
102
- if (!(cause instanceof EngineError) || !cause.retriable) throw cause;
107
+ // Failed or timed-out requests may already be billed. Keep the
108
+ // reservation unless the supplier reports a greater charge.
109
+ const charged = Number.isFinite(cause?.costEstimateUsd) ? Math.max(estimate, cause.costEstimateUsd) : estimate;
110
+ spent += charged - estimate;
111
+ attemptCost += charged - estimate;
112
+ if (spent > maxUsd) aborted = { reason: 'supplier_cost_exceeded', spent, maxUsd };
113
+ if (!(cause instanceof EngineError) || (!cause.retriable && !cause.unknown)) throw cause;
114
+ if (!cause.retriable || aborted) break;
103
115
  if (options.err && attempt === 0) options.err(`retrying observation (${cell.id}): ${cause.message}`);
104
116
  }
105
117
  }
106
118
  if (!run) {
107
- // An unknown is honest, but never silent: the reason travels to
108
- // stderr so a run of unknowns names its wall.
119
+ if (!lastCause && aborted) break outer;
109
120
  if (options.err) options.err(`unknown observation (${cell.id}): ${lastCause?.message ?? 'unspecified'}`);
110
- outcomesByCell.get(cell.id).push('unknown');
111
- continue;
121
+ run = { engine: cell.engine.identity?.engine ?? 'unknown', model: cell.engine.identity?.model ?? 'unknown', answer: '', citations: [], unknown: true,
122
+ provenance: lastCause?.provenance, failure: { code: lastCause?.code ?? 'ENGINE_UNAVAILABLE', message: lastCause?.message ?? 'Engine unavailable' } };
112
123
  }
113
- spent += run.costEstimateUsd ?? 0;
124
+ run.costEstimateUsd = attemptCost;
114
125
 
115
126
  const envelope = {
116
127
  schema_version: CITATIONS_VERSION,
117
128
  epoch_id: epochId, cell_id: cell.id, run_index: runIndex, prompt: cell.prompt.text,
118
129
  engine_identity: cell.identity,
130
+ locale_context: cell.context,
119
131
  provider_model_version: run.providerModelVersion ?? `${run.engine}:${run.model}`,
120
132
  answer: run.answer, citations: run.citations, fan_out: run.fanOut ?? [],
121
133
  usage: run.usage ?? { input_tokens: 0, output_tokens: 0 },
122
134
  cost_estimate_usd: run.costEstimateUsd ?? 0,
123
135
  at: nowIso(),
136
+ ...(run.provenance ? { provenance: run.provenance } : {}),
137
+ ...(run.browserContext ? { browser_context: run.browserContext } : {}),
138
+ ...(run.failure ? { failure: run.failure } : {}),
124
139
  };
125
140
  const digest = sha256(JSON.stringify(envelope));
126
141
  // The screenshot is a sibling of the envelope: same content-addressed
@@ -133,14 +148,19 @@ export async function runEpoch(panelInput, options = {}) {
133
148
  }
134
149
  await writeFile(join(evidenceRoot, `${digest}.json`), JSON.stringify(evidenceEnvelopeSchema.parse(envelope)), 'utf8');
135
150
 
136
- const tiered = classifyOutcome(cell.target, run, options.verifyFetch ?? null);
151
+ const tiered = run.unknown ? { outcome: 'unknown', citedUrls: [], verify: 'unknown' } : classifyOutcome(cell.target, run, options.verifyFetch ?? null);
152
+ const analysis = analyzeRetainedAnswer(cell.target, run);
137
153
  outcomesByCell.get(cell.id).push(tiered.outcome);
138
154
  observationRows.push(observationRowSchema.parse({
139
155
  schema_version: CITATIONS_VERSION, epoch_id: epochId, cell_id: cell.id, run_index: runIndex,
140
- outcome: tiered.outcome, cited_urls: tiered.citedUrls, verify: tiered.verify,
156
+ outcome: tiered.outcome, brand_mentioned: analysis.mentioned, target_cited: analysis.cited, cited_urls: tiered.citedUrls, verify: tiered.verify,
141
157
  evidence_sha256: digest, cost_estimate_usd: run.costEstimateUsd ?? 0, at: nowIso(),
142
158
  }));
143
- if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
159
+ // Duck engines need extra pacing: their anonymous rate limit (~3-4 per exit)
160
+ // benefits from a cooldown between observations even with exit rotation.
161
+ if (aborted) break outer;
162
+ const cellDelay = cell.identity.startsWith('duck') ? Math.max(delayMs, 2000) : delayMs;
163
+ if (cellDelay > 0) await new Promise((r) => setTimeout(r, cellDelay));
144
164
  }
145
165
  }
146
166
 
@@ -149,14 +169,22 @@ export async function runEpoch(panelInput, options = {}) {
149
169
  for (const cell of cells) {
150
170
  const outcomes = outcomesByCell.get(cell.id);
151
171
  const { k, n, unknowns, mentioned } = tallyOutcomes(outcomes);
152
- const ci = wilsonInterval(k, n) ?? [0, 1];
172
+ const ci = wilsonInterval(k, n);
153
173
  const baselineRow = prior.filter((r) => r.cell_id === cell.id && r.n >= CITATION_LIMITS.minSamplesForInterpretation).at(-1);
154
174
  const baseline = baselineRow ? { k: baselineRow.k, n: baselineRow.n } : null;
175
+ const priorCount = prior.filter(row => row.cell_id === cell.id).length;
176
+ const pairAlpha = priorCount > 0 ? 0.05 / (priorCount * (priorCount + 1)) : 0.05;
177
+ const { run_length_posterior, ...changepoint } = changepointPosterior(outcomes);
178
+ const statistics = { scope: 'fixed-epoch-supplemental',
179
+ confidence_sequence: confidenceSequence(outcomes, { alpha: 0.05, comparisons: cells.length }).at(-1) ?? null,
180
+ epoch_comparison: baseline ? compareEpochs({ k, n }, baseline, { alpha: pairAlpha, comparisons: cells.length }) : null,
181
+ changepoint,
182
+ };
155
183
  const row = epochRowSchema.parse({
156
184
  schema_version: CITATIONS_VERSION, epoch_id: epochId, cell_id: cell.id,
157
185
  k, n, unknowns, mentioned,
158
- rate: smoothedRate(k, n) ?? 0, ci_low: ci[0], ci_high: ci[1],
159
- classification: classifyEpoch({ k, n }, baseline, CITATION_LIMITS),
186
+ rate: smoothedRate(k, n), ci_low: ci?.[0] ?? null, ci_high: ci?.[1] ?? null,
187
+ classification: classifyEpoch({ k, n }, baseline, CITATION_LIMITS), statistics,
160
188
  baseline: baselineRow ? { epoch_id: baselineRow.epoch_id, rate: baselineRow.rate, ci_low: baselineRow.ci_low, ci_high: baselineRow.ci_high } : undefined,
161
189
  spent_estimate_usd: Number(spent.toFixed(6)), at: nowIso(),
162
190
  });