@dench.com/cli 2.7.5 → 2.7.7-staging.21

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.
@@ -31,6 +31,7 @@ export type CustomOperation = {
31
31
  | "chatFollow"
32
32
  | "cronDelete"
33
33
  | "cronRunNow"
34
+ | "toolDisconnect"
34
35
  | "billingTopup"
35
36
  | "upgrade"
36
37
  | "filesMove"
@@ -583,6 +584,29 @@ const rawApiOperations = [
583
584
  responseSchema: anyResponse,
584
585
  backend: convex("mutation", "functions/crm/objects:remove"),
585
586
  }),
587
+ op({
588
+ id: "crm.objects.archive",
589
+ group: "crm",
590
+ summary:
591
+ "Hide a CRM object's workspace sidebar row for the whole workspace. Not a delete — the table keeps working. Admin-only.",
592
+ cli: "dench crm objects archive",
593
+ method: "POST",
594
+ path: "/crm/objects/{name}/archive",
595
+ requestSchema: anyObject,
596
+ responseSchema: anyResponse,
597
+ backend: convex("mutation", "functions/crm/objects:setArchived"),
598
+ }),
599
+ op({
600
+ id: "crm.objects.unarchive",
601
+ group: "crm",
602
+ summary: "Restore a CRM object's workspace sidebar row. Admin-only.",
603
+ cli: "dench crm objects unarchive",
604
+ method: "POST",
605
+ path: "/crm/objects/{name}/unarchive",
606
+ requestSchema: anyObject,
607
+ responseSchema: anyResponse,
608
+ backend: convex("mutation", "functions/crm/objects:setArchived"),
609
+ }),
586
610
 
