salesprompter-cli 0.1.20 → 0.1.22

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 (3) hide show
  1. package/README.md +28 -308
  2. package/dist/cli.js +213 -27
  3. package/package.json +10 -1
package/README.md CHANGED
@@ -1,338 +1,58 @@
1
1
  # salesprompter-cli
2
2
 
3
- `salesprompter-cli` is a JSON-first command line interface for running a practical sales workflow:
3
+ Salesprompter CLI helps you go from company or product input to qualified leads with a guided terminal flow.
4
4
 
5
- - Authenticate via Salesprompter app backend
6
- - Crawl LinkedIn product categories
7
- - Derive intended-role job titles
8
- - Split broad Sales Navigator searches into exportable slices
9
- - Export those slices through Phantombuster
10
- - Store product and people data in Salesprompter
11
- - Enrich leads
12
- - Score leads
13
- - Sync leads into CRM and outreach systems
14
- - Analyze upstream lead-list and domain-enrichment bottlenecks
15
- - Replace opaque Pipedream logic with deterministic CLI workflows
16
-
17
- It is built for two users at the same time:
18
-
19
- - humans working in a terminal
20
- - coding agents such as Codex, Claude Code, and other LLM-driven shell workflows
21
-
22
- The app, CLI, and Chrome extension all write into one shared Salesprompter backend and database. Access is scoped to
23
- the active workspace you choose at login.
24
-
25
- ## Start Here
26
-
27
- If someone discovers Salesprompter from a vague prompt, give them the shortest working path for their context.
28
-
29
- ```bash
30
- ## Human-friendly guided path
31
- npx -y salesprompter-cli@latest
32
-
33
- ## Explicit guided path
34
- npx -y salesprompter-cli@latest wizard
35
-
36
- ## Primary raw command for agents and scripts
37
- npx -y salesprompter-cli@latest salesnav:from-product-category --input deel.com --dry-run
38
- ```
39
-
40
- Or install it globally:
41
-
42
- ```bash
43
- npm install -g salesprompter-cli
44
- salesprompter
45
- salesprompter wizard
46
- salesprompter --help
47
- ```
48
-
49
- Bare `salesprompter` now opens a guided wizard in an interactive terminal. Keep using explicit subcommands for agents, CI, and copy-paste docs.
50
- If your Salesprompter user belongs to multiple workspaces, browser login asks which workspace the CLI session should use.
51
-
52
- ## Prompt To Command
53
-
54
- The primary live path is:
55
-
56
- 1. crawl a LinkedIn product category
57
- 2. derive the intended job titles
58
- 3. turn those into Sales Navigator queries
59
- 4. split broad searches into exportable chunks
60
- 5. export them through Phantombuster
61
- 6. store everything in Salesprompter
62
-
63
- ### "I sell for Deel and want the right people at similar companies"
64
-
65
- Start from the company, product, or category input:
5
+ ## Install
66
6
 
67
7
  ```bash
68
- salesprompter salesnav:from-product-category --input deel.com
8
+ npm i -g salesprompter-cli
69
9
  ```
70
10
 
71
- The CLI now stays attached while durable title crawls are still running remotely, writes JSONL trace logs, and exits non-zero if any title crawl finishes with failures. Use `--allow-partial-success` only when you explicitly want a best-effort run.
72
-
73
- Preview the generated titles and first split queries before creating crawl jobs:
11
+ or run directly:
74
12
 
75
13
  ```bash
76
- salesprompter --json salesnav:from-product-category --input deel.com --dry-run
77
- ```
78
-
79
- ### "Find people at deel.com"
80
-
81
- That is still a direct target-company lookup:
82
-
83
- ```bash
84
- salesprompter account:resolve --domain deel.com --company-name Deel --out ./data/deel-account.json
85
- salesprompter leads:generate --icp ./data/icp.json --count 5 --domain deel.com --company-name Deel --out ./data/deel-leads.json
86
- ```
87
-
88
- ### Historical warehouse fallback
89
-
90
- `salesnav:ensure-count` remains a historical backfill path, not the primary workflow:
91
-
92
- ```bash
93
- salesprompter salesnav:ensure-count --target-count 200000 --org-id "$SALESPROMPTER_ORG_ID"
94
- ```
95
-
96
- ## Documentation
97
-
98
- This repository now includes the public Salesprompter docs site for the wider Salesprompter universe, including the app contract, CLI surface, Chrome extension contract, and the main warehouse-backed workflows.
99
-
100
- - Live docs: `https://salesprompter-cli.vercel.app`
101
-
102
- - Docs home: `./index.mdx`
103
- - Quickstart: `./quickstart.mdx`
104
- - Architecture: `./architecture.mdx`
105
- - App: `./platform/app.mdx`
106
- - CLI: `./platform/cli.mdx`
107
- - Chrome extension: `./platform/chrome-extension.mdx`
108
- - Domain finder: `./workflows/domain-finder.mdx`
109
- - Command reference: `./reference/cli.mdx`
110
- - Environment variables: `./reference/environment-variables.mdx`
111
- - Troubleshooting: `./operations/troubleshooting.mdx`
112
-
113
- Run the docs locally with:
114
-
115
- ```bash
116
- npm run docs:dev
14
+ npx -y salesprompter-cli@latest
117
15
  ```
