salesprompter-cli 0.1.83 → 0.1.84
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 +22 -4
- package/dist/cli.js +46 -9
- package/dist/company-identity-review.js +78 -0
- package/dist/company-leads.js +28 -2
- package/dist/research-browser-preference.js +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,24 @@ For headless or automation use, generate a CLI token in the app and run `salespr
|
|
|
44
44
|
|
|
45
45
|
`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.
|
|
46
46
|
|
|
47
|
+
Start with company names; no LinkedIn IDs are needed in your brief:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{ "companies": [{ "name": "Your target company", "maxContacts": 20 }] }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
salesprompter auth:login
|
|
55
|
+
salesprompter browser:connect
|
|
56
|
+
salesprompter leads:at-companies --brief companies.json --out-dir ./research --resolve-companies --all
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
After a successful connection, Chrome is remembered for company research. If names are ambiguous, an interactive terminal shows verified candidate locations, websites and evidence links. Choose a number, then confirm; nothing is preselected. The CLI saves the decision and resumes without editing your brief. Use `--review` to review a saved ambiguity later. Enter 0 or press Enter to leave it unresolved.
|
|
60
|
+
|
|
61
|
+
JSON, quiet and non-interactive runs never prompt. Their `outcome` is `complete`, `incomplete` or `needs_review`; `nextCommand` gives the terminal command for pending collection or identity review. `collectionComplete` refers only to collection, so it can be true while role review remains. Add `--require-complete` for exit code 2 whenever collection or review is unfinished. Without it, exit 0 means the command ran, not that all research is complete. `--no-interactive` disables prompts in a terminal too.
|
|
62
|
+
|
|
63
|
+
Guided review fetches native details for at most ten exact-name candidates. Non-exact, composite or larger identity choices still need independently checked `--review-file` decisions. Missing/mismatched details cannot be approved. Explicit connection flags override the remembered choice; failed Chrome authentication never falls back silently to another identity.
|
|
64
|
+
|
|
47
65
|
The checkpoint, shortlist and review exports refresh after every collected page, so long runs expose saved progress immediately. Company labels with punctuation, such as `(TKMS)`, are escaped safely in search queries.
|
|
48
66
|
|
|
49
67
|
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.
|
|
@@ -93,23 +111,23 @@ The CLI validates and persists the complete review file as `review-decisions.jso
|
|
|
93
111
|
|
|
94
112
|
Partial continuation checkpoints the raw next offset and deduplicates people. Old checkpoints without an offset replay the first page once before advancing. `quota` stops at the shortlist ceiling; `exhaustive` means attempting the accessible 2,500-result window, **not** a promise of exhaustive coverage. Count changes, empty/repeated pages and larger result sets remain explicitly incomplete. Stopped searches require investigation; the CLI does not automatically clear `stoppedReason`. `--all` controls the number of jobs, not these safety limits. Make a private copy of the output directory before comparing old and new role-policy results.
|
|
95
113
|
|
|
96
|
-
For standalone browser research, sign in once to the CLI-owned Chrome profile
|
|
114
|
+
For standalone browser research, sign in once to the CLI-owned Chrome profile:
|
|
97
115
|
|
|
98
116
|
```bash
|
|
99
117
|
salesprompter browser:connect
|
|
100
118
|
salesprompter leads:at-companies --brief companies.json --out-dir ./research \
|
|
101
|
-
--resolve-companies --all
|
|
119
|
+
--resolve-companies --all
|
|
102
120
|
```
|
|
103
121
|
|
|
104
122
|
`browser:connect` opens a separate Chrome window and waits up to ten minutes for your manual Sales Navigator sign-in (change with `--wait <seconds>`). It confirms a successful native authenticated request, then closes its own browser. Google Chrome must be installed. The private profile lives at `~/.config/salesprompter/chrome-research` (or under `SALESPROMPTER_CONFIG_DIR`). Your everyday Chrome profile stays untouched. The control connection uses a private OS pipe, not a debugging port. No cookies or browser storage are read/exported. The CLI keeps only allowlisted native request headers in memory.
|
|
105
123
|
|
|
106
124
|
Research runs reuse that profile in background Chrome, execute the GET requests themselves, and close their own browser when finished. No Codex task, relay worker, extension, or manual request polling is needed. A first sign-in, expired login, or security challenge still requires you: rerun `browser:connect`, then repeat the research command with the same output directory. Rate limits stop immediately without switching accounts. `--browser chrome`, `--browser-relay-port`, and `--wait-for-session` are mutually exclusive. Chrome connects lazily, so completed checkpoints and report-only runs never launch it.
|
|
107
125
|
|
|
108
|
-
The
|
|
126
|
+
`browser:connect` remembers Chrome only after confirming a successful authenticated request. `browser:connect --check` checks an existing sign-in in background and also remembers it; it does not open a sign-in window. The preference is private and scoped to the CLI configuration directory. Explicit `--browser session` selects extension credentials; without a remembered choice, session remains the fallback. Explicit relay/wait flags take precedence over the preference. Credentials load only for pending requests; on an auth failure the session route tries changed credentials for the same identity once. Add `--wait-for-session 300` for a bounded refresh wait. The optional Codex `--browser-relay-port` route still needs its external worker. Salesprompter workspace authentication applies in every mode.
|
|
109
127
|
|
|
110
128
|
Ctrl+C, SIGTERM and terminal hangup stop Chrome research gracefully: completed searches stay saved, the owned browser closes, and the research lock is released before exit. Repeat the same command to resume only pending work. A forced kill or power loss cannot run cleanup; if a stale lock remains, confirm that its process stopped before removing only `.research-lock` as the CLI error explains.
|
|
111
129
|
|
|
112
|
-
Create a JSON brief with verified numeric LinkedIn company IDs. Use
|
|
130
|
+
Create a JSON brief with company names or verified numeric LinkedIn company IDs. Use `--resolve-companies` for names. Each subsidiary must resolve to its own ID; unresolved identities stay in the coverage report and are never guessed.
|
|
113
131
|
|
|
114
132
|
For a name-only list, the executable end-to-end research mode is:
|
|
115
133
|
|
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,8 @@ import { z } from "zod";
|
|
|
17
17
|
import { AffiliateCopyReviewSchema, renderAffiliateCopyReview } from "./affiliate-copy.js";
|
|
18
18
|
import { CompanyBriefSchema, planCompanySearches, runCompanyResearch } from "./company-leads.js";
|
|
19
19
|
import { CompanyReviewSchema, resolveCompanyIdentity } from "./company-recovery.js";
|
|
20
|
+
import { chooseCompanyIdentity, companyResearchNextCommand, createCompanyReviewPrompt } from "./company-identity-review.js";
|
|
21
|
+
import { rememberResearchBrowser, selectResearchBrowser } from "./research-browser-preference.js";
|
|
20
22
|
import { createLazySessionRecovery, waitForFreshSession, isFreshSessionForIdentity } from "./session-recovery.js";
|
|
21
23
|
import { createChromeResearchBrowser, ChromeResearchInterruptedError } from "./chrome-browser.js";
|
|
22
24
|
import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
|
|
@@ -15142,10 +15144,13 @@ program
|
|
|
15142
15144
|
.command("browser:connect")
|
|
15143
15145
|
.description("Sign in once to the CLI-owned research Chrome; no external browser worker required.")
|
|
15144
15146
|
.option("--wait <seconds>", "Time allowed for manual LinkedIn sign-in", "600")
|
|
15147
|
+
.option("--check", "Check the saved sign-in in background without opening a sign-in window", false)
|
|
15145
15148
|
.action(async (options) => {
|
|
15146
|
-
const browser = createChromeResearchBrowser({ visible:
|
|
15149
|
+
const browser = createChromeResearchBrowser({ visible: !options.check, loginWaitMs: options.check ? 5000 : z.coerce.number().int().min(1).max(3600).parse(options.wait) * 1000, onProgress: message => process.stderr.write(`${message}\n`) });
|
|
15147
15150
|
try {
|
|
15148
|
-
|
|
15151
|
+
const connection = await browser.connect();
|
|
15152
|
+
await rememberResearchBrowser(getSalesprompterConfigDir(), "chrome");
|
|
15153
|
+
printOutput({ status: "ok", ...connection, defaultResearchBrowser: "chrome" });
|
|
15149
15154
|
}
|
|
15150
15155
|
finally {
|
|
15151
15156
|
await browser.close();
|
|
@@ -15154,7 +15159,7 @@ program
|
|
|
15154
15159
|
program
|
|
15155
15160
|
.command("leads:at-companies")
|
|
15156
15161
|
.description("Find senior contacts at named companies, balanced across functions; export a review shortlist.")
|
|
15157
|
-
.requiredOption("--brief <path>", "JSON company
|
|
15162
|
+
.requiredOption("--brief <path>", "JSON company names or verified IDs, with optional role criteria")
|
|
15158
15163
|
.option("--out-dir <path>", "Private output directory and resume checkpoint", "./company-leads")
|
|
15159
15164
|
.option("--max-searches <number>", "Maximum company/function searches this invocation", "12")
|
|
15160
15165
|
.option("--report-only", "Refresh saved research reports without contacting LinkedIn", false)
|
|
@@ -15162,15 +15167,24 @@ program
|
|
|
15162
15167
|
.option("--resolve-companies", "Resolve unique exact company names and collect their contacts in the same run", false)
|
|
15163
15168
|
.option("--retry-unresolved", "Retry cached identity misses without discarding completed searches (requires --resolve-companies)", false)
|
|
15164
15169
|
.option("--review-file <path>", "Apply fingerprint-bound, evidence-backed identity/alias decisions without changing the brief")
|
|
15170
|
+
.option("--review", "Choose unresolved company identities in this terminal, then resume collection", false)
|
|
15171
|
+
.option("--no-interactive", "Never prompt for company review; return needs_review for unresolved choices")
|
|
15172
|
+
.option("--require-complete", "Exit 2 unless collection and review are complete; still write reports", false)
|
|
15165
15173
|
.option("--verify-employers", "Verify canonical employer names against LinkedIn company details and save safe aliases", false)
|
|
15166
15174
|
.option("--continue-partial <mode>", "Resume saved partial pages: quota or exhaustive (2500-result window)")
|
|
15167
15175
|
.option("--company <name>", "Limit recovery to an exact brief company name; repeat for multiple companies", (value, previous) => [...previous, value], [])
|
|
15168
15176
|
.option("--wait-for-session <seconds>", "Wait for a refreshed extension session after an auth failure; never retry rate limits", "0")
|
|
15169
15177
|
.option("--browser-relay-port <number>", "Use the signed-in Codex browser through a loopback relay")
|
|
15170
|
-
.option("--browser <mode>", "
|
|
15178
|
+
.option("--browser <mode>", "Override saved connection: chrome or session; browser:connect remembers chrome")
|
|
15171
15179
|
.option("--dry-run", "Preview targeting and unresolved companies without network calls", false)
|
|
15172
15180
|
.action(async (options) => {
|
|
15173
|
-
const browserMode =
|
|
15181
|
+
const browserMode = await selectResearchBrowser(options, getSalesprompterConfigDir());
|
|
15182
|
+
const canReview = Boolean(process.stdin.isTTY && process.stderr.isTTY && !runtimeOutputOptions.json && !runtimeOutputOptions.quiet && options.interactive);
|
|
15183
|
+
if (options.review && !canReview)
|
|
15184
|
+
throw new Error("--review requires an interactive terminal without --json, --quiet or --no-interactive. Run the displayed nextCommand in a terminal, or use --review-file for an evidence-backed scripted decision.");
|
|
15185
|
+
if (options.review && (options.reportOnly || options.dryRun))
|
|
15186
|
+
throw new Error("--review cannot be combined with --report-only or --dry-run; candidate verification requires live company details.");
|
|
15187
|
+
const interactiveReview = canReview && !options.reportOnly && !options.dryRun && (options.review || options.resolveCompanies);
|
|
15174
15188
|
if (options.retryUnresolved && !options.resolveCompanies)
|
|
15175
15189
|
throw new Error("--retry-unresolved requires --resolve-companies.");
|
|
15176
15190
|
const waitMs = z.coerce.number().int().min(0).max(3600).parse(options.waitForSession) * 1000;
|
|
@@ -15188,16 +15202,30 @@ program
|
|
|
15188
15202
|
const maxSearches = options.all ? Number.MAX_SAFE_INTEGER : z.coerce.number().int().min(1).max(10000).parse(options.maxSearches);
|
|
15189
15203
|
if (options.dryRun) {
|
|
15190
15204
|
const inScope = (name) => !options.company.length || options.company.includes(name);
|
|
15191
|
-
printOutput({ status: "ok", dryRun: true, searches: jobs.filter(job => inScope(job.company.name)), unresolvedCompanies: brief.companies.filter(c => !c.companyId && inScope(c.name)), maxSearches, recovery: { companies: options.company, verifyEmployers: options.verifyEmployers, continuePartial: continuePartial ?? null, reviewFile: options.reviewFile ? path.resolve(options.reviewFile) : null, savedCheckpointNotRead: true }, outreachStarted: false, emailEnrichmentStarted: false });
|
|
15205
|
+
printOutput({ status: "ok", outcome: "preview", browser: browserMode, dryRun: true, searches: jobs.filter(job => inScope(job.company.name)), unresolvedCompanies: brief.companies.filter(c => !c.companyId && inScope(c.name)), maxSearches, recovery: { companies: options.company, verifyEmployers: options.verifyEmployers, continuePartial: continuePartial ?? null, reviewFile: options.reviewFile ? path.resolve(options.reviewFile) : null, savedCheckpointNotRead: true }, outreachStarted: false, emailEnrichmentStarted: false });
|
|
15192
15206
|
return;
|
|
15193
15207
|
}
|
|
15194
15208
|
const session = await requireAuthSession();
|
|
15195
15209
|
const orgId = session.user.orgId;
|
|
15196
15210
|
if (!orgId)
|
|
15197
15211
|
throw new Error("Choose a Salesprompter workspace before company research.");
|
|
15212
|
+
const finish = (report, reportOnly = false) => {
|
|
15213
|
+
const nextCommand = companyResearchNextCommand({ brief: path.resolve(options.brief), outDir: report.output, browser: browserMode, unresolved: report.progress.unresolvedCompanies, pending: report.progress.remainingSearches, companies: options.company });
|
|
15214
|
+
writeProgress(`\n${report.outcome.replace(/_/g, " ").toUpperCase()}: ${report.selected.length} contacts; ${report.progress.completedSearches}/${report.progress.totalSearches} searches; ${report.progress.reviewOnlyPeople} additional people to review.`);
|
|
15215
|
+
if (nextCommand)
|
|
15216
|
+
writeProgress(`Next: ${nextCommand}`);
|
|
15217
|
+
if (report.review.length)
|
|
15218
|
+
writeProgress(`Role/employer review: ${path.join(report.output, "review.csv")}`);
|
|
15219
|
+
if (process.stdout.isTTY && !runtimeOutputOptions.json)
|
|
15220
|
+
writeProgress(`Contacts: ${path.join(report.output, "contacts.csv")}\nReport: ${path.join(report.output, "coverage.json")}`);
|
|
15221
|
+
else
|
|
15222
|
+
printOutput({ status: report.status, outcome: report.outcome, browser: browserMode, reportOnly, selected: report.selected.length, reviewRequired: report.review.length, progress: report.progress, nextActions: report.nextActions, nextCommand, completedSearches: report.completedSearches, totalSearches: report.totalSearches, coverage: report.coverage, output: report.output, resumable: true, outreachStarted: false, emailEnrichmentStarted: false });
|
|
15223
|
+
if (options.requireComplete && report.outcome !== "complete")
|
|
15224
|
+
process.exitCode = 2;
|
|
15225
|
+
};
|
|
15198
15226
|
if (options.reportOnly) {
|
|
15199
15227
|
const report = await runCompanyResearch({ brief, review, onlyCompanies: options.company, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches: 0, search: async () => { throw new Error("Report-only cannot collect leads."); } });
|
|
15200
|
-
|
|
15228
|
+
finish(report, true);
|
|
15201
15229
|
return;
|
|
15202
15230
|
}
|
|
15203
15231
|
const port = options.browserRelayPort == null ? null : z.coerce.number().int().min(1).max(65535).parse(options.browserRelayPort);
|
|
@@ -15252,12 +15280,21 @@ program
|
|
|
15252
15280
|
const record = cliImportCompanyRecord(body?.[id]);
|
|
15253
15281
|
const explicitId = record?.entityUrn ? extractLinkedInSalesCompanyIdFromUrn(record.entityUrn) : null;
|
|
15254
15282
|
const evidence = response.status === 200 && typeof record?.name === "string" && (!explicitId || explicitId === id)
|
|
15255
|
-
? { companyId: id, name: record.name, website: typeof record.website === "string" ? record.website : undefined, evidenceUrl: `https://www.linkedin.com/sales/company/${id}` } : null;
|
|
15283
|
+
? { companyId: id, name: record.name, website: typeof record.website === "string" ? record.website : undefined, location: formatCliImportCompanyLocation(record.headquarters) ?? formatCliImportCompanyLocation(record.location) ?? undefined, evidenceUrl: `https://www.linkedin.com/sales/company/${id}` } : null;
|
|
15256
15284
|
detailsCache.set(id, evidence);
|
|
15257
15285
|
return evidence;
|
|
15258
15286
|
};
|
|
15259
15287
|
try {
|
|
15260
15288
|
const report = await runCompanyResearch({ brief, review, onlyCompanies: options.company, continuePartial, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches, retryUnresolved: options.retryUnresolved,
|
|
15289
|
+
reviewIdentity: !interactiveReview ? undefined : async (company, diagnostic) => {
|
|
15290
|
+
const prompt = createCompanyReviewPrompt();
|
|
15291
|
+
try {
|
|
15292
|
+
return await chooseCompanyIdentity({ targetName: company.name, diagnostic, details, io: prompt.io });
|
|
15293
|
+
}
|
|
15294
|
+
finally {
|
|
15295
|
+
prompt.close();
|
|
15296
|
+
}
|
|
15297
|
+
},
|
|
15261
15298
|
verifyEmployer: options.verifyEmployers ? details : undefined,
|
|
15262
15299
|
diagnoseCompany: !options.resolveCompanies ? undefined : company => resolveCompanyIdentity(company.name, async (query, start) => {
|
|
15263
15300
|
await paced();
|
|
@@ -15295,7 +15332,7 @@ program
|
|
|
15295
15332
|
});
|
|
15296
15333
|
},
|
|
15297
15334
|
});
|
|
15298
|
-
|
|
15335
|
+
finish(report);
|
|
15299
15336
|
}
|
|
15300
15337
|
finally {
|
|
15301
15338
|
await relay?.close();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { companyIdentityKey } from "./company-recovery.js";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
/** Cancelling a terminal question must reject it so research can release its lock. */
|
|
4
|
+
export function createCompanyReviewPrompt(input = process.stdin, output = process.stderr) {
|
|
5
|
+
const rl = createInterface({ input, output, terminal: true });
|
|
6
|
+
const cancellation = new AbortController();
|
|
7
|
+
const cancel = () => cancellation.abort();
|
|
8
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
9
|
+
rl.on("SIGINT", cancel);
|
|
10
|
+
rl.once("close", cancel);
|
|
11
|
+
for (const signal of signals)
|
|
12
|
+
process.on(signal, cancel);
|
|
13
|
+
return {
|
|
14
|
+
io: {
|
|
15
|
+
write: (text) => { output.write(text); },
|
|
16
|
+
ask: async (question) => {
|
|
17
|
+
try {
|
|
18
|
+
return await rl.question(question, { signal: cancellation.signal });
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (cancellation.signal.aborted)
|
|
22
|
+
throw new Error("Company review cancelled. Saved work is preserved; rerun with the same --out-dir to resume.");
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
close: () => {
|
|
28
|
+
for (const signal of signals)
|
|
29
|
+
process.off(signal, cancel);
|
|
30
|
+
rl.close();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export const terminalText = (text) => text.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, " ").slice(0, 300);
|
|
35
|
+
export async function chooseCompanyIdentity(input) {
|
|
36
|
+
const { targetName, diagnostic, details, io } = input;
|
|
37
|
+
const candidates = [...new Map(diagnostic.candidates.filter(c => /^[1-9]\d*$/.test(c.companyId) && companyIdentityKey(c.name) === companyIdentityKey(targetName)).map(c => [c.companyId, c])).values()];
|
|
38
|
+
io.write(`\nCompany review: ${terminalText(targetName)}\n`);
|
|
39
|
+
if (!candidates.length || candidates.length > 10) {
|
|
40
|
+
io.write("No bounded exact-name choice is available. Inspect recovery.json and use an evidence-backed --review-file; this company remains unresolved.\n");
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const verified = [];
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
const evidence = await details(candidate.companyId);
|
|
46
|
+
if (!evidence || evidence.companyId !== candidate.companyId || evidence.evidenceUrl !== `https://www.linkedin.com/sales/company/${candidate.companyId}` || companyIdentityKey(evidence.name) !== companyIdentityKey(candidate.name))
|
|
47
|
+
continue;
|
|
48
|
+
verified.push(evidence);
|
|
49
|
+
io.write(`\n${verified.length}. ${terminalText(evidence.name)}\n Location: ${terminalText(evidence.location ?? "not provided")}\n Website: ${terminalText(evidence.website ?? "not provided")}\n Evidence: ${terminalText(evidence.evidenceUrl)}\n`);
|
|
50
|
+
}
|
|
51
|
+
if (!verified.length) {
|
|
52
|
+
io.write("No candidate could be verified. Nothing approved.\n");
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
io.write("\nMatch the location and website to your intended company. No choice is preselected.\n");
|
|
56
|
+
while (true) {
|
|
57
|
+
const answer = (await io.ask("Company number [0 = skip]: ")).trim();
|
|
58
|
+
if (!answer || answer === "0")
|
|
59
|
+
return null;
|
|
60
|
+
if (!/^[1-9]\d*$/.test(answer) || Number(answer) > verified.length) {
|
|
61
|
+
io.write("Enter a listed number, or 0 to skip.\n");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const chosen = verified[Number(answer) - 1];
|
|
65
|
+
const confirm = await io.ask(`Use ${terminalText(chosen.name)} (${terminalText(chosen.location ?? chosen.companyId)}) for ${terminalText(targetName)}? [y/N]: `);
|
|
66
|
+
return /^(y|yes)$/i.test(confirm.trim()) ? chosen : null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function companyResearchNextCommand(input) {
|
|
70
|
+
const quote = (text) => `'${text.replace(/'/g, "'\\''")}'`;
|
|
71
|
+
const scope = (input.companies ?? []).map(name => ` --company ${quote(name)}`).join("");
|
|
72
|
+
const base = `salesprompter leads:at-companies --brief ${quote(input.brief)} --out-dir ${quote(input.outDir)} --browser ${input.browser}${scope}`;
|
|
73
|
+
if (input.unresolved)
|
|
74
|
+
return `${base} --resolve-companies --review --all`;
|
|
75
|
+
if (input.pending)
|
|
76
|
+
return `${base} --all`;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
package/dist/company-leads.js
CHANGED
|
@@ -182,11 +182,12 @@ export function shortlistCompanyLeads(brief, results) {
|
|
|
182
182
|
};
|
|
183
183
|
const nextActions = [
|
|
184
184
|
...(progress.remainingSearches ? ["Resume the unchanged brief and output directory to collect pending searches."] : []),
|
|
185
|
-
...(unresolved.length ? ["Inspect recovery.json
|
|
185
|
+
...(unresolved.length ? ["Use --review in a terminal to choose a verified exact-name identity. Inspect recovery.json for non-exact decisions or an intentional --retry-unresolved --resolve-companies retry."] : []),
|
|
186
186
|
...(incomplete.length || unknown.length ? ["Use --continue-partial quota or exhaustive to resume eligible pages; inspect stoppedReason before claiming exhaustive coverage."] : []),
|
|
187
187
|
...(review.length ? ["Use --verify-employers for canonical names, or evidence-backed --review-file aliases. Ambiguous roles remain review-only."] : []),
|
|
188
188
|
];
|
|
189
|
-
|
|
189
|
+
const outcome = unresolved.length ? "needs_review" : !progress.collectionComplete ? "incomplete" : review.length ? "needs_review" : "complete";
|
|
190
|
+
return { outcome, selected, review, rejected, coverage, progress, nextActions, rolePolicyVersion: 2, outreachStarted: false, emailEnrichmentStarted: false };
|
|
190
191
|
}
|
|
191
192
|
export function companyLeadCsv(rows) {
|
|
192
193
|
const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt", "sourceCompanyName", "reason", "reasons", "employerAliasEvidence"];
|
|
@@ -386,6 +387,31 @@ async function runCompanyResearchLocked(input) {
|
|
|
386
387
|
await collect();
|
|
387
388
|
}
|
|
388
389
|
}
|
|
390
|
+
if (input.reviewIdentity)
|
|
391
|
+
for (const [index, company] of brief.companies.entries()) {
|
|
392
|
+
if (!allowed(company.name) || company.companyId || state.resolutions?.[String(index)])
|
|
393
|
+
continue;
|
|
394
|
+
const diagnostic = state.diagnostics?.[String(index)];
|
|
395
|
+
if (!diagnostic)
|
|
396
|
+
continue;
|
|
397
|
+
const choice = await input.reviewIdentity(company, diagnostic);
|
|
398
|
+
if (!choice)
|
|
399
|
+
continue;
|
|
400
|
+
if (!diagnostic.candidates.some(c => c.companyId === choice.companyId && companyIdentityKey(c.name) === companyIdentityKey(choice.name)))
|
|
401
|
+
throw new Error("Reviewed identity must match a verified candidate from this research.");
|
|
402
|
+
if (effectiveBrief().companies.some(c => c.companyId === choice.companyId))
|
|
403
|
+
throw new Error("Reviewed identity is already assigned to another target.");
|
|
404
|
+
const identity = { targetName: company.name, companyId: choice.companyId, canonicalName: choice.name, evidenceUrl: choice.evidenceUrl };
|
|
405
|
+
review = CompanyReviewSchema.parse({ fingerprint, employerAliases: review?.employerAliases ?? [], identities: [...(review?.identities ?? []), identity] });
|
|
406
|
+
// The durable review overlay is written first. A crash before the checkpoint
|
|
407
|
+
// write is recovered by the normal review-file replay on the next invocation.
|
|
408
|
+
await save(path.join(outDir, "review-decisions.json"), JSON.stringify(review, null, 2));
|
|
409
|
+
state.resolutions ??= {};
|
|
410
|
+
state.resolutions[String(index)] = { companyId: choice.companyId, companyName: company.name, canonicalName: choice.name, evidenceUrl: choice.evidenceUrl };
|
|
411
|
+
await save(checkpoint, JSON.stringify(state));
|
|
412
|
+
await publish();
|
|
413
|
+
await collect();
|
|
414
|
+
}
|
|
389
415
|
}
|
|
390
416
|
catch (e) {
|
|
391
417
|
await publish();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const Preference = z.object({ browser: z.enum(["chrome", "session"]) }).strict();
|
|
6
|
+
export async function readResearchBrowserPreference(configDir) {
|
|
7
|
+
const file = path.join(configDir, "research-browser.json");
|
|
8
|
+
try {
|
|
9
|
+
return Preference.parse(JSON.parse(await readFile(file, "utf8"))).browser;
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code === "ENOENT")
|
|
13
|
+
return "session";
|
|
14
|
+
throw new Error(`Cannot read research browser preference at ${file}. Run browser:connect again, or choose --browser chrome or --browser session explicitly.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function rememberResearchBrowser(configDir, browser) {
|
|
18
|
+
const value = Preference.parse({ browser });
|
|
19
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
20
|
+
const file = path.join(configDir, "research-browser.json");
|
|
21
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
22
|
+
try {
|
|
23
|
+
await writeFile(temporary, JSON.stringify(value) + "\n", { mode: 0o600, flag: "wx" });
|
|
24
|
+
await rename(temporary, file);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await rm(temporary, { force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function selectResearchBrowser(options, configDir) {
|
|
31
|
+
if (options.browser !== undefined)
|
|
32
|
+
return Preference.shape.browser.parse(options.browser);
|
|
33
|
+
// An explicit connection route takes precedence over the saved preference.
|
|
34
|
+
if (options.browserRelayPort !== undefined || Number(options.waitForSession ?? 0) > 0)
|
|
35
|
+
return "session";
|
|
36
|
+
return readResearchBrowserPreference(configDir);
|
|
37
|
+
}
|
package/package.json
CHANGED