@agent-pattern-labs/leads-rig 0.1.3 → 0.1.4

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.
@@ -41,10 +41,10 @@ Available commands:
41
41
 
42
42
  Local commands:
43
43
  npx public-leads crawl --input data/domains.tsv --out data/lead-results.json
44
- npx public-leads pipeline --input data/domains.tsv --ingest --target-project /path/to/cold-agent-leads
44
+ npx public-leads pipeline --input data/domains.tsv --ingest
45
45
  npx public-leads validate --input data/lead-results.json
46
46
  npx public-leads manifest --input data/lead-results.json
47
- npx public-leads ingest --input data/lead-results.json --target-project /path/to/cold-agent-leads
47
+ npx public-leads ingest --input data/lead-results.json
48
48
  npx public-leads verify
49
49
  ```
50
50
 
@@ -41,10 +41,10 @@ Available commands:
41
41
 
42
42
  Local commands:
43
43
  npx public-leads crawl --input data/domains.tsv --out data/lead-results.json
44
- npx public-leads pipeline --input data/domains.tsv --ingest --target-project /path/to/cold-agent-leads
44
+ npx public-leads pipeline --input data/domains.tsv --ingest
45
45
  npx public-leads validate --input data/lead-results.json
46
46
  npx public-leads manifest --input data/lead-results.json
47
- npx public-leads ingest --input data/lead-results.json --target-project /path/to/cold-agent-leads
47
+ npx public-leads ingest --input data/lead-results.json
48
48
  npx public-leads verify
49
49
  ```
50
50
 
@@ -0,0 +1,15 @@
1
+ # iso-route — Pi notes
2
+
3
+ Pi reads `.pi/settings.json` for the default provider/model and model cycling. It does not have native role-specific subagent binding, so role entries below are advisory for manual model switching or extension/package workflows.
4
+
5
+ ## Default
6
+
7
+ - **anthropic / claude-sonnet-4-6**
8
+
9
+ ## Roles
10
+
11
+ | Role | Provider | Model | Thinking | Fallback |
12
+ | ---- | -------- | ----- | -------- | -------- |
13
+ | `fast` | anthropic | `claude-haiku-4-5` | - | - |
14
+ | `quality` | anthropic | `claude-opus-4-7` | high | - |
15
+ | `minimal` | anthropic | `claude-haiku-4-5` | - | - |
@@ -39,10 +39,10 @@ Available commands:
39
39
 
40
40
  Local commands:
41
41
  npx public-leads crawl --input data/domains.tsv --out data/lead-results.json
42
- npx public-leads pipeline --input data/domains.tsv --ingest --target-project /path/to/cold-agent-leads
42
+ npx public-leads pipeline --input data/domains.tsv --ingest
43
43
  npx public-leads validate --input data/lead-results.json
44
44
  npx public-leads manifest --input data/lead-results.json
45
- npx public-leads ingest --input data/lead-results.json --target-project /path/to/cold-agent-leads
45
+ npx public-leads ingest --input data/lead-results.json
46
46
  npx public-leads verify
47
47
  ```
48
48
 
@@ -39,10 +39,10 @@ Available commands:
39
39
 
40
40
  Local commands:
41
41
  npx public-leads crawl --input data/domains.tsv --out data/lead-results.json
42
- npx public-leads pipeline --input data/domains.tsv --ingest --target-project /path/to/cold-agent-leads
42
+ npx public-leads pipeline --input data/domains.tsv --ingest
43
43
  npx public-leads validate --input data/lead-results.json
44
44
  npx public-leads manifest --input data/lead-results.json
45
- npx public-leads ingest --input data/lead-results.json --target-project /path/to/cold-agent-leads
45
+ npx public-leads ingest --input data/lead-results.json
46
46
  npx public-leads verify
47
47
  ```
48
48
 
@@ -0,0 +1,9 @@
1
+ {
2
+ "defaultProvider": "anthropic",
3
+ "defaultModel": "claude-sonnet-4-6",
4
+ "enabledModels": [
5
+ "claude-sonnet-4-6",
6
+ "claude-haiku-4-5",
7
+ "claude-opus-4-7"
8
+ ]
9
+ }
package/README.md CHANGED
@@ -20,9 +20,9 @@ npm run smoke:iso
20
20
 
21
21
  npx -p @agent-pattern-labs/leads-rig create-public-leads-harness my-lead-project
22
22
  npx public-leads crawl --domains example.com --out data/lead-results.json
23
- npx public-leads pipeline --input data/domains.tsv --ingest --target-project /path/to/cold-agent-leads
23
+ PUBLIC_LEADS_API=https://cold-agent-leads.example.com npx public-leads pipeline --input data/domains.tsv --ingest
24
24
  npx public-leads validate --input examples/sample-leads.json
25
- npx public-leads ingest --input examples/sample-leads.json --dry-run --out output/sample-ingest.json
25
+ PUBLIC_LEADS_API=https://cold-agent-leads.example.com npx public-leads ingest --input examples/sample-leads.json --dry-run --out output/sample-ingest.json
26
26
  batch/batch-runner.sh --help
27
27
  ```
28
28
 
@@ -32,12 +32,27 @@ The default reference client posts to the Cold Agent Leads ingest API:
32
32
 
33
33
  ```http
34
34
  POST /api/lead-ingests
35
- Authorization: Bearer $ADMIN_API_TOKEN
35
+ Authorization: Bearer $PUBLIC_LEADS_API_TOKEN
36
36
  X-Admin-Email: admin@example.com
37
37
  Content-Type: application/json