118
16
 
119
- Build the deployable static docs site with:
17
+ ## Quickstart
120
18
 
121
19
  ```bash
122
- npm run build:docs:site
123
- ```
124
-
125
- ## Integration Contract
126
-
127
- This CLI is not a standalone toy. It is a production integration surface for the Salesprompter app.
128
-
129
- - The Domain Finder flow in this repository is a real end-to-end test case for Salesprompter app integration.
130
- - CLI behavior should be treated as an app contract: auth, BigQuery execution, artifacts, and writeback semantics must remain stable.
131
- - Changes to domain selection, writeback, or audit logic should always be validated with:
132
- - CLI tests (`npm test`)
133
- - BigQuery-backed runs (`domainfinder:run:bq`, `domainfinder:audit-existing:bq`)
134
- - before/after delta checks (`domainfinder:audit-delta`)
135
-
136
- The current live path is product-first. It resolves a LinkedIn product category, derives intended-role Sales Navigator searches, and lets the app run durable exports through Phantombuster. Account-first lead generation still exists for direct target-company lookups.
137
-
138
- When the output `mode` is `fallback`, the leads are modeled contacts for workflow testing, not verified real contacts. A real provider path should return `mode: "real"` using the same JSON shape.
139
-
140
- All non-auth commands require a logged-in CLI session. This gives you one identity model across Salesprompter app, CLI, and Chrome extension.
141
-
142
- Global output flags:
143
-
144
- - `--json`: compact machine-readable JSON (optimized for agent/LLM parsers)
145
- - `--quiet`: suppress successful stdout payloads (errors still surface)
146
-
147
- ## Auth and Session
148
-
149
- The CLI stores a local session file at `~/.config/salesprompter/auth-session.json` (or `SALESPROMPTER_CONFIG_DIR`).
20
+ # Start the interactive wizard
21
+ salesprompter
150
22
 
151
- ```bash
152
- # Preferred path: browser/device login
23
+ # Login explicitly
153
24
  salesprompter auth:login
154
25
 
155
- # Fallback path: generate a short-lived CLI token in the Salesprompter app
156
- salesprompter auth:login --token "$SALESPROMPTER_TOKEN" --api-url "https://salesprompter.ai"
157
-
158
- # Verify active identity with backend
159
- salesprompter auth:whoami --verify
160
-
161
- # Clear local session
162
- salesprompter auth:logout
163
- ```
164
-
165
- If your user belongs to multiple workspaces, the browser flow asks you to choose the workspace for that CLI session before returning to the terminal.
166
-
167
- Environment variables:
168
-
169
- - `SALESPROMPTER_API_BASE_URL`: override backend URL (default `https://salesprompter.ai`)
170
- - `SALESPROMPTER_CONFIG_DIR`: override local config dir
171
- - `SALESPROMPTER_ORG_ID`: optional org override for privileged Sales Navigator backfills
172
- - `SALESPROMPTER_SKIP_AUTH=1`: bypass auth guard (tests/dev only)
173
- - `INSTANTLY_API_KEY`: required for real `sync:outreach --target instantly`
174
- - `INSTANTLY_CAMPAIGN_ID`: default campaign id for Instantly sync
175
- - `SALESPROMPTER_INSTANTLY_BASE_URL`: override Instantly API base URL (tests/local proxies)
176
- - `NEXT_PUBLIC_SUPABASE_URL` or `SALESPROMPTER_SUPABASE_URL`: required for `salesnav:ensure-count` and for CLI-managed Sales Navigator exports
177
- - `SUPABASE_SERVICE_ROLE_KEY`: required for `salesnav:ensure-count` and for CLI-managed Sales Navigator exports
178
- - `LINKEDIN_SESSION_COOKIE_ENCRYPTION_KEY` or `EXTENSION_AUTH_SECRET` or `CLERK_SECRET_KEY`: required for Sales Navigator export and crawl commands that launch exports
179
- - `SALESPROMPTER_LINKEDIN_SESSION_EXCLUDED_EMAILS`: optional comma-separated emails to keep out of CLI-managed Sales Navigator rotation
180
- - `SALESPROMPTER_LINKEDIN_SESSION_EXCLUDED_HANDLES`: optional comma-separated LinkedIn handles to keep out of CLI-managed Sales Navigator rotation
181
- - `SALESPROMPTER_CLI_MANAGE_LINKEDIN_SESSIONS=0`: optional test or legacy fallback override; when omitted, the CLI claims, rotates, and preflights Sales Navigator session cookies before export launch
182
- - `GOOGLE_SERVICE_ACCOUNT_KEY`: required for `salesnav:ensure-count`
183
- - `BIGQUERY_PROJECT_ID`: optional when the Google service-account JSON already includes `project_id`
184
-
185
- App compatibility:
186
-
187
- - Salesprompter app should expose `/api/cli/auth/device/start`, `/api/cli/auth/device/poll`, and `/api/cli/auth/me`.
188
- - `salesprompter auth:login` uses browser/device login and prints the verification URL plus code before polling.
189
- - `POST /api/cli/auth/token` remains the fallback path when browser/device login is disabled or unavailable.
190
-
191
- Fallback command:
192
-
193
- ```bash
194
- salesprompter auth:login --token "<token-from-app>" --api-url "https://salesprompter.ai"
26
+ # Verify your session
27
+ salesprompter auth:whoami
195
28
  ```
