agentlinkops 0.5.1 → 0.6.1

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
File without changes
File without changes
package/.mcp.json CHANGED
File without changes
package/CHANGELOG.md CHANGED
File without changes
package/LICENSE CHANGED
File without changes
package/NOTICE CHANGED
File without changes
package/README.md CHANGED
File without changes
File without changes
package/bin/linktrail.mjs CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/cli/args.js CHANGED
File without changes
package/cli/catalog.js CHANGED
File without changes
package/cli/check.js CHANGED
File without changes
package/cli/citation.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // Exit codes follow the link commands: 0 nothing actionable (including
9
9
  // insufficient_data — an unknown never fails), 1 a cell classified declined with
10
10
  // complete evidence, 2 usage, configuration or budget-abort errors.
11
- import { resolve } from 'node:path';
11
+ import { resolve, join } from 'node:path';
12
12
 
13
13
  import { parseArgs } from './args.js';
14
14
  import { loadPanel, runEpoch } from '../src/citations/runner.js';
@@ -25,6 +25,17 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
25
25
  measure the consumer UI through Camoufox
26
26
  (dedicated account, personal cadence; evidence
27
27
  includes a screenshot of every answer)
28
+ agentlinkops citation report PANEL.json [--out FILE] [--dir DIR]
29
+ render the latest epoch as a self-contained
30
+ HTML report: trends, screenshots inline,
31
+ Wilson intervals, honest classifications
32
+ agentlinkops citation watch PANEL.json --cadence weekly|daily|hourly|monthly
33
+ register a cron entry that runs the epoch
34
+ and generates the report on schedule
35
+ agentlinkops citation watch --list list registered citation watches
36
+ agentlinkops citation watch --remove NAME remove a registered watch
37
+ agentlinkops citation alert PANEL.json check the latest epoch for declines;
38
+ logs an alert line if any cell declined
28
39
  agentlinkops citation help this help
29
40
 
30
41
  Panel (JSON): targets (domain or url, brand, aliases), prompts, engines
@@ -41,6 +52,81 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
41
52
  const args = parseArgs(argv);
42
53
  const command = args._[0] ?? 'help';
43
54
  if (command === 'help' || args.help) { out(USAGE); return 0; }