38
38
  ```
39
39
 
40
- All endpoint details are configurable through `config/profile.yml`, CLI flags, environment variables, or `--target-project /path/to/cold-agent-leads` for reading that app's `.env` at runtime. Payloads contain `jobId`, `domains`, `leads`, `results`, and `errors`, with the schema defined in `templates/lead-schema.json`.
40
+ For upstream ingest, set `PUBLIC_LEADS_API`, `PUBLIC_LEADS_API_TOKEN`, and `PUBLIC_LEADS_OPERATOR_EMAIL`; no local Postgres or local Cold Agent Leads server is required. Endpoint details are configurable through environment variables, `config/profile.yml`, or CLI flags. `--target-project /path/to/cold-agent-leads` remains available only as compatibility for reading a local app `.env`. Payloads contain `jobId`, `domains`, `leads`, `results`, and `errors`, with the schema defined in `templates/lead-schema.json`.
41
+
42
+ Run from a Cold Agent Leads checkout without installing local package dependencies:
43
+
44
+ ```bash
45
+ cd /Users/charlie/Razroo/cold-agent-leads
46
+ export PUBLIC_LEADS_API=https://cold-agent-leads.example.com
47
+ export PUBLIC_LEADS_API_TOKEN=...
48
+ export PUBLIC_LEADS_OPERATOR_EMAIL=admin@example.com
49
+ npx -p @agent-pattern-labs/leads-rig public-leads pipeline \
50
+ --domains example.com \
51
+ --ingest \
52
+ --out data/lead-results.json \
53
+ --manifest data/lead-manifest.json \
54
+ --ingest-out data/ingest-response.json
55
+ ```
41
56
 
42
57
  Legacy aliases `lead-harness` and `create-leads-harness` remain available for compatibility.
43
58
 
@@ -53,7 +68,7 @@ gh run watch
53
68
  You can also publish by creating a GitHub release for the package version:
54
69
 
55
70
  ```bash
56
- gh release create v0.1.3 --title v0.1.3 --generate-notes
71
+ gh release create v0.1.4 --title v0.1.4 --generate-notes
57
72
  ```
58
73
 
59
74
  The package version must not already exist on npm.
@@ -51,7 +51,7 @@ write('package.json', JSON.stringify({
51
51
  'update-harness': 'npm install @agent-pattern-labs/leads-rig@latest && public-leads sync',
52
52
  },
53
53
  dependencies: {
54
- '@agent-pattern-labs/leads-rig': '^0.1.3',
54
+ '@agent-pattern-labs/leads-rig': '^0.1.4',
55
55
  },
56
56
  engines: {
57
57
  node: '>=20.6.0',
@@ -143,7 +143,7 @@ npm run verify
143
143
  opencode
144
144
  \`\`\`
145
145
 
146
- Update \`config/profile.yml\` with your ingest API settings and add domains to \`data/domains.tsv\` or \`data/pipeline.md\`.
146
+ Set \`PUBLIC_LEADS_API\`, \`PUBLIC_LEADS_API_TOKEN\`, and \`PUBLIC_LEADS_OPERATOR_EMAIL\` for upstream ingest. Add domains to \`data/domains.tsv\` or \`data/pipeline.md\`.
147
147
  `);
148
148
 
149
149
  console.log('\nNext: npm install, then edit config/profile.yml and data/domains.tsv.\n');
@@ -2,16 +2,19 @@
2
2
  # Prefer environment variables for secrets in shared terminals.
3
3
 
4
4
  api:
5
- # Cold Agent Leads local API default. Set target_project to a local
6
- # cold-agent-leads checkout to read ADMIN_API_TOKEN/ADMIN_EMAILS from .env.
7
- base_url: "http://localhost:8080"
5
+ # Set the deployed Cold Agent Leads API base URL through PUBLIC_LEADS_API,
6
+ # or put it here for a non-shared local profile. Example:
7
+ # base_url: "https://cold-agent-leads.example.com"
8
+ base_url: ""
8
9
  ingest_path: "/api/lead-ingests"
10
+ # Optional compatibility only: set when reading ADMIN_API_TOKEN/ADMIN_EMAILS
11
+ # from a local cold-agent-leads checkout. Not needed for upstream ingest.
9
12
  target_project: ""
10
- operator_email: "admin@example.com"
13
+ operator_email: ""
11
14
  operator_email_header: "X-Admin-Email"
12
15
  auth_header: "Authorization"
13
16
  auth_scheme: "Bearer"
14
- auth_token_env: "ADMIN_API_TOKEN"
17
+ auth_token_env: "PUBLIC_LEADS_API_TOKEN"
15
18
 
16
19
  crawl:
17
20
  max_pages: 10
@@ -37,4 +37,4 @@
37
37
  6. Run `npm audit`.
38
38
  7. Run `npm pack` and confirm generated runtime surfaces are included.
39
39
  8. Scaffold a clean consumer project and test install-time sync from the packed package or source tree.
40
- 9. Publish through GitHub Actions with `gh workflow run publish.yml --ref main`, or create a published release with `gh release create v0.1.3 --title v0.1.3 --generate-notes`.
40
+ 9. Publish through GitHub Actions with `gh workflow run publish.yml --ref main`, or create a published release with `gh release create v0.1.4 --title v0.1.4 --generate-notes`.
package/docs/SETUP.md CHANGED
@@ -32,7 +32,9 @@ Then edit:
32
32
  Use environment variables for secrets:
33
33
 
