fullstackgtm 0.43.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +18 -7
  3. package/dist/cli.js +634 -56
  4. package/dist/connectors/outboxChannel.d.ts +64 -0
  5. package/dist/connectors/outboxChannel.js +170 -0
  6. package/dist/connectors/prospectSources.d.ts +22 -0
  7. package/dist/connectors/prospectSources.js +43 -0
  8. package/dist/connectors/signalSources.d.ts +105 -0
  9. package/dist/connectors/signalSources.js +316 -0
  10. package/dist/connectors/theirstack.d.ts +84 -0
  11. package/dist/connectors/theirstack.js +125 -0
  12. package/dist/icp.d.ts +47 -3
  13. package/dist/icp.js +105 -11
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -0
  16. package/dist/init.js +3 -0
  17. package/dist/judgeEval.d.ts +7 -0
  18. package/dist/judgeEval.js +8 -1
  19. package/dist/runReport.d.ts +26 -0
  20. package/dist/runReport.js +15 -0
  21. package/dist/schedule.js +18 -4
  22. package/dist/signals.d.ts +54 -0
  23. package/dist/signals.js +64 -0
  24. package/dist/tam.d.ts +225 -0
  25. package/dist/tam.js +470 -0
  26. package/docs/api.md +18 -4
  27. package/docs/outbox-format.md +92 -0
  28. package/docs/recipes.md +37 -0
  29. package/docs/roadmap-to-1.0.md +31 -1
  30. package/docs/signal-spool-format.md +162 -0
  31. package/docs/tam.md +195 -0
  32. package/llms.txt +68 -5
  33. package/package.json +1 -1
  34. package/skills/fullstackgtm/SKILL.md +3 -2
  35. package/src/cli.ts +714 -51
  36. package/src/connectors/outboxChannel.ts +202 -0
  37. package/src/connectors/prospectSources.ts +57 -0
  38. package/src/connectors/signalSources.ts +363 -0
  39. package/src/connectors/theirstack.ts +170 -0
  40. package/src/icp.ts +113 -11
  41. package/src/index.ts +32 -0
  42. package/src/init.ts +3 -0
  43. package/src/judgeEval.ts +8 -1
  44. package/src/runReport.ts +39 -0
  45. package/src/schedule.ts +20 -4
  46. package/src/signals.ts +90 -0
  47. package/src/tam.ts +654 -0
