agentlinkops 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.claude-plugin/plugin.json +0 -0
  2. package/.codex-plugin/plugin.json +0 -0
  3. package/.mcp.json +0 -0
  4. package/CHANGELOG.md +0 -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/agent-setup.js +0 -0
  14. package/cli/agentlinkops.mjs +0 -0
  15. package/cli/args.js +0 -0
  16. package/cli/catalog.js +0 -0
  17. package/cli/check.js +0 -0
  18. package/cli/citation.js +105 -1
  19. package/cli/client.js +0 -0
  20. package/cli/commands.js +0 -0
  21. package/cli/config.js +0 -0
  22. package/cli/connection.js +0 -0
  23. package/cli/context-mcp.js +0 -0
  24. package/cli/context.js +0 -0
  25. package/cli/delivery.js +0 -0
  26. package/cli/doctor.js +0 -0
  27. package/cli/env.js +0 -0
  28. package/cli/fleet.js +0 -0
  29. package/cli/gsc-browser.js +0 -0
  30. package/cli/import.js +0 -0
  31. package/cli/ledger.js +0 -0
  32. package/cli/linktrail.mjs +0 -0
  33. package/cli/local-result.js +0 -0
  34. package/cli/main.js +0 -0
  35. package/cli/mirror.js +0 -0
  36. package/cli/mix.js +0 -0
  37. package/cli/platforms.js +0 -0
  38. package/cli/receipt-performance.js +0 -0
  39. package/cli/receipts.js +0 -0
  40. package/cli/report.js +0 -0
  41. package/cli/setup-plan.js +0 -0
  42. package/cli/skill.js +0 -0
  43. package/cli/state.js +0 -0
  44. package/cli/status.js +0 -0
  45. package/cli/sync.js +0 -0
  46. package/package.json +6 -3
  47. package/references/cli.md +0 -0
  48. package/references/disavow.md +0 -0
  49. package/references/reports.md +0 -0
  50. package/scripts/agentlinkops.py +0 -0
  51. package/scripts/disavow.py +0 -0
  52. package/scripts/linktrail.py +0 -0
  53. package/scripts/migrations/001_crm.sql +0 -0
  54. package/scripts/migrations/002_cloud_events.sql +0 -0
  55. package/scripts/migrations/003_disavow.sql +0 -0
  56. package/scripts/migrations/004_placement_costs.sql +0 -0
  57. package/scripts/report.py +0 -0
  58. package/shared/agent-clients.js +0 -0
  59. package/shared/observation-event.js +0 -0
  60. package/shared/response-contract.js +0 -0
  61. package/shared/search-connection-contract.js +0 -0
  62. package/skills/agentlinkops-assets/SKILL.md +0 -0
  63. package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
  64. package/skills/agentlinkops-campaigns/SKILL.md +0 -0
  65. package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
  66. package/skills/agentlinkops-connect/SKILL.md +0 -0
  67. package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
  68. package/skills/agentlinkops-connect/references/agentlinkops.md +0 -0
  69. package/skills/agentlinkops-crm/SKILL.md +0 -0
  70. package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
  71. package/skills/content-defingerprinting/SKILL.md +0 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
  82. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
  83. package/skills/internal-linking-optimizer/SKILL.md +0 -0
  84. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
  85. package/skills/pre-publish-review/SKILL.md +0 -0
  86. package/skills/source-cited-content-builder/SKILL.md +0 -0
  87. package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
  88. package/skills/source-cited-humanizer/SKILL.md +0 -0
  89. package/src/citations/adapter.js +173 -0
  90. package/src/citations/alert.js +53 -0
  91. package/src/citations/browser/engine.js +81 -10
  92. package/src/citations/browser/gen/core/providers/_shared/google.js +0 -0
  93. package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
  94. package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
  95. package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
  96. package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
  97. package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
  98. package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
  99. package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
  100. package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
  101. package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
  102. package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
  103. package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
  104. package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
  105. package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
  106. package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
  107. package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
  108. package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
  109. package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
  110. package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
  111. package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
  112. package/src/citations/browser/gen/core/providers/index.js +0 -0
  113. package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
  114. package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
  115. package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
  116. package/src/citations/browser/gen/core/providers/types.js +0 -0
  117. package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
  118. package/src/citations/browser/gen/lib/browser/display.js +0 -0
  119. package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
  120. package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
  121. package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
  122. package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -0
  123. package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -0
  124. package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
  125. package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
  126. package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -0
  127. package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
  128. package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
  129. package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -0
  130. package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
  131. package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
  132. package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
  133. package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
  134. package/src/citations/browser/gen/lib/input/response/getText.js +0 -0
  135. package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
  136. package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
  137. package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
  138. package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
  139. package/src/citations/browser/gen/utils/agent-constants.js +0 -0
  140. package/src/citations/browser/providers.js +166 -0
  141. package/src/citations/browser/shims/errors.js +0 -0
  142. package/src/citations/browser/shims/types.js +0 -0
  143. package/src/citations/browser/shims/utils.js +0 -0
  144. package/src/citations/contract.js +10 -1
  145. package/src/citations/fixtures/dataforseo-aio.json +82 -0
  146. package/src/citations/match.js +0 -0
  147. package/src/citations/panel-builder.js +44 -0
  148. package/src/citations/report.js +165 -0
  149. package/src/citations/runner.js +25 -14
  150. package/src/citations/stats.js +63 -0
  151. package/src/citations/watch.js +85 -0
  152. package/src/competitors/contract.js +0 -0
  153. package/src/competitors/domain-mix.js +0 -0
  154. package/src/context/ga4.js +0 -0
  155. package/src/context/gsc.js +0 -0
  156. package/src/context/site-profile.js +0 -0
  157. package/src/context/util.js +0 -0
  158. package/src/context-tool-registry.js +0 -0
  159. package/src/corpus/acquire.js +0 -0
  160. package/src/corpus/seeds.js +0 -0
  161. package/src/discovery/contract.js +0 -0
  162. package/src/discovery/import.js +0 -0
  163. package/src/tool-views.js +0 -0
  164. package/src/verifier/fetch.js +0 -0
  165. package/src/verifier/index.js +0 -0
  166. package/src/verifier/observation-kind.js +0 -0
  167. package/src/verifier/parser.js +0 -0
  168. package/src/verifier/readiness.js +0 -0
  169. package/src/verifier/render-evidence.js +0 -0
  170. package/src/verifier/robots.js +0 -0
  171. package/src/verifier/transition.js +0 -0
  172. package/src/verifier/url.js +0 -0
  173. package/templates/SITE.template.md +0 -0
  174. package/templates/campaigns.v1.json +0 -0