34
34
  ```bash
35
- export ADMIN_API_TOKEN=...
35
+ export PUBLIC_LEADS_API=https://cold-agent-leads.example.com
36
+ export PUBLIC_LEADS_API_TOKEN=...
37
+ export PUBLIC_LEADS_OPERATOR_EMAIL=admin@example.com
36
38
  ```
37
39
 
38
40
  ## Validate A Payload
@@ -51,27 +53,43 @@ npx public-leads pipeline --input data/domains.tsv --out data/lead-results.json
51
53
  ## Dry-Run Ingest
52
54
 
53
55
  ```bash
54
- npx public-leads ingest --input examples/sample-leads.json --dry-run --out output/sample-ingest.json
56
+ PUBLIC_LEADS_API=https://cold-agent-leads.example.com npx public-leads ingest --input examples/sample-leads.json --dry-run --out output/sample-ingest.json
55
57
  ```
56
58
 
57
59
  ## Live Ingest
58
60
 
59
61
  ```bash
60
- npx public-leads pipeline --input data/domains.tsv --ingest --target-project /path/to/cold-agent-leads
61
- npx public-leads ingest --input data/lead-results.json --target-project /path/to/cold-agent-leads
62
+ npx public-leads pipeline --input data/domains.tsv --ingest
63
+ npx public-leads ingest --input data/lead-results.json
64
+ ```
65
+
66
+ Run from `/Users/charlie/Razroo/cold-agent-leads` without local Postgres or a local API:
67
+
68
+ ```bash
69
+ cd /Users/charlie/Razroo/cold-agent-leads
70
+ export PUBLIC_LEADS_API=https://cold-agent-leads.example.com
71
+ export PUBLIC_LEADS_API_TOKEN=...
72
+ export PUBLIC_LEADS_OPERATOR_EMAIL=admin@example.com
73
+ npx -p @agent-pattern-labs/leads-rig public-leads pipeline \
74
+ --domains example.com \
75
+ --ingest \
76
+ --out data/lead-results.json \
77
+ --manifest data/lead-manifest.json \
78
+ --ingest-out data/ingest-response.json
62
79
  ```
63
80
 
64
81
  Required request settings come from flags, environment, or `config/profile.yml`:
65
82
 
66
83
  - `api.base_url`
67
84
  - `api.ingest_path`
68
- - `api.target_project`
69
85
  - `api.operator_email`
70
86
  - `api.operator_email_header`
71
87
  - `api.auth_header`
72
88
  - `api.auth_scheme`
73
89
  - `api.auth_token_env`
74
90
 
91
+ Use `api.target_project` only when intentionally reading credentials from a local Cold Agent Leads checkout. Upstream ingest should use `PUBLIC_LEADS_API`, `PUBLIC_LEADS_API_TOKEN`, and `PUBLIC_LEADS_OPERATOR_EMAIL`.
92
+
75
93
  ## Batch
76
94
 
77
95
  Create `batch/batch-input.tsv`:
@@ -44,10 +44,10 @@ Available commands:
44
44
 
45
45
  Local commands:
46
46
  npx public-leads crawl --input data/domains.tsv --out data/lead-results.json
47
- npx public-leads pipeline --input data/domains.tsv --ingest --target-project /path/to/cold-agent-leads
47
+ npx public-leads pipeline --input data/domains.tsv --ingest
48
48
  npx public-leads validate --input data/lead-results.json
49
49
  npx public-leads manifest --input data/lead-results.json
50
- npx public-leads ingest --input data/lead-results.json --target-project /path/to/cold-agent-leads
50
+ npx public-leads ingest --input data/lead-results.json
51
51
  npx public-leads verify
52
52
  ```
53
53
 
@@ -44,10 +44,10 @@ Available commands:
44
44
 
45
45
  Local commands:
46
46
  npx public-leads crawl --input data/domains.tsv --out data/lead-results.json
47
- npx public-leads pipeline --input data/domains.tsv --ingest --target-project /path/to/cold-agent-leads
47
+ npx public-leads pipeline --input data/domains.tsv --ingest
48
48
  npx public-leads validate --input data/lead-results.json
49
49
  npx public-leads manifest --input data/lead-results.json
50
- npx public-leads ingest --input data/lead-results.json --target-project /path/to/cold-agent-leads
50
+ npx public-leads ingest --input data/lead-results.json
51
51
  npx public-leads verify
52
52
  ```
53
53
 
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { lookup, resolveMx } from 'dns/promises';
3
3
  import { setTimeout as sleep } from 'timers/promises';
4
- import { cleanDomains, normalizeDomain } from './leadharness-leads.mjs';
4
+ import { cleanDomains, dedupeLeadRecords, mergeLeadRecords, normalizeDomain } from './leadharness-leads.mjs';
5
5
 
6
6
  const DEFAULT_MAX_PAGES = 10;
7
7
  const HARD_MAX_PAGES = 25;
@@ -51,16 +51,14 @@ export async function crawlDomains(inputs, options = {}) {
51
51
  for (const lead of result.leads) {
52
52
  const key = leadKey(lead);
53
53
  const current = leadsByKey.get(key);
54
- if (!current || isBetterLead(lead, current)) {
55
- leadsByKey.set(key, lead);
56
- }
54
+ leadsByKey.set(key, current ? mergeLeadRecords(current, lead) : lead);
57
55
  }
58
56
  } catch (error) {
59
57
  errors.push(`${target.domain}: ${error instanceof Error ? error.message : String(error)}`);
60
58
  }
61
59
  }
62
60
 