196
29
 
197
- ## Why this shape works for humans and LLMs
198
-
199
- - Every command reads and writes plain JSON.
200
- - Output is machine-readable and composable (`--json` for compact transport).
201
- - The top-level use cases map ambiguous prompts like "determine the ICP of deel.com" into explicit command paths.
202
- - Domain contracts are explicit and validated with `zod`.
203
- - External integrations are behind narrow provider interfaces.
204
- - Lead generation reports which provider and mode produced the result.
205
- - Workflow bottlenecks become inspectable artifacts instead of hidden Pipedream state.
206
- - Real prospect lookup can be normalized straight into the CLI lead schema with `--lead-out`.
207
-
208
- ## Commands
30
+ ## Common commands
209
31
 
210
32
  ```bash
211
- salesprompter icp:define --name "EU SaaS RevOps" \
212
- --description "RevOps and sales leaders in European growth-stage software companies" \
213
- --industries "Software,Financial Services" \
214
- --company-sizes "50-199,200-499" \
215
- --regions "Europe" \
216
- --countries "DE,NL,GB" \
217
- --titles "Head of Revenue Operations,VP Sales" \
218
- --required-signals "recent funding,growing outbound team" \
219
- --keywords "revenue operations,outbound,sales tooling" \
220
- --out ./data/icp.json
33
+ # Product/category to leads workflow
34
+ salesprompter salesnav:from-product-category --input "https://www.linkedin.com/company/example/"
221
35
 
222
- salesprompter auth:login
223
- salesprompter auth:whoami --verify
224
- salesprompter --json salesnav:from-product-category --input deel.com --dry-run
225
- salesprompter salesnav:from-product-category --input deel.com
226
- # Historical fallback only. Resumes from prior CLI historical backfill runs unless you pass --start-offset.
227
- salesprompter salesnav:ensure-count --target-count 200000 --org-id "$SALESPROMPTER_ORG_ID"
36
+ # Run a Sales Navigator crawl from a query URL
228
37
  salesprompter salesnav:crawl --query-url "https://www.linkedin.com/sales/search/people?query=..."
229
- salesprompter icp:vendor --vendor deel --market dach --out ./data/deel-icp.json
230
- salesprompter leads:lookup:bq --icp ./data/deel-icp.json --limit 100 --execute --out ./data/deel-leads-raw.json --lead-out ./data/deel-leads.json
231
- salesprompter leads:enrich --in ./data/deel-leads.json --out ./data/deel-enriched.json
232
- salesprompter leads:score --icp ./data/deel-icp.json --in ./data/deel-enriched.json --out ./data/deel-scored.json
233
- salesprompter sync:outreach --target instantly --in ./data/deel-scored.json --campaign-id "$INSTANTLY_CAMPAIGN_ID"
234
- salesprompter sync:outreach --target instantly --in ./data/deel-scored.json --campaign-id "$INSTANTLY_CAMPAIGN_ID" --apply
235
- salesprompter icp:from-historical-queries:bq --vendor deel --market dach --out ./data/deel-icp-historical.json --report-out ./data/deel-historical-report.json
236
- salesprompter leadlists:funnel:bq --vendor deel --market dach --out ./data/deel-leadlists-funnel.json
237
- salesprompter leadlists:direct-export:bq --vendor deel --market dach --limit 20000 --out-dir ./data/direct-path --sql-out ./data/direct-path/deel-direct-dach.sql
238
- salesprompter domainfinder:backlog:bq --market dach --out ./data/deel-domainfinder-backlog.json
239
- salesprompter domainfinder:candidates:bq --market dach --limit 500 --out ./data/domain-candidates.json --sql-out ./data/domain-candidates.sql
240
- salesprompter domainfinder:input-sql --market dach --out ./data/domainFinder_input_v2.sql
241
- salesprompter domainfinder:select --in ./data/domain-candidates.json --out ./data/domain-decisions.json
242
- salesprompter domainfinder:audit --in ./data/domain-decisions.json --out ./data/domain-audit.json
243
- salesprompter domainfinder:compare-pipedream --in ./data/domain-candidates.json --out ./data/domain-comparison.json
244
- salesprompter domainfinder:audit-existing:bq --market dach --out ./data/domain-existing-audit.json
245
- salesprompter domainfinder:audit-delta --before ./data/domain-existing-audit-before.json --after ./data/domain-existing-audit-after.json --out ./data/domain-existing-audit-delta.json
246
- salesprompter domainfinder:repair-existing:bq --market dach --mode conservative --limit 5000 --out ./data/domain-repair.sql --trace-id salesprompter-cli-repair-dach
247
- salesprompter domainfinder:writeback-sql --in ./data/domain-decisions.json --out ./data/domain-writeback.sql --trace-id salesprompter-cli-dach-20260308
248
- salesprompter domainfinder:writeback:bq --in ./data/domain-decisions.json --out ./data/domain-writeback.sql --trace-id salesprompter-cli-dach-20260308
249
- salesprompter domainfinder:run:bq --market dach --limit 500 --out-dir ./data/domainfinder-run --trace-id salesprompter-cli-dach-20260308
250
- salesprompter account:resolve --domain deel.com --company-name Deel --out ./data/deel-account.json
251
- salesprompter leads:generate --icp ./data/icp.json --count 5 --out ./data/leads.json
252
- salesprompter leads:generate --icp ./data/icp.json --count 5 --domain deel.com --company-name Deel --out ./data/deel-leads.json
253
- salesprompter leads:enrich --in ./data/leads.json --out ./data/enriched.json
254
- salesprompter leads:score --icp ./data/icp.json --in ./data/enriched.json --out ./data/scored.json
255
- salesprompter leads:lookup:bq --icp ./data/deel-icp.json --limit 100
256
- salesprompter queries:analyze:bq --search-kind sales-people --include-function "Human Resources" --out ./data/hr-query-report.json
257
- salesprompter sync:crm --target hubspot --in ./data/scored.json
258
- salesprompter sync:outreach --target instantly --in ./data/scored.json
259
- ```
260
-
261
- ## Domain Finder Migration
262
-
263
- The original Pipedream `domainFinder` workflow was doing three things:
264
-
265
- 1. fetch a small input set from BigQuery
266
- 2. ask OpenAI / Hunter for candidate domains
267
- 3. pick a domain and write it back
268
-
269
- The CLI now models that logic directly and improves it:
270
38
 