587
611
  op({
588
612
  id: "crm.views.list",
@@ -652,6 +676,119 @@ const rawApiOperations = [
652
676
  responseSchema: anyResponse,
653
677
  backend: convex("mutation", "functions/crm/objects:deleteView"),
654
678
  }),
679
+ op({
680
+ id: "crm.views.archive",
681
+ group: "crm",
682
+ summary:
683
+ "Move a pinned view's sidebar row into the Archived flyout. Admin-only.",
684
+ cli: "dench crm views archive",
685
+ method: "POST",
686
+ path: "/crm/objects/{objectName}/views/{viewName}/archive",
687
+ requestSchema: anyObject,
688
+ responseSchema: anyResponse,
689
+ backend: convex("mutation", "functions/crm/objects:setArchived"),
690
+ }),
691
+ op({
692
+ id: "crm.views.unarchive",
693
+ group: "crm",
694
+ summary: "Restore a view's sidebar row (re-asserts its pin). Admin-only.",
695
+ cli: "dench crm views unarchive",
696
+ method: "POST",
697
+ path: "/crm/objects/{objectName}/views/{viewName}/unarchive",
698
+ requestSchema: anyObject,
699
+ responseSchema: anyResponse,
700
+ backend: convex("mutation", "functions/crm/objects:setArchived"),
701
+ }),
702
+
703
+ // The workspace app rail's Library. The whole layout — row order AND
704
+ // named groups — is WORKSPACE-SHARED (one tree for every member,
705
+ // stored on the organization). Reordering (`move`) and archiving are
706
+ // admin-only; group management and view pinning are open to any member.
707
+ op({
708
+ id: "crm.sidebar.list",
709
+ group: "crm",
710
+ summary:
711
+ "Describe the workspace sidebar: visible rows in tree order (with their groups), what is hidden and why, and whether the caller is an admin.",
712
+ cli: "dench crm sidebar list",
713
+ method: "GET",
714
+ path: "/crm/sidebar",
715
+ requestSchema: noBody,
716
+ responseSchema: anyResponse,
717
+ backend: convex("query", "functions/workspaceNav:describeMySidebar"),
718
+ }),
719
+ op({
720
+ id: "crm.sidebar.move",
721
+ group: "crm",
722
+ summary:
723
+ "Reposition one sidebar row (or group) among the top-level slots. The layout is workspace-shared; admin-only.",
724
+ cli: "dench crm sidebar move",
725
+ method: "POST",
726
+ path: "/crm/sidebar/move",
727
+ requestSchema: anyObject,
728
+ responseSchema: anyResponse,
729
+ backend: convex("mutation", "functions/workspaceNav:repositionMyNavItem"),
730
+ }),
731
+ op({
732
+ id: "crm.sidebar.groups.create",
733
+ group: "crm",
734
+ summary:
735
+ 'Create a named sidebar group (e.g. "Hiring") in the workspace-shared Library. Open to any member.',
736
+ cli: "dench crm sidebar group create",
737
+ method: "POST",
738
+ path: "/crm/sidebar/groups",
739
+ requestSchema: anyObject,
740
+ responseSchema: anyResponse,
741
+ backend: convex("mutation", "functions/workspaceNav:createNavGroup"),
742
+ }),
743
+ op({
744
+ id: "crm.sidebar.groups.rename",
745
+ group: "crm",
746
+ summary: "Rename a sidebar group. Open to any member.",
747
+ cli: "dench crm sidebar group rename",
748
+ method: "POST",
749
+ path: "/crm/sidebar/groups/rename",
750
+ requestSchema: anyObject,
751
+ responseSchema: anyResponse,
752
+ backend: convex("mutation", "functions/workspaceNav:renameNavGroup"),
753
+ }),
754
+ op({
755
+ id: "crm.sidebar.groups.delete",
756
+ group: "crm",
757
+ summary:
758
+ "Dissolve a sidebar group; its rows return to the top level in its slot. Open to any member.",
759
+ cli: "dench crm sidebar group delete",
760
+ method: "POST",
761
+ path: "/crm/sidebar/groups/delete",
762
+ requestSchema: anyObject,
763
+ responseSchema: anyResponse,
764
+ backend: convex("mutation", "functions/workspaceNav:deleteNavGroup"),
765
+ }),
766
+ op({
767
+ id: "crm.sidebar.groups.move-item",
768
+ group: "crm",
769
+ summary:
770
+ "Move a sidebar row into a group (groupId set) or back to the top level (groupId null). Open to any member.",
771
+ cli: "dench crm sidebar group add",
772
+ aliases: ["dench crm sidebar group remove"],
773
+ method: "POST",
774
+ path: "/crm/sidebar/groups/move",
775
+ requestSchema: anyObject,
776
+ responseSchema: anyResponse,
777
+ backend: convex("mutation", "functions/workspaceNav:moveNavItemToGroup"),
778
+ }),
779
+ op({
780
+ id: "crm.sidebar.pins.set",
781
+ group: "crm",
782
+ summary:
783
+ "Pin (pinned true) or unpin a Library row — or a whole group — on the Sidebar's home-level shortcut strip. Workspace-shared; open to any member.",
784
+ cli: "dench crm sidebar pin",
785
+ aliases: ["dench crm sidebar unpin"],
786
+ method: "POST",
787
+ path: "/crm/sidebar/pins",
788
+ requestSchema: anyObject,
789
+ responseSchema: anyResponse,
790
+ backend: convex("mutation", "functions/workspaceNav:setNavRootPin"),
791
+ }),
655
792
 
656
793
  op({
657
794
  id: "crm.fields.list",
@@ -1507,7 +1644,8 @@ const rawApiOperations = [
1507
1644
  op({
1508
1645
  id: "email.campaign.create",
1509
1646
  group: "email",
1510
- summary: "Create a managed email campaign draft.",
1647
+ summary:
1648
+ "Create a managed email campaign draft from a connected inbox or verified sender.",
1511
1649
  cli: "dench email campaign create",
1512
1650
  method: "POST",
1513
1651
  path: "/email/campaigns",
@@ -1637,7 +1775,7 @@ const rawApiOperations = [
1637
1775
  id: "email.sequence.create",
1638
1776
  group: "email",
1639
1777
  summary:
1640
- "Create a follow-up sequence; choose a verified sender and reply/stop behavior.",
1778
+ "Create a follow-up sequence from a connected inbox or verified sender.",
1641
1779
  cli: "dench email sequence create",
1642
1780
  method: "POST",
1643
1781
  path: "/email/sequences",
@@ -2256,7 +2394,35 @@ const rawApiOperations = [
2256
2394
  path: "/tools/connections/{connectionId}",
2257
2395
  requestSchema: anyObject,
2258
2396
  responseSchema: anyResponse,
2259
- backend: gateway("DELETE", "/composio/connections/{connectionId}"),
2397
+ // Proxies the same gateway route, then drops the account's label; the
2398
+ // name lives in Convex and would otherwise outlive the connection.
2399
+ backend: custom("toolDisconnect", "bearer"),
2400
+ }),
2401
+ // Composio reports no account identity for most toolkits, so a workspace
2402
+ // with three Gmail accounts sees three identical rows. These let a human
2403
+ // name a connection once; the label is presentational and grants nothing.
2404
+ op({
2405
+ id: "tool.labels.list",
2406
+ group: "gateway",
2407
+ summary: "List human names given to connected accounts.",
2408
+ cli: "dench tool labels",
2409
+ method: "GET",
2410
+ path: "/tools/labels",
2411
+ requestSchema: noBody,
2412
+ responseSchema: anyResponse,
2413
+ backend: convex("query", "functions/composioConnectionLabels:list"),
2414
+ }),
2415
+ op({
2416
+ id: "tool.label.set",
2417
+ group: "gateway",
2418
+ summary:
2419
+ "Name a connected account, or clear its name with an empty string.",
2420
+ cli: "dench tool label",
2421
+ method: "PUT",
2422
+ path: "/tools/connections/{connectionId}/label",
2423
+ requestSchema: anyObject,
2424
+ responseSchema: anyResponse,
2425
+ backend: convex("mutation", "functions/composioConnectionLabels:set"),
2260
2426
  }),
2261
2427
 
2262
2428
  // Browser automation (Dench Browser). The provider stays server-side; the
@@ -1215,6 +1215,67 @@ export async function searchPeople(
1215
1215
  });
1216
1216
  }
1217
1217
 
1218
+ /**
1219
+ * Number of Exa candidates to pull when resolving ONE person for enrichment.
1220
+ *
1221
+ * Kept small on purpose. Exa's per-request price includes the first 10
1222
+ * results and charges per result beyond that, and column enrichment issues
1223
+ * one search PER ROW — so a bulk fill over 500 rows is 500 searches. Five is
1224
+ * enough for the match gate to find the right person when they are in the
1225
+ * index, and stays inside the included tier.
1226
+ */
1227
+ export const EXA_ENRICHMENT_NUM_RESULTS = 5;
1228
+
1229
+ /**
1230
+ * Exa People Search through the gateway's `/v1/search` route.
1231
+ *
1232
+ * Unlike the other helpers in this file this is a SEARCH, not an identifier
1233
+ * lookup — it returns relevance-ranked strangers, so callers must run the
1234
+ * result through `matchExaPerson` before writing anything. See
1235
+ * cli/lib/exa-people.ts.
1236
+ *
1237
+ * `type` is pinned to "auto": "deep"/"deep-reasoning" cost roughly 2x and buy
1238
+ * nothing when we are resolving a person we can already name.
1239
+ */
1240
+ export async function searchExaPeople(
1241
+ body: { query: string; numResults?: number },
1242
+ options: EnrichmentGatewayOptions = {},
1243
+ ): Promise<Record<string, unknown>> {
1244
+ return callGatewayJson("/v1/search", {
1245
+ method: "POST",
1246
+ body: {
1247
+ query: body.query,
1248
+ category: "people",
1249
+ type: "auto",
1250
+ numResults: body.numResults ?? EXA_ENRICHMENT_NUM_RESULTS,
1251
+ contents: { highlights: { maxCharacters: 200 } },
1252
+ },
1253
+ options,
1254
+ });
1255
+ }
1256
+
1257
+ /**
1258
+ * Exa Company Search through the gateway's `/v1/search` route. Same
1259
+ * search-not-lookup caveat as `searchExaPeople`: run the result through
1260
+ * `matchExaCompany` before writing anything. See cli/lib/exa-companies.ts.
1261
+ */
1262
+ export async function searchExaCompanies(
1263
+ body: { query: string; numResults?: number },
1264
+ options: EnrichmentGatewayOptions = {},
1265
+ ): Promise<Record<string, unknown>> {
1266
+ return callGatewayJson("/v1/search", {
1267
+ method: "POST",
1268
+ body: {
1269
+ query: body.query,
1270
+ category: "company",
1271
+ type: "auto",
1272
+ numResults: body.numResults ?? EXA_ENRICHMENT_NUM_RESULTS,
1273
+ contents: { highlights: { maxCharacters: 200 } },
1274
+ },
1275
+ options,
1276
+ });
1277
+ }
1278
+
1218
1279
  // ---------------------------------------------------------------------------
1219
1280
  // Shared HTTP helpers
1220
1281
  // ---------------------------------------------------------------------------
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Exa Company Search — entity parsing, Apollo-shape projection, and matching.
3
+ *
4
+ * The company twin of `exa-people.ts`. Exa's Company Search (`POST /search`
5
+ * with `category: "company"`) covers 50M+ company pages and returns structured
6
+ * firmographics on `results[].entities[]` as
7
+ * `{ id, type: "company", version, properties }`.
8
+ *
9
+ * Same two jobs as the people mapper: project into the Apollo-shaped envelope
10
+ * the existing extraction machinery already speaks, and refuse to hand back a
11
+ * company whose identity is not corroborated (Exa is a search, so it always
12
+ * returns *something*).
13
+ *
14
+ * Company matching is materially safer than person matching, because a company
15
+ * has a domain and a domain is an identity. When the caller knows the domain
16
+ * we compare hostnames and that is that; only the name-only path needs care.
17
+ *
18
+ * Field shapes are taken from live responses, not just the docs — real
19
+ * payloads carry an undocumented `research` key and frequently null out
20
+ * `webTraffic` and `financials.fundingLatestRound`, so everything is read
21
+ * defensively.
22
+ */
23
+
24
+ import {
25
+ asArray,
26
+ asObject,
27
+ asString,
28
+ normalizeCompany,
29
+ withScheme,
30
+ } from "./exa-people";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export type ExaCompanyHeadquarters = {
37
+ address?: string;
38
+ city?: string;
39
+ postalCode?: string;
40
+ country?: string;
41
+ };
42
+
43
+ export type ExaFundingRound = {
44
+ name?: string;
45
+ date?: string;
46
+ amount?: number;
47
+ };
48
+
49
+ export type ExaCompanyFinancials = {
50
+ revenueAnnual?: number;
51
+ fundingTotal?: number;
52
+ fundingLatestRound?: ExaFundingRound | null;
53
+ };
54
+
55
+ export type ExaWebTraffic = {
56
+ visitsMonthly?: number;
57
+ countryRank?: number;
58
+ avgDurationSeconds?: number;
59
+ };
60
+
61
+ export type ExaCompany = {
62
+ /** Stable Exa company entity id, when present. */
63
+ id?: string;
64
+ name?: string;
65
+ foundedYear?: number;
66
+ description?: string;
67
+ /** Estimated total headcount (`workforce.total`). */
68
+ headcount?: number;
69
+ headquarters?: ExaCompanyHeadquarters | null;
70
+ financials?: ExaCompanyFinancials | null;
71
+ webTraffic?: ExaWebTraffic | null;
72
+ };
73
+
74
+ export type ExaCompanyHit = {
75
+ /** Company URL from the enclosing search result. May be "" if absent. */
76
+ url: string;
77
+ title?: string;
78
+ highlights?: string[];
79
+ company: ExaCompany;
80
+ };
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Defensive readers
84
+ // ---------------------------------------------------------------------------
85
+
86
+ function asFiniteNumber(value: unknown): number | undefined {
87
+ return typeof value === "number" && Number.isFinite(value)
88
+ ? value
89
+ : undefined;
90
+ }
91
+
92
+ function asStringArray(value: unknown): string[] | undefined {
93
+ if (!Array.isArray(value)) return undefined;
94
+ const out = value.filter(
95
+ (item): item is string =>
96
+ typeof item === "string" && item.trim().length > 0,
97
+ );
98
+ return out.length > 0 ? out : undefined;
99
+ }
100
+
101
+ function readHeadquarters(value: unknown): ExaCompanyHeadquarters | null {
102
+ const raw = asObject(value);
103
+ if (!raw) return null;
104
+ const hq: ExaCompanyHeadquarters = {
105
+ address: asString(raw.address),
106
+ city: asString(raw.city),
107
+ postalCode: asString(raw.postalCode),
108
+ country: asString(raw.country),
109
+ };
110
+ return Object.values(hq).some((part) => part !== undefined) ? hq : null;
111
+ }
112
+
113
+ function readFundingRound(value: unknown): ExaFundingRound | null {
114
+ const raw = asObject(value);
115
+ if (!raw) return null;
116
+ const round: ExaFundingRound = {
117
+ name: asString(raw.name),
118
+ date: asString(raw.date),
119
+ amount: asFiniteNumber(raw.amount),
120
+ };
121
+ return Object.values(round).some((part) => part !== undefined) ? round : null;
122
+ }
123
+
124
+ function readFinancials(value: unknown): ExaCompanyFinancials | null {
125
+ const raw = asObject(value);
126
+ if (!raw) return null;
127
+ const financials: ExaCompanyFinancials = {
128
+ revenueAnnual: asFiniteNumber(raw.revenueAnnual),
129
+ fundingTotal: asFiniteNumber(raw.fundingTotal),
130
+ fundingLatestRound: readFundingRound(raw.fundingLatestRound),
131
+ };
132
+ const hasValue =
133
+ financials.revenueAnnual !== undefined ||
134
+ financials.fundingTotal !== undefined ||
135
+ financials.fundingLatestRound !== null;
136
+ return hasValue ? financials : null;
137
+ }
138
+
139
+ function readWebTraffic(value: unknown): ExaWebTraffic | null {
140
+ const raw = asObject(value);
141
+ if (!raw) return null;
142
+ const traffic: ExaWebTraffic = {
143
+ visitsMonthly: asFiniteNumber(raw.visitsMonthly),
144
+ countryRank: asFiniteNumber(raw.countryRank),
145
+ avgDurationSeconds: asFiniteNumber(raw.avgDurationSeconds),
146
+ };
147
+ return Object.values(traffic).some((part) => part !== undefined)
148
+ ? traffic
149
+ : null;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Parsing
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Walk an Exa `/search` response and pull out every company entity. */
157
+ export function parseExaCompanyResults(data: unknown): ExaCompanyHit[] {
158
+ const root = asObject(data);
159
+ if (!root) return [];
160
+ const hits: ExaCompanyHit[] = [];
161
+
162
+ for (const entry of asArray(root.results)) {
163
+ const result = asObject(entry);
164
+ if (!result) continue;
165
+ const url = asString(result.url) ?? asString(result.link) ?? "";
166
+ const title = asString(result.title);
167
+ const highlights = asStringArray(result.highlights);
168
+
169
+ for (const entityEntry of asArray(result.entities)) {
170
+ const entity = asObject(entityEntry);
171
+ if (!entity) continue;
172
+ if (asString(entity.type) !== "company") continue;
173
+ const properties = asObject(entity.properties);
174
+ if (!properties) continue;
175
+
176
+ hits.push({
177
+ url,
178
+ title,
179
+ highlights,
180
+ company: {
181
+ id: asString(entity.id),
182
+ name: asString(properties.name),
183
+ foundedYear: asFiniteNumber(properties.foundedYear),
184
+ description: asString(properties.description),
185
+ headcount: asFiniteNumber(asObject(properties.workforce)?.total),
186
+ headquarters: readHeadquarters(properties.headquarters),
187
+ financials: readFinancials(properties.financials),
188
+ webTraffic: readWebTraffic(properties.webTraffic),
189
+ },
190
+ });
191
+ }
192
+ }
193
+
194
+ return hits;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Domain helpers
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /** Hostname without protocol, `www.`, path, query, or trailing dot. */
202
+ export function normalizeDomain(value: string): string {
203
+ const trimmed = value.trim();
204
+ if (!trimmed) return "";
205
+ let host = trimmed;
206
+ try {
207
+ host = new URL(withScheme(trimmed)).hostname;
208
+ } catch {
209
+ host = trimmed.split("/")[0].split("?")[0];
210
+ }
211
+ return host
212
+ .toLowerCase()
213
+ .replace(/^www\./, "")
214
+ .replace(/\.$/, "");
215
+ }
216
+
217
+ /**
218
+ * A domain is accepted as identity evidence only when hostnames are exact.
219
+ * Parent/subdomain matching is unsafe on shared hosting:
220
+ * `tenant.vercel.app` and `vercel.app` are not necessarily the same company.
221
+ */
222
+ function domainsAgree(left: string, right: string): boolean {
223
+ const a = normalizeDomain(left);
224
+ const b = normalizeDomain(right);
225
+ if (!a || !b) return false;
226
+ return a === b;
227
+ }
228
+
229
+ /** Format a USD amount the way the CRM funding columns expect ("$11.3B"). */
230
+ export function formatUsdCompact(amount: number | undefined): string | null {
231
+ if (amount === undefined || !Number.isFinite(amount)) return null;
232
+ const abs = Math.abs(amount);
233
+ const units: Array<[number, string]> = [
234
+ [1e12, "T"],
235
+ [1e9, "B"],
236
+ [1e6, "M"],
237
+ [1e3, "K"],
238
+ ];
239
+ for (const [size, suffix] of units) {
240
+ if (abs >= size) {
241
+ const scaled = amount / size;
242
+ // One decimal below 100 ("$11.3B"), none above ("$250M").
243
+ const digits = Math.abs(scaled) < 100 ? 1 : 0;
244
+ return `$${scaled.toFixed(digits).replace(/\.0$/, "")}${suffix}`;
245
+ }
246
+ }
247
+ return `$${Math.round(amount)}`;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Apollo-shape projection
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * Project a company hit into the Apollo-shaped envelope the existing
256
+ * enrichment extractors understand.
257
+ *
258
+ * The keys mirror the `apolloPath` values already declared by
259
+ * COMPANY_ENRICHMENT_COLUMNS (`organization.name`, `organization.domain`,
260
+ * `organization.description`, `organization.headcount`, …) so Exa can back
261
+ * those columns without touching the extractor.
262
+ */
263
+ export function toCompanyApolloEnvelope(
264
+ hit: ExaCompanyHit,
265
+ ): Record<string, unknown> {
266
+ const { company } = hit;
267
+ const hq = company.headquarters;
268
+ const financials = company.financials;
269
+ const domain = hit.url ? normalizeDomain(hit.url) : null;
270
+ const hqLocation =
271
+ [hq?.city, hq?.country].filter(Boolean).join(", ") || hq?.address || null;
272
+
273
+ const organization: Record<string, unknown> = {
274
+ id: company.id ?? null,
275
+ name: company.name ?? null,
276
+ domain: domain || null,
277
+ website_url: hit.url || null,
278
+ description: company.description ?? null,
279
+ founded_year: company.foundedYear ?? null,
280
+ headcount: company.headcount ?? null,
281
+ hq_location: hqLocation,
282
+ hq_address: hq?.address ?? null,
283
+ hq_city: hq?.city ?? null,
284
+ hq_country: hq?.country ?? null,
285
+ total_funding: financials?.fundingTotal ?? null,
286
+ total_funding_printed: formatUsdCompact(financials?.fundingTotal),
287
+ revenue_annual: financials?.revenueAnnual ?? null,
288
+ revenue_annual_printed: formatUsdCompact(financials?.revenueAnnual),
289
+ latest_funding_round: financials?.fundingLatestRound?.name ?? null,
290
+ latest_funding_date: financials?.fundingLatestRound?.date ?? null,
291
+ latest_funding_amount: financials?.fundingLatestRound?.amount ?? null,
292
+ monthly_visits: company.webTraffic?.visitsMonthly ?? null,
293
+ };
294
+
295
+ return {
296
+ organization,
297
+ // Mirror at the top level too: some column definitions use bare paths
298
+ // (`name`, `domain`) as extraction fallbacks.
299
+ name: organization.name,
300
+ domain: organization.domain,
301
+ // See the people envelope: the URL is useful provenance, the provider
302
+ // name is not — this object is handed back as the tool result.
303
+ source: { url: hit.url || null },
304
+ };
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Match gating
309
+ // ---------------------------------------------------------------------------
310
+
311
+ export type ExaCompanyMatchCriteria = {
312
+ /** Preferred: a domain is an identity, so this is an exact match. */
313
+ domain?: string;
314
+ /** Fallback when no domain is known. Weaker — names collide. */
315
+ name?: string;
316
+ };
317
+
318
+ export type ExaCompanyMatch = {
319
+ hit: ExaCompanyHit;
320
+ confidence: "exact" | "high";
321
+ matchedOn: string;
322
+ };
323
+
324
+ export type ExaCompanyMatchOutcome =
325
+ | { status: "matched"; match: ExaCompanyMatch }
326
+ | { status: "no_match" }
327
+ | { status: "insufficient_criteria"; missing: string[] };
328
+
329
+ /**
330
+ * Pick the one company we are willing to write into a CRM cell, or refuse.
331
+ *
332
+ * Domain wins outright when supplied — it is an identity, not a hint. Falling
333
+ * back to the name requires an exact normalized match (after stripping legal
334
+ * suffixes), never containment: "Example" must not match "Example Health" when
335
+ * those are different companies, and Exa will happily rank both.
336
+ */
337
+ export function matchExaCompany(
338
+ hits: ExaCompanyHit[],
339
+ criteria: ExaCompanyMatchCriteria,
340
+ ): ExaCompanyMatchOutcome {
341
+ const domain = criteria.domain?.trim();
342
+ const name = criteria.name?.trim();
343
+ if (!domain && !name) {
344
+ return { status: "insufficient_criteria", missing: ["domain or name"] };
345
+ }
346
+
347
+ if (domain) {
348
+ for (const hit of hits) {
349
+ if (hit.url && domainsAgree(hit.url, domain)) {
350
+ return {
351
+ status: "matched",
352
+ match: { hit, confidence: "exact", matchedOn: "domain" },
353
+ };
354
+ }
355
+ }
356
+ // A domain was supplied and nothing served it. Do NOT quietly fall back to
357
+ // a name match: the caller told us exactly which company they meant.
358
+ return { status: "no_match" };
359
+ }
360
+
361
+ const wanted = normalizeCompany(name ?? "");
362
+ if (!wanted) return { status: "insufficient_criteria", missing: ["name"] };
363
+ const exactNameMatches = hits.filter((hit) => {
364
+ const candidate = hit.company.name;
365
+ return candidate ? normalizeCompany(candidate) === wanted : false;
366
+ });
367
+ if (exactNameMatches.length === 1) {
368
+ return {
369
+ status: "matched",
370
+ match: {
371
+ hit: exactNameMatches[0],
372
+ confidence: "high",
373
+ matchedOn: "unique_exact_name",
374
+ },
375
+ };
376
+ }
377
+ return { status: "no_match" };
378
+ }
379
+
380
+ /** Natural-language query for resolving ONE known company. */
381
+ export function buildCompanyLookupQuery(
382
+ criteria: ExaCompanyMatchCriteria,
383
+ ): string {
384
+ if (criteria.name && criteria.domain) {
385
+ return `${criteria.name} (${normalizeDomain(criteria.domain)})`;
386
+ }
387
+ if (criteria.name) return criteria.name;
388
+ return normalizeDomain(criteria.domain ?? "");
389
+ }