@@ -0,0 +1,165 @@
1
+ // The citation report: a single self-contained HTML file that renders every
2
+ // cell's evidence — trend, screenshots inline, Wilson intervals, honest
3
+ // classifications — in a format a marketing manager can forward to a client.
4
+ //
5
+ // No external assets. No server. No JavaScript. Opens in any browser,
6
+ // survives email forwarding, and carries the screenshots as base64 because
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';
10
+
11
+ const esc = (text) => String(text ?? '')
12
+ .replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
13
+
14
+ const pct = (v) => `${Math.round(v * 100)}%`;
15
+
16
+ const CLASSIFICATION_STYLE = {
17
+ first_epoch: { label: 'First epoch', color: '#6b7280', bg: '#f3f4f6' },
18
+ insufficient_data: { label: 'Insufficient data', color: '#92400e', bg: '#fef3c7' },
19
+ declined: { label: 'Declined', color: '#991b1b', bg: '#fee2e2' },
20
+ grown: { label: 'Grown', color: '#065f46', bg: '#d1fae5' },
21
+ not_distinguishable: { label: 'Not distinguishable', color: '#6b7280', bg: '#f3f4f6' },
22
+ };
23
+
24
+ function classificationBadge(cls) {
25
+ const s = CLASSIFICATION_STYLE[cls] ?? { label: cls, color: '#6b7280', bg: '#f3f4f6' };
26
+ 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
+ }
28
+
29
+ function rateBar(rate, low, high) {
30
+ return `
31
+ <div style="margin:8px 0 4px 0">
32
+ <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>
33
+ <div style="position:relative;height:8px;border-radius:4px;background:#e5e7eb;margin-top:6px;overflow:hidden">
34
+ <div style="position:absolute;left:${low * 100}%;width:${(high - low) * 100}%;top:0;bottom:0;background:#3b82f6;border-radius:4px;opacity:0.5"></div>
35
+ <div style="position:absolute;left:${rate * 100}%;width:2px;top:-2px;bottom:-2px;background:#1d4ed8"></div>
36
+ </div>
37
+ </div>`;
38
+ }
39
+
40
+ function evidenceCard(cell, envelope, screenshotB64) {
41
+ const [enginePart, promptPart, targetPart] = cell.cell_id.split('|');
42
+ const citedDomains = [...new Set((envelope?.citations ?? []).map((c) => {
43
+ try { return new URL(c.url).hostname; } catch { return null; }
44
+ }).filter(Boolean))];
45
+
46
+ return `
47
+ <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">
49
+ <div>
50
+ <span style="font-size:14px;font-weight:700;color:#111827;font-family:ui-monospace,monospace">${esc(enginePart)}</span>
51
+ <span style="font-size:12px;color:#6b7280;margin-left:8px">${esc(promptPart)}</span>
52
+ </div>
53
+ ${classificationBadge(cell.classification)}
54
+ </div>
55
+ ${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>
57
+ ${screenshotB64 ? `
58
+ <details style="margin-top:12px">
59
+ <summary style="font-size:12px;color:#3b82f6;cursor:pointer;font-weight:500">View answer screenshot</summary>
60
+ <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>` : ''}
62
+ ${envelope?.answer ? `
63
+ <details style="margin-top:8px">
64
+ <summary style="font-size:12px;color:#3b82f6;cursor:pointer;font-weight:500">View answer text</summary>
65
+ <div style="font-size:13px;color:#374151;margin-top:8px;padding:12px;background:#f9fafb;border-radius:8px;white-space:pre-wrap;max-height:300px;overflow-y:auto">${esc(envelope.answer.slice(0, 2000))}${envelope.answer.length > 2000 ? '\n…' : ''}</div>
66
+ </details>` : ''}
67
+ ${citedDomains.length > 0 ? `
68
+ <div style="margin-top:10px">
69
+ <span style="font-size:11px;font-weight:600;color:#374151">Cited domains:</span>
70
+ ${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>`}
72
+ </div>`;
73
+ }
74
+
75
+ function summaryParagraph(cells, panelName) {
76
+ const totalN = cells.reduce((sum, c) => sum + c.n, 0);
77
+ const totalK = cells.reduce((sum, c) => sum + c.k, 0);
78
+ const declined = cells.filter((c) => c.classification === 'declined').length;
79
+ const grown = cells.filter((c) => c.classification === 'grown').length;
80
+ const insufficient = cells.filter((c) => c.classification === 'insufficient_data').length;
81
+ const unknowns = cells.reduce((sum, c) => sum + c.unknowns, 0);
82
+
83
+ const parts = [];
84
+ parts.push(`${totalK} of ${totalN} rendered observations cited the target`);
85
+ 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`);
87
+ if (grown > 0) parts.push(`${grown} cell${grown === 1 ? '' : 's'} grew`);
88
+ 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');
90
+
91
+ return `Across ${cells.length} monitored cell${cells.length === 1 ? '' : 's'}, ${parts.join('. ')}. `;
92
+ }
93
+
94
+ export async function generateReport({ dir, epochId, panel, now = new Date() }) {
95
+ // Read the epoch rows for this epoch
96
+ const epochsPath = join(dir, 'citations-epochs.jsonl');
97
+ const epochsText = await readFile(epochsPath, 'utf8').catch(() => '');
98
+ const allRows = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
99
+ const cells = allRows.filter((r) => r.epoch_id === epochId);
100
+ if (cells.length === 0) throw new Error(`no epoch rows found for ${epochId}`);
101
+
102
+ // Read the evidence envelopes for this epoch (latest per cell)
103
+ const evidenceRoot = join(dir, 'citations', 'evidence', epochId);
104
+ const evidenceFiles = await readdir(evidenceRoot).catch(() => []);
105
+ const envelopes = new Map();
106
+ const screenshots = new Map();
107
+ for (const file of evidenceFiles) {
108
+ if (!file.endsWith('.json')) continue;
109
+ try {
110
+ const env = JSON.parse(await readFile(join(evidenceRoot, file), 'utf8'));
111
+ const existing = envelopes.get(env.cell_id);
112
+ if (!existing || env.run_index > existing.run_index) {
113
+ envelopes.set(env.cell_id, env);
114
+ // Look for the sibling screenshot
115
+ const shotFile = file.replace('.json', '.screenshot.png');
116
+ if (evidenceFiles.includes(shotFile)) {
117
+ const shotBuf = await readFile(join(evidenceRoot, shotFile));
118
+ screenshots.set(env.cell_id, shotBuf.toString('base64'));
119
+ }
120
+ }
121
+ } catch { /* skip unreadable */ }
122
+ }
123
+
124
+ const totalSpent = cells.reduce((sum, c) => sum + (c.spent_estimate_usd ?? 0), 0);
125
+ const engineList = [...new Set(cells.map((c) => c.cell_id.split('|')[0]))];
126
+
127
+ const cards = cells.map((cell) => evidenceCard(cell, envelopes.get(cell.cell_id), screenshots.get(cell.cell_id))).join('\n');
128
+ const summary = summaryParagraph(cells, panel?.note ?? 'Citation panel');
129
+
130
+ return `<!DOCTYPE html>
131
+ <html lang="en">
132
+ <head>
133
+ <meta charset="UTF-8">
134
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
135
+ <title>Citation Report — ${esc(now.toISOString().slice(0, 10))}</title>
136
+ </head>
137
+ <body style="margin:0;padding:24px;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#111827">
138
+ <div style="max-width:720px;margin:0 auto">
139
+
140
+ <div style="background:#111827;border-radius:16px;padding:24px 28px;margin-bottom:20px">
141
+ <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>
143
+ <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
+ </div>
145
+
146
+ <div style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px 20px;margin-bottom:20px">
147
+ <div style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Summary</div>
148
+ <div style="font-size:14px;line-height:1.6;color:#374151">${esc(summary)}</div>
149
+ </div>
150
+
151
+ ${cards}
152
+
153
+ <div style="margin-top:24px;padding:16px;background:#f9fafb;border-radius:12px;border:1px solid #e5e7eb">
154
+ <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.
159
+ </div>
160
+ </div>
161
+
162
+ </div>
163
+ </body>
164
+ </html>`;
165
+ }
@@ -89,18 +89,26 @@ export async function runEpoch(panelInput, options = {}) {
89
89
  aborted = { reason: 'budget', spent, nextEstimate: estimate, maxUsd };
90
90
  break outer;
91
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
- // An unknown is honest, but never silent: the reason travels to
98
- // stderr so a run of unknowns names its wall.
99
- if (options.err) options.err(`unknown observation (${cell.id}): ${cause.message}`);
100
- outcomesByCell.get(cell.id).push('unknown');
101
- continue;
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.
97
+ for (let attempt = 0; attempt < 2 && !run; attempt += 1) {
98
+ try {
99
+ run = await cell.engine.run({ prompt: cell.prompt.text, runIndex });
100
+ } catch (cause) {
101
+ lastCause = cause;
102
+ if (!(cause instanceof EngineError) || !cause.retriable) throw cause;
103
+ if (options.err && attempt === 0) options.err(`retrying observation (${cell.id}): ${cause.message}`);
102
104
  }
103
- throw cause;
105
+ }
106
+ if (!run) {
107
+ // An unknown is honest, but never silent: the reason travels to
108
+ // stderr so a run of unknowns names its wall.
109
+ if (options.err) options.err(`unknown observation (${cell.id}): ${lastCause?.message ?? 'unspecified'}`);
110
+ outcomesByCell.get(cell.id).push('unknown');
111
+ continue;
104
112
  }
105
113
  spent += run.costEstimateUsd ?? 0;
106
114
 
@@ -144,18 +152,21 @@ export async function runEpoch(panelInput, options = {}) {
144
152
  const ci = wilsonInterval(k, n) ?? [0, 1];
145
153
  const baselineRow = prior.filter((r) => r.cell_id === cell.id && r.n >= CITATION_LIMITS.minSamplesForInterpretation).at(-1);
146
154
  const baseline = baselineRow ? { k: baselineRow.k, n: baselineRow.n } : null;
147
- epochRows.push(epochRowSchema.parse({
155
+ const row = epochRowSchema.parse({
148
156
  schema_version: CITATIONS_VERSION, epoch_id: epochId, cell_id: cell.id,
149
157
  k, n, unknowns, mentioned,
150
158
  rate: smoothedRate(k, n) ?? 0, ci_low: ci[0], ci_high: ci[1],
151
159
  classification: classifyEpoch({ k, n }, baseline, CITATION_LIMITS),
152
160
  baseline: baselineRow ? { epoch_id: baselineRow.epoch_id, rate: baselineRow.rate, ci_low: baselineRow.ci_low, ci_high: baselineRow.ci_high } : undefined,
153
161
  spent_estimate_usd: Number(spent.toFixed(6)), at: nowIso(),
154
- }));
162
+ });
163
+ epochRows.push(row);
164
+ // Incremental flush: every completed cell's row is on disk immediately, so
165
+ // a killed epoch loses nothing — the lesson of the lost 3x3 tallies.
166
+ await writeFile(join(dir, CITATION_FILES.epochs), JSON.stringify(row) + '\n', { flag: 'a' });
155
167
  }
156
168
 
157
169
  await writeFile(join(dir, CITATION_FILES.observations), observationRows.map((r) => JSON.stringify(r)).join('\n') + (observationRows.length ? '\n' : ''), { flag: 'a' });
158
- await writeFile(join(dir, CITATION_FILES.epochs), epochRows.map((r) => JSON.stringify(r)).join('\n') + (epochRows.length ? '\n' : ''), { flag: 'a' });
159
170
 
160
171
  // Browser engines hold a browser and maybe an Xvfb display for the whole
161
172
  // epoch; release both once the tallies are written.
@@ -72,3 +72,66 @@ export function describeChange(current, baseline) {
72
72
  if (!baseline) return `first epoch: citation rate ${fmt(current)}`;
73
73
  return `citation rate moved from ${fmt(baseline)} to ${fmt(current)} between epochs`;
74
74
  }
75
+
76
+ // === v2 statistics (DP-0045): the borrow-list math that makes alerts trustworthy ===
77
+
78
+ /** Wald sequential probability ratio test for a decline from baseline rate.
79
+ * Returns { decision: 'decline' | 'accept' | 'continue', evidence: number }.
80
+ * The SPRT fires the moment the evidence crosses either threshold — no fixed
81
+ * n needed, which is exactly what monitoring at unknown-noise surfaces wants.
82
+ * Thresholds follow the standard H0: p = p0 (baseline) vs H1: p = p1 (decline). */
83
+ export function sprtDecline(k, n, baselineRate, declineRate = baselineRate * 0.5, alpha = 0.05, beta = 0.10) {
84
+ if (n === 0 || k > n) return { decision: 'continue', evidence: 0 };
85
+ const p0 = Math.max(baselineRate, 0.001);
86
+ const p1 = Math.max(declineRate, 0.001);
87
+ // Log-likelihood ratio
88
+ const llr = k * Math.log(p1 / p0) + (n - k) * Math.log((1 - p1) / (1 - p0));
89
+ const upper = Math.log((1 - beta) / alpha);
90
+ const lower = Math.log(beta / (1 - alpha));
91
+ // Positive LLR favors H1 (the rate has declined); negative favors H0 (still at baseline).
92
+ if (llr >= upper) return { decision: 'decline', evidence: Number(llr.toFixed(4)) };
93
+ if (llr <= lower) return { decision: 'stable', evidence: Number(llr.toFixed(4)) };
94
+ return { decision: 'continue', evidence: Number(llr.toFixed(4)) };
95
+ }
96
+
97
+ /** Bayesian online changepoint detection (simplified run-length posterior).
98
+ * Takes a binary sequence (1 = cited, 0 = not cited) and returns the most
99
+ * likely changepoint index, or null if no changepoint is detected.
100
+ * This is the "when did it shift" answer that epoch-over-epoch comparison
101
+ * can never give. */
102
+ export function detectChangepoint(observations, { hazard = 0.1, threshold = 0.3 } = {}) {
103
+ if (observations.length < 6) return null;
104
+ const n = observations.length;
105
+ // Simple two-segment maximum likelihood: for each split point, compute
106
+ // the log-likelihood of segment 1 and segment 2 under Bernoulli models.
107
+ // The best split is the changepoint candidate.
108
+ let bestSplit = null;
109
+ let bestScore = -Infinity;
110
+ for (let split = 3; split <= n - 3; split += 1) {
111
+ const seg1 = observations.slice(0, split);
112
+ const seg2 = observations.slice(split);
113
+ const p1 = (seg1.reduce((a, b) => a + b, 0) + 1) / (seg1.length + 2);
114
+ const p2 = (seg2.reduce((a, b) => a + b, 0) + 1) / (seg2.length + 2);
115
+ // Skip trivially-identical segments
116
+ if (Math.abs(p1 - p2) < threshold) continue;
117
+ const ll1 = seg1.reduce((acc, x) => acc + Math.log(x ? p1 : 1 - p1), 0);
118
+ const ll2 = seg2.reduce((acc, x) => acc + Math.log(x ? p2 : 1 - p2), 0);
119
+ // Penalize the split by the hazard rate
120
+ const score = ll1 + ll2 + Math.log(1 - hazard) * (n - split - 1) + Math.log(hazard);
121
+ if (score > bestScore) { bestScore = score; bestSplit = split; }
122
+ }
123
+ return bestSplit;
124
+ }
125
+
126
+ /** Laplace-smoothed rate for a rolling window of observations. Used for the
127
+ * trend sparkline in reports — shows the recent direction without claiming
128
+ * statistical significance. */
129
+ export function rollingRate(observations, windowSize = 5) {
130
+ const rates = [];
131
+ for (let i = 0; i < observations.length; i += 1) {
132
+ const start = Math.max(0, i - windowSize + 1);
133
+ const window = observations.slice(start, i + 1);
134
+ rates.push(smoothedRate(window.reduce((a, b) => a + b, 0), window.length));
135
+ }
136
+ return rates;
137
+ }
@@ -0,0 +1,85 @@
1
+ // The citation watch: register a cron entry that runs the epoch, generates
2
+ // the report, and alerts on decline. This is what turns a measurement script
3
+ // into a monitoring product — the buyer sets it up once and gets weekly
4
+ // evidence in their inbox.
5
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
6
+ import { join, resolve, basename } from 'node:path';
7
+ import { execFile } from 'node:child_process';
8
+ import { homedir } from 'node:os';
9
+
10
+ const CADENCE_HOURS = { hourly: 1, daily: 24, weekly: 168, monthly: 720 };
11
+
12
+ export async function registerWatch({ panelPath, cadence = 'weekly', dir, out }) {
13
+ if (!CADENCE_HOURS[cadence]) {
14
+ throw new Error(`unknown cadence: ${cadence} (known: ${Object.keys(CADENCE_HOURS).join(', ')})`);
15
+ }
16
+ const absolute = resolve(panelPath);
17
+ const panelName = basename(absolute, '.json');
18
+ const agentlinkopsBin = process.argv[1] ?? 'agentlinkops';
19
+ const ledgerDir = dir ?? join(homedir(), '.agentlinkops');
20
+ const reportDir = out ?? join(ledgerDir, 'citations', 'reports');
21
+
22
+ // The runner: epoch + report + alert in one shell line. The report path is
23
+ // deterministic so the alert hook can find the latest artifact.
24
+ const command = [
25
+ `cd ${join(absolute, '..')}`,
26
+ `&& ${agentlinkopsBin} citation run ${absolute}`,
27
+ `&& ${agentlinkopsBin} citation report ${absolute}`,
28
+ // The alert hook reads the latest epoch rows; if any cell declined,
29
+ // the report is flagged (delivery is a follow-up: email or webhook).
30
+ `&& ${agentlinkopsBin} citation alert ${absolute}`,
31
+ ].join(' ');
32
+
33
+ // Cron line: m h dom mon dow command — weekly on Monday at 09:17 UTC,
34
+ // daily at 09:17, hourly at minute 17 (off the :00 mark per convention).
35
+ const cronSpec = cadence === 'weekly' ? '17 9 * * 1'
36
+ : cadence === 'daily' ? '17 9 * * *'
37
+ : cadence === 'hourly' ? '17 * * * *'
38
+ : '17 9 1 * *'; // monthly
39
+
40
+ const marker = `# agentlinkops:citation-watch:${panelName}`;
41
+ const cronLine = `${cronSpec} ${command} 2>&1 | logger -t agentlinkops-citation ${marker}`;
42
+
43
+ // Read the existing crontab, replace our entry if present, append otherwise.
44
+ const existing = await new Promise((r) => {
45
+ execFile('crontab', ['-l'], (err, stdout) => r(err ? '' : stdout));
46
+ });
47
+ const lines = existing.split('\n').filter((l) => !l.includes(marker));
48
+ lines.push(cronLine);
49
+ await new Promise((r, j) => {
50
+ execFile('crontab', ['-'], { input: lines.join('\n') + '\n' }, (err) => err ? j(err) : r());
51
+ });
52
+
53
+ return {
54
+ cadence,
55
+ cronSpec,
56
+ panelPath: absolute,
57
+ marker,
58
+ next: cadence === 'weekly' ? 'next Monday 09:17 UTC' : `every ${CADENCE_HOURS[cadence]}h at :17`,
59
+ };
60
+ }
61
+
62
+ export async function listWatches() {
63
+ const existing = await new Promise((r) => {
64
+ execFile('crontab', ['-l'], (err, stdout) => r(err ? '' : stdout));
65
+ });
66
+ return existing.split('\n')
67
+ .filter((l) => l.includes('# agentlinkops:citation-watch:'))
68
+ .map((l) => {
69
+ const marker = l.match(/agentlinkops:citation-watch:(\S+)/);
70
+ const cronPart = l.split(/\s+/).slice(0, 5).join(' ');
71
+ return { panel: marker?.[1] ?? 'unknown', cron: cronPart };
72
+ });
73
+ }
74
+
75
+ export async function removeWatch(panelName) {
76
+ const marker = `# agentlinkops:citation-watch:${panelName}`;
77
+ const existing = await new Promise((r) => {
78
+ execFile('crontab', ['-l'], (err, stdout) => r(err ? '' : stdout));
79
+ });
80
+ const lines = existing.split('\n').filter((l) => !l.includes(marker));
81
+ await new Promise((r, j) => {
82
+ execFile('crontab', ['-'], { input: lines.join('\n') + '\n' }, (err) => err ? j(err) : r());
83
+ });
84
+ return { removed: panelName };
85
+ }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/src/tool-views.js CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes