fullstackgtm 0.42.0 → 0.44.0
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/CHANGELOG.md +273 -0
- package/README.md +18 -7
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +718 -60
- package/dist/connectors/hubspot.js +58 -24
- package/dist/connectors/outboxChannel.d.ts +64 -0
- package/dist/connectors/outboxChannel.js +170 -0
- package/dist/connectors/prospectSources.d.ts +22 -0
- package/dist/connectors/prospectSources.js +43 -0
- package/dist/connectors/salesforce.js +40 -15
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +316 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/draft.js +27 -5
- package/dist/enrich.d.ts +6 -0
- package/dist/enrich.js +47 -1
- package/dist/health.d.ts +12 -0
- package/dist/health.js +29 -2
- package/dist/icp.d.ts +47 -3
- package/dist/icp.js +105 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +143 -0
- package/dist/judge.d.ts +36 -3
- package/dist/judge.js +46 -10
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.js +18 -4
- package/dist/signals.d.ts +58 -0
- package/dist/signals.js +65 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +12 -0
- package/docs/api.md +26 -4
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +195 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +162 -0
- package/docs/tam.md +195 -0
- package/llms.txt +89 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +17 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +809 -55
- package/src/connectors/hubspot.ts +55 -23
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +39 -14
- package/src/connectors/signalSources.ts +363 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/draft.ts +26 -5
- package/src/enrich.ts +51 -1
- package/src/health.ts +47 -2
- package/src/icp.ts +113 -11
- package/src/index.ts +42 -0
- package/src/init.ts +166 -0
- package/src/judge.ts +85 -11
- package/src/judgeEval.ts +8 -1
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +20 -4
- package/src/signals.ts +95 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
|
@@ -506,18 +506,34 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
506
506
|
};
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
-
/** Exact-
|
|
510
|
-
async function
|
|
509
|
+
/** Exact-value company lookup (by `name` or `domain`) for resolve-first creates. */
|
|
510
|
+
async function searchCompaniesBy(property: string, value: string): Promise<string[]> {
|
|
511
511
|
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
512
512
|
method: "POST",
|
|
513
513
|
body: JSON.stringify({
|
|
514
|
-
filterGroups: [{ filters: [{ propertyName:
|
|
515
|
-
properties: [
|
|
514
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
|
|
515
|
+
properties: [property],
|
|
516
516
|
limit: 3,
|
|
517
517
|
}),
|
|
518
518
|
});
|
|
519
519
|
return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
|
|
520
520
|
}
|
|
521
|
+
const searchCompaniesByName = (name: string) => searchCompaniesBy("name", name);
|
|
522
|
+
|
|
523
|
+
/** Stamp `domain` on a company only when it has none (fill-blank, never clobber). */
|
|
524
|
+
async function fillCompanyDomainIfEmpty(companyId: string, domain: string): Promise<void> {
|
|
525
|
+
try {
|
|
526
|
+
const data = await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}?properties=domain`);
|
|
527
|
+
const current = data?.properties?.domain;
|
|
528
|
+
if (typeof current === "string" && current.trim()) return; // already has one — leave it
|
|
529
|
+
await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}`, {
|
|
530
|
+
method: "PATCH",
|
|
531
|
+
body: JSON.stringify({ properties: { domain } }),
|
|
532
|
+
});
|
|
533
|
+
} catch {
|
|
534
|
+
// Best-effort: a failed domain fill must not sink the contact create.
|
|
535
|
+
}
|
|
536
|
+
}
|
|
521
537
|
|
|
522
538
|
/** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
|
|
523
539
|
async function searchContactsBy(property: string, value: string): Promise<string[]> {
|
|
@@ -533,36 +549,51 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
533
549
|
}
|
|
534
550
|
|
|
535
551
|
/**
|
|
536
|
-
* Resolve a company
|
|
537
|
-
*
|
|
538
|
-
*
|
|
552
|
+
* Resolve a company to a real account record, creating it on a confirmed miss.
|
|
553
|
+
* Matches by DOMAIN first (the accurate key) and falls back to exact name;
|
|
554
|
+
* creates with the domain stamped, and fills the domain on a name-matched
|
|
555
|
+
* account that lacks one — so the account is signal-watchable. Returns null on
|
|
556
|
+
* ambiguity (≥2 matches) so the caller skips the association rather than
|
|
557
|
+
* guessing. Same-run safe via createdCompaniesByName.
|
|
539
558
|
*/
|
|
540
|
-
async function
|
|
541
|
-
const
|
|
542
|
-
const already = createdCompaniesByName.get(
|
|
559
|
+
async function resolveOrCreateCompany(name: string, domain: string | undefined, opId: string): Promise<string | null> {
|
|
560
|
+
const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
|
|
561
|
+
const already = createdCompaniesByName.get(cacheKey);
|
|
543
562
|
if (already) return already;
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (
|
|
547
|
-
|
|
548
|
-
return
|
|
563
|
+
|
|
564
|
+
// 1. Domain-first match (accurate; an account matched here already has it).
|
|
565
|
+
if (domain) {
|
|
566
|
+
const byDomain = await searchCompaniesBy("domain", domain);
|
|
567
|
+
if (byDomain.length > 1) return null;
|
|
568
|
+
if (byDomain.length === 1) {
|
|
569
|
+
createdCompaniesByName.set(cacheKey, byDomain[0]);
|
|
570
|
+
return byDomain[0];
|
|
571
|
+
}
|
|
549
572
|
}
|
|
573
|
+
// 2. Exact-name match; stamp the domain if the account has none.
|
|
574
|
+
const byName = await searchCompaniesByName(name);
|
|
575
|
+
if (byName.length > 1) return null;
|
|
576
|
+
if (byName.length === 1) {
|
|
577
|
+
if (domain) await fillCompanyDomainIfEmpty(byName[0], domain);
|
|
578
|
+
createdCompaniesByName.set(cacheKey, byName[0]);
|
|
579
|
+
return byName[0];
|
|
580
|
+
}
|
|
581
|
+
// 3. Create with name + domain (provenance is best-effort).
|
|
582
|
+
const props: Record<string, string> = { name, ...(domain ? { domain } : {}) };
|
|
550
583
|
let created;
|
|
551
584
|
try {
|
|
552
585
|
created = await request(`/crm/v3/objects/companies`, {
|
|
553
586
|
method: "POST",
|
|
554
|
-
body: JSON.stringify({
|
|
555
|
-
properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
|
|
556
|
-
}),
|
|
587
|
+
body: JSON.stringify({ properties: { ...props, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` } }),
|
|
557
588
|
});
|
|
558
589
|
} catch {
|
|
559
590
|
created = await request(`/crm/v3/objects/companies`, {
|
|
560
591
|
method: "POST",
|
|
561
|
-
body: JSON.stringify({ properties:
|
|
592
|
+
body: JSON.stringify({ properties: props }),
|
|
562
593
|
});
|
|
563
594
|
}
|
|
564
595
|
const id = String(created.id);
|
|
565
|
-
createdCompaniesByName.set(
|
|
596
|
+
createdCompaniesByName.set(cacheKey, id);
|
|
566
597
|
return id;
|
|
567
598
|
}
|
|
568
599
|
|
|
@@ -588,7 +619,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
588
619
|
}
|
|
589
620
|
|
|
590
621
|
if (operation.objectType === "account") {
|
|
591
|
-
const id = await
|
|
622
|
+
const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
|
|
592
623
|
if (id === null) {
|
|
593
624
|
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
|
|
594
625
|
}
|
|
@@ -637,13 +668,14 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
637
668
|
let companyNote = "";
|
|
638
669
|
if (payload.associateCompanyName) {
|
|
639
670
|
try {
|
|
640
|
-
const companyId = await
|
|
671
|
+
const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
|
|
641
672
|
if (companyId) {
|
|
642
673
|
await request(
|
|
643
674
|
`/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
|
|
644
675
|
{ method: "PUT" },
|
|
645
676
|
);
|
|
646
|
-
|
|
677
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
678
|
+
companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
|
|
647
679
|
} else {
|
|
648
680
|
companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
649
681
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox channel connector — the governed SEND-side terminus of the outbound
|
|
3
|
+
* loop (signal → judge → draft → approve → apply → outbox), and the mirror image
|
|
4
|
+
* of the webhook spool. It is a `GtmConnector` whose `applyOperation` RENDERS an
|
|
5
|
+
* approved drafted opener to a local outbox artifact instead of writing a CRM —
|
|
6
|
+
* so it reuses the entire governed apply machinery (approval set, integrity/HMAC
|
|
7
|
+
* verification, idempotency, run recording) with no change to the apply engine.
|
|
8
|
+
*
|
|
9
|
+
* THE INVARIANT IT KEEPS: the CLI **transmits nothing**. Rendering an approved
|
|
10
|
+
* opener to `<home>/signals/outbox/<channel>.jsonl` is a local file write, never
|
|
11
|
+
* an SMTP/API connection. A downstream sender (the hosted product, or the
|
|
12
|
+
* operator's own MTA) drains the outbox and does the actual transmission. This
|
|
13
|
+
* is the send-side half of the open-core boundary: the governed artifact + its
|
|
14
|
+
* format are open; always-on transmission infrastructure is hosted/opt-in.
|
|
15
|
+
*
|
|
16
|
+
* Governance: it only renders ops that came from `draft` (a `create_task` op
|
|
17
|
+
* whose policy is `draft:<channel>`); any other op is `skipped` (it is not a
|
|
18
|
+
* general CRM writer). Idempotent on the operation id — re-applying an approved
|
|
19
|
+
* plan never duplicates an outbox row. Read paths (`fetchSnapshot`) intentionally
|
|
20
|
+
* throw: a channel has no snapshot, and `applyPatchPlan` never calls it for a
|
|
21
|
+
* draft plan (no guards/filter/irreversible ops → no snapshot needed).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { ensureSecureHomeDir, writeSecureFile } from "../credentials.ts";
|
|
27
|
+
import { signalsOutboxDir } from "../signals.ts";
|
|
28
|
+
import type { CanonicalGtmSnapshot, GtmConnector, PatchOperation, PatchOperationResult } from "../types.ts";
|
|
29
|
+
|
|
30
|
+
/** One approved, ready-to-send touch — a governed send INTENT, not a sent message. */
|
|
31
|
+
export type OutboxEntry = {
|
|
32
|
+
/** Idempotency key = the source operation id (stable per draft op). */
|
|
33
|
+
id: string;
|
|
34
|
+
/** email | linkedin | task — from the draft op's `draft:<channel>` policy. */
|
|
35
|
+
channel: string;
|
|
36
|
+
/** The CRM target the opener is addressed to (a sender resolves id → address). */
|
|
37
|
+
objectType: string;
|
|
38
|
+
objectId: string;
|
|
39
|
+
/** The APPROVED opener, verbatim as it was signed in the plan op. */
|
|
40
|
+
body: string;
|
|
41
|
+
/** The draft op's human-readable reason (carries the account + trigger). */
|
|
42
|
+
reason?: string;
|
|
43
|
+
/** Evidence ids the opener was grounded in (the verbatim signal quote). */
|
|
44
|
+
evidenceIds: string[];
|
|
45
|
+
/** ISO 8601 — when the CLI rendered this to the outbox (NOT a send time). */
|
|
46
|
+
renderedAt: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Channel ids this build can render to. */
|
|
50
|
+
export const CHANNELS = ["outbox"] as const;
|
|
51
|
+
|
|
52
|
+
export type OutboxChannelOptions = {
|
|
53
|
+
/** Outbox directory; defaults to the profile-scoped `signalsOutboxDir()`. */
|
|
54
|
+
outboxDir?: string;
|
|
55
|
+
/** Injectable clock for deterministic tests. */
|
|
56
|
+
now?: () => Date;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const DRAFT_POLICY_PREFIX = "draft:";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the outbox channel connector. With no `outboxDir`, writes to the
|
|
63
|
+
* profile-scoped conventional outbox and locks the home down (0700/0600) like
|
|
64
|
+
* the signal store; with an explicit `outboxDir` (tests), it writes there
|
|
65
|
+
* without touching the real home.
|
|
66
|
+
*/
|
|
67
|
+
export function createOutboxChannelConnector(options: OutboxChannelOptions = {}): GtmConnector {
|
|
68
|
+
const usingDefaultHome = options.outboxDir === undefined;
|
|
69
|
+
const dir = options.outboxDir ?? signalsOutboxDir();
|
|
70
|
+
const now = options.now ?? (() => new Date());
|
|
71
|
+
|
|
72
|
+
function fileFor(channel: string): string {
|
|
73
|
+
if (!/^[\w.-]+$/.test(channel)) throw new Error(`Invalid outbox channel name: ${channel}`);
|
|
74
|
+
return join(dir, `${channel}.jsonl`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Existing entry ids in a channel file (for idempotent re-apply). */
|
|
78
|
+
function existingIds(channel: string): Set<string> {
|
|
79
|
+
const ids = new Set<string>();
|
|
80
|
+
let raw: string;
|
|
81
|
+
try {
|
|
82
|
+
raw = readFileSync(fileFor(channel), "utf8");
|
|
83
|
+
} catch {
|
|
84
|
+
return ids; // no file yet
|
|
85
|
+
}
|
|
86
|
+
for (const line of raw.split("\n")) {
|
|
87
|
+
const t = line.trim();
|
|
88
|
+
if (!t) continue;
|
|
89
|
+
try {
|
|
90
|
+
const id = (JSON.parse(t) as { id?: unknown }).id;
|
|
91
|
+
if (typeof id === "string") ids.add(id);
|
|
92
|
+
} catch {
|
|
93
|
+
// Skip a corrupt line rather than fail the whole apply.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return ids;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function append(channel: string, entry: OutboxEntry): void {
|
|
100
|
+
if (usingDefaultHome) ensureSecureHomeDir();
|
|
101
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
102
|
+
const path = fileFor(channel);
|
|
103
|
+
// Owner-only like the signal store: outbox carries opener text + CRM ids.
|
|
104
|
+
if (!existsSync(path)) writeSecureFile(path, "");
|
|
105
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
109
|
+
const policy = String(operation.sourceRuleOrPolicy ?? "");
|
|
110
|
+
if (operation.operation !== "create_task" || !policy.startsWith(DRAFT_POLICY_PREFIX)) {
|
|
111
|
+
return {
|
|
112
|
+
operationId: operation.id,
|
|
113
|
+
status: "skipped",
|
|
114
|
+
detail:
|
|
115
|
+
"The outbox channel only renders drafted openers (create_task ops from `draft`). " +
|
|
116
|
+
"Apply other operations through a CRM connector (--provider hubspot|salesforce).",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const channel = policy.slice(DRAFT_POLICY_PREFIX.length) || "task";
|
|
120
|
+
|
|
121
|
+
// Idempotent: a re-applied approved plan must not duplicate the row.
|
|
122
|
+
if (existingIds(channel).has(operation.id)) {
|
|
123
|
+
return {
|
|
124
|
+
operationId: operation.id,
|
|
125
|
+
status: "applied",
|
|
126
|
+
detail: `Already in outbox (${channel}.jsonl); idempotent — not duplicated. Nothing transmitted.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const entry: OutboxEntry = {
|
|
131
|
+
id: operation.id,
|
|
132
|
+
channel,
|
|
133
|
+
objectType: operation.objectType,
|
|
134
|
+
objectId: operation.objectId,
|
|
135
|
+
body: typeof operation.afterValue === "string" ? operation.afterValue : String(operation.afterValue ?? ""),
|
|
136
|
+
...(operation.reason ? { reason: operation.reason } : {}),
|
|
137
|
+
evidenceIds: operation.evidenceIds ?? [],
|
|
138
|
+
renderedAt: now().toISOString(),
|
|
139
|
+
};
|
|
140
|
+
append(channel, entry);
|
|
141
|
+
return {
|
|
142
|
+
operationId: operation.id,
|
|
143
|
+
status: "applied",
|
|
144
|
+
detail: `Rendered to outbox (${channel}.jsonl) for a downstream sender. Nothing was transmitted.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
|
|
149
|
+
// A channel has no readable state. apply never calls this for a draft plan
|
|
150
|
+
// (no guards/filter/irreversible ops); make the misuse explicit if it does.
|
|
151
|
+
throw new Error(
|
|
152
|
+
"The outbox channel has no snapshot to read — it is a send-side render target. " +
|
|
153
|
+
"Use it only to `apply` an approved draft plan (`apply --plan-id <id> --channel outbox`).",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
provider: "outbox",
|
|
159
|
+
fetchSnapshot,
|
|
160
|
+
applyOperation,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Resolve a channel connector by id (mirrors the source-connector registry). */
|
|
165
|
+
export function createChannelConnector(id: string, options: OutboxChannelOptions = {}): GtmConnector {
|
|
166
|
+
if (id === "outbox") return createOutboxChannelConnector(options);
|
|
167
|
+
throw new Error(`Unknown channel: ${id} (one of: ${CHANNELS.join(", ")}).`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read every outbox entry across all channel files in `dir` (default: the
|
|
172
|
+
* conventional outbox), newest-appended last. The reader a downstream sender (or
|
|
173
|
+
* a future `signals outbox` command) uses to drain the queue.
|
|
174
|
+
*/
|
|
175
|
+
export function listOutbox(dir?: string): OutboxEntry[] {
|
|
176
|
+
const outboxDir = dir ?? signalsOutboxDir();
|
|
177
|
+
let names: string[];
|
|
178
|
+
try {
|
|
179
|
+
names = readdirSync(outboxDir).filter((name) => name.endsWith(".jsonl")).sort();
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const out: OutboxEntry[] = [];
|
|
184
|
+
for (const name of names) {
|
|
185
|
+
let raw: string;
|
|
186
|
+
try {
|
|
187
|
+
raw = readFileSync(join(outboxDir, name), "utf8");
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (const line of raw.split("\n")) {
|
|
192
|
+
const t = line.trim();
|
|
193
|
+
if (!t) continue;
|
|
194
|
+
try {
|
|
195
|
+
out.push(JSON.parse(t) as OutboxEntry);
|
|
196
|
+
} catch {
|
|
197
|
+
// Skip corrupt lines.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
@@ -166,6 +166,63 @@ function strField(field: { value?: unknown } | undefined): string | undefined {
|
|
|
166
166
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// TAM universe count — "how many ACCOUNTS match this ICP firmographic?"
|
|
171
|
+
//
|
|
172
|
+
// Verified empirically (2026-06-25) against the live providers, because the
|
|
173
|
+
// obvious endpoints lie about totals:
|
|
174
|
+
// - Explorium /v1/prospects `total_results` == the PAGE size (1→1, 10→10), not
|
|
175
|
+
// the universe — useless for sizing. So is /v1/businesses for tiny pages? No:
|
|
176
|
+
// - Explorium /v1/businesses `total_results` IS a real COUNT of matching
|
|
177
|
+
// companies (US+10000-emp → 9,754; Liechtenstein+10000 → 11), capped at
|
|
178
|
+
// EXPLORIUM_BUSINESS_COUNT_CAP. At/above the cap it saturates at exactly that
|
|
179
|
+
// number, so the caller must treat a capped reading as a FLOOR, not a count.
|
|
180
|
+
// - pipe0/Crustdata people search returns only a pagination cursor, NO total —
|
|
181
|
+
// it cannot size a universe at all (callers use it for discovery, not counting).
|
|
182
|
+
// So the TAM count source is Explorium /v1/businesses (a company/account count).
|
|
183
|
+
|
|
184
|
+
export const EXPLORIUM_BUSINESS_COUNT_CAP = 60_000;
|
|
185
|
+
|
|
186
|
+
export type BusinessCountProbe = {
|
|
187
|
+
/** matching companies (the account universe); == cap when saturated. */
|
|
188
|
+
total: number;
|
|
189
|
+
/** true when total hit EXPLORIUM_BUSINESS_COUNT_CAP — treat total as a lower bound. */
|
|
190
|
+
capped: boolean;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Count the COMPANIES (accounts) matching an ICP firmographic via Explorium
|
|
195
|
+
* /v1/businesses. Returns the real total (capped at 60k — `capped` flags the
|
|
196
|
+
* ceiling) or null if the response carries no `total_results`. Use
|
|
197
|
+
* `icpToExploriumBusinessFilters` to build `filters` (firmographic field names
|
|
198
|
+
* differ from /v1/prospects).
|
|
199
|
+
*/
|
|
200
|
+
export async function probeExploriumBusinessCount(opts: {
|
|
201
|
+
apiKey: string;
|
|
202
|
+
filters: Record<string, { values?: string[] }>;
|
|
203
|
+
apiBaseUrl?: string;
|
|
204
|
+
fetchImpl?: FetchImpl;
|
|
205
|
+
}): Promise<BusinessCountProbe | null> {
|
|
206
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
207
|
+
const base = (opts.apiBaseUrl ?? "https://api.explorium.ai").replace(/\/$/, "");
|
|
208
|
+
const response = await fetchImpl(`${base}/v1/businesses`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
|
|
211
|
+
// page_size 1: we want the envelope's total_results, not the rows. CRITICAL:
|
|
212
|
+
// do NOT send `size` — Explorium caps total_results to `size` when present
|
|
213
|
+
// (verified: size:1 → total_results:1; omitted → the real count, e.g. 19,058).
|
|
214
|
+
body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
|
|
215
|
+
});
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
throw new Error(`Explorium /v1/businesses count failed: HTTP ${response.status} ${await safeText(response)}`);
|
|
218
|
+
}
|
|
219
|
+
const body = (await response.json()) as { total_results?: unknown };
|
|
220
|
+
const total = body.total_results;
|
|
221
|
+
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return null;
|
|
222
|
+
const rounded = Math.round(total);
|
|
223
|
+
return { total: rounded, capped: rounded >= EXPLORIUM_BUSINESS_COUNT_CAP };
|
|
224
|
+
}
|
|
225
|
+
|
|
169
226
|
function normalizeLinkedin(value: string | undefined): string | undefined {
|
|
170
227
|
if (!value) return undefined;
|
|
171
228
|
const v = value.trim().replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
|
@@ -80,28 +80,52 @@ export function createSalesforceConnector(
|
|
|
80
80
|
// confirmed miss is created, and an ambiguous name (>1 match) is refused.
|
|
81
81
|
// Caches within the run so the same name is never created twice — shared by
|
|
82
82
|
// `link_record`'s create:<Name> path and `create_record`'s company linking.
|
|
83
|
-
async function
|
|
83
|
+
async function resolveOrCreateAccount(
|
|
84
84
|
name: string,
|
|
85
|
+
domain?: string,
|
|
85
86
|
): Promise<{ id: string; createdNew: boolean } | { ambiguous: number }> {
|
|
86
|
-
const
|
|
87
|
-
const cached = createdAccountsByName.get(
|
|
87
|
+
const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
|
|
88
|
+
const cached = createdAccountsByName.get(cacheKey);
|
|
88
89
|
if (cached) return { id: cached, createdNew: false };
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
const esc = (v: string) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
91
|
+
|
|
92
|
+
// 1. Domain-first match on Website (the accurate key; Website often stores a
|
|
93
|
+
// full URL, so match by substring).
|
|
94
|
+
if (domain) {
|
|
95
|
+
const byDomain = await query(`SELECT Id FROM Account WHERE Website LIKE '%${esc(domain)}%' LIMIT 3`);
|
|
96
|
+
if (byDomain.length > 1) return { ambiguous: byDomain.length };
|
|
97
|
+
if (byDomain.length === 1) {
|
|
98
|
+
const id = String(byDomain[0].Id);
|
|
99
|
+
createdAccountsByName.set(cacheKey, id);
|
|
100
|
+
return { id, createdNew: false };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 2. Exact-name match; stamp Website if the account has none (fill-blank).
|
|
104
|
+
const byName = await query(`SELECT Id, Website FROM Account WHERE Name = '${esc(name)}' LIMIT 3`);
|
|
105
|
+
if (byName.length > 1) return { ambiguous: byName.length };
|
|
92
106
|
let id: string;
|
|
93
107
|
let createdNew = false;
|
|
94
|
-
if (
|
|
95
|
-
id = String(
|
|
108
|
+
if (byName.length === 1) {
|
|
109
|
+
id = String(byName[0].Id);
|
|
110
|
+
if (domain && !stringOrUndefined(byName[0].Website)) {
|
|
111
|
+
try {
|
|
112
|
+
await request(`/services/data/${apiVersion}/sobjects/Account/${encodeURIComponent(id)}`, {
|
|
113
|
+
method: "PATCH",
|
|
114
|
+
body: JSON.stringify({ Website: domain }),
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
// best-effort fill — a failed Website stamp must not sink the create.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
96
120
|
} else {
|
|
97
121
|
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
98
122
|
method: "POST",
|
|
99
|
-
body: JSON.stringify({ Name: name }),
|
|
123
|
+
body: JSON.stringify({ Name: name, ...(domain ? { Website: domain } : {}) }),
|
|
100
124
|
});
|
|
101
125
|
id = String(created.id);
|
|
102
126
|
createdNew = true;
|
|
103
127
|
}
|
|
104
|
-
createdAccountsByName.set(
|
|
128
|
+
createdAccountsByName.set(cacheKey, id);
|
|
105
129
|
return { id, createdNew };
|
|
106
130
|
}
|
|
107
131
|
|
|
@@ -534,7 +558,7 @@ export function createSalesforceConnector(
|
|
|
534
558
|
}
|
|
535
559
|
|
|
536
560
|
if (operation.objectType === "account") {
|
|
537
|
-
const resolved = await
|
|
561
|
+
const resolved = await resolveOrCreateAccount(matchValue);
|
|
538
562
|
if ("ambiguous" in resolved) {
|
|
539
563
|
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
|
|
540
564
|
}
|
|
@@ -574,7 +598,7 @@ export function createSalesforceConnector(
|
|
|
574
598
|
let companyNote = "";
|
|
575
599
|
if (payload.associateCompanyName) {
|
|
576
600
|
try {
|
|
577
|
-
const resolved = await
|
|
601
|
+
const resolved = await resolveOrCreateAccount(payload.associateCompanyName, payload.associateCompanyDomain);
|
|
578
602
|
if ("ambiguous" in resolved) {
|
|
579
603
|
companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
580
604
|
} else {
|
|
@@ -582,7 +606,8 @@ export function createSalesforceConnector(
|
|
|
582
606
|
method: "PATCH",
|
|
583
607
|
body: JSON.stringify({ AccountId: resolved.id }),
|
|
584
608
|
});
|
|
585
|
-
|
|
609
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
610
|
+
companyNote = ` Linked to account "${payload.associateCompanyName}"${domainNote} (${resolved.id}).`;
|
|
586
611
|
}
|
|
587
612
|
} catch (error) {
|
|
588
613
|
// Association is best-effort — the contact create already succeeded.
|
|
@@ -726,7 +751,7 @@ export function createSalesforceConnector(
|
|
|
726
751
|
if (!name) {
|
|
727
752
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
728
753
|
}
|
|
729
|
-
const resolved = await
|
|
754
|
+
const resolved = await resolveOrCreateAccount(name);
|
|
730
755
|
if ("ambiguous" in resolved) {
|
|
731
756
|
return {
|
|
732
757
|
operationId: operation.id,
|