271
- - `domainfinder:backlog:bq` measures whether the backlog is being starved before enrichment
272
- - `domainfinder:candidates:bq` fetches real candidate rows from BigQuery for backlog companies
273
- - `domainfinder:input-sql` generates a replacement input view driven by `linkedin_companies`, not `leadPool_new`
274
- - `domainfinder:select` applies deterministic domain selection rules
275
- - `domainfinder:audit` turns decisions into a review queue and writeback summary
276
- - `domainfinder:compare-pipedream` quantifies how often the old selector would disagree with the improved selector
277
- - `domainfinder:audit-existing:bq` measures current warehouse-visible mismatches and bad chosen domains
278
- - `domainfinder:audit-delta` compares two audit snapshots and reports metric deltas
279
- - `domainfinder:repair-existing:bq` generates (and optionally executes) targeted repair writes with selectable mode:
280
- - `conservative`: only missing/blacklisted chosen domains
281
- - `aggressive`: missing/blacklisted plus all mismatches
282
- - `mismatch-only`: only mismatched chosen domains
283
- - `domainfinder:writeback-sql` emits conservative SQL for `domainFinder_output`
284
- - `domainfinder:writeback:bq` can execute that writeback in BigQuery when explicitly asked
285
- - `domainfinder:run:bq` runs the full candidate -> decision -> audit -> writeback pipeline and stores all artifacts
286
-
287
- Improved selection policy:
288
-
289
- 1. prefer `domain_linkedin` when present and not blacklisted
290
- 2. otherwise prefer `website_linkedin` root domain when present and not blacklisted
291
- 3. otherwise choose the non-blacklisted candidate with the highest Hunter email count
292
- 4. otherwise fall back to the first non-null candidate
293
-
294
- This removes the earlier failure mode where OpenAI or Hunter could override a good LinkedIn domain purely because of a higher Hunter count.
295
-
296
- Writeback policy:
297
-
298
- - write to `SalesPrompter.domainFinder_output`, which is the source feeding `linkedin_companies.domain`
299
- - exclude `no-domain` decisions
300
- - exclude blacklisted domains from generated writeback SQL
301
- - preserve batch provenance through `trace_id`
302
-
303
- ## Development
304
-
305
- ```bash
306
- npm install
307
- npm run build
308
- node ./dist/cli.js --help
309
- npm test
39
+ # Check crawl status
40
+ salesprompter salesnav:crawl:status --job-id "<job-id>"
310
41
  ```
311
42
 
312
- BigQuery project selection:
313
-
314
- - The CLI runs `bq query` with `--project_id` from `BQ_PROJECT_ID`.
315
- - Fallback order: `BQ_PROJECT_ID` -> `GOOGLE_CLOUD_PROJECT` -> `GCLOUD_PROJECT` -> `icpidentifier`.
316
-
317
- ## Real Deel Flow
43
+ ## Output modes
318
44
 
319
- For Deel as the vendor you sell for, do not use `--domain deel.com`. That path targets contacts at Deel itself.
45
+ - `--json` for machine-readable output
46
+ - `--quiet` to suppress successful payload output
320
47
 
321
- Use this path instead:
48
+ ## Notes
322
49
 
323
- 1. `salesprompter icp:vendor --vendor deel --market dach --out ./data/deel-icp.json`
324
- 2. `salesprompter leads:lookup:bq --icp ./data/deel-icp.json --limit 100 --execute --out ./data/deel-leads-raw.json --lead-out ./data/deel-leads.json`
325
- 3. `salesprompter leads:enrich --in ./data/deel-leads.json --out ./data/deel-enriched.json`
326
- 4. `salesprompter leads:score --icp ./data/deel-icp.json --in ./data/deel-enriched.json --out ./data/deel-scored.json`
327
- 5. `salesprompter sync:outreach --target instantly --in ./data/deel-scored.json --campaign-id "$INSTANTLY_CAMPAIGN_ID"`
328
- 6. Add `--apply` only when the dry-run output looks correct.
50
+ - Use your own authorized LinkedIn / Sales Navigator access.
51
+ - Respect LinkedIn and provider terms.
52
+ - The CLI is designed for interactive users and agent-assisted workflows.
329
53
 
