@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.
- package/README.md +1 -1
- package/crm.ts +164 -26
- package/dench.ts +2 -3
- package/email.ts +50 -6
- package/host.ts +1 -1
- package/lib/api-schemas.ts +101 -5
- package/lib/command-registry.ts +169 -3
- package/lib/enrichment-gateway.ts +61 -0
- package/lib/exa-companies.ts +389 -0
- package/lib/exa-people.ts +682 -0
- package/lib/exa-search-constraints.ts +70 -0
- package/package.json +1 -1
- package/search.ts +204 -9
package/lib/command-registry.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|