@@ -0,0 +1,316 @@
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
+ import { readFileSync, readdirSync, statSync } from "node:fs";
22
+ import { join, resolve } from "node:path";
23
+ import { SIGNAL_BUCKETS } from "../signals.js";
24
+ // ---------------------------------------------------------------------------
25
+ // file — local JSON / JSONL intake (also the webhook landing-zone reader)
26
+ /**
27
+ * Read staged rows from a local path — the webhook landing-zone reader. The path
28
+ * (from `options.path`/`options.file`) may be:
29
+ * - a single FILE: a JSON array, or newline-delimited JSON (one row per line);
30
+ * - a DIRECTORY (the conventional spool): every `*.jsonl` / `*.json` file in
31
+ * it is read and concatenated (sorted by name), so multiple receivers can
32
+ * each append their own file (`rb2b.jsonl`, `hubspot.jsonl`) and they all
33
+ * land in one fetch.
34
+ * No auth. This is how every push platform (RB2B, Trigify, HubSpot webhooks)
35
+ * reaches the CLI: a receiver appends a row to the spool, this reads it on the
36
+ * next `signals fetch`. The CLI defaults the path to the conventional spool dir
37
+ * (`signalsSpoolDir`) when `--connector file` is used with no path; a bare
38
+ * library call with no path is inactive (returns []).
39
+ *
40
+ * Resilient: a missing path / unreadable file yields [] (an empty source, not a
41
+ * crash), and one unreadable file in a spool directory is skipped. A file that
42
+ * IS present but malformed throws — a corrupt spool is a real error to surface,
43
+ * not silent data loss. The central `stagedRowToSignal` gate validates rows.
44
+ */
45
+ export const fileSource = {
46
+ id: "file",
47
+ bucket: "company",
48
+ shape: "pull",
49
+ auth: "none",
50
+ async fetch(ctx) {
51
+ const path = ctx.options?.path ?? ctx.options?.file;
52
+ if (!path)
53
+ return []; // no spool configured (the CLI fills the default dir)
54
+ const abs = resolve(process.cwd(), path);
55
+ let isDir;
56
+ try {
57
+ isDir = statSync(abs).isDirectory();
58
+ }
59
+ catch {
60
+ return []; // missing path: an empty source, not a crash
61
+ }
62
+ const files = isDir ? spoolFilesIn(abs) : [abs];
63
+ const rows = [];
64
+ for (const file of files) {
65
+ let raw;
66
+ try {
67
+ raw = readFileSync(file, "utf8");
68
+ }
69
+ catch {
70
+ continue; // one unreadable file in a spool dir must not sink the rest
71
+ }
72
+ const trimmed = raw.trim();
73
+ if (!trimmed)
74
+ continue;
75
+ const parsed = trimmed.startsWith("[") ? parseJsonArray(trimmed, file) : parseJsonl(trimmed, file);
76
+ rows.push(...parsed);
77
+ }
78
+ return rows.map((row) => coerceRow(row, this.bucket));
79
+ },
80
+ };
81
+ /** Spool files in a landing-zone directory: `*.jsonl` / `*.json`, name-sorted. */
82
+ function spoolFilesIn(dir) {
83
+ let names;
84
+ try {
85
+ names = readdirSync(dir);
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ return names
91
+ .filter((name) => name.endsWith(".jsonl") || name.endsWith(".json"))
92
+ .sort()
93
+ .map((name) => join(dir, name));
94
+ }
95
+ function parseJsonArray(raw, path) {
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(raw);
99
+ }
100
+ catch (error) {
101
+ throw new Error(`file source ${path}: not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
102
+ }
103
+ if (!Array.isArray(parsed))
104
+ throw new Error(`file source ${path}: expected a JSON array of staged signal rows.`);
105
+ return parsed.map((entry, index) => {
106
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
107
+ throw new Error(`file source ${path}: row ${index} is not an object.`);
108
+ }
109
+ return entry;
110
+ });
111
+ }
112
+ function parseJsonl(raw, path) {
113
+ const out = [];
114
+ const lines = raw.split("\n");
115
+ lines.forEach((line, index) => {
116
+ const t = line.trim();
117
+ if (!t)
118
+ return;
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(t);
122
+ }
123
+ catch (error) {
124
+ throw new Error(`file source ${path}: line ${index} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
125
+ }
126
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
127
+ throw new Error(`file source ${path}: line ${index} is not a JSON object.`);
128
+ }
129
+ out.push(parsed);
130
+ });
131
+ return out;
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // serpapi-news — funding/company signals from a news REST query (API key)
135
+ const SERPAPI_BASE_URL = "https://serpapi.com";
136
+ /**
137
+ * Pull recent news per watchlist account and stage funding/company signals. One
138
+ * query per account (`q="<domain>"`, Google News engine); each result becomes a
139
+ * row whose verbatim `quote` is the headline (+ source), so the downstream judge
140
+ * can ground a why-now on a real, linkable article. API key via the credential
141
+ * ladder (provider "serpapi"); no key -> [] (the source is simply inactive).
142
+ *
143
+ * Bucket defaults to `funding`; override per run with `--connector-opt bucket=company`.
144
+ */
145
+ export const serpapiNewsSource = {
146
+ id: "serpapi-news",
147
+ bucket: "funding",
148
+ shape: "pull",
149
+ auth: "api_key",
150
+ async fetch(ctx) {
151
+ const apiKey = ctx.getApiKey ? await ctx.getApiKey("serpapi") : null;
152
+ if (!apiKey)
153
+ return [];
154
+ const fetchImpl = ctx.fetchImpl ?? fetch;
155
+ const base = (ctx.options?.apiBaseUrl ?? SERPAPI_BASE_URL).replace(/\/$/, "");
156
+ const bucket = pickBucket(ctx.options?.bucket, this.bucket);
157
+ const out = [];
158
+ for (const account of ctx.watchlist) {
159
+ const domain = account.domain.trim();
160
+ if (!domain)
161
+ continue;
162
+ try {
163
+ const url = `${base}/search.json?engine=google_news&q=${encodeURIComponent(`"${domain}"`)}` +
164
+ `&api_key=${encodeURIComponent(apiKey)}`;
165
+ const response = await fetchImpl(url, { headers: { Accept: "application/json" } });
166
+ if (!response.ok)
167
+ continue;
168
+ const data = (await response.json());
169
+ for (const item of data.news_results ?? []) {
170
+ const title = typeof item.title === "string" ? item.title.trim() : "";
171
+ if (!title)
172
+ continue; // no headline -> no verbatim evidence -> drop
173
+ const sourceName = typeof item.source === "string" ? item.source : item.source?.name ?? "";
174
+ const quote = sourceName ? `${title} — ${sourceName}` : title;
175
+ out.push({
176
+ bucket,
177
+ accountDomain: domain,
178
+ trigger: `news: ${title}`,
179
+ quote,
180
+ sourceUrl: typeof item.link === "string" ? item.link : "",
181
+ });
182
+ }
183
+ }
184
+ catch {
185
+ continue; // one account's outage must not sink the run
186
+ }
187
+ }
188
+ return out;
189
+ },
190
+ };
191
+ // ---------------------------------------------------------------------------
192
+ // hubspot-forms — first-party demand from recent HubSpot form submissions
193
+ const HUBSPOT_BASE_URL = "https://api.hubapi.com";
194
+ /**
195
+ * Stage `demand` signals from recent HubSpot form submissions — the first real
196
+ * `demand`-bucket producer (a form fill is first-party demand). Reuses the
197
+ * EXISTING HubSpot credential via the ladder (provider "hubspot"); no separate
198
+ * login. Each submission whose email carries a company domain becomes a row
199
+ * whose verbatim `quote` is the form name + submitted email (the evidence a rep
200
+ * can verify). Submissions without a corporate domain (free-mail) are dropped —
201
+ * no account to attach demand to.
202
+ *
203
+ * Phase 1 is a pull over the Forms submissions API; the form-submission webhook
204
+ * (push) lands in Phase 2 via the spool + `file` source.
205
+ */
206
+ export const hubspotFormsSource = {
207
+ id: "hubspot-forms",
208
+ bucket: "demand",
209
+ shape: "pull",
210
+ auth: "oauth",
211
+ async fetch(ctx) {
212
+ const token = ctx.getApiKey ? await ctx.getApiKey("hubspot") : null;
213
+ if (!token)
214
+ return [];
215
+ const fetchImpl = ctx.fetchImpl ?? fetch;
216
+ const base = (ctx.options?.apiBaseUrl ?? HUBSPOT_BASE_URL).replace(/\/$/, "");
217
+ const out = [];
218
+ let forms = [];
219
+ try {
220
+ const response = await fetchImpl(`${base}/marketing/v3/forms`, {
221
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
222
+ });
223
+ if (!response.ok)
224
+ return [];
225
+ const data = (await response.json());
226
+ forms = data.results ?? [];
227
+ }
228
+ catch {
229
+ return [];
230
+ }
231
+ for (const form of forms) {
232
+ const formId = form.guid ?? form.id;
233
+ if (!formId)
234
+ continue;
235
+ const formName = typeof form.name === "string" && form.name ? form.name : "form";
236
+ try {
237
+ const response = await fetchImpl(`${base}/form-integrations/v1/submissions/forms/${encodeURIComponent(formId)}?limit=50`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } });
238
+ if (!response.ok)
239
+ continue;
240
+ const data = (await response.json());
241
+ for (const submission of data.results ?? []) {
242
+ const email = fieldValue(submission.values, "email");
243
+ const domain = corporateDomain(email);
244
+ if (!domain)
245
+ continue; // free-mail / no email: no account to attach to
246
+ const submittedAt = typeof submission.submittedAt === "number"
247
+ ? new Date(submission.submittedAt).toISOString()
248
+ : undefined;
249
+ out.push({
250
+ bucket: "demand",
251
+ accountDomain: domain,
252
+ trigger: `form: ${formName}`,
253
+ quote: `Submitted "${formName}" — ${email}`,
254
+ sourceUrl: "",
255
+ ...(submittedAt ? { firstSeen: submittedAt } : {}),
256
+ });
257
+ }
258
+ }
259
+ catch {
260
+ continue;
261
+ }
262
+ }
263
+ return out;
264
+ },
265
+ };
266
+ // ---------------------------------------------------------------------------
267
+ // Registry
268
+ const CONNECTORS = [fileSource, serpapiNewsSource, hubspotFormsSource];
269
+ const REGISTRY = new Map(CONNECTORS.map((c) => [c.id, c]));
270
+ /** All registered source connectors (stable order). */
271
+ export function listSignalSources() {
272
+ return [...CONNECTORS];
273
+ }
274
+ /** Resolve a connector by id, or null when unknown. */
275
+ export function getSignalSource(id) {
276
+ return REGISTRY.get(id) ?? null;
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ // Helpers
280
+ function coerceRow(row, defaultBucket) {
281
+ // Fill the bucket from the connector default when a file row omits it; the
282
+ // central validator still rejects an unknown bucket and an empty quote.
283
+ const bucket = row.bucket === undefined ? defaultBucket : row.bucket;
284
+ return { ...row, bucket };
285
+ }
286
+ function pickBucket(raw, fallback) {
287
+ if (raw && SIGNAL_BUCKETS.includes(raw))
288
+ return raw;
289
+ return fallback;
290
+ }
291
+ function fieldValue(values, name) {
292
+ for (const field of values ?? []) {
293
+ if (field.name === name && typeof field.value === "string")
294
+ return field.value.trim();
295
+ }
296
+ return "";
297
+ }
298
+ const FREE_MAIL = new Set([
299
+ "gmail.com",
300
+ "yahoo.com",
301
+ "hotmail.com",
302
+ "outlook.com",
303
+ "icloud.com",
304
+ "aol.com",
305
+ "proton.me",
306
+ "protonmail.com",
307
+ ]);
308
+ /** Company domain from an email, or "" for free-mail / no email. */
309
+ function corporateDomain(email) {
310
+ if (!email.includes("@"))
311
+ return "";
312
+ const domain = email.split("@").at(-1).trim().toLowerCase();
313
+ if (!domain || FREE_MAIL.has(domain))
314
+ return "";
315
+ return domain;
316
+ }
@@ -0,0 +1,84 @@
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
+ type FetchImpl = typeof fetch;
21
+ /** TheirStack charges per company RETURNED — verified live (a list pull of N
22
+ * companies spends 3N). The count (limit:1) returns 1 row, so it's ~3 credits. */
23
+ export declare const THEIRSTACK_CREDITS_PER_COMPANY = 3;
24
+ export type TheirStackCost = {
25
+ companies: number;
26
+ credits: number;
27
+ /** USD estimate — present ONLY when a per-credit rate is supplied (no
28
+ * fabricated default; TheirStack pricing varies ~$0.04–$0.11/credit by tier). */
29
+ usd?: number;
30
+ };
31
+ /** Deterministic cost of pulling `companies` records (the credits are the hard
32
+ * fact; the dollar figure needs an explicit rate). */
33
+ export declare function theirStackPullCost(companies: number, usdPerCredit?: number): TheirStackCost;
34
+ /** Firmographic + technographic filter for TheirStack company search. */
35
+ export type TheirStackFilters = {
36
+ /** technology slugs, OR-matched — e.g. ["salesforce","hubspot","pipedrive"]. */
37
+ company_technology_slug_or?: string[];
38
+ min_employee_count?: number;
39
+ max_employee_count?: number;
40
+ /** ISO2 country codes, OR-matched — e.g. ["US"]. */
41
+ company_country_code_or?: string[];
42
+ };
43
+ /** A real company record from TheirStack (the materialized list element). */
44
+ export type TheirStackCompany = {
45
+ name?: string;
46
+ domain?: string;
47
+ employeeCount?: number;
48
+ countryCode?: string;
49
+ city?: string;
50
+ linkedinUrl?: string;
51
+ /** the matched technology slugs that put this company in the list. */
52
+ technologies?: string[];
53
+ };
54
+ /** Read the total-match count from the response envelope (field name varies). */
55
+ export declare function readTheirStackTotal(body: unknown): number | null;
56
+ /**
57
+ * Count companies matching the technographic + firmographic filter — the real
58
+ * TAM size. Uses `limit: 1` (the API rejects `limit: 0` with a 422 — verified
59
+ * live) + `include_total_results`, and reads `metadata.total_results`. Costs the
60
+ * 1 returned company's credits; the total itself is the point.
61
+ * Returns null if the envelope carries no total.
62
+ */
63
+ export declare function theirStackCountCompanies(opts: {
64
+ apiKey: string;
65
+ filters: TheirStackFilters;
66
+ apiBaseUrl?: string;
67
+ fetchImpl?: FetchImpl;
68
+ }): Promise<number | null>;
69
+ /**
70
+ * Pull a page of real companies matching the filter — the materialized list.
71
+ * Returns the records plus the total (when the envelope reports it).
72
+ */
73
+ export declare function theirStackSearchCompanies(opts: {
74
+ apiKey: string;
75
+ filters: TheirStackFilters;
76
+ limit?: number;
77
+ page?: number;
78
+ apiBaseUrl?: string;
79
+ fetchImpl?: FetchImpl;
80
+ }): Promise<{
81
+ companies: TheirStackCompany[];
82
+ total: number | null;
83
+ }>;
84
+ export {};
@@ -0,0 +1,125 @@
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
+ /** TheirStack charges per company RETURNED — verified live (a list pull of N
21
+ * companies spends 3N). The count (limit:1) returns 1 row, so it's ~3 credits. */
22
+ export const THEIRSTACK_CREDITS_PER_COMPANY = 3;
23
+ /** Deterministic cost of pulling `companies` records (the credits are the hard
24
+ * fact; the dollar figure needs an explicit rate). */
25
+ export function theirStackPullCost(companies, usdPerCredit) {
26
+ const n = Math.max(0, Math.round(companies));
27
+ const credits = n * THEIRSTACK_CREDITS_PER_COMPANY;
28
+ return {
29
+ companies: n,
30
+ credits,
31
+ ...(usdPerCredit && usdPerCredit > 0 ? { usd: Math.round(credits * usdPerCredit) } : {}),
32
+ };
33
+ }
34
+ async function safeText(res) {
35
+ try {
36
+ return (await res.text()).slice(0, 300);
37
+ }
38
+ catch {
39
+ return "";
40
+ }
41
+ }
42
+ const BASE = "https://api.theirstack.com";
43
+ const SEARCH_PATH = "/v1/companies/search";
44
+ function buildBody(filters, limit, page, includeTotal) {
45
+ // Drop undefined keys so we never send empty filters the API rejects.
46
+ const body = { limit, page };
47
+ if (includeTotal)
48
+ body.include_total_results = true;
49
+ if (filters.company_technology_slug_or?.length)
50
+ body.company_technology_slug_or = filters.company_technology_slug_or;
51
+ if (filters.company_country_code_or?.length)
52
+ body.company_country_code_or = filters.company_country_code_or;
53
+ if (typeof filters.min_employee_count === "number")
54
+ body.min_employee_count = filters.min_employee_count;
55
+ if (typeof filters.max_employee_count === "number")
56
+ body.max_employee_count = filters.max_employee_count;
57
+ return JSON.stringify(body);
58
+ }
59
+ /** Read the total-match count from the response envelope (field name varies). */
60
+ export function readTheirStackTotal(body) {
61
+ if (!body || typeof body !== "object")
62
+ return null;
63
+ const obj = body;
64
+ const meta = (obj.metadata ?? obj.meta ?? obj);
65
+ for (const key of ["total_results", "total_companies", "total_count", "total", "count"]) {
66
+ const v = meta[key];
67
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0)
68
+ return Math.round(v);
69
+ }
70
+ return null;
71
+ }
72
+ function mapCompany(row) {
73
+ const str = (v) => (typeof v === "string" && v.trim() ? v : undefined);
74
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
75
+ const slugs = row.technology_slugs ?? row.technology_names;
76
+ return {
77
+ name: str(row.name),
78
+ domain: str(row.domain),
79
+ employeeCount: num(row.employee_count),
80
+ countryCode: str(row.country_code),
81
+ city: str(row.city),
82
+ linkedinUrl: str(row.linkedin_url),
83
+ technologies: Array.isArray(slugs) ? slugs.filter((s) => typeof s === "string") : undefined,
84
+ };
85
+ }
86
+ /**
87
+ * Count companies matching the technographic + firmographic filter — the real
88
+ * TAM size. Uses `limit: 1` (the API rejects `limit: 0` with a 422 — verified
89
+ * live) + `include_total_results`, and reads `metadata.total_results`. Costs the
90
+ * 1 returned company's credits; the total itself is the point.
91
+ * Returns null if the envelope carries no total.
92
+ */
93
+ export async function theirStackCountCompanies(opts) {
94
+ const fetchImpl = opts.fetchImpl ?? fetch;
95
+ const base = (opts.apiBaseUrl ?? BASE).replace(/\/$/, "");
96
+ const res = await fetchImpl(`${base}${SEARCH_PATH}`, {
97
+ method: "POST",
98
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
99
+ body: buildBody(opts.filters, 1, 0, true),
100
+ });
101
+ if (!res.ok) {
102
+ throw new Error(`TheirStack count failed: HTTP ${res.status} ${await safeText(res)}`);
103
+ }
104
+ return readTheirStackTotal(await res.json());
105
+ }
106
+ /**
107
+ * Pull a page of real companies matching the filter — the materialized list.
108
+ * Returns the records plus the total (when the envelope reports it).
109
+ */
110
+ export async function theirStackSearchCompanies(opts) {
111
+ const fetchImpl = opts.fetchImpl ?? fetch;
112
+ const base = (opts.apiBaseUrl ?? BASE).replace(/\/$/, "");
113
+ const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
114
+ const res = await fetchImpl(`${base}${SEARCH_PATH}`, {
115
+ method: "POST",
116
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
117
+ body: buildBody(opts.filters, limit, opts.page ?? 0, true),
118
+ });
119
+ if (!res.ok) {
120
+ throw new Error(`TheirStack search failed: HTTP ${res.status} ${await safeText(res)}`);
121
+ }
122
+ const body = (await res.json());
123
+ const rows = body.data ?? body.results ?? [];
124
+ return { companies: rows.map(mapCompany), total: readTheirStackTotal(body) };
125
+ }
package/dist/icp.d.ts CHANGED
@@ -23,6 +23,10 @@ export type Icp = {
23
23
  employeeBands?: string[];
24
24
  /** ISO country codes, lowercased, e.g. ["us"] */
25
25
  geos?: string[];
26
+ /** technographic targeting: technology slugs the account must use, e.g.
27
+ * ["salesforce","hubspot","pipedrive"] — the real CRM/MAP buying signal,
28
+ * consumed by TheirStack company search. OR-matched. */
29
+ technologies?: string[];
26
30
  };