55
+ if (command === 'alert') {
56
+ const panelPath = args._[1];
57
+ if (!panelPath) { err('citation alert needs a panel file'); return 2; }
58
+ const { readFile } = await import('node:fs/promises');
59
+ const dir = resolve(cwd, args.dir ?? '.agentlinkops');
60
+ let latestEpochId = null;
61
+ try {
62
+ const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
63
+ const rows = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
64
+ if (rows.length > 0) latestEpochId = rows[rows.length - 1].epoch_id;
65
+ } catch { /* no epochs */ }
66
+ if (!latestEpochId) { out('no epochs to check'); return 0; }
67
+ const { shouldAlert } = await import('../src/citations/alert.js');
68
+ const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
69
+ const cells = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l)).filter((r) => r.epoch_id === latestEpochId);
70
+ if (shouldAlert(cells)) {
71
+ const declined = cells.filter((c) => c.classification === 'declined');
72
+ err(`ALERT: ${declined.length} cell${declined.length === 1 ? '' : 's'} declined:`);
73
+ for (const c of declined) err(` ${c.cell_id}: rate ${c.rate.toFixed(2)} (was ${c.baseline?.rate?.toFixed(2) ?? 'n/a'})`);
74
+ return 1; // exit 1 = actionable, same convention as link checks
75
+ }
76
+ out('no declines detected');
77
+ return 0;
78
+ }
79
+ if (command === 'watch') {
80
+ const { registerWatch, listWatches, removeWatch } = await import('../src/citations/watch.js');
81
+ if (args.list) {
82
+ const watches = await listWatches();
83
+ if (watches.length === 0) { out('no citation watches registered'); return 0; }
84
+ for (const w of watches) out(`${w.panel} ${w.cron}`);
85
+ return 0;
86
+ }
87
+ if (args.remove) {
88
+ const result = await removeWatch(args.remove);
89
+ out(`removed watch: ${result.removed}`);
90
+ return 0;
91
+ }
92
+ const panelPath = args._[1];
93
+ if (!panelPath) { err('citation watch needs a panel file (or --list / --remove)'); return 2; }
94
+ const cadence = args.cadence ?? 'weekly';
95
+ try {
96
+ const result = await registerWatch({ panelPath, cadence });
97
+ out(`citation watch registered: ${result.panelPath}`);
98
+ out(`cadence: ${result.cadence} (${result.next})`);
99
+ out(`cron: ${result.cronSpec}`);
100
+ return 0;
101
+ } catch (cause) {
102
+ err(cause.message);
103
+ return 2;
104
+ }
105
+ }
106
+ if (command === 'report') {
107
+ const panelPath = args._[1];
108
+ if (!panelPath) { err('citation report needs a panel file'); return 2; }
109
+ const dir = resolve(cwd, args.dir ?? '.agentlinkops');
110
+ const { generateReport } = await import('../src/citations/report.js');
111
+ const { readFile, writeFile: wf } = await import('node:fs/promises');
112
+ // Find the latest epoch for this panel by reading the epochs file
113
+ let latestEpochId = null;
114
+ try {
115
+ const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
116
+ const rows = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
117
+ if (rows.length > 0) latestEpochId = rows[rows.length - 1].epoch_id;
118
+ } catch { /* no epochs yet */ }
119
+ if (!latestEpochId) { err('no epochs found — run an epoch first'); return 2; }
120
+ const panel = JSON.parse(await readFile(resolve(cwd, panelPath), 'utf8'));
121
+ const html = await generateReport({ dir, epochId: latestEpochId, panel });
122
+ const outPath = args.out ? resolve(cwd, args.out) : join(dir, 'citations', `report-${latestEpochId.slice(0, 15)}.html`);
123
+ const { dirname, join: j } = await import('node:path');
124
+ const { mkdir } = await import('node:fs/promises');
125
+ await mkdir(dirname(outPath), { recursive: true });
126
+ await wf(outPath, html, 'utf8');
127
+ out(`report written: ${outPath} (${Math.round(html.length / 1024)} KB, self-contained)`);
128
+ return 0;
129
+ }
44
130
  if (command !== 'run') { err(`unknown citation command: ${command}`); err(USAGE); return 2; }
45
131
 
46
132
  const panelPath = args._[1];
@@ -83,6 +169,24 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
83
169
  }