330
- ## Next integrations
54
+ ## Links
331
55
 
332
- - Replace `HeuristicCompanyProvider` with a real account lookup provider.
333
- - Replace `HeuristicPeopleSearchProvider` with Apollo, Clay, LinkedIn, or custom people-data providers.
334
- - Replace `HeuristicEnrichmentProvider` with enrichment APIs such as Clearbit, FullEnrich, or custom LLM workflows.
335
- - Replace `DryRunSyncProvider` with real HubSpot, Salesforce, Pipedrive, Instantly, Apollo, or Outreach clients.
336
- - Add provider selection and credentials for a first real `--domain deel.com` workflow.
337
- - Replace configurable `bq` field mapping with a typed adapter per warehouse schema.
338
- - Add a real candidate-fetch command that reads domain candidates from BigQuery and feeds them into `domainfinder:select`.
56
+ - Docs: https://salesprompter-cli.vercel.app
57
+ - App: https://salesprompter.ai
58
+ - Repository: https://github.com/danielsinewe/salesprompter-cli
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { access, appendFile, mkdir } from "node:fs/promises";
3
+ import { access, appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
+ import os from "node:os";
5
6
  import path from "node:path";
6
7
  import { emitKeypressEvents } from "node:readline";
7
8
  import { createInterface } from "node:readline/promises";
@@ -19,6 +20,7 @@ import { analyzeHistoricalQueries } from "./historical-queries.js";
19
20
  import { buildHistoricalVendorIcp, buildVendorIcp } from "./icp-templates.js";
20
21
  import { InstantlySyncProvider } from "./instantly.js";
21
22
  import { crawlLinkedInProductCategory } from "./linkedin-products.js";
23
+ import { claimValidatedSalesNavigatorSessionCookieForCli } from "./linkedin-session.js";
22
24
  import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
23
25
  import { readJsonFile, splitCsv, writeJsonFile, writeTextFile } from "./io.js";
24
26
  import { buildSalesNavigatorCrawlPreview, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSlice, deriveSalesNavigatorTitleQuerySeeds, expandSalesNavigatorCrawlAttempt, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
@@ -783,29 +785,10 @@ function summarizeSalesNavigatorQuery(url, appliedFilters) {
783
785
  appliedFilters
784
786
  };
785
787
  }
786
- function extractSalesNavigatorFilterTypes(url, appliedFilters) {
787
- const filterTypes = new Set(appliedFilters.map((filter) => filter.type));
788
- const decodedQuery = decodeSalesNavigatorQueryParam(url) ?? "";
789
- for (const match of decodedQuery.matchAll(/type:([A-Z_]+)/g)) {
790
- const value = match[1]?.trim();
791
- if (value) {
792
- filterTypes.add(value);
793
- }
794
- }
795
- return [...filterTypes];
796
- }
797
788
  function shouldPreSplitSalesNavigatorRootSlice(slice, maxSplitDepth) {
798
- if (slice.depth !== 0 || slice.splitTrail.length > 0) {
799
- return false;
800
- }
801
- if (!nextSalesNavigatorSplitDimension(slice, maxSplitDepth)) {
802
- return false;
803
- }
804
- const filterTypes = new Set(extractSalesNavigatorFilterTypes(slice.slicedQueryUrl, slice.appliedFilters));
805
- if (!filterTypes.has("CURRENT_TITLE")) {
806
- return false;
807
- }
808
- return !DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS.some((dimension) => filterTypes.has(dimension.filterType));
789
+ void slice;
790
+ void maxSplitDepth;
791
+ return false;
809
792
  }
810
793
  function buildTraceHeaders(traceId) {
811
794
  return traceId ? { "X-Salesprompter-Trace-Id": traceId } : {};
@@ -1632,6 +1615,10 @@ async function runSalesNavigatorCrawlAttempt(session, attempt, options, context)
1632
1615
  if (totalResults === null || totalResults > attempt.maxResultsPerSearch) {
1633
1616
  return probeResult;
1634
1617
  }
1618
+ const splitTriggerResults = Math.min(attempt.maxResultsPerSearch, SALES_NAVIGATOR_SPLIT_TRIGGER_RESULTS);
1619
+ if (totalResults > splitTriggerResults) {
1620
+ throw new SalesNavigatorSliceTooBroadError(`Sales Navigator slice produced ${totalResults} results, exceeding the split trigger of ${splitTriggerResults}.`, { totalResults });
1621
+ }
1635
1622
  return await runSalesNavigatorExportWithAgentWait(session, {
1636
1623
  sourceQueryUrl: attempt.sourceQueryUrl,
1637
1624
  slicedQueryUrl: attempt.slicedQueryUrl,
@@ -1731,10 +1718,112 @@ function nextSalesNavigatorSplitDimension(slice, maxSplitDepth) {
1731
1718
  if (slice.depth >= maxSplitDepth) {
1732
1719
  return null;
1733
1720
  }
1734
- return DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS[slice.depth] ?? null;
1721
+ const usedDimensionKeys = new Set(slice.splitTrail.map((entry) => entry.key));
1722
+ const orderedDimensions = getLearnedSalesNavigatorDimensionOrder();
1723
+ return orderedDimensions.find((dimension) => !usedDimensionKeys.has(dimension.key)) ?? null;
1735
1724
  }
1736
1725
  const SALES_NAVIGATOR_COOKIE_RETRY_LIMIT = 8;
1737
1726
  const SALES_NAVIGATOR_RESULT_RETRY_LIMIT = 3;
1727
+ const SALES_NAVIGATOR_SPLIT_TRIGGER_RESULTS = 1500;
1728
+ const SALES_NAVIGATOR_FILTER_IMPACT_MIN_OBSERVATIONS = 3;
1729
+ let salesNavigatorFilterImpactModel = null;
1730
+ let salesNavigatorFilterImpactLoaded = false;
1731
+ function getSalesprompterConfigDir() {
1732
+ const override = process.env.SALESPROMPTER_CONFIG_DIR?.trim();
1733
+ if (override !== undefined && override.length > 0) {
1734
+ return override;
1735
+ }
1736
+ return path.join(os.homedir(), ".config", "salesprompter");
1737
+ }
1738
+ function getSalesNavigatorFilterImpactPath() {
1739
+ return path.join(getSalesprompterConfigDir(), "salesnav-filter-impact.json");
1740
+ }
1741
+ async function loadSalesNavigatorFilterImpactModel() {
1742
+ if (salesNavigatorFilterImpactLoaded) {
1743
+ return salesNavigatorFilterImpactModel;
1744
+ }
1745
+ salesNavigatorFilterImpactLoaded = true;
1746
+ const filePath = getSalesNavigatorFilterImpactPath();
1747
+ try {
1748
+ const content = await readFile(filePath, "utf8");
1749
+ const parsed = JSON.parse(content);
1750
+ if (parsed && parsed.version === 1 && parsed.dimensions && typeof parsed.dimensions === "object") {
1751
+ salesNavigatorFilterImpactModel = parsed;
1752
+ }
1753
+ }
1754
+ catch {
1755
+ salesNavigatorFilterImpactModel = null;
1756
+ }
1757
+ return salesNavigatorFilterImpactModel;
1758
+ }
1759
+ async function persistSalesNavigatorFilterImpactModel() {
1760
+ if (!salesNavigatorFilterImpactModel) {
1761
+ return;
1762
+ }
1763
+ const filePath = getSalesNavigatorFilterImpactPath();
1764
+ await mkdir(path.dirname(filePath), { recursive: true });
1765
+ await writeFile(filePath, `${JSON.stringify(salesNavigatorFilterImpactModel, null, 2)}\n`, "utf8");
1766
+ }
1767
+ function getLearnedSalesNavigatorDimensionOrder() {
1768
+ const model = salesNavigatorFilterImpactModel;
1769
+ if (!model) {
1770
+ return DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS;
1771
+ }
1772
+ const defaultIndex = new Map(DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS.map((dimension, index) => [dimension.key, index]));
1773
+ return [...DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS].sort((left, right) => {
1774
+ const leftStats = model.dimensions[left.key];
1775
+ const rightStats = model.dimensions[right.key];
1776
+ const leftReliable = (leftStats?.observations ?? 0) >= SALES_NAVIGATOR_FILTER_IMPACT_MIN_OBSERVATIONS;
1777
+ const rightReliable = (rightStats?.observations ?? 0) >= SALES_NAVIGATOR_FILTER_IMPACT_MIN_OBSERVATIONS;
1778
+ if (leftReliable && rightReliable) {
1779
+ const delta = (leftStats?.avgResults ?? Number.POSITIVE_INFINITY) -
1780
+ (rightStats?.avgResults ?? Number.POSITIVE_INFINITY);
1781
+ if (delta !== 0) {
1782
+ return delta;
1783
+ }
1784
+ }
1785
+ else if (leftReliable !== rightReliable) {
1786
+ return leftReliable ? -1 : 1;
1787
+ }
1788
+ return (defaultIndex.get(left.key) ?? 0) - (defaultIndex.get(right.key) ?? 0);
1789
+ });
1790
+ }
1791
+ async function recordSalesNavigatorFilterImpactObservation(slice, totalResults, options) {
1792
+ if (totalResults === null || totalResults === undefined || !Number.isFinite(totalResults)) {
1793
+ return;
1794
+ }
1795
+ const learnedDimension = slice.splitTrail.at(-1)?.key ?? null;
1796
+ if (!learnedDimension) {
1797
+ return;
1798
+ }
1799
+ await loadSalesNavigatorFilterImpactModel();
1800
+ if (!salesNavigatorFilterImpactModel) {
1801
+ salesNavigatorFilterImpactModel = {
1802
+ version: 1,
1803
+ updatedAt: new Date().toISOString(),
1804
+ dimensions: {}
1805
+ };
1806
+ }
1807
+ const previous = salesNavigatorFilterImpactModel.dimensions[learnedDimension];
1808
+ const observations = (previous?.observations ?? 0) + 1;
1809
+ const sumResults = (previous?.sumResults ?? 0) + totalResults;
1810
+ const avgResults = sumResults / observations;
1811
+ salesNavigatorFilterImpactModel.dimensions[learnedDimension] = {
1812
+ observations,
1813
+ sumResults,
1814
+ avgResults,
1815
+ lastObservedAt: new Date().toISOString()
1816
+ };
1817
+ salesNavigatorFilterImpactModel.updatedAt = new Date().toISOString();
1818
+ await persistSalesNavigatorFilterImpactModel();
1819
+ await options?.logger?.log("salesnav.filter_impact.updated", {
1820
+ dimensionKey: learnedDimension,
1821
+ observations,
1822
+ avgResults,
1823
+ totalResults,
1824
+ outcome: options?.outcome ?? null
1825
+ });
1826
+ }
1738
1827
  function buildSalesNavigatorSplitChildren(slice, dimension) {
1739
1828
  const attempt = buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice);
1740
1829
  return expandSalesNavigatorCrawlAttempt(attempt, dimension).map((child) => ({
@@ -1812,6 +1901,42 @@ function buildSalesNavigatorSliceFailureReport(slice, error, options) {
1812
1901
  function formatSalesNavigatorSplitTrail(splitTrail) {
1813
1902
  return splitTrail.map((entry) => `${entry.key}:${entry.value.text}`);
1814
1903
  }
1904
+ async function ensureSalesNavigatorSessionPoolReady(queryUrl, options) {
1905
+ try {
1906
+ await options.logger?.log("salesnav.session_pool.preflight.started", {
1907
+ source: options.source,
1908
+ queryUrl
1909
+ });
1910
+ const claimed = await claimValidatedSalesNavigatorSessionCookieForCli({
1911
+ queryUrl,
1912
+ source: options.source,
1913
+ env: process.env
1914
+ });
1915
+ await options.logger?.log("salesnav.session_pool.preflight.completed", {
1916
+ source: options.source,
1917
+ queryUrl,
1918
+ status: claimed ? "ok" : "skipped",
1919
+ selectedSessionUserEmail: claimed?.userEmail ?? null,
1920
+ selectedSessionUserHandle: claimed?.userHandle ?? null,
1921
+ selectedSessionCookieSha256: claimed?.sessionCookieSha256 ?? null
1922
+ });
1923
+ return {
1924
+ ready: true
1925
+ };
1926
+ }
1927
+ catch (error) {
1928
+ const message = error instanceof Error ? error.message : String(error);
1929
+ await options.logger?.log("salesnav.session_pool.preflight.failed", {
1930
+ source: options.source,
1931
+ queryUrl,
1932
+ error: message
1933
+ });
1934
+ return {
1935
+ ready: false,
1936
+ error: message
1937
+ };
1938
+ }
1939
+ }
1815
1940
  async function processSalesNavigatorClaimedCrawlSlice(session, jobId, slice, options) {
1816
1941
  let currentSession = session;
1817
1942
  await options.logger?.log("salesnav.crawl.slice.claimed", {
@@ -1869,7 +1994,8 @@ async function processSalesNavigatorClaimedCrawlSlice(session, jobId, slice, opt
1869
1994
  error: `Pre-split by ${nextDimension.key}`,
1870
1995
  errorCode: "presplit_root_title_query",
1871
1996
  totalResults: null
1872
- }
1997
+ },
1998
+ forceSessionPoolRecheck: false
1873
1999
  };
1874
2000
  }
1875
2001
  }
@@ -1909,6 +2035,10 @@ async function processSalesNavigatorClaimedCrawlSlice(session, jobId, slice, opt
1909
2035
  })
1910
2036
  }, options.traceId);
1911
2037
  currentSession = reported.session;
2038
+ await recordSalesNavigatorFilterImpactObservation(slice, result.totalResults ?? null, {
2039
+ logger: options.logger,
2040
+ outcome: "exported"
2041
+ });
1912
2042
  await options.logger?.log("salesnav.crawl.slice.exported", {
1913
2043
  jobId,
1914
2044
  sliceId: slice.id,
@@ -1927,7 +2057,8 @@ async function processSalesNavigatorClaimedCrawlSlice(session, jobId, slice, opt
1927
2057
  outcome: "exported",
1928
2058
  runId: result.runId,
1929
2059
  totalResults: result.totalResults ?? null
1930
- }
2060
+ },
2061
+ forceSessionPoolRecheck: false
1931
2062
  };
1932
2063
  }
1933
2064
  catch (error) {
@@ -1956,6 +2087,10 @@ async function processSalesNavigatorClaimedCrawlSlice(session, jobId, slice, opt
1956
2087
  });
1957
2088
  const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, payload, options.traceId);
1958
2089
  currentSession = reported.session;
2090
+ await recordSalesNavigatorFilterImpactObservation(slice, payload.totalResults ?? null, {
2091
+ logger: options.logger,
2092
+ outcome: payload.outcome
2093
+ });
1959
2094
  await options.logger?.log("salesnav.crawl.slice.reported", {
1960
2095
  jobId,
1961
2096
  sliceId: slice.id,
@@ -1976,11 +2111,13 @@ async function processSalesNavigatorClaimedCrawlSlice(session, jobId, slice, opt
1976
2111
  error: payload.error,
1977
2112
  errorCode: payload.errorCode,
1978
2113
  totalResults: payload.totalResults
1979
- }
2114
+ },
2115
+ forceSessionPoolRecheck: payload.errorCode === "invalid_session"
1980
2116
  };
1981
2117
  }
1982
2118
  }