27
31
  persona: {
28
32
  /** seniority: "cxo","vp","director","manager","owner","senior" */
@@ -47,13 +51,53 @@ export declare function icpToExploriumFilters(icp: Icp): Record<string, {
47
51
  values?: string[];
48
52
  value?: boolean;
49
53
  }>;
54
+ /**
55
+ * Explorium /v1/businesses filters from the ICP — the COMPANY (account) side, for
56
+ * sizing the account universe (TAM). Firmographics only, no persona: the count is
57
+ * of matching companies. Field names differ from /v1/prospects (verified live):
58
+ * `country_code` (not company_country_code), `company_size` (same employee bands),
59
+ * `naics_category`. `/v1/businesses` total_results is a real count, capped at
60
+ * 60,000 (see EXPLORIUM_BUSINESS_COUNT_CAP in the connector).
61
+ */
62
+ export declare function icpToExploriumBusinessFilters(icp: Icp): Record<string, {
63
+ values?: string[];
64
+ }>;
65
+ /**
66
+ * Collapse provider-agnostic employee bands ("51-200","10001+") into a single
67
+ * {min,max} envelope for APIs that take integer bounds (TheirStack). An open
68
+ * top band ("10001+") leaves max undefined.
69
+ */
70
+ export declare function employeeBandsToRange(bands: string[] | undefined): {
71
+ min?: number;
72
+ max?: number;
73
+ };
74
+ /**
75
+ * TheirStack company-search filter from the ICP: the technographic targeting that
76
+ * Explorium can't do. `company_technology_slug_or` is the CRM/MAP buying signal
77
+ * (firmographics.technologies); employee bands become min/max bounds; geos become
78
+ * ISO2 codes (uppercased).
79
+ */
80
+ export declare function icpToTheirStackFilters(icp: Icp): {
81
+ company_technology_slug_or?: string[];
82
+ min_employee_count?: number;
83
+ max_employee_count?: number;
84
+ company_country_code_or?: string[];
85
+ };
50
86
  /**
51
87
  * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
52
88
  * matches real LinkedIn title strings (case-sensitive), so keywords are Title
53
89
  * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
54
- * tables above. NOTE: the exact pipe0→Crustdata value set could not be re-run
55
- * live (pipe0 credits were exhausted) — validate when credits refill; fit
56
- * scoring is the safety net for persona precision regardless.
90
+ * tables above.
91
+ *
92
+ * LIVE FINDINGS (2026-06-26): `current_job_titles` is confirmed working (a
93
+ * titles-only RevOps search returns results); `current_title` is rejected (422).
94
+ * The full ICP filter returned 0 — the prime suspect is the industry vocab
95
+ * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
96
+ * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
97
+ * exhausted mid-investigation) — re-run `enrich acquire --source pipe0` once
98
+ * credits refill; if it still returns 0, the next suspects are the
99
+ * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
100
+ * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
57
101
  */
58
102
  export declare function icpToCrustdataFilters(icp: Icp): Record<string, unknown>;
59
103
  export type IcpFit = {