84
170
  if (spec.engine === 'mock') {
85
171
  engines.set(identity, createMockEngine({ fixtures: panel.mockFixtures ?? {} }));
172
+ } else if (spec.engine === 'google-aio') {
173
+ // Fixture-first: runs against the recorded response with no credentials.
174
+ // Activates live the moment DATAFORSEO_LOGIN + DATAFORSEO_PASSWORD exist.
175
+ const { createGoogleAioEngine } = await import('../src/citations/adapter.js');
176
+ const creds = env.DATAFORSEO_LOGIN && env.DATAFORSEO_PASSWORD
177
+ ? { login: env.DATAFORSEO_LOGIN, password: env.DATAFORSEO_PASSWORD }
178
+ : null;
179
+ engines.set(identity, createGoogleAioEngine({ credentials: creds }));
180
+ if (!creds) err(`google-aio: fixture mode (set DATAFORSEO_LOGIN + DATAFORSEO_PASSWORD for live)`);
181
+ } else if (spec.engine === 'duck') {
182
+ // Self-hosted Duck2api on the VPS: the whole Claude family plus GPT-5.6
183
+ // anonymously through duck.ai. Localhost endpoint, no key.
184
+ const { createOpenAiCompatibleEngine } = await import('../src/citations/adapter.js');
185
+ engines.set(identity, createOpenAiCompatibleEngine({
186
+ baseUrl: env.DUCK2API_URL ?? 'http://127.0.0.1:8090/v1',
187
+ model: spec.model ?? 'claude-haiku-4-5',
188
+ providerLabel: 'duck-anon',
189
+ }));
86
190
  } else if (spec.engine === 'perplexity') {
87
191
  // Presence only, never the value — the same rule doctor applies to tokens.
88
192
  if (!env.PERPLEXITY_API_KEY) {
package/cli/client.js CHANGED
File without changes
package/cli/commands.js CHANGED
File without changes
package/cli/config.js CHANGED
File without changes
package/cli/connection.js CHANGED
File without changes
File without changes
package/cli/context.js CHANGED
File without changes
package/cli/delivery.js CHANGED
File without changes
package/cli/doctor.js CHANGED
File without changes
package/cli/env.js CHANGED
File without changes
package/cli/fleet.js CHANGED
File without changes
File without changes
package/cli/import.js CHANGED
File without changes
package/cli/ledger.js CHANGED
File without changes
package/cli/linktrail.mjs CHANGED
File without changes
File without changes
package/cli/main.js CHANGED
File without changes
package/cli/mirror.js CHANGED
File without changes
package/cli/mix.js CHANGED
File without changes
package/cli/platforms.js CHANGED
File without changes
File without changes
package/cli/receipts.js CHANGED
File without changes
package/cli/report.js CHANGED
File without changes
package/cli/setup-plan.js CHANGED
File without changes
package/cli/skill.js CHANGED
File without changes
package/cli/state.js CHANGED
File without changes
package/cli/status.js CHANGED
File without changes
package/cli/sync.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.5.1",
4
- "description": "A backlink ledger that lives in your repository: local link checks with dated evidence, supplier-export imports and optional sync to the hosted AgentLinkOps service.",
3
+ "version": "0.6.1",
4
+ "description": "A backlink and AI citation ledger that lives in your repository: local link and citation checks with dated evidence, supplier-export imports and optional sync to the hosted AgentLinkOps service.",
5
5
  "author": "AgentLinkOps (https://agentlinkops.com)",
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
@@ -46,9 +46,12 @@
46
46
  "mcp",
47
47
  "backlinks",
48
48
  "seo",
49
+ "citation-monitoring",
50
+ "ai-citations",
49
51
  "agent",
50
52
  "cli",
51
53
  "link-monitoring",
54
+ "citation-watch",
52
55
  "ledger",
53
56
  "jsonl"
54
57
  ],
@@ -64,4 +67,4 @@
64
67
  "optionalDependencies": {
65
68
  "playwright-core": "^1.63.0"
66
69
  }
67
- }
70
+ }
package/references/cli.md 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
package/scripts/report.py 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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -139,3 +139,176 @@ export function createEngine(spec, options = {}) {
139
139
  if (spec.engine === 'perplexity') return createPerplexityEngine({ model: spec.model ?? 'sonar', ...(options.perplexity ?? {}) });
140
140
  throw new EngineError(`unknown engine: ${spec.engine}`);
141
141
  }
