@dench.com/cli 2.7.4 → 2.7.6-staging.1

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.
@@ -0,0 +1,682 @@
1
+ /**
2
+ * Exa People Search — entity parsing, Apollo-shape projection, and match gating.
3
+ *
4
+ * Exa's People Search (`POST /search` with `category: "people"`) returns
5
+ * structured person metadata on `results[].entities[]`, each
6
+ * `{ id, type: "person", version, properties }`. The gateway passes Exa's
7
+ * response through untouched (gateway/src/http.ts), so those entities arrive
8
+ * here verbatim.
9
+ *
10
+ * Two jobs live in this file:
11
+ *
12
+ * 1. **Projection.** `toApolloEnvelope` rewrites a person entity into the
13
+ * Apollo-shaped envelope the rest of the codebase already speaks, so the
14
+ * existing extraction machinery (`extractEnrichmentValue` /
15
+ * `apolloPath` / `crmFields.enrichment`) works against Exa with no new
16
+ * extraction path. Exa becomes just another provider.
17
+ *
18
+ * 2. **Match gating.** Unlike FullEnrich and Aviato, Exa is a *search*, not a
19
+ * lookup by identifier — it always returns *somebody*. Writing the top hit
20
+ * into a CRM cell would silently corrupt data with a plausible-looking
21
+ * stranger. `matchExaPerson` therefore refuses to return a candidate
22
+ * unless identity is corroborated (exact LinkedIn URL, or name AND company
23
+ * agreement), and callers are expected to surface `not_found` rather than
24
+ * write a low-confidence guess.
25
+ *
26
+ * Everything here is pure and I/O-free so it can be unit tested directly, and
27
+ * it lives in `cli/lib/` because that is where this repo keeps logic shared
28
+ * between the `dench` CLI and the agent workflow steps (see
29
+ * src/workflows/enrichment-steps.ts, which already imports from here).
30
+ *
31
+ * Exa's docs are explicit that profile sources vary in what they include, so
32
+ * every field is treated as optional and every nested value defensively.
33
+ */
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export type ExaDateRange = {
40
+ from?: string | null;
41
+ to?: string | null;
42
+ };
43
+
44
+ export type ExaCompanyRef = {
45
+ id?: string | null;
46
+ name?: string | null;
47
+ };
48
+
49
+ export type ExaWorkHistoryItem = {
50
+ title?: string;
51
+ location?: string;
52
+ dates?: ExaDateRange | null;
53
+ company?: ExaCompanyRef | null;
54
+ };
55
+
56
+ export type ExaInstitutionRef = {
57
+ id?: string | null;
58
+ name?: string | null;
59
+ };
60
+
61
+ export type ExaEducationItem = {
62
+ degree?: string;
63
+ dates?: ExaDateRange | null;
64
+ institution?: ExaInstitutionRef | null;
65
+ };
66
+
67
+ export type ExaPerson = {
68
+ /** Stable Exa person entity id, when present. */
69
+ id?: string;
70
+ name?: string;
71
+ firstName?: string;
72
+ lastName?: string;
73
+ location?: string;
74
+ workHistory: ExaWorkHistoryItem[];
75
+ educationHistory: ExaEducationItem[];
76
+ };
77
+
78
+ export type ExaPersonHit = {
79
+ /** Profile URL from the enclosing search result. May be "" if absent. */
80
+ url: string;
81
+ title?: string;
82
+ highlights?: string[];
83
+ person: ExaPerson;
84
+ };
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Defensive readers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export function asString(value: unknown): string | undefined {
91
+ if (typeof value !== "string") return undefined;
92
+ const trimmed = value.trim();
93
+ return trimmed.length > 0 ? trimmed : undefined;
94
+ }
95
+
96
+ export function asObject(value: unknown): Record<string, unknown> | undefined {
97
+ return value && typeof value === "object" && !Array.isArray(value)
98
+ ? (value as Record<string, unknown>)
99
+ : undefined;
100
+ }
101
+
102
+ export function asArray(value: unknown): unknown[] {
103
+ return Array.isArray(value) ? value : [];
104
+ }
105
+
106
+ function asStringArray(value: unknown): string[] | undefined {
107
+ if (!Array.isArray(value)) return undefined;
108
+ const out = value.filter(
109
+ (item): item is string =>
110
+ typeof item === "string" && item.trim().length > 0,
111
+ );
112
+ return out.length > 0 ? out : undefined;
113
+ }
114
+
115
+ /**
116
+ * `dates` is documented as `{ from, to } | null`, but a source may omit it
117
+ * entirely or hand back a bare string. Anything we cannot read as an object
118
+ * becomes `null` — "unknown", which is deliberately NOT the same as
119
+ * `{ to: null }` ("still there"). `currentRole` depends on that distinction.
120
+ */
121
+ function readDates(value: unknown): ExaDateRange | null {
122
+ const raw = asObject(value);
123
+ if (!raw) return null;
124
+ const from = asString(raw.from);
125
+ const to = asString(raw.to);
126
+ const range: ExaDateRange = {};
127
+ if (from !== undefined) range.from = from;
128
+ // Preserve an explicit null `to` — it is the "current role" signal.
129
+ if (to !== undefined) range.to = to;
130
+ else if ("to" in raw) range.to = null;
131
+ return range;
132
+ }
133
+
134
+ function readCompanyRef(value: unknown): ExaCompanyRef | null {
135
+ const raw = asObject(value);
136
+ if (!raw) return null;
137
+ const name = asString(raw.name);
138
+ const id = asString(raw.id);
139
+ if (name === undefined && id === undefined) return null;
140
+ return { id: id ?? null, name: name ?? null };
141
+ }
142
+
143
+ function readWorkHistory(value: unknown): ExaWorkHistoryItem[] {
144
+ const out: ExaWorkHistoryItem[] = [];
145
+ for (const entry of asArray(value)) {
146
+ const raw = asObject(entry);
147
+ if (!raw) continue;
148
+ const item: ExaWorkHistoryItem = {
149
+ title: asString(raw.title),
150
+ location: asString(raw.location),
151
+ dates: readDates(raw.dates),
152
+ company: readCompanyRef(raw.company),
153
+ };
154
+ // A row with nothing usable in it is noise, not history.
155
+ if (!item.title && !item.company && !item.dates) continue;
156
+ out.push(item);
157
+ }
158
+ return out;
159
+ }
160
+
161
+ function readEducationHistory(value: unknown): ExaEducationItem[] {
162
+ const out: ExaEducationItem[] = [];
163
+ for (const entry of asArray(value)) {
164
+ const raw = asObject(entry);
165
+ if (!raw) continue;
166
+ const item: ExaEducationItem = {
167
+ degree: asString(raw.degree),
168
+ dates: readDates(raw.dates),
169
+ institution: readCompanyRef(raw.institution),
170
+ };
171
+ if (!item.degree && !item.institution) continue;
172
+ out.push(item);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Parsing
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Walk an Exa `/search` response and pull out every person entity.
183
+ *
184
+ * Only `type: "person"` entities are kept — the docs describe `entities` as
185
+ * populated "for result rows that resolve to a person", so a result without
186
+ * one is a page we could not resolve, not a person with no data.
187
+ */
188
+ export function parseExaPeopleResults(data: unknown): ExaPersonHit[] {
189
+ const root = asObject(data);
190
+ if (!root) return [];
191
+ const hits: ExaPersonHit[] = [];
192
+
193
+ for (const entry of asArray(root.results)) {
194
+ const result = asObject(entry);
195
+ if (!result) continue;
196
+ const url = asString(result.url) ?? asString(result.link) ?? "";
197
+ const title = asString(result.title);
198
+ const highlights = asStringArray(result.highlights);
199
+
200
+ for (const entityEntry of asArray(result.entities)) {
201
+ const entity = asObject(entityEntry);
202
+ if (!entity) continue;
203
+ if (asString(entity.type) !== "person") continue;
204
+ const properties = asObject(entity.properties);
205
+ if (!properties) continue;
206
+
207
+ const person: ExaPerson = {
208
+ id: asString(entity.id),
209
+ name: asString(properties.name),
210
+ firstName: asString(properties.firstName),
211
+ lastName: asString(properties.lastName),
212
+ location: asString(properties.location),
213
+ workHistory: readWorkHistory(properties.workHistory),
214
+ educationHistory: readEducationHistory(properties.educationHistory),
215
+ };
216
+
217
+ hits.push({ url, title, highlights, person });
218
+ }
219
+ }
220
+
221
+ return hits;
222
+ }
223
+
224
+ /** Full name, falling back to first + last when `name` is absent. */
225
+ export function personFullName(person: ExaPerson): string | undefined {
226
+ if (person.name) return person.name;
227
+ const parts = [person.firstName, person.lastName].filter(
228
+ (part): part is string => typeof part === "string" && part.length > 0,
229
+ );
230
+ return parts.length > 0 ? parts.join(" ") : undefined;
231
+ }
232
+
233
+ /**
234
+ * Best guess at the person's current role.
235
+ *
236
+ * Only an entry whose `dates.to` is explicitly null counts as current. Ended
237
+ * or undated roles are historical/unknown; projecting either as current writes
238
+ * stale employment data into CRM fields.
239
+ */
240
+ export function currentRole(person: ExaPerson): ExaWorkHistoryItem | null {
241
+ const history = person.workHistory;
242
+ if (history.length === 0) return null;
243
+
244
+ const open = history.filter(
245
+ (item) => item.dates != null && "to" in item.dates && item.dates.to == null,
246
+ );
247
+ if (open.length > 0) {
248
+ return open.reduce((best, item) =>
249
+ compareDateStrings(item.dates?.from, best.dates?.from) > 0 ? item : best,
250
+ );
251
+ }
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * Order two partial date strings. Exa mixes granularities ("2014",
257
+ * "2022-01-01"), but both are zero-padded and year-first, so lexicographic
258
+ * comparison orders them correctly. Missing sorts before present.
259
+ */
260
+ function compareDateStrings(
261
+ a: string | null | undefined,
262
+ b: string | null | undefined,
263
+ ): number {
264
+ const left = asString(a);
265
+ const right = asString(b);
266
+ if (left === right) return 0;
267
+ if (left === undefined) return -1;
268
+ if (right === undefined) return 1;
269
+ return left < right ? -1 : 1;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Apollo-shape projection
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * Prefix a bare host with a scheme so `new URL` can parse it.
278
+ *
279
+ * The scheme test must be case-insensitive: "HTTPS://Example.com" does not
280
+ * start with lowercase "http", so a naive check prepends a second scheme and
281
+ * `new URL("https://HTTPS://Example.com").hostname` quietly evaluates to
282
+ * "https" — a wrong domain rather than a parse error. CRM data is pasted by
283
+ * humans, so mixed-case URLs really do show up.
284
+ */
285
+ export function withScheme(value: string): string {
286
+ return /^https?:\/\//i.test(value) ? value : `https://${value}`;
287
+ }
288
+
289
+ const LINKEDIN_PROFILE_PATTERN = /(^|\.)linkedin\.com$/i;
290
+
291
+ /** True when the URL looks like a LinkedIn *person* profile (`/in/...`). */
292
+ export function isLinkedInProfileUrl(url: string): boolean {
293
+ const trimmed = url.trim();
294
+ if (!trimmed) return false;
295
+ try {
296
+ const parsed = new URL(withScheme(trimmed));
297
+ return (
298
+ LINKEDIN_PROFILE_PATTERN.test(parsed.hostname) &&
299
+ /\/in\/[^/]+/i.test(parsed.pathname)
300
+ );
301
+ } catch {
302
+ return false;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Split Exa's single location string into the city/state/country fields the
308
+ * `__computed.location` extractor reads (see `computeLocation` in
309
+ * cli/lib/crm-enrichment.ts, which joins city + state + country).
310
+ *
311
+ * Exa formats these as "San Francisco, California, United States". We only
312
+ * assign parts when the shape is unambiguous; the raw string is always kept
313
+ * on `person.location` regardless, which is a declared extraction fallback.
314
+ */
315
+ function splitLocation(location: string | undefined): {
316
+ city?: string;
317
+ state?: string;
318
+ country?: string;
319
+ } {
320
+ if (!location) return {};
321
+ const parts = location
322
+ .split(",")
323
+ .map((part) => part.trim())
324
+ .filter((part) => part.length > 0);
325
+ if (parts.length >= 3) {
326
+ return {
327
+ city: parts[0],
328
+ state: parts[1],
329
+ country: parts[parts.length - 1],
330
+ };
331
+ }
332
+ if (parts.length === 2) return { city: parts[0], country: parts[1] };
333
+ if (parts.length === 1) return { city: parts[0] };
334
+ return {};
335
+ }
336
+
337
+ /**
338
+ * Project a person hit into the Apollo-shaped envelope the existing
339
+ * enrichment extractors understand.
340
+ *
341
+ * The keys here are load-bearing: they are the `apolloPath` targets declared
342
+ * by enrichment columns. `extractApolloValue` (cli/lib/crm-enrichment.ts) is a
343
+ * generic dot-path resolver, so any path listed in an Exa-backed column
344
+ * definition must exist in this object.
345
+ */
346
+ export function toApolloEnvelope(hit: ExaPersonHit): Record<string, unknown> {
347
+ const { person } = hit;
348
+ const role = currentRole(person);
349
+ const title = role?.title;
350
+ const companyName = role?.company?.name ?? undefined;
351
+ const name = personFullName(person);
352
+ const { city, state, country } = splitLocation(person.location);
353
+
354
+ const employmentHistory = person.workHistory.map((item) => ({
355
+ title: item.title ?? null,
356
+ organization_name: item.company?.name ?? null,
357
+ location: item.location ?? null,
358
+ start_date: item.dates?.from ?? null,
359
+ end_date: item.dates?.to ?? null,
360
+ current: item.dates != null && "to" in item.dates && item.dates.to == null,
361
+ }));
362
+
363
+ const education = person.educationHistory.map((item) => ({
364
+ degree: item.degree ?? null,
365
+ school_name: item.institution?.name ?? null,
366
+ start_date: item.dates?.from ?? null,
367
+ end_date: item.dates?.to ?? null,
368
+ }));
369
+
370
+ const personEnvelope: Record<string, unknown> = {
371
+ id: person.id ?? null,
372
+ name: name ?? null,
373
+ first_name: person.firstName ?? null,
374
+ last_name: person.lastName ?? null,
375
+ title: title ?? null,
376
+ headline:
377
+ title && companyName ? `${title} at ${companyName}` : (title ?? null),
378
+ location: person.location ?? null,
379
+ city: city ?? null,
380
+ state: state ?? null,
381
+ country: country ?? null,
382
+ organization: companyName ? { name: companyName } : null,
383
+ organization_name: companyName ?? null,
384
+ linkedin_url: isLinkedInProfileUrl(hit.url) ? hit.url : null,
385
+ employment_history: employmentHistory,
386
+ education,
387
+ };
388
+
389
+ return {
390
+ person: personEnvelope,
391
+ // Top-level organization mirror so company-category columns
392
+ // (apolloPath "organization.name") resolve against the same payload.
393
+ organization: companyName ? { name: companyName } : null,
394
+ // Provenance — this is a search result, not an identifier lookup, so the
395
+ // source URL matters when a human audits the written cell.
396
+ // Provenance the model and a human auditor both want — the profile this
397
+ // came from. Deliberately NOT the provider name: this envelope is
398
+ // returned as the tool result, and naming the source there is exactly
399
+ // what the enrichment surface is meant to keep internal.
400
+ source: { url: hit.url || null },
401
+ };
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Match gating
406
+ // ---------------------------------------------------------------------------
407
+
408
+ export type ExaMatchCriteria =
409
+ | { kind: "linkedin_url"; linkedinUrl: string }
410
+ | {
411
+ kind: "name_company";
412
+ fullName?: string;
413
+ firstName?: string;
414
+ lastName?: string;
415
+ organizationName?: string;
416
+ };
417
+
418
+ export type ExaMatchConfidence = "exact" | "high";
419
+
420
+ export type ExaPersonMatch = {
421
+ hit: ExaPersonHit;
422
+ confidence: ExaMatchConfidence;
423
+ /** Human-readable reason the candidate was accepted, for observability. */
424
+ matchedOn: string;
425
+ };
426
+
427
+ export type ExaMatchOutcome =
428
+ | { status: "matched"; match: ExaPersonMatch }
429
+ | { status: "no_match" }
430
+ | { status: "insufficient_criteria"; missing: string[] };
431
+
432
+ /**
433
+ * Build the natural-language query for resolving ONE known person.
434
+ *
435
+ * Exa People Search takes no structured filters at all — every constraint has
436
+ * to live in the sentence (see exa-search-constraints.ts). Naming the company
437
+ * alongside the person is also what makes the match gate able to corroborate
438
+ * identity, so it is included whenever we have it.
439
+ */
440
+ export function buildPersonLookupQuery(criteria: ExaMatchCriteria): string {
441
+ if (criteria.kind === "linkedin_url") return criteria.linkedinUrl;
442
+ const name =
443
+ criteria.fullName ??
444
+ [criteria.firstName, criteria.lastName].filter(Boolean).join(" ");
445
+ return criteria.organizationName
446
+ ? `${name} at ${criteria.organizationName}`
447
+ : name;
448
+ }
449
+
450
+ /** Lowercase, strip combining marks and punctuation, preserve every script. */
451
+ export function normalizeName(value: string): string {
452
+ return value
453
+ .normalize("NFKD")
454
+ .replace(/(\p{Script=Latin})\p{M}+/gu, "$1")
455
+ .toLocaleLowerCase()
456
+ .replace(/[^\p{L}\p{N}\p{M}]+/gu, " ")
457
+ .trim()
458
+ .normalize("NFC");
459
+ }
460
+
461
+ const TRAILING_LEGAL_SUFFIXES = new Set([
462
+ "inc",
463
+ "incorporated",
464
+ "llc",
465
+ "ltd",
466
+ "limited",
467
+ "corp",
468
+ "corporation",
469
+ "co",
470
+ "gmbh",
471
+ "bv",
472
+ "nv",
473
+ "sa",
474
+ "ag",
475
+ "plc",
476
+ "pte",
477
+ "pty",
478
+ ]);
479
+
480
+ /**
481
+ * Normalize a company name and drop only trailing legal forms. Removing these
482
+ * words from the middle destroys real brands ("The Company Store", "Acme
483
+ * Labs") and can collapse unrelated companies onto the same identity.
484
+ */
485
+ export function normalizeCompany(value: string): string {
486
+ const tokens = normalizeName(value).split(" ").filter(Boolean);
487
+ while (
488
+ tokens.length > 1 &&
489
+ TRAILING_LEGAL_SUFFIXES.has(tokens[tokens.length - 1])
490
+ ) {
491
+ tokens.pop();
492
+ }
493
+ return tokens.join(" ");
494
+ }
495
+
496
+ /**
497
+ * Reduce a LinkedIn URL to a comparable identity: no protocol, no `www.`, no
498
+ * query or hash, no trailing slash, lowercased.
499
+ */
500
+ export function normalizeLinkedInUrl(value: string): string {
501
+ const trimmed = value.trim();
502
+ if (!trimmed) return "";
503
+ let working = trimmed;
504
+ try {
505
+ const parsed = new URL(withScheme(trimmed));
506
+ const profile = parsed.pathname.match(/^\/in\/([^/]+)/i);
507
+ working =
508
+ LINKEDIN_PROFILE_PATTERN.test(parsed.hostname) && profile
509
+ ? `linkedin.com/in/${profile[1]}`
510
+ : `${parsed.hostname}${parsed.pathname}`;
511
+ } catch {
512
+ working = trimmed.split(/[?#]/)[0];
513
+ }
514
+ return working
515
+ .toLowerCase()
516
+ .replace(/^www\./, "")
517
+ .replace(/\/+$/, "");
518
+ }
519
+
520
+ /**
521
+ * Company identity evidence must be exact after legal-suffix normalization.
522
+ * Substring matching makes short names dangerous ("Meta" matched
523
+ * "Metaverse"), which defeats the two-factor name + employer gate.
524
+ */
525
+ function companiesAgree(left: string, right: string): boolean {
526
+ const a = normalizeCompany(left);
527
+ const b = normalizeCompany(right);
528
+ if (!a || !b) return false;
529
+ return a === b;
530
+ }
531
+
532
+ /**
533
+ * First and last name must agree positionally. Matching an initial is allowed
534
+ * only for the candidate's final surname token.
535
+ *
536
+ * One concession to how LinkedIn actually stores names: a surname abbreviated
537
+ * to an initial on the CANDIDATE side ("Adarsh K.") counts as agreeing with a
538
+ * full wanted token starting with that letter ("Adarsh Kumar"). Without it
539
+ * these profiles never match their own CRM row — abbreviated surnames showed
540
+ * up in the first handful of live results. It is safe here only because the
541
+ * caller additionally requires company agreement, so a false positive needs
542
+ * the same first name AND same surname initial AND the same employer.
543
+ */
544
+ function namesAgree(
545
+ candidate: string,
546
+ first: string | undefined,
547
+ last: string | undefined,
548
+ ): boolean {
549
+ const normalizedCandidate = normalizeName(candidate);
550
+ if (!normalizedCandidate) return false;
551
+ const candidateTokens = normalizedCandidate.split(" ").filter(Boolean);
552
+ const wantedFirst = first
553
+ ? normalizeName(first).split(" ").filter(Boolean)
554
+ : [];
555
+ const wantedLast = last ? normalizeName(last).split(" ").filter(Boolean) : [];
556
+ if (
557
+ candidateTokens.length < 2 ||
558
+ wantedFirst.length === 0 ||
559
+ wantedLast.length === 0
560
+ ) {
561
+ return false;
562
+ }
563
+
564
+ const candidateFirst = candidateTokens[0];
565
+ const candidateLast = candidateTokens[candidateTokens.length - 1];
566
+ const requestedFirst = wantedFirst[0];
567
+ const requestedLast = wantedLast[wantedLast.length - 1];
568
+ const firstMatches = candidateFirst === requestedFirst;
569
+ const lastMatches =
570
+ candidateLast === requestedLast ||
571
+ (candidateLast.length === 1 &&
572
+ requestedLast.length > 1 &&
573
+ requestedLast.startsWith(candidateLast));
574
+ return firstMatches && lastMatches;
575
+ }
576
+
577
+ function splitFullName(fullName: string): {
578
+ firstName?: string;
579
+ lastName?: string;
580
+ } {
581
+ const parts = normalizeName(fullName)
582
+ .split(" ")
583
+ .filter((part) => part.length > 0);
584
+ if (parts.length < 2) return {};
585
+ return { firstName: parts[0], lastName: parts[parts.length - 1] };
586
+ }
587
+
588
+ /**
589
+ * Pick the one candidate we are willing to write into a CRM cell, or refuse.
590
+ *
591
+ * This is the data-integrity boundary for Exa-backed enrichment. Exa ranks by
592
+ * relevance and always returns *someone*, so "the first result" is never
593
+ * sufficient evidence of identity. A caller that gets `no_match` must report
594
+ * `not_found` — it must not fall back to `hits[0]`.
595
+ */
596
+ export function matchExaPerson(
597
+ hits: ExaPersonHit[],
598
+ criteria: ExaMatchCriteria,
599
+ ): ExaMatchOutcome {
600
+ if (criteria.kind === "linkedin_url") {
601
+ const wanted = normalizeLinkedInUrl(criteria.linkedinUrl);
602
+ if (!wanted || !isLinkedInProfileUrl(criteria.linkedinUrl)) {
603
+ return { status: "insufficient_criteria", missing: ["linkedinUrl"] };
604
+ }
605
+ for (const hit of hits) {
606
+ if (
607
+ hit.url &&
608
+ isLinkedInProfileUrl(hit.url) &&
609
+ normalizeLinkedInUrl(hit.url) === wanted
610
+ ) {
611
+ return {
612
+ status: "matched",
613
+ match: { hit, confidence: "exact", matchedOn: "linkedin_url" },
614
+ };
615
+ }
616
+ }
617
+ return { status: "no_match" };
618
+ }
619
+
620
+ // name_company: identity needs BOTH halves. A name alone cannot
621
+ // disambiguate — the index has many people per common name, and picking one
622
+ // is exactly how the wrong person's title lands in a cell.
623
+ const derived = criteria.fullName ? splitFullName(criteria.fullName) : {};
624
+ const firstName = criteria.firstName ?? derived.firstName;
625
+ const lastName = criteria.lastName ?? derived.lastName;
626
+ const organizationName = criteria.organizationName?.trim();
627
+
628
+ // Structured as one guard so both names and the company narrow to `string`
629
+ // for the comparisons below.
630
+ if (!firstName || !lastName || !organizationName) {
631
+ const missing: string[] = [];
632
+ if (!firstName || !lastName) missing.push("name");
633
+ if (!organizationName) missing.push("organizationName");
634
+ return { status: "insufficient_criteria", missing };
635
+ }
636
+
637
+ const currentMatches = new Map<string, ExaPersonMatch>();
638
+ for (const hit of hits) {
639
+ const candidateName = personFullName(hit.person);
640
+ if (!candidateName) continue;
641
+ if (!namesAgree(candidateName, firstName, lastName)) continue;
642
+
643
+ // Check every role at the target company, not just the best-guess current
644
+ // one: `currentRole` falls back to the most recently *ended* role, so
645
+ // keying off it would read "left Example AI in 2018" as "works at Example
646
+ // AI now" for anyone with a single, finished stint there.
647
+ const rolesAtCompany = hit.person.workHistory.filter(
648
+ (item) =>
649
+ item.company?.name &&
650
+ companiesAgree(item.company.name, organizationName),
651
+ );
652
+ if (rolesAtCompany.length === 0) continue;
653
+
654
+ if (
655
+ rolesAtCompany.some(
656
+ (item) =>
657
+ item.dates != null && "to" in item.dates && item.dates.to == null,
658
+ )
659
+ ) {
660
+ const identity =
661
+ hit.person.id ||
662
+ normalizeLinkedInUrl(hit.url) ||
663
+ `${normalizeName(candidateName)}:${currentMatches.size}`;
664
+ currentMatches.set(identity, {
665
+ hit,
666
+ confidence: "high",
667
+ matchedOn: "name+current_company",
668
+ });
669
+ }
670
+
671
+ // Ended or undated employment is not enough to claim the CRM's company is
672
+ // current. False negatives are safer than writing another person's data.
673
+ }
674
+
675
+ if (currentMatches.size === 1) {
676
+ return {
677
+ status: "matched",
678
+ match: [...currentMatches.values()][0],
679
+ };
680
+ }
681
+ return { status: "no_match" };
682
+ }