1983
2119
  async function executeSalesNavigatorCrawlJob(session, jobId, options) {
2120
+ await loadSalesNavigatorFilterImpactModel();
1984
2121
  let currentSession = session;
1985
2122
  let claimedSlices = 0;
1986
2123
  const seenSliceIds = new Set();
@@ -1992,11 +2129,56 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
1992
2129
  const inFlight = new Map();
1993
2130
  let nextSlot = 0;
1994
2131
  let noMoreClaimableWork = false;
2132
+ let sessionPoolFailures = 0;
2133
+ let nextSessionPoolRetryAt = 0;
2134
+ let lastSessionPoolReadyAt = 0;
2135
+ const sessionPoolReadinessCooldownMs = 120_000;
1995
2136
  while (true) {
1996
2137
  while (!noMoreClaimableWork && inFlight.size < parallelExports) {
1997
2138
  if (claimedSlices >= options.maxSlices) {
1998
2139
  break;
1999
2140
  }
2141
+ if (inFlight.size === 0) {
2142
+ const now = Date.now();
2143
+ if (now < nextSessionPoolRetryAt) {
2144
+ await delay(Math.max(0, nextSessionPoolRetryAt - now));
2145
+ continue;
2146
+ }
2147
+ if (now - lastSessionPoolReadyAt >= sessionPoolReadinessCooldownMs) {
2148
+ const readiness = await ensureSalesNavigatorSessionPoolReady(job?.sourceQueryUrl ?? "https://www.linkedin.com/sales/search/people", {
2149
+ logger: options.logger,
2150
+ source: "cli_salesnav_crawl_preflight"
2151
+ });
2152
+ if (!readiness.ready) {
2153
+ sessionPoolFailures += 1;
2154
+ idlePollCount += 1;
2155
+ const waitSeconds = Math.min(120, 10 * Math.max(1, sessionPoolFailures));
2156
+ nextSessionPoolRetryAt = Date.now() + waitSeconds * 1000;
2157
+ await options.logger?.log("salesnav.crawl.session_pool.waiting", {
2158
+ jobId,
2159
+ idlePollCount,
2160
+ idleMaxPolls: options.idleMaxPolls,
2161
+ sessionPoolFailures,
2162
+ waitSeconds,
2163
+ error: readiness.error
2164
+ });
2165
+ if (idlePollCount >= options.idleMaxPolls) {
2166
+ lastOutcome = {
2167
+ outcome: "terminal_failed",
2168
+ error: readiness.error ??
2169
+ `Sales Navigator session pool stayed unavailable for ${options.idleMaxPolls} checks.`,
2170
+ errorCode: "blocked_no_valid_salesnav_session"
2171
+ };
2172
+ noMoreClaimableWork = true;
2173
+ break;
2174
+ }
2175
+ continue;
2176
+ }
2177
+ sessionPoolFailures = 0;
2178
+ nextSessionPoolRetryAt = 0;
2179
+ lastSessionPoolReadyAt = Date.now();
2180
+ }
2181
+ }
2000
2182
  const claimed = await claimNextSalesNavigatorCrawlSlice(currentSession, jobId, options.traceId);
2001
2183
  currentSession = claimed.session;
2002
2184
  job = claimed.value.job;
@@ -2089,6 +2271,10 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
2089
2271
  job = completed.value.job;
2090
2272
  activeSlice = completed.value.activeSlice;
2091
2273
  lastOutcome = completed.value.lastOutcome;
2274
+ if (completed.value.forceSessionPoolRecheck) {
2275
+ lastSessionPoolReadyAt = 0;
2276
+ nextSessionPoolRetryAt = 0;
2277
+ }
2092
2278
  }
2093
2279
  if (!job) {
2094
2280
  const status = await getSalesNavigatorCrawlStatus(currentSession, jobId, options.traceId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "JSON-first sales prospecting CLI for ICP definition, lead generation, enrichment, scoring, and CRM/outreach sync.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,12 @@
22
22
  "verify:all": "npm run check && npm test && npm run build:docs:site && npm run docs:broken-links && npm run docs:a11y",
23
23
  "vercel-build": "npm run build:docs:site"
24
24
  },
25
+ "engines": {
26
+ "node": ">=20.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
25
31
  "keywords": [
26
32
  "sales",
27
33
  "salesprompter",
@@ -57,6 +63,9 @@
57
63
  "commander": "^14.0.1",
58
64
  "zod": "^4.1.5"
59
65
  },
66
+ "overrides": {
67
+ "undici": "^7.24.0"
68
+ },
60
69
  "devDependencies": {
61
70
  "@types/node": "^24.3.0",
62
71
  "gray-matter": "^4.0.3",