142
+
143
+ // duck.ai anonymous engine: DuckDuckGo's AI chat serves Claude Haiku 4.5
144
+ // without an account. The VQD handshake follows Duck2api (MIT): the status
145
+ // call returns a token; when it returns a JS hash challenge instead, the
146
+ // documented fallback payload is accepted. Pure HTTP through the proxy —
147
+ // cheaper than a browser, and the surface has no bot wall at personal cadence.
148
+ const DUCK_BASE = 'https://duck.ai';
149
+ const DUCK_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36';
150
+ const DUCK_ORIGIN = 'https://duck.ai';
151
+ const DUCK_STACK = 'Error\n at l (https://duck.ai/dist/duckai-dist/entry.duckai.c0a8c794abcbc8ee2d3c.js:2:1446307)\n at async https://duck.ai/dist/duckai-dist/entry.duckai.c0a8c794abcbc8ee2d3c.js:2:1294181';
152
+
153
+ async function duckDispatcher(proxyUrl) {
154
+ if (!proxyUrl) return undefined;
155
+ const { ProxyAgent } = await import('undici');
156
+ return new ProxyAgent(proxyUrl);
157
+ }
158
+
159
+ export function createDuckAiEngine({ model = 'claude-haiku-4-5', proxyUrl } = {}) {
160
+ let dispatcher = null;
161
+ const getDispatcher = async () => dispatcher ?? (dispatcher = await duckDispatcher(proxyUrl));
162
+
163
+ const duckFetch = async (path, init = {}) => {
164
+ const d = await getDispatcher();
165
+ return fetch(`${DUCK_BASE}${path}`, {
166
+ ...init,
167
+ headers: { 'user-agent': DUCK_UA, origin: DUCK_ORIGIN, referer: `${DUCK_BASE}/`, ...init.headers },
168
+ ...(d ? { dispatcher: d } : {}),
169
+ });
170
+ };
171
+
172
+ const vqdFallback = (challengeB64) => {
173
+ const decoded = Buffer.from(challengeB64, 'base64').toString('utf8');
174
+ const payload = `${decoded}::no-exec::${DUCK_STACK}::${DUCK_ORIGIN}`;
175
+ return Buffer.from(payload).toString('base64');
176
+ };
177
+
178
+ return {
179
+ identity: { engine: 'duckai', provider: 'anon-api', model },
180
+ estimateCostUsd: () => 0.0002,
181
+ async run({ prompt }) {
182
+ const status = await duckFetch('/duckchat/v1/status', { headers: { accept: '*/*' } });
183
+ if (!status.ok) throw new EngineError(`duckai status HTTP ${status.status}`, { retriable: true });
184
+ let vqd = status.headers.get('x-vqd-4') ?? status.headers.get('x-vqd');
185
+ const hashChallenge = status.headers.get('x-vqd-hash-1');
186
+ const headers = { accept: 'text/event-stream', 'content-type': 'application/json' };
187
+ if (!vqd && hashChallenge) {
188
+ vqd = vqdFallback(hashChallenge);
189
+ headers['x-vqd-hash-1'] = vqd;
190
+ }
191
+ if (!vqd) throw new EngineError('duckai: no vqd token or challenge in status response', { retriable: true });
192
+ headers['x-vqd-4'] = vqd;
193
+ if (vqd !== headers['x-vqd-hash-1']) headers['x-vqd'] = vqd;
194
+
195
+ const chat = await duckFetch('/duckchat/v1/chat', {
196
+ method: 'POST',
197
+ headers,
198
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], stream: true }),
199
+ });
200
+ if (!chat.ok) throw new EngineError(`duckai chat HTTP ${chat.status}`, { retriable: true });
201
+ const body = await chat.text();
202
+ let message = '';
203
+ for (const line of body.split('\n')) {
204
+ const t = line.trim();
205
+ if (!t.startsWith('data:') || t.includes('[DONE]')) continue;
206
+ try { message += (JSON.parse(t.slice(5).trim()).message ?? ''); } catch { /* chunk */ }
207
+ }
208
+ if (message.trim().length < 20) throw new EngineError('duckai: empty chat message', { retriable: true });
209
+ return {
210
+ engine: 'duckai', provider: 'anon-api', model,
211
+ providerModelVersion: `duck:${model}`,
212
+ answer: message, citations: [], fanOut: [],
213
+ usage: { input_tokens: 0, output_tokens: 0 },
214
+ costEstimateUsd: 0.0002,
215
+ };
216
+ },
217
+ };
218
+ }
219
+
220
+ // OpenAI-compatible engine: any endpoint speaking /v1/chat/completions —
221
+ // including self-hosted Duck2api on the VPS, which serves the whole Claude
222
+ // family (haiku/sonnet/opus) plus GPT-5.6 anonymously through duck.ai.
223
+ // The engine identity names the model explicitly; never averaged with others.
224
+ export function createOpenAiCompatibleEngine({ baseUrl, model, apiKey, fetchImpl, costEstimateUsd = 0.0002, providerLabel = 'openai-compatible' } = {}) {
225
+ if (!baseUrl || !model) throw new EngineError('openai-compatible engine needs baseUrl and model');
226
+ const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
227
+ return {
228
+ identity: { engine: model.split(':')[0] ?? model, provider: providerLabel, model },
229
+ estimateCostUsd: () => costEstimateUsd,
230
+ async run({ prompt }) {
231
+ const headers = { 'content-type': 'application/json' };
232
+ if (apiKey) headers.authorization = `Bearer ${apiKey}`;
233
+ const response = await doFetch(`${baseUrl}/chat/completions`, {
234
+ method: 'POST', headers,
235
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }] }),
236
+ signal: AbortSignal.timeout(CITATION_LIMITS.timeoutMs * 2),
237
+ }).catch((cause) => { throw new EngineError(`openai-compatible request failed: ${cause.message}`, { retriable: true, cause }); });
238
+ if (!response.ok) throw new EngineError(`openai-compatible responded ${response.status}`, { retriable: response.status >= 500 || [429, 418, 408, 425].includes(response.status) });
239
+ const body = await response.json().catch(() => { throw new EngineError('openai-compatible response was not JSON'); });
240
+ const answer = body?.choices?.[0]?.message?.content ?? '';
241
+ if (answer.trim().length < 20) throw new EngineError('openai-compatible returned an empty answer', { retriable: true });
242
+ return {
243
+ engine: model.split(':')[0] ?? model, provider: providerLabel, model,
244
+ providerModelVersion: `${providerLabel}:${model}`,
245
+ answer, citations: [], fanOut: [],
246
+ usage: { input_tokens: body?.usage?.prompt_tokens ?? 0, output_tokens: body?.usage?.completion_tokens ?? 0 },
247
+ costEstimateUsd,
248
+ };
249
+ },
250
+ };
251
+ }
252
+
253
+ // Google AI Overview engine via DataForSEO SERP API (fixture-first, DP-0044).
254
+ // AIO is a SERP feature, not a chat: the engine sends a search query and reads
255
+ // the ai_overview block from the returned items. Cited sources are domains the
256
+ // overview linked to — the citation equivalent of a chat engine's source list.
257
+ // The fixture below proves the parser against the documented response shape;
258
+ // the live adapter activates with credentials in fleet, never before.
259
+ export function createGoogleAioEngine({ fetchImpl, credentials, costEstimateUsd = 0.0012 } = {}) {
260
+ const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
261
+ const hasCredentials = Boolean(credentials?.login && credentials?.password);
262
+
263
+ return {
264
+ identity: { engine: 'google-aio', provider: hasCredentials ? 'dataforseo-serp' : 'fixture', model: 'ai-overview' },
265
+ estimateCostUsd: () => costEstimateUsd,
266
+
267
+ async run({ prompt }) {
268
+ if (!hasCredentials) {
269
+ // Fixture mode: prove the parser against the recorded response.
270
+ const fixture = await import('./fixtures/dataforseo-aio.json', { with: { type: 'json' } });
271
+ return parseAioResponse(fixture.default, prompt);
272
+ }
273
+ // Live mode: DataForSEO live/advanced POST.
274
+ const auth = Buffer.from(`${credentials.login}:${credentials.password}`).toString('base64');
275
+ const response = await doFetch('https://api.dataforseo.com/v3/serp/google/organic/live/advanced', {
276
+ method: 'POST',
277
+ headers: { 'authorization': `Basic ${auth}`, 'content-type': 'application/json' },
278
+ body: JSON.stringify([{ keyword: prompt, location_code: 2840, language_code: 'en' }]),
279
+ signal: AbortSignal.timeout(120_000),
280
+ }).catch((cause) => { throw new EngineError(`dataforseo request failed: ${cause.message}`, { retriable: true, cause }); });
281
+ if (!response.ok) throw new EngineError(`dataforseo responded ${response.status}`, { retriable: response.status >= 500 });
282
+ const body = await response.json().catch(() => { throw new EngineError('dataforseo response was not JSON'); });
283
+ return parseAioResponse(body, prompt);
284
+ },
285
+ };
286
+ }
287
+
288
+ function parseAioResponse(body, prompt) {
289
+ const result = body?.tasks?.[0]?.result?.[0];
290
+ if (!result) throw new EngineError('google-aio: no result in response', { retriable: true });
291
+ const aioBlock = (result.items ?? []).find((item) => item.type === 'ai_overview');
292
+ if (!aioBlock) {
293
+ // An honest absence: the SERP rendered without an AI Overview block.
294
+ throw new EngineError('google-aio: no AI Overview block rendered for this query', { retriable: true });
295
+ }
296
+ const answer = aioBlock.text ?? '';
297
+ if (answer.trim().length < 40) {
298
+ throw new EngineError('google-aio: AI Overview text too short to interpret', { retriable: true });
299
+ }
300
+ const citations = (aioBlock.items ?? [])
301
+ .filter((src) => typeof src.url === 'string' && src.url.length > 0)
302
+ .map((src) => ({ url: src.url, title: src.title ?? null }));
303
+ return {
304
+ engine: 'google-aio',
305
+ provider: 'dataforseo-serp',
306
+ model: 'ai-overview',
307
+ providerModelVersion: 'google-aio-serp-v1',
308
+ answer,
309
+ citations,
310
+ fanOut: [],
311
+ usage: { input_tokens: 0, output_tokens: 0 },
312
+ costEstimateUsd: body?.cost ?? 0.0012,
313
+ };
314
+ }
@@ -0,0 +1,53 @@
1
+ // Citation alert: deliver the report when a cell classifies as declined.
2
+ // Uses the same SMTP pattern as the send-email skill (zero-dependency Python
3
+ // script). The alert only fires on complete evidence — insufficient_data
4
+ // and first_epoch never trigger, exactly as the contract requires.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { execFile } from 'node:child_process';
8
+
9
+ export function shouldAlert(cells) {
10
+ return cells.some((c) => c.classification === 'declined');
11
+ }
12
+
13
+ export async function sendAlertEmail({ to, subject, htmlPath, smtp = {} }) {
14
+ if (!to || !htmlPath) throw new Error('sendAlertEmail needs to and htmlPath');
15
+ const { host, port, user, pass, from } = smtp;
16
+ if (!host || !user || !pass) {
17
+ // No SMTP configured — log the alert locally; the report file IS the alert.
18
+ return { delivered: false, reason: 'no smtp configured', htmlPath };
19
+ }
20
+ const html = await readFile(htmlPath, 'utf8');
21
+ // Strip the outer HTML tags for the plain-text body (the HTML stays as an
22
+ // attachment in a real deployment; for v1 the file path is the artifact).
23
+ const textBody = html
24
+ .replace(/<style[^>]*>.*?<\/style>/gs, '')
25
+ .replace(/<[^>]+>/g, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim()
28
+ .slice(0, 2000);
29
+
30
+ const python = `
31
+ import smtplib
32
+ from email.mime.multipart import MIMEMultipart
33
+ from email.mime.text import MIMEText
34
+ msg = MIMEMultipart('alternative')
35
+ msg['Subject'] = ${JSON.stringify(subject)}
36
+ msg['From'] = ${JSON.stringify(from ?? user)}
37
+ msg['To'] = ${JSON.stringify(to)}
38
+ msg.attach(MIMEText(${JSON.stringify(textBody)}, 'plain'))
39
+ with open(${JSON.stringify(htmlPath)}, 'r') as f:
40
+ msg.attach(MIMEText(f.read(), 'html'))
41
+ with smtplib.SMTP(${JSON.stringify(host)}, ${parseInt(port ?? 587, 10)}) as s:
42
+ s.starttls()
43
+ s.login(${JSON.stringify(user)}, ${JSON.stringify(pass)})
44
+ s.send_message(msg)
45
+ print('sent')
46
+ `;
47
+ return new Promise((resolve, reject) => {
48
+ execFile('python3', ['-c', python], (err, stdout) => {
49
+ if (err) reject(new Error(`smtp send failed: ${err.message}`));
50
+ else resolve({ delivered: true, htmlPath });
51
+ });
52
+ });
53
+ }