salesprompter-cli 0.1.76 → 0.1.78

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
@@ -42,6 +42,12 @@ For headless or automation use, generate a CLI token in the app and run `salespr
42
42
 
43
43
  `leads:at-companies` researches Director, Head-of, VP and C-level contacts across functions. It exports a shortlist for review without starting email enrichment or outreach.
44
44
 
45
+ The checkpoint, shortlist and review exports refresh after every completed search, so long runs expose saved progress immediately. Company labels with punctuation, such as `(TKMS)`, are escaped safely in search queries.
46
+
47
+ Use `--report-only` to refresh saved reports without contacting LinkedIn. The workspace-bound checkpoint is still checked. `progress` separates unique shortlisted people, review rows, review people, review-only people, pending searches, partial/unknown coverage and unresolved companies. `collectionComplete` is false while any coverage gap remains; `nextActions` explains what is left. Unrestricted geography is labeled explicitly.
48
+
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
+
45
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.
46
52
 
47
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.
package/dist/cli.js CHANGED
@@ -15141,6 +15141,7 @@ program
15141
15141
  .requiredOption("--brief <path>", "JSON company list with verified company IDs and optional role criteria")
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
+ .option("--report-only", "Refresh saved research reports without contacting LinkedIn", false)
15144
15145
  .option("--browser-relay-port <number>", "Use the signed-in Codex browser through a loopback relay")
15145
15146
  .option("--dry-run", "Preview targeting and unresolved companies without network calls", false)
15146
15147
  .action(async (options) => {
@@ -15155,6 +15156,11 @@ program
15155
15156
  const orgId = session.user.orgId;
15156
15157
  if (!orgId)
15157
15158
  throw new Error("Choose a Salesprompter workspace before company research.");
15159
+ if (options.reportOnly) {
15160
+ const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches: 0, search: async () => { throw new Error("Report-only cannot collect leads."); } });
15161
+ printOutput({ status: "ok", reportOnly: true, progress: report.progress, nextActions: report.nextActions, output: report.output, outreachStarted: false, emailEnrichmentStarted: false });
15162
+ return;
15163
+ }
15158
15164
  const port = options.browserRelayPort == null ? null : z.coerce.number().int().min(1).max(65535).parse(options.browserRelayPort);
15159
15165
  const relay = port == null ? null : await createLocalAccountSearchBrowserRelay(port);
15160
15166
  const config = relay ? null : await readLinkedInDirectLookupConfig();
@@ -15187,7 +15193,7 @@ program
15187
15193
  });
15188
15194
  },
15189
15195
  });
15190
- printOutput({ status: report.status, selected: report.selected.length, reviewRequired: report.review.length, completedSearches: report.completedSearches, totalSearches: report.totalSearches, coverage: report.coverage, output: report.output, resumable: true, outreachStarted: false, emailEnrichmentStarted: false });
15196
+ printOutput({ status: report.status, selected: report.selected.length, reviewRequired: report.review.length, progress: report.progress, nextActions: report.nextActions, completedSearches: report.completedSearches, totalSearches: report.totalSearches, coverage: report.coverage, output: report.output, resumable: true, outreachStarted: false, emailEnrichmentStarted: false });
15191
15197
  }
15192
15198
  finally {
15193
15199
  await relay?.close();
@@ -14,6 +14,7 @@ export const defaultDepartments = [
14
14
  const companySchema = z.object({
15
15
  name: z.string().trim().min(1),
16
16
  companyId: z.string().regex(/^[1-9]\d*$/).optional(),
17
+ verifiedEmployerAliases: z.array(z.object({ name: z.string().trim().min(1), evidenceUrl: z.url() }).strict()).optional(),
17
18
  maxContacts: z.number().int().min(1).max(100).default(20),
18
19
  }).strict();
19
20
  export const CompanyBriefSchema = z.object({
@@ -115,8 +116,9 @@ export function shortlistCompanyLeads(brief, results) {
115
116
  continue;
116
117
  }
117
118
  const sourceCompanyName = positions?.find(p => p.title === title)?.companyName ?? p.companyName ?? "";
118
- const employerNameDiffers = Boolean(sourceCompanyName) && words(String(sourceCompanyName)) !== words(company.name);
119
- const row = { profileUrl: profile, fullName: p.fullName ?? "", title, companyId: company.companyId, companyName: company.name, sourceCompanyName, department: job.department.name, location: p.location ?? "", sourceQueryUrl: job.queryUrl, observedAt: p.timestamp ?? "" };
119
+ const alias = company.verifiedEmployerAliases?.find(a => words(a.name) === words(String(sourceCompanyName)));
120
+ const employerNameDiffers = Boolean(sourceCompanyName) && words(String(sourceCompanyName)) !== words(company.name) && !alias;
121
+ const row = { profileUrl: profile, fullName: p.fullName ?? "", title, companyId: company.companyId, companyName: company.name, sourceCompanyName, employerAliasEvidence: alias?.evidenceUrl ?? "", department: job.department.name, location: p.location ?? "", sourceQueryUrl: job.queryUrl, observedAt: p.timestamp ?? "" };
120
122
  if (role === "review" || employerNameDiffers) {
121
123
  const key = `${company.companyId}:${job.department.name}:${profile}`;
122
124
  if (!reviewSeen.has(key)) {
@@ -151,10 +153,32 @@ export function shortlistCompanyLeads(brief, results) {
151
153
  status: !company.companyId ? "unresolved_company" : jobs.some(j => !results[j.key]) ? "pending" : local.length ? "review_ready" : "no_verified_matches",
152
154
  departments: jobs.map(j => ({ name: j.department.name, selected: local.filter(p => p.department === j.department.name).length, collected: results[j.key]?.people.length ?? null, reported: results[j.key]?.totalResults ?? null, searchComplete: results[j.key]?.totalResults != null ? results[j.key].people.length >= results[j.key].totalResults : null })) });
153
155
  }
154
- return { selected, review, rejected, coverage, outreachStarted: false, emailEnrichmentStarted: false };
156
+ const jobs = planCompanySearches(brief);
157
+ const completed = jobs.filter(j => results[j.key]);
158
+ const incomplete = completed.filter(j => results[j.key].totalResults != null && results[j.key].people.length < results[j.key].totalResults);
159
+ const unknown = completed.filter(j => results[j.key].totalResults == null);
160
+ const selectedIds = new Set(selected.map(p => String(p.profileUrl)));
161
+ const reviewIds = new Set(review.map(p => String(p.profileUrl)));
162
+ const unresolved = brief.companies.filter(c => !c.companyId);
163
+ const progress = {
164
+ selectedPeople: selectedIds.size, reviewRows: review.length, reviewPeople: reviewIds.size,
165
+ reviewOnlyPeople: [...reviewIds].filter(id => !selectedIds.has(id)).length,
166
+ candidateRows: completed.reduce((n, j) => n + results[j.key].people.length, 0),
167
+ completedSearches: completed.length, totalSearches: jobs.length, remainingSearches: jobs.length - completed.length,
168
+ partialSearches: incomplete.length, unknownCoverageSearches: unknown.length, unresolvedCompanies: unresolved.length,
169
+ collectionComplete: completed.length === jobs.length && !unresolved.length && !incomplete.length && !unknown.length,
170
+ geography: brief.regionIds.length ? "explicit_regions" : "unrestricted",
171
+ };
172
+ const nextActions = [
173
+ ...(progress.remainingSearches ? ["Resume the unchanged brief and output directory to collect pending searches."] : []),
174
+ ...(unresolved.length ? ["Verify unresolved company identities; use a new output directory after changing the brief."] : []),
175
+ ...(incomplete.length || unknown.length ? ["Review partial or unknown search coverage before claiming exhaustive collection."] : []),
176
+ ...(review.length ? ["Review roles and employer names separately. Add only evidence-backed verifiedEmployerAliases; exact company ID checks still apply."] : []),
177
+ ];
178
+ return { selected, review, rejected, coverage, progress, nextActions, outreachStarted: false, emailEnrichmentStarted: false };
155
179
  }
156
180
  export function companyLeadCsv(rows) {
157
- const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt", "sourceCompanyName", "reason"];
181
+ const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt", "sourceCompanyName", "reason", "employerAliasEvidence"];
158
182
  const escape = (value) => { let s = String(value ?? ""); if (/^[=+@\-\t\r]/.test(s))
159
183
  s = "'" + s; return `"${s.replace(/"/g, '""')}"`; };
160
184
  return [columns.join(","), ...rows.map(r => columns.map(c => escape(r[c])).join(","))].join("\n") + "\n";
@@ -204,6 +228,7 @@ async function runCompanyResearchLocked(input) {
204
228
  state.results[job.key] = await input.search(job);
205
229
  performed++;
206
230
  await save(checkpoint, JSON.stringify(state));
231
+ await publish();
207
232
  }
208
233
  }
209
234
  catch (e) {
@@ -423,7 +423,8 @@ function buildFilterBlock(filter) {
423
423
  if (typeof value.id === "string" && value.id.trim().length > 0) {
424
424
  segments.push(`id:${value.id}`);
425
425
  }
426
- segments.push(`text:${encodeURIComponent(value.text)}`);
426
+ // Parentheses are Rest.li delimiters but encodeURIComponent leaves them raw.
427
+ segments.push(`text:${encodeURIComponent(value.text).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)}`);
427
428
  segments.push(`selectionType:${selectionType}`);
428
429
  return `(${segments.join(",")})`;
429
430
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
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",