agentlinkops 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +24 -0
  3. package/CHANGELOG.md +52 -0
  4. package/LICENSE +190 -0
  5. package/NOTICE +17 -0
  6. package/README.md +202 -0
  7. package/bin/agentlinkops.mjs +4 -0
  8. package/bin/linktrail.mjs +6 -0
  9. package/cli/adapters/csv.js +68 -0
  10. package/cli/adapters/sqlite-crm.js +79 -0
  11. package/cli/adapters/suppliers.js +180 -0
  12. package/cli/agent-setup.js +203 -0
  13. package/cli/agentlinkops.mjs +4 -0
  14. package/cli/args.js +18 -0
  15. package/cli/catalog.js +7483 -0
  16. package/cli/check.js +119 -0
  17. package/cli/citation.js +109 -0
  18. package/cli/client.js +60 -0
  19. package/cli/commands.js +183 -0
  20. package/cli/config.js +104 -0
  21. package/cli/connection.js +90 -0
  22. package/cli/context-mcp.js +193 -0
  23. package/cli/context.js +340 -0
  24. package/cli/delivery.js +67 -0
  25. package/cli/doctor.js +283 -0
  26. package/cli/env.js +66 -0
  27. package/cli/fleet.js +82 -0
  28. package/cli/gsc-browser.js +239 -0
  29. package/cli/import.js +108 -0
  30. package/cli/ledger.js +232 -0
  31. package/cli/linktrail.mjs +6 -0
  32. package/cli/local-result.js +161 -0
  33. package/cli/main.js +523 -0
  34. package/cli/mirror.js +126 -0
  35. package/cli/mix.js +123 -0
  36. package/cli/platforms.js +161 -0
  37. package/cli/receipt-performance.js +64 -0
  38. package/cli/receipts.js +122 -0
  39. package/cli/report.js +158 -0
  40. package/cli/setup-plan.js +211 -0
  41. package/cli/skill.js +39 -0
  42. package/cli/state.js +106 -0
  43. package/cli/status.js +51 -0
  44. package/cli/sync.js +179 -0
  45. package/package.json +59 -0
  46. package/references/cli.md +198 -0
  47. package/references/disavow.md +73 -0
  48. package/references/reports.md +53 -0
  49. package/scripts/agentlinkops.py +651 -0
  50. package/scripts/disavow.py +207 -0
  51. package/scripts/linktrail.py +10 -0
  52. package/scripts/migrations/001_crm.sql +45 -0
  53. package/scripts/migrations/002_cloud_events.sql +13 -0
  54. package/scripts/migrations/003_disavow.sql +20 -0
  55. package/scripts/migrations/004_placement_costs.sql +2 -0
  56. package/scripts/report.py +113 -0
  57. package/shared/agent-clients.js +49 -0
  58. package/shared/observation-event.js +43 -0
  59. package/shared/response-contract.js +59 -0
  60. package/shared/search-connection-contract.js +18 -0
  61. package/skills/agentlinkops-assets/SKILL.md +13 -0
  62. package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
  63. package/skills/agentlinkops-campaigns/SKILL.md +17 -0
  64. package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
  65. package/skills/agentlinkops-connect/SKILL.md +33 -0
  66. package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
  67. package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
  68. package/skills/agentlinkops-crm/SKILL.md +27 -0
  69. package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
  70. package/skills/content-defingerprinting/SKILL.md +29 -0
  71. package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
  82. package/skills/internal-linking-optimizer/SKILL.md +56 -0
  83. package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
  84. package/skills/pre-publish-review/SKILL.md +179 -0
  85. package/skills/source-cited-content-builder/SKILL.md +56 -0
  86. package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
  87. package/skills/source-cited-humanizer/SKILL.md +20 -0
  88. package/src/citations/adapter.js +141 -0
  89. package/src/citations/contract.js +171 -0
  90. package/src/citations/match.js +120 -0
  91. package/src/citations/runner.js +151 -0
  92. package/src/citations/stats.js +74 -0
  93. package/src/competitors/contract.js +157 -0
  94. package/src/competitors/domain-mix.js +199 -0
  95. package/src/context/ga4.js +652 -0
  96. package/src/context/gsc.js +925 -0
  97. package/src/context/site-profile.js +445 -0
  98. package/src/context/util.js +71 -0
  99. package/src/context-tool-registry.js +27 -0
  100. package/src/corpus/acquire.js +135 -0
  101. package/src/corpus/seeds.js +182 -0
  102. package/src/discovery/contract.js +237 -0
  103. package/src/discovery/import.js +156 -0
  104. package/src/tool-views.js +117 -0
  105. package/src/verifier/fetch.js +303 -0
  106. package/src/verifier/index.js +311 -0
  107. package/src/verifier/observation-kind.js +91 -0
  108. package/src/verifier/parser.js +241 -0
  109. package/src/verifier/readiness.js +90 -0
  110. package/src/verifier/render-evidence.js +57 -0
  111. package/src/verifier/robots.js +85 -0
  112. package/src/verifier/transition.js +129 -0
  113. package/src/verifier/url.js +56 -0
  114. package/templates/SITE.template.md +59 -0
  115. package/templates/campaigns.v1.json +65 -0
