salesprompter-cli 0.1.78 → 0.1.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,10 +48,23 @@ Use `--report-only` to refresh saved reports without contacting LinkedIn. The wo
48
48
 
49
49
  For a manually verified legal-name alias, add `verifiedEmployerAliases: [{ "name": "Exact source employer name", "evidenceUrl": "https://official.example/legal" }]` to that company. The CLI records the evidence link but does not independently verify its contents. Aliases never bypass the numeric current-company ID check and are never inferred from similar names. Changing the brief requires a new output directory; no existing review rows are silently promoted.
50
50
 
51
- If LinkedIn rejects the saved session, the command tries a different local extension-synced session once and keeps completed searches. If none works, reconnect the extension or use `--browser-relay-port` with a signed-in browser worker. Rate limits stop the run without switching sessions.
51
+ LinkedIn credentials are loaded only when a pending request needs them; a completed checkpoint can be reopened without LinkedIn credentials (Salesprompter workspace authentication still applies). If LinkedIn rejects the saved session, the command tries refreshed local extension credentials for the same LinkedIn identity once and keeps completed searches. Add `--wait-for-session 300` to wait up to five minutes for that refresh, with progress notices. The CLI does not log in to LinkedIn for you. If none arrives, reconnect the extension and rerun, or use `--browser-relay-port` with a signed-in browser worker. Rate limits stop the run without switching sessions. Session waiting and browser relay are mutually exclusive.
52
52
 
53
53
  Create a JSON brief with verified numeric LinkedIn company IDs. Use each subsidiary's own ID; names alone are not an employer match. Missing IDs stay in the coverage report as unresolved.
54
54
 
