fullstackgtm 0.43.0 → 0.45.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 +378 -1
- package/README.md +37 -14
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +80 -0
- package/dist/cli/auth.js +500 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +373 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +788 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +344 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +609 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +454 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +70 -0
- package/dist/cli/shared.js +331 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +121 -0
- package/dist/cli/ui.js +375 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +104 -4556
- package/dist/connector.d.ts +15 -0
- package/dist/connector.js +35 -0
- package/dist/connectors/hubspot.d.ts +3 -1
- package/dist/connectors/hubspot.js +10 -3
- 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.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +264 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/icp.d.ts +52 -4
- package/dist/icp.js +112 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +3 -0
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/marketClassify.d.ts +2 -0
- package/dist/marketClassify.js +7 -1
- package/dist/mcp-bin.js +2 -2
- package/dist/mcp.js +259 -166
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +272 -5
- package/dist/signals.d.ts +54 -0
- package/dist/signals.js +64 -0
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +91 -11
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +37 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +175 -0
- package/docs/tam.md +195 -0
- package/llms.txt +83 -11
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +4 -3
- package/src/audit.ts +27 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +549 -0
- package/src/cli/call.ts +398 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +885 -0
- package/src/cli/fix.ts +372 -0
- package/src/cli/help.ts +664 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +523 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +375 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +426 -0
- package/src/cli.ts +103 -5170
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +314 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/icp.ts +120 -34
- package/src/index.ts +32 -0
- package/src/init.ts +3 -0
- package/src/judge.ts +7 -0
- package/src/judgeEval.ts +8 -1
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +330 -7
- package/src/signals.ts +90 -0
- package/src/spoolFiles.ts +116 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source connectors for the `signals` layer — the connection-based intake that
|
|
3
|
+
* generalizes the no-auth ATS adapters (`atsBoards.ts`) and the hand-staged
|
|
4
|
+
* `--from <file.json>` path into one contract: a platform connection in, a list
|
|
5
|
+
* of evidence-bearing staged signal rows out.
|
|
6
|
+
*
|
|
7
|
+
* Design (see docs/spec-connectors-signals-outbound.md):
|
|
8
|
+
* - Zero runtime deps: global `fetch` only, injectable for tests.
|
|
9
|
+
* - Read-only: a source never writes a CRM record and never emits a
|
|
10
|
+
* PatchOperation. `signals fetch` stays read-only re: the CRM.
|
|
11
|
+
* - Verbatim evidence: every row carries a non-empty `quote`; a row that
|
|
12
|
+
* cannot ground a why-now is dropped, never faked. The central
|
|
13
|
+
* `stagedRowToSignal` gate enforces this again.
|
|
14
|
+
* - Secrets via the credential ladder: API keys come from
|
|
15
|
+
* `ctx.getApiKey(provider)` (login store -> env -> broker), NEVER from argv.
|
|
16
|
+
* `ctx.options` carries non-secret knobs only (a file path, a query term).
|
|
17
|
+
* - Per-source resilience: a connector's own failure yields `[]` (logged by
|
|
18
|
+
* the caller), it must never sink a multi-source run — the per-provider
|
|
19
|
+
* try/catch idiom of atsBoards/prospectSources.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync, statSync } from "node:fs";
|
|
23
|
+
import { resolve } from "node:path";
|
|
24
|
+
import type { SignalBucket, StagedSignalRow } from "../signals.ts";
|
|
25
|
+
import { SIGNAL_BUCKETS } from "../signals.ts";
|
|
26
|
+
import { parseSpoolText, spoolFilesIn } from "../spoolFiles.ts";
|
|
27
|
+
|
|
28
|
+
export type SignalSourceShape = "pull" | "push";
|
|
29
|
+
export type SignalSourceAuth = "none" | "api_key" | "oauth";
|
|
30
|
+
|
|
31
|
+
/** Everything a source connector needs for one `fetch`, supplied by the CLI. */
|
|
32
|
+
export type SignalSourceContext = {
|
|
33
|
+
/** Accounts to scope a pull. May be empty (a file/spool source ignores it). */
|
|
34
|
+
watchlist: { domain: string }[];
|
|
35
|
+
/** Evidence keywords for job/listing-style sources; may be empty. */
|
|
36
|
+
keywords: string[];
|
|
37
|
+
now: Date;
|
|
38
|
+
/** Injectable fetch for tests; global `fetch` by default. */
|
|
39
|
+
fetchImpl?: typeof fetch;
|
|
40
|
+
/**
|
|
41
|
+
* Credential-ladder lookup. Returns a usable secret for `provider`, or null
|
|
42
|
+
* when nothing is configured (the connector then returns []). Secrets NEVER
|
|
43
|
+
* arrive via argv — only through this.
|
|
44
|
+
*/
|
|
45
|
+
getApiKey?: (provider: string) => Promise<string | null>;
|
|
46
|
+
/** Non-secret per-connector knobs from `--connector-opt k=v` (paths, queries). */
|
|
47
|
+
options?: Record<string, string>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type SignalSourceConnector = {
|
|
51
|
+
id: string;
|
|
52
|
+
/** Default bucket this source feeds (a row may still override it). */
|
|
53
|
+
bucket: SignalBucket;
|
|
54
|
+
shape: SignalSourceShape;
|
|
55
|
+
auth: SignalSourceAuth;
|
|
56
|
+
/**
|
|
57
|
+
* Produce staged rows now. Resilient by contract: the connector's own
|
|
58
|
+
* failures (offline, non-2xx, malformed payload, missing key) resolve to []
|
|
59
|
+
* rather than throwing, so one source's outage never aborts a multi-source
|
|
60
|
+
* `signals fetch`. Validation/evidence-gating of the returned rows happens
|
|
61
|
+
* centrally in `stagedRowToSignal`.
|
|
62
|
+
*/
|
|
63
|
+
fetch(ctx: SignalSourceContext): Promise<StagedSignalRow[]>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// file — local JSON / JSONL intake (also the webhook landing-zone reader)
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read staged rows from a local path — the webhook landing-zone reader. The path
|
|
71
|
+
* (from `options.path`/`options.file`) may be:
|
|
72
|
+
* - a single FILE: a JSON array, or newline-delimited JSON (one row per line);
|
|
73
|
+
* - a DIRECTORY (the conventional spool): every `*.jsonl` / `*.json` file in
|
|
74
|
+
* it is read and concatenated (sorted by name), so multiple receivers can
|
|
75
|
+
* each append their own file (`rb2b.jsonl`, `hubspot.jsonl`) and they all
|
|
76
|
+
* land in one fetch.
|
|
77
|
+
* No auth. This is how every push platform (RB2B, Trigify, HubSpot webhooks)
|
|
78
|
+
* reaches the CLI: a receiver appends a row to the spool, this reads it on the
|
|
79
|
+
* next `signals fetch`. The CLI defaults the path to the conventional spool dir
|
|
80
|
+
* (`signalsSpoolDir`) when `--connector file` is used with no path; a bare
|
|
81
|
+
* library call with no path is inactive (returns []).
|
|
82
|
+
*
|
|
83
|
+
* Resilient: a missing path / unreadable file yields [] (an empty source, not a
|
|
84
|
+
* crash), and one unreadable file in a spool directory is skipped. A file that
|
|
85
|
+
* IS present but malformed throws — a corrupt spool is a real error to surface,
|
|
86
|
+
* not silent data loss. The central `stagedRowToSignal` gate validates rows.
|
|
87
|
+
*/
|
|
88
|
+
export const fileSource: SignalSourceConnector = {
|
|
89
|
+
id: "file",
|
|
90
|
+
bucket: "company",
|
|
91
|
+
shape: "pull",
|
|
92
|
+
auth: "none",
|
|
93
|
+
async fetch(ctx) {
|
|
94
|
+
const path = ctx.options?.path ?? ctx.options?.file;
|
|
95
|
+
if (!path) return []; // no spool configured (the CLI fills the default dir)
|
|
96
|
+
const abs = resolve(process.cwd(), path);
|
|
97
|
+
let isDir: boolean;
|
|
98
|
+
try {
|
|
99
|
+
isDir = statSync(abs).isDirectory();
|
|
100
|
+
} catch {
|
|
101
|
+
return []; // missing path: an empty source, not a crash
|
|
102
|
+
}
|
|
103
|
+
// Container parsing lives in the shared spool reader (src/spoolFiles.ts) so
|
|
104
|
+
// `signals fetch --from`, `enrich ingest`, and `market observe --from` read
|
|
105
|
+
// the same convention; the "file source" label keeps error text unchanged.
|
|
106
|
+
const files = isDir ? spoolFilesIn(abs) : [abs];
|
|
107
|
+
const rows: Record<string, unknown>[] = [];
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
let raw: string;
|
|
110
|
+
try {
|
|
111
|
+
raw = readFileSync(file, "utf8");
|
|
112
|
+
} catch {
|
|
113
|
+
continue; // one unreadable file in a spool dir must not sink the rest
|
|
114
|
+
}
|
|
115
|
+
rows.push(...parseSpoolText(raw, file, "file source"));
|
|
116
|
+
}
|
|
117
|
+
return rows.map((row) => coerceRow(row, this.bucket));
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// serpapi-news — funding/company signals from a news REST query (API key)
|
|
123
|
+
|
|
124
|
+
const SERPAPI_BASE_URL = "https://serpapi.com";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pull recent news per watchlist account and stage funding/company signals. One
|
|
128
|
+
* query per account (`q="<domain>"`, Google News engine); each result becomes a
|
|
129
|
+
* row whose verbatim `quote` is the headline (+ source), so the downstream judge
|
|
130
|
+
* can ground a why-now on a real, linkable article. API key via the credential
|
|
131
|
+
* ladder (provider "serpapi"); no key -> [] (the source is simply inactive).
|
|
132
|
+
*
|
|
133
|
+
* Bucket defaults to `funding`; override per run with `--connector-opt bucket=company`.
|
|
134
|
+
*/
|
|
135
|
+
export const serpapiNewsSource: SignalSourceConnector = {
|
|
136
|
+
id: "serpapi-news",
|
|
137
|
+
bucket: "funding",
|
|
138
|
+
shape: "pull",
|
|
139
|
+
auth: "api_key",
|
|
140
|
+
async fetch(ctx) {
|
|
141
|
+
const apiKey = ctx.getApiKey ? await ctx.getApiKey("serpapi") : null;
|
|
142
|
+
if (!apiKey) return [];
|
|
143
|
+
const fetchImpl = ctx.fetchImpl ?? fetch;
|
|
144
|
+
const base = (ctx.options?.apiBaseUrl ?? SERPAPI_BASE_URL).replace(/\/$/, "");
|
|
145
|
+
const bucket = pickBucket(ctx.options?.bucket, this.bucket);
|
|
146
|
+
const out: StagedSignalRow[] = [];
|
|
147
|
+
for (const account of ctx.watchlist) {
|
|
148
|
+
const domain = account.domain.trim();
|
|
149
|
+
if (!domain) continue;
|
|
150
|
+
try {
|
|
151
|
+
const url =
|
|
152
|
+
`${base}/search.json?engine=google_news&q=${encodeURIComponent(`"${domain}"`)}` +
|
|
153
|
+
`&api_key=${encodeURIComponent(apiKey)}`;
|
|
154
|
+
const response = await fetchImpl(url, { headers: { Accept: "application/json" } });
|
|
155
|
+
if (!response.ok) continue;
|
|
156
|
+
const data = (await response.json()) as {
|
|
157
|
+
news_results?: Array<{ title?: string; link?: string; source?: { name?: string } | string; date?: string }>;
|
|
158
|
+
};
|
|
159
|
+
for (const item of data.news_results ?? []) {
|
|
160
|
+
const title = typeof item.title === "string" ? item.title.trim() : "";
|
|
161
|
+
if (!title) continue; // no headline -> no verbatim evidence -> drop
|
|
162
|
+
const sourceName =
|
|
163
|
+
typeof item.source === "string" ? item.source : item.source?.name ?? "";
|
|
164
|
+
const quote = sourceName ? `${title} — ${sourceName}` : title;
|
|
165
|
+
out.push({
|
|
166
|
+
bucket,
|
|
167
|
+
accountDomain: domain,
|
|
168
|
+
trigger: `news: ${title}`,
|
|
169
|
+
quote,
|
|
170
|
+
sourceUrl: typeof item.link === "string" ? item.link : "",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
continue; // one account's outage must not sink the run
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// hubspot-forms — first-party demand from recent HubSpot form submissions
|
|
183
|
+
|
|
184
|
+
const HUBSPOT_BASE_URL = "https://api.hubapi.com";
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Stage `demand` signals from recent HubSpot form submissions — the first real
|
|
188
|
+
* `demand`-bucket producer (a form fill is first-party demand). Reuses the
|
|
189
|
+
* EXISTING HubSpot credential via the ladder (provider "hubspot"); no separate
|
|
190
|
+
* login. Each submission whose email carries a company domain becomes a row
|
|
191
|
+
* whose verbatim `quote` is the form name + submitted email (the evidence a rep
|
|
192
|
+
* can verify). Submissions without a corporate domain (free-mail) are dropped —
|
|
193
|
+
* no account to attach demand to.
|
|
194
|
+
*
|
|
195
|
+
* Phase 1 is a pull over the Forms submissions API; the form-submission webhook
|
|
196
|
+
* (push) lands in Phase 2 via the spool + `file` source.
|
|
197
|
+
*/
|
|
198
|
+
export const hubspotFormsSource: SignalSourceConnector = {
|
|
199
|
+
id: "hubspot-forms",
|
|
200
|
+
bucket: "demand",
|
|
201
|
+
shape: "pull",
|
|
202
|
+
auth: "oauth",
|
|
203
|
+
async fetch(ctx) {
|
|
204
|
+
const token = ctx.getApiKey ? await ctx.getApiKey("hubspot") : null;
|
|
205
|
+
if (!token) return [];
|
|
206
|
+
const fetchImpl = ctx.fetchImpl ?? fetch;
|
|
207
|
+
const base = (ctx.options?.apiBaseUrl ?? HUBSPOT_BASE_URL).replace(/\/$/, "");
|
|
208
|
+
const out: StagedSignalRow[] = [];
|
|
209
|
+
let forms: Array<{ guid?: string; id?: string; name?: string }> = [];
|
|
210
|
+
try {
|
|
211
|
+
const response = await fetchImpl(`${base}/marketing/v3/forms`, {
|
|
212
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
213
|
+
});
|
|
214
|
+
if (!response.ok) return [];
|
|
215
|
+
const data = (await response.json()) as { results?: typeof forms };
|
|
216
|
+
forms = data.results ?? [];
|
|
217
|
+
} catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
for (const form of forms) {
|
|
221
|
+
const formId = form.guid ?? form.id;
|
|
222
|
+
if (!formId) continue;
|
|
223
|
+
const formName = typeof form.name === "string" && form.name ? form.name : "form";
|
|
224
|
+
try {
|
|
225
|
+
const response = await fetchImpl(
|
|
226
|
+
`${base}/form-integrations/v1/submissions/forms/${encodeURIComponent(formId)}?limit=50`,
|
|
227
|
+
{ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } },
|
|
228
|
+
);
|
|
229
|
+
if (!response.ok) continue;
|
|
230
|
+
const data = (await response.json()) as {
|
|
231
|
+
results?: Array<{ values?: Array<{ name?: string; value?: string }>; submittedAt?: number }>;
|
|
232
|
+
};
|
|
233
|
+
for (const submission of data.results ?? []) {
|
|
234
|
+
const email = fieldValue(submission.values, "email");
|
|
235
|
+
const domain = corporateDomain(email);
|
|
236
|
+
if (!domain) continue; // free-mail / no email: no account to attach to
|
|
237
|
+
const submittedAt =
|
|
238
|
+
typeof submission.submittedAt === "number"
|
|
239
|
+
? new Date(submission.submittedAt).toISOString()
|
|
240
|
+
: undefined;
|
|
241
|
+
out.push({
|
|
242
|
+
bucket: "demand",
|
|
243
|
+
accountDomain: domain,
|
|
244
|
+
trigger: `form: ${formName}`,
|
|
245
|
+
quote: `Submitted "${formName}" — ${email}`,
|
|
246
|
+
sourceUrl: "",
|
|
247
|
+
...(submittedAt ? { firstSeen: submittedAt } : {}),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Registry
|
|
260
|
+
|
|
261
|
+
const CONNECTORS: SignalSourceConnector[] = [fileSource, serpapiNewsSource, hubspotFormsSource];
|
|
262
|
+
|
|
263
|
+
const REGISTRY = new Map<string, SignalSourceConnector>(CONNECTORS.map((c) => [c.id, c]));
|
|
264
|
+
|
|
265
|
+
/** All registered source connectors (stable order). */
|
|
266
|
+
export function listSignalSources(): SignalSourceConnector[] {
|
|
267
|
+
return [...CONNECTORS];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Resolve a connector by id, or null when unknown. */
|
|
271
|
+
export function getSignalSource(id: string): SignalSourceConnector | null {
|
|
272
|
+
return REGISTRY.get(id) ?? null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Helpers
|
|
277
|
+
|
|
278
|
+
function coerceRow(row: Record<string, unknown>, defaultBucket: SignalBucket): StagedSignalRow {
|
|
279
|
+
// Fill the bucket from the connector default when a file row omits it; the
|
|
280
|
+
// central validator still rejects an unknown bucket and an empty quote.
|
|
281
|
+
const bucket = row.bucket === undefined ? defaultBucket : (row.bucket as SignalBucket);
|
|
282
|
+
return { ...(row as StagedSignalRow), bucket };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function pickBucket(raw: string | undefined, fallback: SignalBucket): SignalBucket {
|
|
286
|
+
if (raw && SIGNAL_BUCKETS.includes(raw as SignalBucket)) return raw as SignalBucket;
|
|
287
|
+
return fallback;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function fieldValue(values: Array<{ name?: string; value?: string }> | undefined, name: string): string {
|
|
291
|
+
for (const field of values ?? []) {
|
|
292
|
+
if (field.name === name && typeof field.value === "string") return field.value.trim();
|
|
293
|
+
}
|
|
294
|
+
return "";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const FREE_MAIL = new Set([
|
|
298
|
+
"gmail.com",
|
|
299
|
+
"yahoo.com",
|
|
300
|
+
"hotmail.com",
|
|
301
|
+
"outlook.com",
|
|
302
|
+
"icloud.com",
|
|
303
|
+
"aol.com",
|
|
304
|
+
"proton.me",
|
|
305
|
+
"protonmail.com",
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
/** Company domain from an email, or "" for free-mail / no email. */
|
|
309
|
+
function corporateDomain(email: string): string {
|
|
310
|
+
if (!email.includes("@")) return "";
|
|
311
|
+
const domain = email.split("@").at(-1)!.trim().toLowerCase();
|
|
312
|
+
if (!domain || FREE_MAIL.has(domain)) return "";
|
|
313
|
+
return domain;
|
|
314
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TheirStack — technographic company discovery.
|
|
3
|
+
*
|
|
4
|
+
* Explorium can only size a firmographic universe (NAICS/size/geo) and can't even
|
|
5
|
+
* return the list. For a CRM-hygiene/RevOps tool the real buying signal is
|
|
6
|
+
* "company runs a CRM", and the deliverable is an actual list of those companies.
|
|
7
|
+
* TheirStack does both: filter companies by the technology they use (Salesforce,
|
|
8
|
+
* HubSpot, Pipedrive, …) plus firmographics, and return real records (name,
|
|
9
|
+
* domain, employee_count, …). A cheap count sizes the market; a paged search
|
|
10
|
+
* materializes the list. Both cost ~3 credits per company RETURNED — counting
|
|
11
|
+
* still returns 1 row (the API rejects limit:0), so a count is ~3 credits, not
|
|
12
|
+
* free; a list pull is ~3 × the rows.
|
|
13
|
+
*
|
|
14
|
+
* API: POST https://api.theirstack.com/v1/companies/search, Bearer auth.
|
|
15
|
+
* Verified live (2026-06-29): filters company_technology_slug_or /
|
|
16
|
+
* company_country_code_or / min_employee_count / max_employee_count; the total is
|
|
17
|
+
* `metadata.total_results` with include_total_results:true; limit must be ≥ 1
|
|
18
|
+
* (limit:0 → 422, the count gotcha — cf. Explorium's `size`-caps-the-total).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type FetchImpl = typeof fetch;
|
|
22
|
+
|
|
23
|
+
/** TheirStack charges per company RETURNED — verified live (a list pull of N
|
|
24
|
+
* companies spends 3N). The count (limit:1) returns 1 row, so it's ~3 credits. */
|
|
25
|
+
export const THEIRSTACK_CREDITS_PER_COMPANY = 3;
|
|
26
|
+
|
|
27
|
+
export type TheirStackCost = {
|
|
28
|
+
companies: number;
|
|
29
|
+
credits: number;
|
|
30
|
+
/** USD estimate — present ONLY when a per-credit rate is supplied (no
|
|
31
|
+
* fabricated default; TheirStack pricing varies ~$0.04–$0.11/credit by tier). */
|
|
32
|
+
usd?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Deterministic cost of pulling `companies` records (the credits are the hard
|
|
36
|
+
* fact; the dollar figure needs an explicit rate). */
|
|
37
|
+
export function theirStackPullCost(companies: number, usdPerCredit?: number): TheirStackCost {
|
|
38
|
+
const n = Math.max(0, Math.round(companies));
|
|
39
|
+
const credits = n * THEIRSTACK_CREDITS_PER_COMPANY;
|
|
40
|
+
return {
|
|
41
|
+
companies: n,
|
|
42
|
+
credits,
|
|
43
|
+
...(usdPerCredit && usdPerCredit > 0 ? { usd: Math.round(credits * usdPerCredit) } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function safeText(res: { text(): Promise<string> }): Promise<string> {
|
|
48
|
+
try {
|
|
49
|
+
return (await res.text()).slice(0, 300);
|
|
50
|
+
} catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Firmographic + technographic filter for TheirStack company search. */
|
|
56
|
+
export type TheirStackFilters = {
|
|
57
|
+
/** technology slugs, OR-matched — e.g. ["salesforce","hubspot","pipedrive"]. */
|
|
58
|
+
company_technology_slug_or?: string[];
|
|
59
|
+
min_employee_count?: number;
|
|
60
|
+
max_employee_count?: number;
|
|
61
|
+
/** ISO2 country codes, OR-matched — e.g. ["US"]. */
|
|
62
|
+
company_country_code_or?: string[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A real company record from TheirStack (the materialized list element). */
|
|
66
|
+
export type TheirStackCompany = {
|
|
67
|
+
name?: string;
|
|
68
|
+
domain?: string;
|
|
69
|
+
employeeCount?: number;
|
|
70
|
+
countryCode?: string;
|
|
71
|
+
city?: string;
|
|
72
|
+
linkedinUrl?: string;
|
|
73
|
+
/** the matched technology slugs that put this company in the list. */
|
|
74
|
+
technologies?: string[];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const BASE = "https://api.theirstack.com";
|
|
78
|
+
const SEARCH_PATH = "/v1/companies/search";
|
|
79
|
+
|
|
80
|
+
function buildBody(filters: TheirStackFilters, limit: number, page: number, includeTotal: boolean): string {
|
|
81
|
+
// Drop undefined keys so we never send empty filters the API rejects.
|
|
82
|
+
const body: Record<string, unknown> = { limit, page };
|
|
83
|
+
if (includeTotal) body.include_total_results = true;
|
|
84
|
+
if (filters.company_technology_slug_or?.length) body.company_technology_slug_or = filters.company_technology_slug_or;
|
|
85
|
+
if (filters.company_country_code_or?.length) body.company_country_code_or = filters.company_country_code_or;
|
|
86
|
+
if (typeof filters.min_employee_count === "number") body.min_employee_count = filters.min_employee_count;
|
|
87
|
+
if (typeof filters.max_employee_count === "number") body.max_employee_count = filters.max_employee_count;
|
|
88
|
+
return JSON.stringify(body);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Read the total-match count from the response envelope (field name varies). */
|
|
92
|
+
export function readTheirStackTotal(body: unknown): number | null {
|
|
93
|
+
if (!body || typeof body !== "object") return null;
|
|
94
|
+
const obj = body as Record<string, unknown>;
|
|
95
|
+
const meta = (obj.metadata ?? obj.meta ?? obj) as Record<string, unknown>;
|
|
96
|
+
for (const key of ["total_results", "total_companies", "total_count", "total", "count"]) {
|
|
97
|
+
const v = meta[key];
|
|
98
|
+
if (typeof v === "number" && Number.isFinite(v) && v >= 0) return Math.round(v);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function mapCompany(row: Record<string, unknown>): TheirStackCompany {
|
|
104
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v : undefined);
|
|
105
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
106
|
+
const slugs = row.technology_slugs ?? row.technology_names;
|
|
107
|
+
return {
|
|
108
|
+
name: str(row.name),
|
|
109
|
+
domain: str(row.domain),
|
|
110
|
+
employeeCount: num(row.employee_count),
|
|
111
|
+
countryCode: str(row.country_code),
|
|
112
|
+
city: str(row.city),
|
|
113
|
+
linkedinUrl: str(row.linkedin_url),
|
|
114
|
+
technologies: Array.isArray(slugs) ? slugs.filter((s): s is string => typeof s === "string") : undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Count companies matching the technographic + firmographic filter — the real
|
|
120
|
+
* TAM size. Uses `limit: 1` (the API rejects `limit: 0` with a 422 — verified
|
|
121
|
+
* live) + `include_total_results`, and reads `metadata.total_results`. Costs the
|
|
122
|
+
* 1 returned company's credits; the total itself is the point.
|
|
123
|
+
* Returns null if the envelope carries no total.
|
|
124
|
+
*/
|
|
125
|
+
export async function theirStackCountCompanies(opts: {
|
|
126
|
+
apiKey: string;
|
|
127
|
+
filters: TheirStackFilters;
|
|
128
|
+
apiBaseUrl?: string;
|
|
129
|
+
fetchImpl?: FetchImpl;
|
|
130
|
+
}): Promise<number | null> {
|
|
131
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
132
|
+
const base = (opts.apiBaseUrl ?? BASE).replace(/\/$/, "");
|
|
133
|
+
const res = await fetchImpl(`${base}${SEARCH_PATH}`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
136
|
+
body: buildBody(opts.filters, 1, 0, true),
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
throw new Error(`TheirStack count failed: HTTP ${res.status} ${await safeText(res)}`);
|
|
140
|
+
}
|
|
141
|
+
return readTheirStackTotal(await res.json());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Pull a page of real companies matching the filter — the materialized list.
|
|
146
|
+
* Returns the records plus the total (when the envelope reports it).
|
|
147
|
+
*/
|
|
148
|
+
export async function theirStackSearchCompanies(opts: {
|
|
149
|
+
apiKey: string;
|
|
150
|
+
filters: TheirStackFilters;
|
|
151
|
+
limit?: number;
|
|
152
|
+
page?: number;
|
|
153
|
+
apiBaseUrl?: string;
|
|
154
|
+
fetchImpl?: FetchImpl;
|
|
155
|
+
}): Promise<{ companies: TheirStackCompany[]; total: number | null }> {
|
|
156
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
157
|
+
const base = (opts.apiBaseUrl ?? BASE).replace(/\/$/, "");
|
|
158
|
+
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
|
159
|
+
const res = await fetchImpl(`${base}${SEARCH_PATH}`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
162
|
+
body: buildBody(opts.filters, limit, opts.page ?? 0, true),
|
|
163
|
+
});
|
|
164
|
+
if (!res.ok) {
|
|
165
|
+
throw new Error(`TheirStack search failed: HTTP ${res.status} ${await safeText(res)}`);
|
|
166
|
+
}
|
|
167
|
+
const body = (await res.json()) as { data?: Array<Record<string, unknown>>; results?: Array<Record<string, unknown>> };
|
|
168
|
+
const rows = body.data ?? body.results ?? [];
|
|
169
|
+
return { companies: rows.map(mapCompany), total: readTheirStackTotal(body) };
|
|
170
|
+
}
|
package/src/icp.ts
CHANGED
|
@@ -24,6 +24,10 @@ export type Icp = {
|
|
|
24
24
|
employeeBands?: string[];
|
|
25
25
|
/** ISO country codes, lowercased, e.g. ["us"] */
|
|
26
26
|
geos?: string[];
|
|
27
|
+
/** technographic targeting: technology slugs the account must use, e.g.
|
|
28
|
+
* ["salesforce","hubspot","pipedrive"] — the real CRM/MAP buying signal,
|
|
29
|
+
* consumed by TheirStack company search. OR-matched. */
|
|
30
|
+
technologies?: string[];
|
|
27
31
|
};
|
|
28
32
|
persona: {
|
|
29
33
|
/** seniority: "cxo","vp","director","manager","owner","senior" */
|
|
@@ -80,37 +84,111 @@ export function icpToExploriumFilters(icp: Icp): Record<string, { values?: strin
|
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
87
|
+
* Explorium /v1/businesses filters from the ICP — the COMPANY (account) side, for
|
|
88
|
+
* sizing the account universe (TAM). Firmographics only, no persona: the count is
|
|
89
|
+
* of matching companies. Field names differ from /v1/prospects (verified live):
|
|
90
|
+
* `country_code` (not company_country_code), `company_size` (same employee bands),
|
|
91
|
+
* `naics_category`. `/v1/businesses` total_results is a real count, capped at
|
|
92
|
+
* 60,000 (see EXPLORIUM_BUSINESS_COUNT_CAP in the connector).
|
|
86
93
|
*/
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
};
|
|
94
|
+
export function icpToExploriumBusinessFilters(icp: Icp): Record<string, { values?: string[] }> {
|
|
95
|
+
const f: Record<string, { values?: string[] }> = {};
|
|
96
|
+
if (icp.firmographics.geos?.length) f.country_code = { values: icp.firmographics.geos };
|
|
97
|
+
if (icp.firmographics.employeeBands?.length) f.company_size = { values: icp.firmographics.employeeBands };
|
|
98
|
+
if (icp.firmographics.naics?.length) f.naics_category = { values: icp.firmographics.naics };
|
|
99
|
+
return f;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Collapse provider-agnostic employee bands ("51-200","10001+") into a single
|
|
104
|
+
* {min,max} envelope for APIs that take integer bounds (TheirStack). An open
|
|
105
|
+
* top band ("10001+") leaves max undefined.
|
|
106
|
+
*/
|
|
107
|
+
export function employeeBandsToRange(bands: string[] | undefined): { min?: number; max?: number } {
|
|
108
|
+
if (!bands?.length) return {};
|
|
109
|
+
let min: number | undefined;
|
|
110
|
+
let max: number | undefined;
|
|
111
|
+
let openTop = false;
|
|
112
|
+
for (const band of bands) {
|
|
113
|
+
const plus = /^(\d+)\+$/.exec(band.trim());
|
|
114
|
+
if (plus) {
|
|
115
|
+
const lo = Number(plus[1]);
|
|
116
|
+
if (min === undefined || lo < min) min = lo;
|
|
117
|
+
openTop = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const range = /^(\d+)\s*-\s*(\d+)$/.exec(band.trim());
|
|
121
|
+
if (range) {
|
|
122
|
+
const lo = Number(range[1]);
|
|
123
|
+
const hi = Number(range[2]);
|
|
124
|
+
if (min === undefined || lo < min) min = lo;
|
|
125
|
+
if (max === undefined || hi > max) max = hi;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { min, max: openTop ? undefined : max };
|
|
129
|
+
}
|
|
102
130
|
|
|
103
131
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
132
|
+
* TheirStack company-search filter from the ICP: the technographic targeting that
|
|
133
|
+
* Explorium can't do. `company_technology_slug_or` is the CRM/MAP buying signal
|
|
134
|
+
* (firmographics.technologies); employee bands become min/max bounds; geos become
|
|
135
|
+
* ISO2 codes (uppercased).
|
|
136
|
+
*/
|
|
137
|
+
export function icpToTheirStackFilters(icp: Icp): {
|
|
138
|
+
company_technology_slug_or?: string[];
|
|
139
|
+
min_employee_count?: number;
|
|
140
|
+
max_employee_count?: number;
|
|
141
|
+
company_country_code_or?: string[];
|
|
142
|
+
} {
|
|
143
|
+
const f: {
|
|
144
|
+
company_technology_slug_or?: string[];
|
|
145
|
+
min_employee_count?: number;
|
|
146
|
+
max_employee_count?: number;
|
|
147
|
+
company_country_code_or?: string[];
|
|
148
|
+
} = {};
|
|
149
|
+
if (icp.firmographics.technologies?.length) {
|
|
150
|
+
f.company_technology_slug_or = icp.firmographics.technologies.map((t) => t.trim().toLowerCase());
|
|
151
|
+
}
|
|
152
|
+
if (icp.firmographics.geos?.length) {
|
|
153
|
+
f.company_country_code_or = icp.firmographics.geos.map((g) => g.trim().toUpperCase());
|
|
154
|
+
}
|
|
155
|
+
const range = employeeBandsToRange(icp.firmographics.employeeBands);
|
|
156
|
+
if (range.min !== undefined) f.min_employee_count = range.min;
|
|
157
|
+
if (range.max !== undefined) f.max_employee_count = range.max;
|
|
158
|
+
return f;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// NOTE: a CRUSTDATA_SENIORITY vocab table lived here until 2026-07-02 — the
|
|
162
|
+
// `current_seniority_levels` filter is broken upstream (see the live findings
|
|
163
|
+
// on icpToCrustdataFilters below), so job levels are no longer sent to the
|
|
164
|
+
// provider at all. Resurrect the table from git history if pipe0 fixes it.
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* ICP industry keyword → LinkedIn industry names Crustdata filters on.
|
|
168
|
+
*
|
|
169
|
+
* LinkedIn renamed its industry taxonomy (v1 → v2, ~2022), and data vendors
|
|
170
|
+
* normalize to one generation or the other. Sending ONLY the v2 names risks a
|
|
171
|
+
* zero-match when Crustdata stores v1 (and vice-versa) — observed live: a RevOps
|
|
172
|
+
* ICP whose only firmographic constraint was the v2 names returned 0 even though
|
|
173
|
+
* the title-only search returned plenty. So each cluster sends BOTH generations
|
|
174
|
+
* (OR-matched within the field): v2 ("Software Development", "IT Services and IT
|
|
175
|
+
* Consulting", "Technology, Information and Internet") AND v1 ("Computer
|
|
176
|
+
* Software", "Information Technology & Services", "Internet").
|
|
107
177
|
*/
|
|
108
178
|
const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
|
|
109
|
-
software: [
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
179
|
+
software: [
|
|
180
|
+
"Software Development",
|
|
181
|
+
"Computer Software",
|
|
182
|
+
"IT Services and IT Consulting",
|
|
183
|
+
"Information Technology & Services",
|
|
184
|
+
"Information Technology and Services",
|
|
185
|
+
"Technology, Information and Internet",
|
|
186
|
+
"Internet",
|
|
187
|
+
],
|
|
188
|
+
saas: ["Software Development", "Computer Software", "IT Services and IT Consulting", "Information Technology & Services"],
|
|
189
|
+
"information technology & services": ["Information Technology & Services", "IT Services and IT Consulting"],
|
|
190
|
+
"information technology and services": ["Information Technology & Services", "IT Services and IT Consulting"],
|
|
191
|
+
internet: ["Internet", "Technology, Information and Internet"],
|
|
114
192
|
fintech: ["Financial Services"],
|
|
115
193
|
"financial services": ["Financial Services"],
|
|
116
194
|
};
|
|
@@ -118,10 +196,22 @@ const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
|
|
|
118
196
|
/**
|
|
119
197
|
* pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
|
|
120
198
|
* matches real LinkedIn title strings (case-sensitive), so keywords are Title
|
|
121
|
-
* Cased.
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
199
|
+
* Cased. Industry is mapped to Crustdata's controlled vocab via the table above.
|
|
200
|
+
*
|
|
201
|
+
* LIVE FINDINGS (2026-06-26, re-confirmed 2026-07-02 with fresh credits):
|
|
202
|
+
* `current_job_titles` works; `current_title` is rejected (422). The culprit
|
|
203
|
+
* that zeroed the full ICP filter is `current_seniority_levels`: ANY non-empty
|
|
204
|
+
* `include` returns 0 results through pipe0's people:profiles:crustdata@1 —
|
|
205
|
+
* including Crustdata's own documented values ("CXO", "Director",
|
|
206
|
+
* "Vice President"), lowercase, and SNAKE_CASE variants — while the identical
|
|
207
|
+
* search without it returns results. (An array instead of the
|
|
208
|
+
* {include, exclude} object is a 422, so the shape was right; the filter is
|
|
209
|
+
* broken upstream.) So job levels are NOT sent to the provider: persona
|
|
210
|
+
* seniority is enforced by fit scoring (scoreProspectAgainstIcp weights
|
|
211
|
+
* jobLevels), which was always the precision backstop. `locations` and
|
|
212
|
+
* `current_employers_linkedin_industries` (both-taxonomy hedge) are live-
|
|
213
|
+
* verified working in combination with titles. Note fit-scoring backs up
|
|
214
|
+
* PERSONA precision but NOT industry, so the industry filter is load-bearing.
|
|
125
215
|
*/
|
|
126
216
|
export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
|
|
127
217
|
const f: Record<string, unknown> = {};
|
|
@@ -129,10 +219,6 @@ export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
|
|
|
129
219
|
if (icp.firmographics.geos?.length) {
|
|
130
220
|
f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
|
|
131
221
|
}
|
|
132
|
-
if (icp.persona.jobLevels?.length) {
|
|
133
|
-
const include = [...new Set(icp.persona.jobLevels.map((l) => CRUSTDATA_SENIORITY[l.toLowerCase()]).filter(Boolean))];
|
|
134
|
-
if (include.length) f.current_seniority_levels = { include, exclude: [] };
|
|
135
|
-
}
|
|
136
222
|
if (icp.firmographics.industries?.length) {
|
|
137
223
|
const inds = [
|
|
138
224
|
...new Set(icp.firmographics.industries.flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)])),
|