63
- const leads = [...leadsByKey.values()].sort(sortLead);
61
+ const leads = dedupeLeadRecords([...leadsByKey.values()]).sort(sortLead);
64
62
  const domains = cleanDomains([
65
63
  ...targets.map((target) => target.domain),
66
64
  ...results.map((result) => result.domain),
@@ -173,9 +171,7 @@ async function crawlDomainTarget(target, options) {
173
171
  if (!isRelevantLead(lead, { minConfidence, includeBlocked: options.includeBlocked })) continue;
174
172
  const key = leadKey(lead);
175
173
  const current = leadsByKey.get(key);
176
- if (!current || isBetterLead(lead, current)) {
177
- leadsByKey.set(key, lead);
178
- }
174
+ leadsByKey.set(key, current ? mergeLeadRecords(current, lead) : lead);
179
175
  }
180
176
 
181
177
  if (page.hasForm && isContactLikeURL(page.url)) {
@@ -183,9 +179,7 @@ async function crawlDomainTarget(target, options) {
183
179
  if (isRelevantLead(lead, { minConfidence, includeBlocked: options.includeBlocked })) {
184
180
  const key = leadKey(lead);
185
181
  const current = leadsByKey.get(key);
186
- if (!current || isBetterLead(lead, current)) {
187
- leadsByKey.set(key, lead);
188
- }
182
+ leadsByKey.set(key, current ? mergeLeadRecords(current, lead) : lead);
189
183
  }
190
184
  }
191
185
 
@@ -199,7 +193,7 @@ async function crawlDomainTarget(target, options) {
199
193
  await sleep(boundedInteger(options.delayMs, DEFAULT_DELAY_MS, 0, 10_000));
200
194
  }
201
195
 
202
- const leads = [...leadsByKey.values()].sort(sortLead);
196
+ const leads = dedupeLeadRecords([...leadsByKey.values()]).sort(sortLead);
203
197
  const completedAt = new Date().toISOString();
204
198
  if (pages.length === 0) {
205
199
  warnings.push(`no pages could be crawled for ${target.domain}`);
@@ -502,6 +496,11 @@ async function buildEmailLead(email, companyDomain, websiteUrl, page) {
502
496
  emailType,
503
497
  sourceUrl,
504
498
  sourceLabel: sourceLabel(sourceUrl),
499
+ sources: [{
500
+ url: sourceUrl,
501
+ label: sourceLabel(sourceUrl),
502
+ evidence: snippet,
503
+ }],
505
504
  evidence: snippet,
506
505
  extractionMethod: 'agentic_harness_public_page',
507
506
  verificationStatus: emailType === 'blocked' ? 'blocked' : verificationStatus,
@@ -533,6 +532,11 @@ function buildContactPath(domain, websiteUrl, page) {
533
532
  emailType: 'contact_path',
534
533
  sourceUrl,
535
534
  sourceLabel: sourceLabel(sourceUrl),
535
+ sources: [{
536
+ url: sourceUrl,
537
+ label: sourceLabel(sourceUrl),
538
+ evidence: contactPathEvidence(page),
539
+ }],
536
540
  evidence: contactPathEvidence(page),
537
541
  extractionMethod: 'agentic_harness_contact_form',
538
542
  verificationStatus: 'not_applicable',
@@ -14,10 +14,18 @@ export function resolveIngestConfig(opts = {}, profile = loadProfileConfig()) {
14
14
  const configuredOperatorEmail = opts.operatorEmail || env.PUBLIC_LEADS_OPERATOR_EMAIL || env.LEAD_HARNESS_OPERATOR_EMAIL || profile.operatorEmail;
15
15
  const adminEmails = splitList(env.ADMIN_EMAILS);
16
16
  const operatorEmail = configuredOperatorEmail || legacyOperatorEmail || adminEmails[0] || '';
17
- const tokenEnv = opts.tokenEnv || profile.authTokenEnv || profile.adminTokenEnv || 'ADMIN_API_TOKEN';
17
+ const tokenEnv = opts.tokenEnv
18
+ || profile.authTokenEnv
19
+ || profile.adminTokenEnv
20
+ || (env.PUBLIC_LEADS_API_TOKEN ? 'PUBLIC_LEADS_API_TOKEN' : '')
21
+ || (env.LEAD_HARNESS_API_TOKEN ? 'LEAD_HARNESS_API_TOKEN' : '')
22
+ || 'PUBLIC_LEADS_API_TOKEN';
23
+ const baseUrl = opts.api || env.PUBLIC_LEADS_API || env.LEAD_HARNESS_API || profile.baseUrl || '';
18
24
 
19
25
  return {
20
- endpoint: `${stripTrailingSlash(opts.api || env.PUBLIC_LEADS_API || env.LEAD_HARNESS_API || profile.baseUrl || 'http://localhost:8080')}${normalizeEndpointPath(opts.ingestPath || env.PUBLIC_LEADS_INGEST_PATH || env.LEAD_HARNESS_INGEST_PATH || profile.ingestPath || '/api/lead-ingests')}`,
26
+ endpoint: baseUrl
27
+ ? `${stripTrailingSlash(baseUrl)}${normalizeEndpointPath(opts.ingestPath || env.PUBLIC_LEADS_INGEST_PATH || env.LEAD_HARNESS_INGEST_PATH || profile.ingestPath || '/api/lead-ingests')}`
28
+ : '',
21
29
  operatorEmail,
22
30
  operatorEmailHeader: opts.operatorEmailHeader
23
31
  || opts.adminEmailHeader
@@ -25,8 +33,7 @@ export function resolveIngestConfig(opts = {}, profile = loadProfileConfig()) {
25
33
  || env.LEAD_HARNESS_OPERATOR_EMAIL_HEADER
26
34
  || profile.operatorEmailHeader
27
35
  || profile.adminEmailHeader
28
- || (legacyOperatorEmail || adminEmails.length > 0 ? 'X-Admin-Email' : '')
29
- || (operatorEmail ? 'X-Operator-Email' : ''),
36
+ || 'X-Admin-Email',
30
37
  authHeader: opts.authHeader || env.PUBLIC_LEADS_AUTH_HEADER || env.LEAD_HARNESS_AUTH_HEADER || profile.authHeader || 'Authorization',
31
38
  authScheme: opts.authScheme || env.PUBLIC_LEADS_AUTH_SCHEME || env.LEAD_HARNESS_AUTH_SCHEME || profile.authScheme || 'Bearer',
32
39
  tokenEnv,
@@ -56,6 +63,10 @@ export async function ingestPayload(payload, opts = {}) {
56
63
  errors: payload.errors,
57
64
  };
58
65
 
66
+ if (!config.endpoint) {
67
+ throw new Error('ingest API base URL is required (--api, $PUBLIC_LEADS_API, $LEAD_HARNESS_API, or api.base_url)');
68
+ }
69
+
59
70
  if (opts.dryRun) {
60
71
  const output = {
61
72
  status: 'DRY RUN',
@@ -69,10 +80,16 @@ export async function ingestPayload(payload, opts = {}) {
69
80
  }
70
81
 
71
82
  if (config.operatorEmailHeader && !config.operatorEmail) {
72
- throw new Error('operator email is required when an operator email header is configured');
83
+ throw new Error('operator email is required when an operator email header is configured (--operator-email, $PUBLIC_LEADS_OPERATOR_EMAIL, or api.operator_email)');
73
84
  }
74
85
  if (!config.token) {
75
- throw new Error(`API token is required (--token or $${config.tokenEnv})`);
86
+ const tokenHints = [...new Set([
87
+ `$${config.tokenEnv}`,
88
+ '$PUBLIC_LEADS_API_TOKEN',
89
+ '$LEAD_HARNESS_API_TOKEN',
90
+ '$ADMIN_API_TOKEN',
91
+ ])];
92
+ throw new Error(`API token is required (--token, ${tokenHints.slice(0, -1).join(', ')}, or ${tokenHints.at(-1)})`);
76
93
  }
77
94
 
78
95
  const headers = {
@@ -94,7 +94,7 @@ export function normalizePayload(value, { now = new Date().toISOString(), source
94
94
  throw new Error('lead artifact must be a JSON array, JSONL records, or an object with a leads array');
95
95
  }
96
96
 
97
- const leads = payload.leads.map((lead) => normalizeLead(lead, { now }));
97
+ const leads = dedupeLeadRecords(payload.leads.map((lead) => normalizeLead(lead, { now })));
98
98
  const results = Array.isArray(payload.results)
99
99
  ? payload.results.map((result) => normalizeResult(result, { now }))
100
100
  : [];
@@ -146,6 +146,11 @@ export function normalizeLead(input, { now = new Date().toISOString() } = {}) {
146
146
  emailType,
147
147
  sourceUrl,
148
148
  sourceLabel: stringValue(input.sourceLabel) || sourceUrl,
149
+ sources: normalizeLeadSources(input.sources, {
150
+ sourceUrl,
151
+ sourceLabel: stringValue(input.sourceLabel) || sourceUrl,
152
+ evidence: compactWhitespace(stringValue(input.evidence)),
153
+ }),
149
154
  evidence: compactWhitespace(stringValue(input.evidence)),
150
155
  extractionMethod: stringValue(input.extractionMethod) || 'agentic_harness',
151
156
  verificationStatus,
@@ -158,7 +163,7 @@ export function normalizeLead(input, { now = new Date().toISOString() } = {}) {
158
163
  };
159
164
 
160
165
  if (!lead.id) lead.id = stableLeadID(lead);
161
- return lead;
166
+ return normalizeLeadRecord(lead);
162
167
  }
163
168
 
164
169
  export function normalizeResult(input, { now = new Date().toISOString() } = {}) {
@@ -169,7 +174,9 @@ export function normalizeResult(input, { now = new Date().toISOString() } = {})
169
174
  return {
170
175
  domain,
171
176
  websiteUrl: stringValue(input.websiteUrl) || (domain ? `https://${domain}/` : ''),
172
- leads: Array.isArray(input.leads) ? input.leads.map((lead) => normalizeLead(lead, { now })) : [],
177
+ leads: Array.isArray(input.leads)
178
+ ? dedupeLeadRecords(input.leads.map((lead) => normalizeLead(lead, { now })))
179
+ : [],
173
180
  pages: Array.isArray(input.pages) ? input.pages.map(normalizePageVisit) : [],
174
181
  warnings: normalizeWarnings(input.warnings),
175
182
  completedAt: normalizeDateTime(input.completedAt, now),
@@ -299,6 +306,19 @@ export function validateLead(lead, path, issues) {
299
306
  if (!Array.isArray(lead.warnings)) {
300
307
  issues.push(issue('error', `${path}.warnings`, 'invalid_warnings', 'warnings must be an array'));
301
308
  }
309
+ if (!Array.isArray(lead.sources)) {
310
+ issues.push(issue('error', `${path}.sources`, 'invalid_sources', 'sources must be an array'));
311
+ } else {
312
+ lead.sources.forEach((source, index) => {
313
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
314
+ issues.push(issue('error', `${path}.sources[${index}]`, 'invalid_source', 'sources entries must be objects'));
315
+ return;
316
+ }
317
+ if (!isHttpUrl(source.url)) {
318
+ issues.push(issue('error', `${path}.sources[${index}].url`, 'invalid_source_url', 'sources entries require an http(s) URL'));
319
+ }
320
+ });
321
+ }
302
322
  if (lead.foundAt && Number.isNaN(Date.parse(lead.foundAt))) {
303
323
  issues.push(issue('error', `${path}.foundAt`, 'invalid_found_at', 'foundAt must be ISO datetime'));
304
324
  }
@@ -447,6 +467,127 @@ export function domainFromUrl(value) {
447
467
  }
448
468
  }
449
469
 
470
+ export function dedupeLeadRecords(leads) {
471
+ const byKey = new Map();
472
+ for (const rawLead of leads || []) {
473
+ const lead = normalizeLeadRecord(rawLead);
474
+ const key = stableLeadKey(lead);
475
+ const current = byKey.get(key);
476
+ byKey.set(key, current ? mergeLeadRecords(current, lead) : lead);
477
+ }
478
+ return [...byKey.values()];
479
+ }
480
+
481
+ function normalizeLeadRecord(lead) {
482
+ const sources = normalizeLeadSources(lead?.sources, {
483
+ sourceUrl: lead?.sourceUrl,
484
+ sourceLabel: lead?.sourceLabel,
485
+ evidence: lead?.evidence,
486
+ });
487
+ const normalized = {
488
+ ...lead,
489
+ sourceUrl: sources[0]?.url || stringValue(lead?.sourceUrl),
490
+ sourceLabel: sources[0]?.label || stringValue(lead?.sourceLabel),
491
+ sources,
492
+ evidence: sources[0]?.evidence || compactWhitespace(lead?.evidence),
493
+ warnings: normalizeWarnings(lead?.warnings),
494
+ socialUrls: normalizeUrlList(lead?.socialUrls),
495
+ contactUrls: normalizeUrlList(lead?.contactUrls),
496
+ };
497
+ normalized.id = stableLeadID(normalized);
498
+ return normalized;
499
+ }
500
+
501
+ export function mergeLeadRecords(current, candidate) {
502
+ const left = normalizeLeadRecord(current);
503
+ const right = normalizeLeadRecord(candidate);
504
+ const primary = leadQualityScore(right) > leadQualityScore(left) ? right : left;
505
+ const secondary = primary === right ? left : right;
506
+
507
+ const merged = {
508
+ ...primary,
509
+ company: primary.company || secondary.company,
510
+ domain: primary.domain || secondary.domain,
511
+ websiteUrl: primary.websiteUrl || secondary.websiteUrl,
512
+ contactName: primary.contactName || secondary.contactName,
513
+ title: primary.title || secondary.title,
514
+ email: primary.email || secondary.email,
515
+ emailType: primary.emailType || secondary.emailType,
516
+ extractionMethod: primary.extractionMethod || secondary.extractionMethod,
517
+ verificationStatus: verificationRank(secondary.verificationStatus) > verificationRank(primary.verificationStatus)
518
+ ? secondary.verificationStatus
519
+ : primary.verificationStatus,
520
+ confidence: Math.max(primary.confidence || 0, secondary.confidence || 0),
521
+ phone: primary.phone || secondary.phone,
522
+ socialUrls: normalizeUrlList([...(primary.socialUrls || []), ...(secondary.socialUrls || [])]),
523
+ contactUrls: normalizeUrlList([...(primary.contactUrls || []), ...(secondary.contactUrls || [])]),
524
+ warnings: normalizeWarnings([...(primary.warnings || []), ...(secondary.warnings || [])]),
525
+ };
526
+
527
+ merged.foundAt = earliestDateTime(primary.foundAt, secondary.foundAt);
528
+ if ((secondary.evidence || '').length > (merged.evidence || '').length) {
529
+ merged.evidence = secondary.evidence;
530
+ }
531
+
532
+ merged.sources = mergeLeadSources(merged.sourceUrl, left.sources, right.sources);
533
+ merged.sourceUrl = merged.sources[0]?.url || merged.sourceUrl;
534
+ merged.sourceLabel = merged.sources[0]?.label || merged.sourceLabel;
535
+ if (!merged.evidence) {
536
+ merged.evidence = merged.sources[0]?.evidence || '';
537
+ }
538
+ merged.id = stableLeadID(merged);
539
+ return normalizeLeadRecord(merged);
540
+ }
541
+
542
+ function normalizeLeadSources(value, fallback = {}) {
543
+ const byUrl = new Map();
544
+ const ordered = [];
545
+ const addSource = (source) => {
546
+ const url = stringValue(source?.url);
547
+ if (!url) return;
548
+
549
+ const entry = {
550
+ url,
551
+ label: stringValue(source?.label) || defaultSourceLabel(url),
552
+ evidence: compactWhitespace(source?.evidence),
553
+ };
554
+ const current = byUrl.get(url);
555
+ if (!current) {
556
+ byUrl.set(url, entry);
557
+ ordered.push(url);
558
+ return;
559
+ }
560
+ byUrl.set(url, {
561
+ url,
562
+ label: current.label || entry.label,
563
+ evidence: entry.evidence.length > current.evidence.length ? entry.evidence : current.evidence,
564
+ });
565
+ };
566
+
567
+ if (Array.isArray(value)) {
568
+ value.forEach(addSource);
569
+ }
570
+ addSource({
571
+ url: fallback.sourceUrl,
572
+ label: fallback.sourceLabel,
573
+ evidence: fallback.evidence,
574
+ });
575
+
576
+ const preferredUrl = stringValue(fallback.sourceUrl);
577
+ return ordered
578
+ .map((url) => byUrl.get(url))
579
+ .sort((left, right) => sourceSortValue(right, preferredUrl) - sourceSortValue(left, preferredUrl) || left.label.localeCompare(right.label) || left.url.localeCompare(right.url));
580
+ }
581
+
582
+ function mergeLeadSources(preferredUrl, ...lists) {
583
+ return normalizeLeadSources(lists.flat(), { sourceUrl: preferredUrl });
584
+ }
585
+
586
+ function sourceSortValue(source, preferredUrl) {
587
+ if (preferredUrl && source.url === preferredUrl) return 10_000;
588
+ return pagePriority(source.url || '') * 100 + Math.min((source.evidence || '').length, 99);
589
+ }
590
+
450
591
  export function stableLeadID(lead) {
451
592
  const key = stableLeadKey(lead);
452
593
  if (!key.replace(/\|/g, '').trim()) return `agentic-${Date.now()}`;
@@ -469,6 +610,77 @@ function stableLeadKey(lead) {
469
610
  ].join('|').toLowerCase();
470
611
  }
471
612
 
613
+ function leadQualityScore(lead) {
614
+ return (lead?.confidence || 0) * 1000
615
+ + verificationRank(lead?.verificationStatus) * 100
616
+ + pagePriority(lead?.sourceUrl || '') * 10
617
+ + (Array.isArray(lead?.sources) ? lead.sources.length : 0) * 5
618
+ + (lead?.phone ? 25 : 0)
619
+ + (Array.isArray(lead?.socialUrls) ? lead.socialUrls.length : 0) * 5
620
+ + (Array.isArray(lead?.contactUrls) ? lead.contactUrls.length : 0) * 5
621
+ + (lead?.contactName ? 20 : 0)
622
+ + (lead?.title ? 10 : 0);
623
+ }
624
+
625
+ function verificationRank(value) {
626
+ const status = stringValue(value).toLowerCase();
627
+ if (status === 'verified' || status === 'mx_verified') return 4;
628
+ if (status === 'unverified') return 3;
629
+ if (status === 'not_applicable') return 2;
630
+ if (status === 'unknown') return 1;
631
+ return 0;
632
+ }
633
+
634
+ function earliestDateTime(...values) {
635
+ const dates = values
636
+ .map((value) => stringValue(value))
637
+ .filter(Boolean)
638
+ .map((value) => new Date(value))
639
+ .filter((value) => !Number.isNaN(value.getTime()))
640
+ .sort((left, right) => left.getTime() - right.getTime());
641
+ return dates[0]?.toISOString() || new Date().toISOString();
642
+ }
643
+
644
+ function defaultSourceLabel(sourceUrl) {
645
+ const path = safePath(sourceUrl).replace(/^\/+|\/+$/g, '');
646
+ if (!path) return 'Home';
647
+ const last = path.split('/').filter(Boolean).pop() || path;
648
+ return last.replace(/[-_]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
649
+ }
650
+
651
+ function safePath(value) {
652
+ try {
653
+ return new URL(value).pathname.toLowerCase();
654
+ } catch {
655
+ return '';
656
+ }
657
+ }
658
+
659
+ function pagePriority(link) {
660
+ const path = safePath(link);
661
+ const weighted = [
662
+ ['contact', 100],
663
+ ['about', 90],
664
+ ['team', 85],
665
+ ['people', 85],
666
+ ['leadership', 85],
667
+ ['staff', 80],
668
+ ['founder', 80],
669
+ ['press', 65],
670
+ ['media', 65],
671
+ ['blog', 55],
672
+ ['author', 55],
673
+ ['career', 45],
674
+ ['privacy', 25],
675
+ ['legal', 20],
676
+ ['impressum', 20],
677
+ ];
678
+ for (const [needle, score] of weighted) {
679
+ if (path.includes(needle)) return score;
680
+ }
681
+ return path === '/' ? 50 : 0;
682
+ }
683
+
472
684
  export function fileHash(path) {
473
685
  return createHash('sha256').update(readFileSync(path)).digest('hex');
474
686
  }
@@ -507,7 +719,7 @@ function normalizeVerificationStatus(value, emailType) {
507
719
 
508
720
  function normalizeWarnings(value) {
509
721
  if (!Array.isArray(value)) return [];
510
- return value.map((item) => stringValue(item)).filter(Boolean);
722
+ return [...new Set(value.map((item) => stringValue(item)).filter(Boolean))];
511
723
  }
512
724
 
513
725
  function normalizeUrlList(value) {
package/modes/_shared.md CHANGED
@@ -45,6 +45,7 @@ Confidence should reflect source quality, person specificity, domain match, and
45
45
  ```bash
46
46
  npx public-leads validate --input data/lead-results.json
47
47
  npx public-leads manifest --input data/lead-results.json
48
- npx public-leads ingest --input data/lead-results.json --dry-run
48
+ PUBLIC_LEADS_API=https://cold-agent-leads.example.com npx public-leads ingest --input data/lead-results.json --dry-run
49
+ npx public-leads pipeline --input data/domains.tsv --ingest
49
50
  npx public-leads verify
50
51
  ```
package/modes/ingest.md CHANGED
@@ -12,20 +12,23 @@ Use this when the user asks to store validated leads in a configured ingest API.
12
12
  ```bash
13
13
  npx public-leads manifest --input <artifact>
14
14
  ```
15
- 3. Confirm ingest credentials and request settings are available through `config/profile.yml` and the configured environment variable. Do not print token values.
15
+ 3. Confirm upstream ingest settings are available through environment variables, flags, or `config/profile.yml`. Do not print token values.
16
+ - `PUBLIC_LEADS_API`
17
+ - `PUBLIC_LEADS_API_TOKEN`
18
+ - `PUBLIC_LEADS_OPERATOR_EMAIL`
16
19
 
17
20
  ## Dry Run
18
21
 
19
22
  Use dry-run before live ingest unless the user explicitly requested live submission:
20
23
 
21
24
  ```bash
22
- npx public-leads ingest --input <artifact> --dry-run --out data/ingest-response.json
25
+ PUBLIC_LEADS_API=https://cold-agent-leads.example.com npx public-leads ingest --input <artifact> --dry-run --out data/ingest-response.json
23
26
  ```
24
27
 
25
28
  ## Live Ingest
26
29
 
27
30
  ```bash
28
- npx public-leads ingest --input <artifact> --target-project /path/to/cold-agent-leads --out data/ingest-response.json
31
+ npx public-leads ingest --input <artifact> --out data/ingest-response.json
29
32
  ```
30
33
 
31
34
  ## Output
package/modes/pipeline.md CHANGED
@@ -20,7 +20,7 @@ Read domains from the first available source:
20
20
  5. For browser-heavy batch work, use `batch/batch-runner.sh --parallel 2`.
21
21
  6. Validate every artifact with `npx public-leads validate`.
22
22
  7. Merge validated artifacts into a manifest with `npx public-leads manifest --input <artifact>`.
23
- 8. Ingest with `npx public-leads pipeline --ingest --target-project /path/to/cold-agent-leads` only when upload is requested.
23
+ 8. Ingest with `npx public-leads pipeline --ingest` only when upload is requested and upstream API env vars are configured.
24
24
 
25
25
  ## Output
26
26
 
package/modes/setup.md CHANGED
@@ -9,7 +9,7 @@ Use this when the user asks to configure or inspect the lead harness.
9
9
  - `data/domains.tsv`
10
10
  - `data/pipeline.md`
11
11
  - direct domains in the user request
12
- 3. Confirm the ingest API settings:
12
+ 3. Confirm the upstream ingest API settings:
13
13
  - `api.base_url`
14
14
  - `api.ingest_path`
15
15
  - `api.operator_email`
@@ -17,7 +17,8 @@ Use this when the user asks to configure or inspect the lead harness.
17
17
  - `api.auth_header`
18
18
  - `api.auth_scheme`
19
19
  - `api.auth_token_env`
20
- - `api.target_project` when using a local Cold Agent Leads checkout for `.env` credentials
20
+ - or `PUBLIC_LEADS_API`, `PUBLIC_LEADS_API_TOKEN`, and `PUBLIC_LEADS_OPERATOR_EMAIL`
21
+ - `api.target_project` only when intentionally reading credentials from a local Cold Agent Leads checkout
21
22
  4. Run `npx public-leads verify`.
22
23
 
23
24
  ## Output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-pattern-labs/leads-rig",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Agentic public-web lead discovery harness with portable ingest artifacts",
5
5
  "type": "module",
6
6
  "author": "Razroo",
@@ -181,6 +181,6 @@
181
181
  "@razroo/iso": "^0.3.1",
182
182
  "@razroo/iso-eval": "^0.4.0",
183
183
  "@razroo/iso-harness": "^0.8.0",
184
- "@razroo/iso-route": "^0.5.3"
184
+ "@razroo/iso-route": "^0.6.0"
185
185
  }
186
186
  }
@@ -9,7 +9,7 @@ Usage:
9
9
  public-leads ingest --input <file> [--api <base-url>] [--ingest-path </path>]
10
10
  [--operator-email <email>] [--operator-email-header <header>]
11
11
  [--auth-header <header>] [--auth-scheme <scheme>]
12
- [--token <token>] [--token-env ADMIN_API_TOKEN]
12
+ [--token <token>] [--token-env PUBLIC_LEADS_API_TOKEN]
13
13
  [--target-project /path/to/cold-agent-leads]
14
14
  [--job-id <id>] [--out data/ingest-response.json] [--dry-run]
15
15
 
@@ -18,7 +18,7 @@ Defaults are read from config/profile.yml when present:
18
18
  api.auth_header, api.auth_scheme, api.auth_token_env, api.target_project
19
19
 
20
20
  Compatibility fallbacks are still accepted:
21
- --admin-email, api.admin_email, api.admin_token_env, $PUBLIC_LEADS_API_TOKEN
21
+ --admin-email, api.admin_email, api.admin_token_env, $ADMIN_API_TOKEN, --target-project
22
22
  `;
23
23
 
24
24
  const opts = parseArgs(process.argv.slice(2));
@@ -23,11 +23,11 @@ Usage:
23
23
  [--manifest data/lead-manifest.json]
24
24
  [--max-pages 10] [--min-confidence 30]
25
25
  [--ingest | --upload] [--dry-run]
26
- [--target-project /path/to/cold-agent-leads]
26
+ [--api https://cold-agent-leads.example.com]
27
27
 
28
28
  Examples:
29
29
  public-leads pipeline --domains example.com,example.org
30
- public-leads pipeline --input data/domains.tsv --ingest --target-project /path/to/cold-agent-leads
30
+ PUBLIC_LEADS_API=https://cold-agent-leads.example.com public-leads pipeline --input data/domains.tsv --ingest
31
31
  `;
32
32
 
33
33
  const opts = parseArgs(process.argv.slice(2));