@@ -0,0 +1,239 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { tmpdir } from 'node:os';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { parseArgs } from './args.js';
8
+ import { parseCsv, CsvError } from './adapters/csv.js';
9
+
10
+ export const GSC_BROWSER_HELP = `agentlinkops gsc-links --property PROPERTY --session SESSION --out FILE.csv [--kind latest|sample]
11
+
12
+ Export GSC links through your signed-in browser and installed Playwriter.
13
+ Use an existing Playwriter session. The command opens and closes its own tab.
14
+ Files must be new. CSV exports are limited to 20 MiB and include a receipt.
15
+ Review column mapping before importing. The export does not prove links are live.`;
16
+
17
+ // This factory also runs in Playwriter's CommonJS sandbox. Keep dependencies explicit.
18
+ export function createGscBrowserExporter({ fs, path, createHash, parseCsv }) {
19
+ const limit = 20 * 1024 * 1024;
20
+ const fail = (code, message) => Object.assign(new Error(message), { code, exitCode: 2 });
21
+ async function bounded(promise, milliseconds) {
22
+ let timer;
23
+ try { return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(fail('download_timeout', 'The CSV download timed out. Retry the export.')), milliseconds); })]); }
24
+ finally { clearTimeout(timer); }
25
+ }
26
+ function propertyValue(value) {
27
+ if (typeof value !== 'string' || value.length > 2048 || /[\u0000-\u0020\u007f]/u.test(value)) throw fail('invalid_property', 'Provide an exact GSC property.');
28
+ if (value.startsWith('sc-domain:')) {
29
+ const host = value.slice(10);
30
+ if (host.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z](?:[a-z0-9-]*[a-z0-9])?$/i.test(host) || host.split('.').some(x => x.length > 63)) throw fail('invalid_property', 'Provide a valid GSC domain property.');
31
+ return value;
32
+ }
33
+ let url;
34
+ try { url = new URL(value); } catch { throw fail('invalid_property', 'Provide a valid GSC URL property.'); }
35
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.href !== value) throw fail('invalid_property', 'Use the exact GSC URL property, including its trailing slash.');
36
+ return value;
37
+ }
38
+ function assertPage(page, property, { initial = false } = {}) {
39
+ const current = page.url();
40
+ if (initial && current === 'about:blank') return;
41
+ let url;
42
+ try { url = new URL(current); } catch { throw fail('unexpected_origin', 'The browser left Search Console.'); }
43
+ if (url.origin === 'https://accounts.google.com') throw fail('login_required', 'Sign in to Google in your browser, then retry.');
44
+ if (url.origin !== 'https://search.google.com' || url.username || url.password) throw fail('unexpected_origin', 'The browser left Search Console.');
45
+ if (url.pathname !== '/search-console/links' || url.searchParams.getAll('resource_id').length !== 1 || url.searchParams.get('resource_id') !== property) throw fail('property_mismatch', 'The browser property does not match the requested GSC property.');
46
+ }
47
+ async function pageState(page, property) {
48
+ assertPage(page, property);
49
+ const body = await page.locator('body').innerText({ timeout: 5000 });
50
+ if (/processing data|check (?:back )?again in (?:a (?:day|few days)|a day or so)/i.test(body)) throw fail('processing_data', 'Google is processing this property. Retry when its Links report has data.');
51
+ if (/you (?:do not|don.t) have (?:access|permission)|access denied|verify (?:your )?ownership|ownership verification/i.test(body)) throw fail('permission_required', 'This Google account needs access to the selected property.');
52
+ if (/sign in to (?:continue|google search console)/i.test(body)) throw fail('login_required', 'Sign in to Google in your browser, then retry.');
53
+ }
54
+ async function visibleChoice(page, text) {
55
+ for (const locator of [page.getByRole('menuitem', { name: text, exact: true }), page.getByRole('button', { name: text, exact: true }), page.getByText(text, { exact: true })]) {
56
+ if (await locator.count() === 1 && await locator.isVisible()) return locator;
57
+ }
58
+ return null;
59
+ }
60
+ async function choice(page, property, text, timeout = 10000) {
61
+ const until = Date.now() + timeout;
62
+ do {
63
+ await pageState(page, property);
64
+ const found = await visibleChoice(page, text);
65
+ if (found) return found;
66
+ await new Promise(resolve => setTimeout(resolve, 200));
67
+ } while (Date.now() < until);
68
+ throw fail('export_unavailable', 'The expected GSC export control is unavailable. Open the Links report to check its state.');
69
+ }
70
+ return async function exportLinks({ page, property, outputPath, kind = 'latest', now = () => new Date() }) {
71
+ property = propertyValue(property);
72
+ if (!['latest', 'sample'].includes(kind)) throw fail('invalid_kind', 'Choose latest or sample.');
73
+ if (typeof outputPath !== 'string' || !path.isAbsolute(outputPath) || !outputPath.toLowerCase().endsWith('.csv') || /[\u0000-\u001f\u007f]/u.test(outputPath)) throw fail('invalid_output', 'Choose an absolute CSV output path.');
74
+ const parent = await fs.realpath(path.dirname(outputPath));
75
+ if (parent !== path.resolve(path.dirname(outputPath))) throw fail('unsafe_output', 'The output directory must not use symbolic links.');
76
+ const receiptPath = `${outputPath}.receipt.json`;
77
+ for (const candidate of [outputPath, receiptPath]) {
78
+ try { await fs.lstat(candidate); throw fail('output_exists', 'The output or receipt file already exists. Choose a new path.'); }
79
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
80
+ }
81
+ assertPage(page, property, { initial: true });
82
+ const report = new URL('https://search.google.com/search-console/links');
83
+ report.searchParams.set('resource_id', property);
84
+ report.searchParams.set('hl', 'en');
85
+ let download, ownedOutput = false, ownedReceipt = false;
86
+ const receive = item => { if (!download) download = item; };
87
+ try {
88
+ await page.goto(report.href, { waitUntil: 'domcontentloaded', timeout: 20000 });
89
+ const exportButton = await choice(page, property, 'Export external links');
90
+ await exportButton.click({ timeout: 5000 });
91
+ await pageState(page, property);
92
+ const kindButton = await choice(page, property, kind === 'latest' ? 'Latest links' : 'More sample links');
93
+ page.on('download', receive);
94
+ await kindButton.click({ timeout: 5000 });
95
+ const until = Date.now() + 15000;
96
+ let csvClicked = false;
97
+ do {
98
+ await pageState(page, property);
99
+ if (download) break;
100
+ if (!csvClicked) {
101
+ const csvButton = await visibleChoice(page, 'Download CSV') || await visibleChoice(page, 'CSV');
102
+ if (csvButton) { await csvButton.click({ timeout: 5000 }); csvClicked = true; }
103
+ }
104
+ if (!download) await new Promise(resolve => setTimeout(resolve, 200));
105
+ } while (!download && Date.now() < until);
106
+ if (!download) throw fail('download_unavailable', 'Google did not provide a CSV download. Check the Links report and retry.');
107
+ assertPage(page, property);
108
+ // The browser owns authenticated retrieval. Never extract cookies or replay private RPCs.
109
+ let stream;
110
+ try { stream = await bounded(download.createReadStream(), 15000); } catch { /* The extension can emit downloads without a relay-side artifact. */ }
111
+ const hash = createHash('sha256');
112
+ const chunks = [];
113
+ let bytes = 0;
114
+ async function accept(data) {
115
+ const chunk = Buffer.from(data);
116
+ bytes += chunk.length;
117
+ if (bytes > limit) throw fail('export_too_large', 'The CSV export exceeds the 20 MiB limit.');
118
+ hash.update(chunk); chunks.push(chunk);
119
+ }
120
+ let retrievalTransport = 'download-stream';
121
+ try {
122
+ if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
123
+ const iterator = stream[Symbol.asyncIterator]();
124
+ const streamDeadline = Date.now() + 15000;
125
+ while (true) {
126
+ const remaining = streamDeadline - Date.now();
127
+ if (remaining <= 0) throw fail('download_timeout', 'The CSV download timed out. Retry the export.');
128
+ const {value: data, done} = await bounded(iterator.next(), remaining);
129
+ if (done) break;
130
+ await accept(data);
131
+ }
132
+ }
133
+ } finally { stream?.destroy?.(); }
134
+ if (!bytes) {
135
+ // Extension mode may have no relay-side file. Read only the URL emitted by
136
+ // this page's download event, inside that same signed-in page. No cookies,
137
+ // request headers or private RPC identifiers leave the browser.
138
+ const downloadUrl = download.url();
139
+ const parsed = new URL(downloadUrl);
140
+ const allowed = (parsed.protocol === 'https:' && parsed.origin === 'https://search.google.com' && !parsed.username && !parsed.password) ||
141
+ (parsed.protocol === 'blob:' && parsed.origin === 'https://search.google.com') ||
142
+ /^data:text\/csv[;,]/i.test(downloadUrl);
143
+ if (!allowed) throw fail('unsafe_download', 'Google returned an unsupported download address. Export the CSV manually.');
144
+ assertPage(page, property);
145
+ const result = await bounded(page.evaluate(async ({ url, maxBytes }) => {
146
+ const controller = new AbortController();
147
+ const timer = setTimeout(() => controller.abort(), 12000);
148
+ let reader;
149
+ try {
150
+ const response = await fetch(url, { credentials: 'include', redirect: 'error', signal: controller.signal });
151
+ if (!response.ok || !response.body) return { error: 'download_unavailable' };
152
+ if (Number(response.headers.get('content-length')) > maxBytes) return { error: 'export_too_large' };
153
+ reader = response.body.getReader();
154
+ let total = 0;
155
+ const encoded = [];
156
+ while (true) {
157
+ const { value, done } = await reader.read();
158
+ if (done) break;
159
+ total += value.byteLength;
160
+ if (total > maxBytes) return { error: 'export_too_large' };
161
+ let binary = '';
162
+ for (let offset = 0; offset < value.length; offset += 32768) binary += String.fromCharCode(...value.subarray(offset, offset + 32768));
163
+ encoded.push(btoa(binary));
164
+ }
165
+ return { chunks: encoded };
166
+ } catch { return { error: 'download_unavailable' }; }
167
+ finally { clearTimeout(timer); await reader?.cancel().catch(() => {}); controller.abort(); }
168
+ }, { url: downloadUrl, maxBytes: limit }), 15000);
169
+ if (result.error) throw fail(result.error, result.error === 'export_too_large' ? 'The CSV export exceeds the 20 MiB limit.' : 'The browser could not read its CSV download. Export it manually and import the file.');
170
+ for (const encoded of result.chunks) await accept(Buffer.from(encoded, 'base64'));
171
+ retrievalTransport = 'browser-download-url';
172
+ }
173
+ const csv = Buffer.concat(chunks).toString('utf8');
174
+ if (!bytes || /^\s*</.test(csv) || csv.includes('\u0000')) throw fail('invalid_csv', 'The downloaded file is not a CSV export.');
175
+ let rows;
176
+ try { rows = parseCsv(csv); } catch { throw fail('invalid_csv', 'The CSV export has invalid quoting.'); }
177
+ const headers = rows[0]?.map(value => value.trim()) ?? [];
178
+ const sourceHeader = headers.find(value => /^(linking page|links|source url)$/i.test(value));
179
+ if (!sourceHeader || headers.length > 30 || new Set(headers).size !== headers.length || headers.some(value => /^(top linking sites|top linked pages|domain|link count|external links)$/i.test(value)) || rows.slice(1).some(row => row.length !== headers.length)) throw fail('unsupported_csv', 'The CSV columns differ from the supported GSC exports. Review the export format.');
180
+ const sourceIndex = headers.indexOf(sourceHeader);
181
+ if (rows.slice(1).some(row => {
182
+ try { const source = new URL(row[sourceIndex]); return !['http:', 'https:'].includes(source.protocol) || Boolean(source.username || source.password); }
183
+ catch { return true; }
184
+ })) throw fail('unsupported_csv', 'The source column must contain page URLs. Review the export format.');
185
+ const timestamp = typeof now === 'function' ? now() : now;
186
+ const receipt = {
187
+ schema_version: 1, retrieved_by: 'gsc-browser-export', retrieval_transport: retrievalTransport, property, kind,
188
+ captured_at: new Date(timestamp).toISOString(), output_path: outputPath,
189
+ sha256: hash.digest('hex'), bytes, rows: rows.length - 1, headers,
190
+ source_column: sourceHeader, target_mapping_required: true,
191
+ sampled: true, current_link_status: 'unverified',
192
+ };
193
+ await fs.writeFile(outputPath, Buffer.concat(chunks), { flag: 'wx', mode: 0o600 }); ownedOutput = true;
194
+ await fs.writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); ownedReceipt = true;
195
+ return receipt;
196
+ } catch (error) {
197
+ await download?.cancel?.().catch(() => {});
198
+ if (ownedOutput) await fs.unlink(outputPath).catch(() => {});
199
+ if (ownedReceipt) await fs.unlink(receiptPath).catch(() => {});
200
+ if (error.exitCode === 2) throw error;
201
+ throw fail('browser_export_failed', 'GSC export failed. Check your browser session and retry.');
202
+ } finally {
203
+ page.off('download', receive);
204
+ await download?.delete?.().catch(() => {});
205
+ }
206
+ };
207
+ }
208
+
209
+ export const exportGscLinks = createGscBrowserExporter({ fs, path, createHash, parseCsv });
210
+
211
+ export async function gscLinksMain(argv, { cwd = process.cwd(), out = console.log, execFileImpl = promisify(execFile) } = {}) {
212
+ const args = parseArgs(argv);
213
+ if (args._[0] === 'gsc-links') args._.shift();
214
+ if (args.help === true && Object.keys(args).length === 2 && !args._.length) { out(GSC_BROWSER_HELP); return 0; }
215
+ if (args._.length || Object.keys(args).some(key => !['_', 'property', 'session', 'out', 'kind'].includes(key)) ||
216
+ ['property', 'session', 'out'].some(key => typeof args[key] !== 'string' || !args[key]) ||
217
+ !/^[a-zA-Z0-9_-]{1,100}$/.test(args.session) || (args.kind !== undefined && !['latest', 'sample'].includes(args.kind))) {
218
+ throw Object.assign(new Error(GSC_BROWSER_HELP), { exitCode: 2 });
219
+ }
220
+ const outputPath = path.resolve(cwd, args.out);
221
+ const scratch = await fs.mkdtemp(path.join(tmpdir(), 'agentlinkops-gsc-'));
222
+ try {
223
+ // Pass source/config as a file argument, never interpolate a shell command.
224
+ const script = `const CsvError = ${CsvError.toString()};\nconst parseCsv = ${parseCsv.toString()};\nconst exportLinks = (${createGscBrowserExporter.toString()})({fs:require('node:fs').promises,path:require('node:path'),createHash:require('node:crypto').createHash,parseCsv});\nconst config = ${JSON.stringify({ property: args.property, outputPath, kind: args.kind ?? 'latest' })};\nconst exportPage = await context.newPage();\ntry { const receipt = await exportLinks({...config,page:exportPage}); console.log('AGENTLINKOPS_GSC_RECEIPT '+JSON.stringify(receipt)); } catch(error) { console.log('AGENTLINKOPS_GSC_ERROR '+JSON.stringify({code:error.code||'browser_export_failed',message:error.message})); } finally { await exportPage.close(); }\n`;
225
+ const scriptPath = path.join(scratch, 'export.js');
226
+ await fs.writeFile(scriptPath, script, { mode: 0o600 });
227
+ let stdout;
228
+ try { ({ stdout } = await execFileImpl('playwriter', ['-s', args.session, '-f', scriptPath, '--timeout', '60000'], { cwd, timeout: 65000, maxBuffer: 1024 * 1024 })); }
229
+ catch { throw Object.assign(new Error('Playwriter could not finish the export. Check the session and installed command.'), { code: 'playwriter_failed', exitCode: 2 }); }
230
+ const lines = stdout.split('\n').map(line => line.replace(/^\[log\]\s*/, ''));
231
+ const errorLine = lines.find(line => line.startsWith('AGENTLINKOPS_GSC_ERROR '));
232
+ if (errorLine) { const error = JSON.parse(errorLine.slice('AGENTLINKOPS_GSC_ERROR '.length)); throw Object.assign(new Error(error.message), { code: error.code, exitCode: 2 }); }
233
+ const receiptLine = lines.find(line => line.startsWith('AGENTLINKOPS_GSC_RECEIPT '));
234
+ if (!receiptLine) throw Object.assign(new Error('Playwriter returned no export receipt.'), { code: 'receipt_missing', exitCode: 2 });
235
+ const receipt = JSON.parse(receiptLine.slice('AGENTLINKOPS_GSC_RECEIPT '.length));
236
+ out(JSON.stringify(receipt, null, 2));
237
+ return 0;
238
+ } finally { await fs.rm(scratch, { recursive: true, force: true }); }
239
+ }
package/cli/import.js ADDED
@@ -0,0 +1,108 @@
1
+ // `agentlinkops import`: a supplier export becomes candidate rows in the shared shape.
2
+ //
3
+ // The whole lane exists so that "we do not make you depend on our data" is true today rather
4
+ // than after the corpus is built. The customer keeps their supplier; we verify and watch.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { readTable, CsvError } from './adapters/csv.js';
7
+ import { resolveMapping, toImportRow, SUPPLIER_NAMES } from './adapters/suppliers.js';
8
+ import { prepareImport } from '../src/discovery/import.js';
9
+ import { DISCOVERY_LIMITS } from '../src/discovery/contract.js';
10
+
11
+ /** Rejection reasons by frequency, with an example line for each. */
12
+ export function summarizeReasons(rejected) {
13
+ const groups = new Map();
14
+ for (const entry of rejected) {
15
+ // "source_url: invalid_url" and "source_url: empty" are different problems; the row number
16
+ // inside a reason is not part of the kind.
17
+ const kind = String(entry.reason).replace(/\(.*?\)/gu, '(…)').slice(0, 120);
18
+ if (!groups.has(kind)) groups.set(kind, { reason: kind, count: 0, example_row: entry.row });
19
+ groups.get(kind).count++;
20
+ }
21
+ return [...groups.values()].sort((a, b) => b.count - a.count);
22
+ }
23
+
24
+ /** `--map source=Referring page URL,target=Target URL` */
25
+ export function parseMap(value) {
26
+ if (!value || value === true) return {};
27
+ const overrides = {};
28
+ for (const pair of String(value).split(',')) {
29
+ const index = pair.indexOf('=');
30
+ if (index < 1) throw new CsvError(`--map entries look like field=column, not "${pair}"`, 0);
31
+ overrides[pair.slice(0, index).trim()] = pair.slice(index + 1).trim();
32
+ }
33
+ return overrides;
34
+ }
35
+
36
+ /**
37
+ * Reads an export and reports what it would import. Never writes.
38
+ *
39
+ * Rejections are returned in two layers because they have different causes: a row the FILE could
40
+ * not produce (a short row, a broken delimiter) and a row the CONTRACT would not accept (a bad
41
+ * URL, an unreadable date, a link to someone else's site). Collapsing them into one count would
42
+ * hide which half of the pipeline to look at.
43
+ */
44
+ export async function readImport(path, {
45
+ supplier = 'csv', map = {}, target, targetKind = 'domain', includeSubdomains = true,
46
+ runId = 'dr_preview', workspaceId = 'ws_preview', projectId = 'pr_preview', now = new Date().toISOString(),
47
+ supplierGeneratedAt = null,
48
+ } = {}) {
49
+ if (!SUPPLIER_NAMES.includes(supplier)) return { error: `unknown supplier "${supplier}". Known: ${SUPPLIER_NAMES.join(', ')}` };
50
+ const text = await readFile(path, 'utf8');
51
+ let table;
52
+ try { table = readTable(text); }
53
+ catch (error) { return { error: error instanceof CsvError ? `line ${error.line}: ${error.message}` : String(error.message) }; }
54
+
55
+ const resolved = resolveMapping(table.header, { supplier, overrides: map, targetProvided: Boolean(target) });
56
+ if (resolved.error) return { error: resolved.error, header: table.header };
57
+
58
+ const unreadable = table.records.filter(record => record.error).map(record => ({ row: record.line, reason: record.error }));
59
+ const readable = table.records.filter(record => record.values);
60
+ const rows = readable.map(record => toImportRow(record.values, resolved.mapping, resolved.unclaimed));
61
+ // A file with no target column is a file about ONE target, which the operator named with
62
+ // --target. Every row gets that target's site root; the scope below says what "matching"
63
+ // means. Nothing is inferred — without --target the resolver above refuses the file.
64
+ if (!resolved.mapping.target) {
65
+ const targetUrl = targetKind === 'domain' ? `https://${target}/` : target;
66
+ for (const row of rows) row.target_url = targetUrl;
67
+ }
68
+
69
+ const targetUrl = targetKind === 'domain' ? `https://${target}/` : target;
70
+ const scope = targetKind === 'exact_url' ? 'exact' : includeSubdomains ? 'domain' : 'subdomain';
71
+ // One discovery run holds at most 1,000 candidates, and real exports are bigger than that —
72
+ // the Settled registry is 2,241 rows. A preview reads the WHOLE file and reports it as one
73
+ // answer; `sync` is where the chunks become runs. Duplicate detection is shared across
74
+ // chunks, so a placement repeated in two different parts of one file is still one candidate.
75
+ const prepared = { accepted: [], rejected: [], duplicates: [] };
76
+ const seen = new Map();
77
+ const chunks = [];
78
+ for (let index = 0; index < rows.length; index += DISCOVERY_LIMITS.rows) chunks.push(index);
79
+ for (const offset of chunks) {
80
+ const part = await prepareImport({
81
+ runId: `${runId}_${offset / DISCOVERY_LIMITS.rows}`, workspaceId, projectId, supplier,
82
+ rows: rows.slice(offset, offset + DISCOVERY_LIMITS.rows), target: targetUrl, scope,
83
+ taskId: 'import_preview', retrievedAt: now, generatedAt: supplierGeneratedAt,
84
+ seen, rowOffset: offset,
85
+ });
86
+ prepared.accepted.push(...part.accepted);
87
+ prepared.rejected.push(...part.rejected);
88
+ prepared.duplicates.push(...part.duplicates);
89
+ }
90
+ // Rejections carry the file's own line numbers, not the array index, so "row 412" means what
91
+ // it means when the customer opens the file.
92
+ const rejected = [
93
+ ...unreadable,
94
+ ...prepared.rejected.map(entry => ({ row: readable[entry.row - 1]?.line ?? entry.row, reason: entry.reason })),
95
+ ].sort((a, b) => a.row - b.row);
96
+
97
+ return {
98
+ supplier, mapping: resolved.mapping, unclaimed: resolved.unclaimed, header: table.header,
99
+ rows, accepted: prepared.accepted,
100
+ rejected, duplicates: prepared.duplicates.map(entry => ({ row: readable[entry.row - 1]?.line ?? entry.row, first_seen_on_row: readable[entry.first_seen_on_row - 1]?.line ?? entry.first_seen_on_row })),
101
+ counts: { read: table.records.length, accepted: prepared.accepted.length, rejected: rejected.length, duplicates: prepared.duplicates.length },
102
+ chunks: chunks.length,
103
+ // Grouped, because two thousand identical lines is not a report. When one reason accounts
104
+ // for nearly everything, the MAPPING is wrong rather than the data, and the reader needs to
105
+ // see that in the first line instead of inferring it from scrolling.
106
+ reasons: summarizeReasons(rejected),
107
+ };
108
+ }
package/cli/ledger.js ADDED
@@ -0,0 +1,232 @@
1
+ // The ledger: intent, human-owned, one JSON object per line.
2
+ //
3
+ // Reading is deliberately forgiving of everything except silence. A line that does not parse,
4
+ // or an entry that is not valid, is REPORTED with its line number and kept out of the working
5
+ // set — never dropped quietly. A tool that discards a customer's row and carries on produces a
6
+ // number that is wrong in a way they discover months later.
7
+ import { readFile, writeFile, mkdir, rename, open, unlink } from 'node:fs/promises';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ export const INTENTS = Object.freeze(['wanted', 'expected', 'retired']);
11
+ export const SCOPES = Object.freeze(['exact', 'domain', 'subdomain', 'path']);
12
+ export const CADENCES = Object.freeze({ daily: 86_400, weekly: 604_800, monthly: 2_592_000 });
13
+ // What the cloud accepts, mirrored so a local ledger cannot hold something sync must reject.
14
+ export const CADENCE_BOUNDS = Object.freeze({ min: 3_600, max: 2_592_000 });
15
+ // Which transition is news, per intent. This is the whole difference between them.
16
+ export const DEFAULT_CADENCE = Object.freeze({ wanted: 'weekly', expected: 'daily', retired: null });
17
+
18
+ // Stable key order, so `fmt` produces a diff that names the entry that changed rather than
19
+ // reordering every line.
20
+ const KEY_ORDER = ['id', 'intent', 'source', 'target', 'scope', 'expect', 'cadence', 'ref', 'origin', 'tags', 'added', 'note'];
21
+
22
+ // Where an entry came from, when it was not typed by hand. `placement_run` is adopt-side
23
+ // lineage, not import-side: a run's queue holds decisions already made and its output reports
24
+ // placements, which is why T09 consumes it through adopt rather than the discovery import lane.
25
+ export const ORIGIN_KINDS = Object.freeze(['manual', 'import', 'discovery_candidate', 'mention', 'citation', 'competitor', 'placement_run']);
26
+ // What `local_reference` can carry to the cloud. It truncates at 200, and the ledger id plus a
27
+ // space goes first — so how much `ref` survives depends on the ID, which is 7 to 35 characters.
28
+ // A single constant would be wrong for every entry but one, which is why the check below is a
29
+ // function of the entry rather than a number.
30
+ export const LOCAL_REFERENCE_LIMIT = 200;
31
+
32
+ export function newId() {
33
+ return `lk_${crypto.randomUUID().replaceAll('-', '').slice(0, 10)}`;
34
+ }
35
+
36
+ const isUrl = value => {
37
+ try { const url = new URL(value); return url.protocol === 'https:' || url.protocol === 'http:'; }
38
+ catch { return false; }
39
+ };
40
+
41
+ /** Validates one entry and returns it normalised, or throws with a reason a human can act on. */
42
+ export function normalizeEntry(input, { assignId = false } = {}) {
43
+ const problems = [];
44
+ const entry = {};
45
+ entry.id = typeof input.id === 'string' && /^lk_[a-z0-9]{4,32}$/.test(input.id)
46
+ ? input.id : (assignId ? newId() : null);
47
+ if (!entry.id) problems.push('id must look like lk_xxxxxxxx');
48
+ entry.intent = INTENTS.includes(input.intent) ? input.intent : null;
49
+ if (!entry.intent) problems.push(`intent must be one of ${INTENTS.join(', ')}`);
50
+ entry.source = isUrl(input.source) ? input.source : null;
51
+ if (!entry.source) problems.push('source must be an http(s) URL');
52
+ entry.target = isUrl(input.target) ? input.target : null;
53
+ if (!entry.target) problems.push('target must be an http(s) URL');
54
+ entry.scope = input.scope ?? 'exact';
55
+ if (!SCOPES.includes(entry.scope)) problems.push(`scope must be one of ${SCOPES.join(', ')}`);
56
+
57
+ if (input.expect != null) {
58
+ if (typeof input.expect !== 'object' || Array.isArray(input.expect)) problems.push('expect must be an object');
59
+ else {
60
+ const expect = {};
61
+ if (input.expect.anchor != null) {
62
+ if (typeof input.expect.anchor !== 'string' || input.expect.anchor.length > 1000) problems.push('expect.anchor must be a string of at most 1000 characters');
63
+ else expect.anchor = input.expect.anchor;
64
+ }
65
+ if (input.expect.rel != null) {
66
+ const rel = input.expect.rel;
67
+ if (!Array.isArray(rel) || rel.length > 20 || rel.some(token => typeof token !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(token))) {
68
+ problems.push('expect.rel must be up to 20 relation tokens');
69
+ } else expect.rel = [...new Set(rel.map(token => token.toLowerCase()))].sort();
70
+ }
71
+ if (Object.keys(expect).length) entry.expect = expect;
72
+ }
73
+ }
74
+
75
+ const cadence = input.cadence ?? DEFAULT_CADENCE[entry.intent] ?? undefined;
76
+ if (cadence !== undefined && cadence !== null) {
77
+ const seconds = cadenceSeconds(cadence);
78
+ if (seconds === null) problems.push(`cadence must be daily, weekly, monthly or ${CADENCE_BOUNDS.min}-${CADENCE_BOUNDS.max} seconds`);
79
+ else entry.cadence = cadence;
80
+ }
81
+ if (input.ref != null) {
82
+ if (typeof input.ref !== 'string' || input.ref.length > 500) problems.push('ref must be a string of at most 500 characters');
83
+ else entry.ref = input.ref;
84
+ }
85
+ // Lineage: where this entry came from, when a tool produced it rather than a person typing it.
86
+ //
87
+ // **Deliberately its own field and not part of `ref`.** `ref` is the half of `local_reference`
88
+ // the cloud truncates at 200 characters, and how much of it survives depends on the length of
89
+ // the ledger id in front of it — so the same provenance string survives on one entry and is cut
90
+ // on another. A provenance that shortens depending on an unrelated id is one that will name the
91
+ // wrong run, so lineage stays local, where nothing truncates it.
92
+ if (input.origin != null) {
93
+ if (typeof input.origin !== 'object' || Array.isArray(input.origin)) problems.push('origin must be an object');
94
+ else {
95
+ const origin = {};
96
+ if (!ORIGIN_KINDS.includes(input.origin.kind)) problems.push(`origin.kind must be one of ${ORIGIN_KINDS.join(', ')}`);
97
+ else origin.kind = input.origin.kind;
98
+ for (const field of ['id', 'run_id', 'provider', 'observed_at', 'source_url']) {
99
+ if (input.origin[field] == null) continue;
100
+ if (typeof input.origin[field] !== 'string' || input.origin[field].length > 500) problems.push(`origin.${field} must be a string of at most 500 characters`);
101
+ else origin[field] = input.origin[field];
102
+ }
103
+ // An unknown lineage key is kept for the same reason an unknown entry key is.
104
+ for (const [key, value] of Object.entries(input.origin)) if (!(key in origin) && !['kind', 'id', 'run_id', 'provider', 'observed_at', 'source_url'].includes(key)) origin[key] = value;
105
+ if (Object.keys(origin).length) entry.origin = origin;
106
+ }
107
+ }
108
+ if (input.tags != null) {
109
+ if (!Array.isArray(input.tags) || input.tags.some(tag => typeof tag !== 'string')) problems.push('tags must be an array of strings');
110
+ else if (input.tags.length) entry.tags = [...new Set(input.tags)];
111
+ }
112
+ for (const field of ['added', 'note']) {
113
+ if (input[field] != null) {
114
+ if (typeof input[field] !== 'string') problems.push(`${field} must be a string`);
115
+ else entry[field] = input[field];
116
+ }
117
+ }
118
+ // An unknown key is kept rather than discarded: it is the customer's file, and a future
119
+ // version of this tool may well mean something by it.
120
+ for (const [key, value] of Object.entries(input)) if (!KEY_ORDER.includes(key)) entry[key] = value;
121
+ if (problems.length) {
122
+ const error = new Error(problems.join('; '));
123
+ error.problems = problems;
124
+ throw error;
125
+ }
126
+ return entry;
127
+ }
128
+
129
+ /** `daily` / `weekly` / `monthly` / an integer, to seconds the cloud will accept. */
130
+ export function cadenceSeconds(cadence) {
131
+ const seconds = typeof cadence === 'string' ? (CADENCES[cadence] ?? Number(cadence)) : cadence;
132
+ if (!Number.isInteger(seconds) || seconds < CADENCE_BOUNDS.min || seconds > CADENCE_BOUNDS.max) return null;
133
+ return seconds;
134
+ }
135
+
136
+ /**
137
+ * Reads a ledger. Returns the entries that are usable AND every problem found, because a
138
+ * caller that cannot see the problems will report a count that quietly excludes them.
139
+ */
140
+ export async function readLedger(path) {
141
+ let text = '';
142
+ try { text = await readFile(path, 'utf8'); }
143
+ catch (error) { if (error.code === 'ENOENT') return { entries: [], problems: [], missing: true }; throw error; }
144
+ const entries = [], problems = [], seen = new Map();
145
+ text.split('\n').forEach((line, index) => {
146
+ const number = index + 1;
147
+ if (!line.trim() || line.trimStart().startsWith('//')) return;
148
+ let parsed;
149
+ try { parsed = JSON.parse(line); }
150
+ catch (error) { problems.push({ line: number, reason: `not valid JSON: ${error.message}` }); return; }
151
+ let entry;
152
+ try { entry = normalizeEntry(parsed); }
153
+ catch (error) { problems.push({ line: number, id: parsed?.id ?? null, reason: error.message }); return; }
154
+ // A duplicate id is an ambiguity, not a preference: which observation history belongs to
155
+ // which entry stops having an answer, so both are reported and neither is guessed at.
156
+ if (seen.has(entry.id)) {
157
+ problems.push({ line: number, id: entry.id, reason: `duplicate id, first seen on line ${seen.get(entry.id)}` });
158
+ return;
159
+ }
160
+ seen.set(entry.id, number);
161
+ entries.push(entry);
162
+ });
163
+ return { entries, problems, missing: false };
164
+ }
165
+
166
+ /** One entry per line, keys in a fixed order, sorted by id. Written atomically. */
167
+ export function serializeLedger(entries) {
168
+ const ordered = [...entries].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
169
+ return ordered.map(entry => {
170
+ const object = {};
171
+ for (const key of KEY_ORDER) if (entry[key] !== undefined) object[key] = entry[key];
172
+ for (const [key, value] of Object.entries(entry)) if (!KEY_ORDER.includes(key)) object[key] = value;
173
+ return JSON.stringify(object);
174
+ }).join('\n') + (ordered.length ? '\n' : '');
175
+ }
176
+
177
+ export async function writeLedger(path, entries) {
178
+ await mkdir(dirname(path), { recursive: true });
179
+ const temporary = join(dirname(path), `.${crypto.randomUUID()}.tmp`);
180
+ await writeFile(temporary, serializeLedger(entries), 'utf8');
181
+ await rename(temporary, path);
182
+ }
183
+
184
+ /** Read, validate and replace under one lock. A malformed row prevents every mutation. */
185
+ export async function mutateLedger(path, update) {
186
+ const lockPath = `${path}.lock`;
187
+ const lock = await open(lockPath, 'wx').catch(error => {
188
+ if (error.code === 'EEXIST') throw new Error('Ledger is being updated by another process.');
189
+ throw error;
190
+ });
191
+ try {
192
+ const ledger = await readLedger(path);
193
+ if (ledger.missing) throw new Error('Ledger does not exist.');
194
+ if (ledger.problems.length) throw new Error(`Ledger has invalid rows; repair them before writing (line ${ledger.problems[0].line}).`);
195
+ const result = await update(ledger.entries);
196
+ await writeLedger(path, result);
197
+ return result;
198
+ } finally { await lock.close(); await unlink(lockPath); }
199
+ }
200
+
201
+ /**
202
+ * What the cloud is told about this entry.
203
+ *
204
+ * `localReference` carries the ledger id FIRST and the customer's own reference after it, so
205
+ * the id survives truncation at the cloud's 200-character limit. The id is what makes a
206
+ * corrected URL keep its history; the customer's reference is best-effort and the ledger keeps
207
+ * the full value either way.
208
+ */
209
+ export function toWatchInput(entry) {
210
+ // The ledger id always survives; the ref tail may not, and `refTruncatedInTransit` exists so a
211
+ // caller can say so rather than discovering it when a reference comes back short.
212
+ const reference = entry.ref ? `${entry.id} ${entry.ref}` : entry.id;
213
+ return {
214
+ sourceUrl: entry.source, targetUrl: entry.target, targetScope: entry.scope,
215
+ cadenceSeconds: cadenceSeconds(entry.cadence ?? DEFAULT_CADENCE[entry.intent] ?? 'daily'),
216
+ expectedAnchor: entry.expect?.anchor ?? null,
217
+ expectedRel: entry.expect?.rel ?? null,
218
+ localReference: reference.slice(0, 200),
219
+ };
220
+ }
221
+
222
+ /** Whether this entry's `ref` would lose its tail on the way to the cloud. */
223
+ export function refTruncatedInTransit(entry) {
224
+ if (typeof entry?.ref !== 'string' || !entry.ref) return false;
225
+ return `${entry.id} ${entry.ref}`.length > LOCAL_REFERENCE_LIMIT;
226
+ }
227
+
228
+ /** The ledger id inside a `localReference`, or null. */
229
+ export function ledgerIdOf(localReference) {
230
+ const match = /^(lk_[a-z0-9]{4,32})(?:\s|$)/.exec(localReference ?? '');
231
+ return match ? match[1] : null;
232
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Compatibility alias for the pilot window (DP-0029): the executable is `agentlinkops`.
3
+ // One line to stderr, then the same main — stdout stays clean for `--json` readers.
4
+ import { main } from './main.js';
5
+ process.stderr.write('linktrail: this command is now `agentlinkops`; the old name keeps working during the pilot compatibility window.\n');
6
+ process.exitCode = await main();