agentlinkops 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.json +0 -0
- package/CHANGELOG.md +17 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/README.md +0 -0
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +0 -0
- package/cli/admission.js +130 -0
- package/cli/agent-setup.js +1014 -101
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/catalog.js +12837 -4694
- package/cli/check.js +0 -0
- package/cli/citation.js +181 -32
- package/cli/client.js +1 -1
- package/cli/commands.js +3 -3
- package/cli/config.js +2 -1
- package/cli/connection.js +0 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +0 -0
- package/cli/delivery.js +0 -0
- package/cli/disavow.js +54 -0
- package/cli/doctor.js +7 -8
- package/cli/env.js +0 -0
- package/cli/fleet.js +0 -0
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +0 -0
- package/cli/index-observations.js +89 -0
- package/cli/ledger.js +7 -1
- package/cli/lifecycle.js +40 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +0 -0
- package/cli/main.js +99 -7
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/platforms.js +0 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +79 -6
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +41 -4
- package/cli/state.js +0 -0
- package/cli/status.js +0 -0
- package/cli/sync.js +23 -1
- package/package.json +3 -2
- package/references/citation-panels.md +26 -0
- package/references/cli.md +0 -0
- package/references/disavow.md +0 -0
- package/references/discovery-handoff.md +32 -0
- package/references/recipes/catalog.json +148 -0
- package/references/recipes/command-notes.md +9 -0
- package/references/recipes/placement-reconciliation.md +41 -0
- package/references/recipes/qualified-campaign-handoff.md +56 -0
- package/references/recipes/site-context-brief.md +25 -0
- package/references/recipes/sourced-linkable-asset.md +25 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/report.py +0 -0
- package/shared/admission-contract.js +16 -0
- package/shared/admission-report-contract.js +8 -0
- package/shared/agent-clients.js +0 -0
- package/shared/citation-evidence-contract.js +27 -0
- package/shared/disavow-contract.js +18 -0
- package/shared/index-observation-contract.js +14 -0
- package/shared/lifecycle-contract.js +22 -0
- package/shared/lifecycle-money.js +24 -0
- package/shared/observation-event.js +12 -1
- package/shared/opportunity-record.js +259 -0
- package/shared/response-contract.js +2 -0
- package/shared/search-connection-contract.js +0 -0
- package/skills/agentlinkops-assets/SKILL.md +2 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
- package/skills/agentlinkops-campaigns/SKILL.md +5 -1
- package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/SKILL.md +8 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +9 -5
- package/skills/agentlinkops-crm/SKILL.md +2 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
- package/skills/agentlinkops-discovery/SKILL.md +17 -0
- package/skills/content-defingerprinting/SKILL.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
- package/skills/internal-linking-optimizer/SKILL.md +0 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
- package/skills/pre-publish-review/SKILL.md +0 -0
- package/skills/source-cited-content-builder/SKILL.md +0 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
- package/skills/source-cited-humanizer/SKILL.md +0 -0
- package/src/citations/adapter.js +102 -21
- package/src/citations/alert.js +38 -13
- package/src/citations/browser/engine.js +145 -50
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +3 -1
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +1 -0
- package/src/citations/contract.js +55 -9
- package/src/citations/inventory.js +106 -0
- package/src/citations/login.js +122 -0
- package/src/citations/match.js +12 -0
- package/src/citations/panel-builder.js +44 -39
- package/src/citations/report.js +88 -21
- package/src/citations/runner.js +59 -31
- package/src/citations/stats.js +133 -61
- package/src/citations/sync.js +131 -0
- package/src/citations/watch.js +57 -77
- package/src/competitors/contract.js +0 -0
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +0 -0
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +47 -8
- package/src/corpus/seeds.js +132 -19
- package/src/discovery/contract.js +0 -0
- package/src/discovery/import.js +0 -0
- package/src/evidence-reference.js +112 -0
- package/src/index-observation.js +161 -0
- package/src/locator.js +112 -0
- package/src/tool-views.js +6 -2
- package/src/verifier/fetch.js +42 -6
- package/src/verifier/index.js +0 -0
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/parser.js +0 -0
- package/src/verifier/readiness.js +0 -0
- package/src/verifier/render-evidence.js +0 -0
- package/src/verifier/robots.js +0 -0
- package/src/verifier/transition.js +0 -0
- package/src/verifier/url.js +0 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
- package/src/citations/browser/gen/core/providers/_shared/google.js +0 -4
- package/src/citations/browser/gen/core/providers/types.js +0 -0
- package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -321
- package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -251
- package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -54
- package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -21
- package/src/citations/browser/gen/lib/input/response/getText.js +0 -10
- package/src/citations/fixtures/dataforseo-aio.json +0 -82
package/src/citations/report.js
CHANGED
|
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
<
|
|
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>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
package/src/citations/runner.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
});
|