55
+ For a name-only list, the executable end-to-end research mode is:
56
+
57
+ ```bash
58
+ salesprompter leads:at-companies --brief companies.json --out-dir ./research --resolve-companies --all
59
+ ```
60
+
61
+ The CLI first collects prepared IDs, then resolves missing IDs through paginated Account Search and immediately collects their contacts. It accepts only one exact-name identity from a fully read result set of at most 1,000 companies; fuzzy, shared-ID, oversized and ambiguous matches remain unresolved. Resolutions and misses are saved in the workspace-bound checkpoint and `company-resolutions.json`, without editing the input brief. Repeat the command to resume. Add `--retry-unresolved --resolve-companies` to intentionally retry saved identity misses in the same directory without repeating successful resolutions or completed searches. `--all` removes the invocation's job-count limit, not LinkedIn pacing or per-function candidate limits. A working LinkedIn session is required for new requests; the Codex browser relay still requires a connected browser worker when used.
62
+
63
+ ```bash
64
+ salesprompter leads:at-companies --brief companies.json --out-dir ./research \
65
+ --resolve-companies --retry-unresolved --all --wait-for-session 300
66
+ ```
67
+
55
68
  ```json
56
69
  {
57
70
  "companies": [
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { Command } from "commander";
16
16
  import { z } from "zod";
17
17
  import { AffiliateCopyReviewSchema, renderAffiliateCopyReview } from "./affiliate-copy.js";
18
18
  import { CompanyBriefSchema, planCompanySearches, runCompanyResearch } from "./company-leads.js";
19
- import { createSessionRecovery } from "./session-recovery.js";
19
+ import { createLazySessionRecovery, waitForFreshSession, isFreshSessionForIdentity } from "./session-recovery.js";
20
20
  import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
21
21
  import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
22
22
  import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
@@ -15142,12 +15142,21 @@ program
15142
15142
  .option("--out-dir <path>", "Private output directory and resume checkpoint", "./company-leads")
15143
15143
  .option("--max-searches <number>", "Maximum company/function searches this invocation", "12")
15144
15144
  .option("--report-only", "Refresh saved research reports without contacting LinkedIn", false)
15145
+ .option("--all", "Process all pending company/function searches in this invocation", false)
15146
+ .option("--resolve-companies", "Resolve unique exact company names and collect their contacts in the same run", false)
15147
+ .option("--retry-unresolved", "Retry cached identity misses without discarding completed searches (requires --resolve-companies)", false)
15148
+ .option("--wait-for-session <seconds>", "Wait for a refreshed extension session after an auth failure; never retry rate limits", "0")
15145
15149
  .option("--browser-relay-port <number>", "Use the signed-in Codex browser through a loopback relay")
15146
15150
  .option("--dry-run", "Preview targeting and unresolved companies without network calls", false)
15147
15151
  .action(async (options) => {
15152
+ if (options.retryUnresolved && !options.resolveCompanies)
15153
+ throw new Error("--retry-unresolved requires --resolve-companies.");
15154
+ const waitMs = z.coerce.number().int().min(0).max(3600).parse(options.waitForSession) * 1000;
15155
+ if (waitMs && options.browserRelayPort)
15156
+ throw new Error("--wait-for-session refreshes extension credentials, not a browser relay. Use one connection mode.");
15148
15157
  const brief = await readJsonFile(path.resolve(options.brief), CompanyBriefSchema);
15149
15158
  const jobs = planCompanySearches(brief);
15150
- const maxSearches = z.coerce.number().int().min(1).max(10000).parse(options.maxSearches);
15159
+ const maxSearches = options.all ? Number.MAX_SAFE_INTEGER : z.coerce.number().int().min(1).max(10000).parse(options.maxSearches);
15151
15160
  if (options.dryRun) {
15152
15161
  printOutput({ status: "ok", dryRun: true, searches: jobs, unresolvedCompanies: brief.companies.filter(c => !c.companyId), maxSearches, outreachStarted: false, emailEnrichmentStarted: false });
15153
15162
  return;
@@ -15163,11 +15172,34 @@ program
15163
15172
  }
15164
15173
  const port = options.browserRelayPort == null ? null : z.coerce.number().int().min(1).max(65535).parse(options.browserRelayPort);
15165
15174
  const relay = port == null ? null : await createLocalAccountSearchBrowserRelay(port);
15166
- const config = relay ? null : await readLinkedInDirectLookupConfig();
15167
- const recoverSession = createSessionRecovery({
15168
- initial: config,
15169
- isAuthError: error => error instanceof LocalSalesNavigatorHttpError && (error.status === 401 || error.status === 403),
15170
- refresh: async () => relay || shouldDisableLinkedInDirectLookupAutodiscovery() ? null : readLocalLinkedInExtensionDirectLookupConfig(),
15175
+ let currentConfig = null;
15176
+ const refresh = async () => {
15177
+ if (relay || shouldDisableLinkedInDirectLookupAutodiscovery())
15178
+ return null;
15179
+ return waitForFreshSession({
15180
+ read: readLocalLinkedInExtensionDirectLookupConfig,
15181
+ accept: next => isFreshSessionForIdentity(currentConfig, next),
15182
+ waitMs,
15183
+ onWaiting: () => writeProgress("Waiting for a refreshed Sales Navigator session from the same LinkedIn identity. Reconnect the Salesprompter extension; the pending search is preserved."),
15184
+ });
15185
+ };
15186
+ const recoverSession = createLazySessionRecovery(async () => {
15187
+ if (relay)
15188
+ return null;
15189
+ try {
15190
+ currentConfig = await readLinkedInDirectLookupConfig();
15191
+ }
15192
+ catch (error) {
15193
+ if (!waitMs || !(error instanceof Error) || !error.message.startsWith("Missing LinkedIn direct lookup session"))
15194
+ throw error;
15195
+ currentConfig = await refresh();
15196
+ if (!currentConfig)
15197
+ throw new Error("No LinkedIn session arrived before the wait expired. Reconnect the extension and rerun with the same --out-dir; saved work is preserved.");
15198
+ }
15199
+ return currentConfig;
15200
+ }, {
15201
+ isAuthError: error => error instanceof CliImportCompanySessionError || error instanceof LocalSalesNavigatorHttpError && (error.status === 401 || error.status === 403),
15202
+ refresh,
15171
15203
  sameSession: (a, b) => a?.cookie === b?.cookie && a?.csrfToken === b?.csrfToken,
15172
15204
  beforeRetry: async () => {
15173
15205
  writeProgress("Saved LinkedIn session was rejected; trying the latest extension-synced session once.");
@@ -15176,7 +15208,41 @@ program
15176
15208
  });
15177
15209
  let startedSearches = 0;
15178
15210
  try {
15179
- const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches,
15211
+ const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches, retryUnresolved: options.retryUnresolved,
15212
+ resolveCompany: !options.resolveCompanies ? undefined : async (company) => {
15213
+ if (startedSearches++ > 0)
15214
+ await delay(randomIntegerBetween(5000, 8000));
15215
+ writeProgress(`Resolving ${company.name}`);
15216
+ const queryUrl = buildCliImportAccountSearchUrl(company.name);
15217
+ const response = await recoverSession(activeConfig => fetchCliImportSalesNavigatorJson({ url: queryUrl, config: activeConfig, browserRelay: relay, timeoutMs: 30000, label: `Company identity for ${company.name}` }));
15218
+ if (response.status !== 200 || !response.body)
15219
+ throw new Error(`Company identity search failed (${response.status}).`);
15220
+ const elements = extractLocalSalesNavigatorElements(response.body);
15221
+ const total = extractLocalSalesNavigatorTotalResults(response.body);
15222
+ // A truncated or unknown result set cannot establish a unique identity.
15223
+ if (total == null || total > 1000)
15224
+ return null;
15225
+ for (let start = 25; start < total; start += 25) {
15226
+ await delay(randomIntegerBetween(5000, 8000));
15227
+ const pageUrl = queryUrl.replace(/([?&])start=\d+/, `$1start=${start}`);
15228
+ const page = await recoverSession(activeConfig => fetchCliImportSalesNavigatorJson({ url: pageUrl, config: activeConfig, browserRelay: relay, timeoutMs: 30000, label: `Company identity for ${company.name}` }));
15229
+ if (page.status !== 200 || !page.body)
15230
+ throw new Error(`Company identity page failed (${page.status}).`);
15231
+ const rows = extractLocalSalesNavigatorElements(page.body);
15232
+ if (!rows.length)
15233
+ return null;
15234
+ elements.push(...rows);
15235
+ }
15236
+ if (elements.length < total)
15237
+ return null;
15238
+ const accounts = elements.map(element => normalizeLocalSalesNavigatorAccount(element, queryUrl)).filter(Boolean);
15239
+ const exact = accounts.filter(account => normalizeLooseMatchText(String(account.companyName ?? "")) === normalizeLooseMatchText(company.name));
15240
+ const ids = new Set(exact.map(account => String(account.companyId ?? "")).filter(id => /^[1-9]\d*$/.test(id)));
15241
+ if (ids.size !== 1)
15242
+ return null;
15243
+ const companyId = [...ids][0];
15244
+ return { companyId, companyName: company.name, evidenceUrl: `https://www.linkedin.com/sales/company/${companyId}` };
15245
+ },
15180
15246
  search: async (job) => {
15181
15247
  if (startedSearches++ > 0)
15182
15248
  await delay(randomIntegerBetween(5000, 8000));
@@ -217,18 +217,46 @@ async function runCompanyResearchLocked(input) {
217
217
  throw e;
218
218
  }
219
219
  const save = async (file, value) => { await writeFile(file + ".tmp", value, { mode: 0o600 }); await chmod(file + ".tmp", 0o600); await rename(file + ".tmp", file); };
220
- const publish = async () => { const report = shortlistCompanyLeads(brief, state.results); await save(path.join(outDir, "contacts.csv"), companyLeadCsv(report.selected)); await save(path.join(outDir, "review.csv"), companyLeadCsv(report.review)); await save(path.join(outDir, "coverage.json"), JSON.stringify(report, null, 2)); return report; };
220
+ const effectiveBrief = () => CompanyBriefSchema.parse({ ...brief, companies: brief.companies.map((company, index) => {
221
+ const resolution = state.resolutions?.[String(index)];
222
+ return !company.companyId && resolution ? { ...company, companyId: resolution.companyId } : company;
223
+ }) });
224
+ const publish = async () => { const report = shortlistCompanyLeads(effectiveBrief(), state.results); await save(path.join(outDir, "contacts.csv"), companyLeadCsv(report.selected)); await save(path.join(outDir, "review.csv"), companyLeadCsv(report.review)); await save(path.join(outDir, "coverage.json"), JSON.stringify(report, null, 2)); await save(path.join(outDir, "company-resolutions.json"), JSON.stringify(state.resolutions ?? {}, null, 2)); return report; };
221
225
  let performed = 0;
222
226
  try {
223
- for (const job of planCompanySearches(brief)) {
224
- if (state.results[job.key])
225
- continue;
226
- if (performed >= input.maxSearches)
227
- break;
228
- state.results[job.key] = await input.search(job);
229
- performed++;
230
- await save(checkpoint, JSON.stringify(state));
231
- await publish();
227
+ // Known IDs run first; an unresolved identity cannot block already prepared work.
228
+ const collect = async () => {
229
+ for (const job of planCompanySearches(effectiveBrief())) {
230
+ if (state.results[job.key])
231
+ continue;
232
+ if (performed >= input.maxSearches)
233
+ break;
234
+ state.results[job.key] = await input.search(job);
235
+ performed++;
236
+ await save(checkpoint, JSON.stringify(state));
237
+ await publish();
238
+ }
239
+ };
240
+ await collect();
241
+ if (input.resolveCompany) {
242
+ let resolutionsPerformed = 0;
243
+ for (const [index, company] of brief.companies.entries()) {
244
+ if (performed >= input.maxSearches || resolutionsPerformed >= input.maxSearches)
245
+ break;
246
+ const savedResolution = state.resolutions?.[String(index)];
247
+ if (company.companyId || (Object.hasOwn(state.resolutions ?? {}, String(index)) && !(input.retryUnresolved && savedResolution === null)))
248
+ continue;
249
+ const resolution = await input.resolveCompany(company);
250
+ resolutionsPerformed++;
251
+ if (resolution && (!/^[1-9]\d*$/.test(resolution.companyId) || words(resolution.companyName) !== words(company.name)))
252
+ throw new Error("Company resolver returned a non-exact identity.");
253
+ const occupied = new Set(effectiveBrief().companies.map(c => c.companyId).filter(Boolean));
254
+ state.resolutions ??= {};
255
+ state.resolutions[String(index)] = resolution && !occupied.has(resolution.companyId) ? resolution : null;
256
+ await save(checkpoint, JSON.stringify(state));
257
+ await publish();
258
+ await collect();
259
+ }
232
260
  }
233
261
  }
234
262
  catch (e) {
@@ -236,5 +264,5 @@ async function runCompanyResearchLocked(input) {
236
264
  throw e;
237
265
  }
238
266
  const report = await publish();
239
- return { status: "ok", ...report, completedSearches: Object.keys(state.results).length, totalSearches: planCompanySearches(brief).length, output: outDir, resumable: true };
267
+ return { status: "ok", ...report, completedSearches: Object.keys(state.results).length, totalSearches: planCompanySearches(effectiveBrief()).length, output: outDir, resumable: true };
240
268
  }
@@ -1,3 +1,34 @@
1
+ export function isFreshSessionForIdentity(current, next) {
2
+ return current === null || next.identity === current.identity && (next.cookie !== current.cookie || next.csrfToken !== current.csrfToken);
3
+ }
4
+ /** Poll only the configured credential source. No provider requests or identity rotation. */
5
+ export async function waitForFreshSession(options) {
6
+ const now = options.now ?? Date.now;
7
+ const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
8
+ const deadline = now() + options.waitMs;
9
+ let nextNotice = now();
10
+ while (true) {
11
+ const value = await options.read();
12
+ if (value !== null && options.accept(value))
13
+ return value;
14
+ const remaining = deadline - now();
15
+ if (remaining <= 0)
16
+ return null;
17
+ if (now() >= nextNotice) {
18
+ options.onWaiting?.();
19
+ nextNotice = now() + 30000;
20
+ }
21
+ await sleep(Math.min(5000, remaining));
22
+ }
23
+ }
24
+ /** Delay credential access until collection really needs it (cached runs need none). */
25
+ export function createLazySessionRecovery(load, options) {
26
+ let runner;
27
+ return async (collect) => {
28
+ runner ??= createSessionRecovery({ ...options, initial: await load() });
29
+ return runner(collect);
30
+ };
31
+ }
1
32
  /** One credential refresh per run; never rotate on rate limits or other failures. */
2
33
  export function createSessionRecovery(options) {
3
34
  let current = options.initial;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.78",
3
+ "version": "0.1.80",
4
4
  "description": "Sales workflow CLI for guided lead generation, enrichment, scoring, and sync.",
5
5
  "author": "Daniel Sinewe <hello@danielsinewe.com>",
6
6
  "type": "module",