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.
Files changed (68) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +18 -7
  3. package/dist/calls.d.ts +13 -0
  4. package/dist/calls.js +14 -3
  5. package/dist/cli.js +718 -60
  6. package/dist/connectors/hubspot.js +58 -24
  7. package/dist/connectors/outboxChannel.d.ts +64 -0
  8. package/dist/connectors/outboxChannel.js +170 -0
  9. package/dist/connectors/prospectSources.d.ts +22 -0
  10. package/dist/connectors/prospectSources.js +43 -0
  11. package/dist/connectors/salesforce.js +40 -15
  12. package/dist/connectors/signalSources.d.ts +105 -0
  13. package/dist/connectors/signalSources.js +316 -0
  14. package/dist/connectors/theirstack.d.ts +84 -0
  15. package/dist/connectors/theirstack.js +125 -0
  16. package/dist/draft.js +27 -5
  17. package/dist/enrich.d.ts +6 -0
  18. package/dist/enrich.js +47 -1
  19. package/dist/health.d.ts +12 -0
  20. package/dist/health.js +29 -2
  21. package/dist/icp.d.ts +47 -3
  22. package/dist/icp.js +105 -11
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/init.d.ts +47 -0
  26. package/dist/init.js +143 -0
  27. package/dist/judge.d.ts +36 -3
  28. package/dist/judge.js +46 -10
  29. package/dist/judgeEval.d.ts +7 -0
  30. package/dist/judgeEval.js +8 -1
  31. package/dist/runReport.d.ts +26 -0
  32. package/dist/runReport.js +15 -0
  33. package/dist/schedule.js +18 -4
  34. package/dist/signals.d.ts +58 -0
  35. package/dist/signals.js +65 -0
  36. package/dist/tam.d.ts +225 -0
  37. package/dist/tam.js +470 -0
  38. package/dist/types.d.ts +12 -0
  39. package/docs/api.md +26 -4
  40. package/docs/outbox-format.md +92 -0
  41. package/docs/recipes.md +195 -0
  42. package/docs/roadmap-to-1.0.md +31 -1
  43. package/docs/signal-spool-format.md +162 -0
  44. package/docs/tam.md +195 -0
  45. package/llms.txt +89 -1
  46. package/package.json +1 -1
  47. package/skills/fullstackgtm/SKILL.md +17 -1
  48. package/src/calls.ts +24 -3
  49. package/src/cli.ts +809 -55
  50. package/src/connectors/hubspot.ts +55 -23
  51. package/src/connectors/outboxChannel.ts +202 -0
  52. package/src/connectors/prospectSources.ts +57 -0
  53. package/src/connectors/salesforce.ts +39 -14
  54. package/src/connectors/signalSources.ts +363 -0
  55. package/src/connectors/theirstack.ts +170 -0
  56. package/src/draft.ts +26 -5
  57. package/src/enrich.ts +51 -1
  58. package/src/health.ts +47 -2
  59. package/src/icp.ts +113 -11
  60. package/src/index.ts +42 -0
  61. package/src/init.ts +166 -0
  62. package/src/judge.ts +85 -11
  63. package/src/judgeEval.ts +8 -1
  64. package/src/runReport.ts +39 -0
  65. package/src/schedule.ts +20 -4
  66. package/src/signals.ts +95 -0
  67. package/src/tam.ts +654 -0
  68. package/src/types.ts +12 -0
@@ -0,0 +1,363 @@
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, readdirSync, statSync } from "node:fs";
23
+ import { join, resolve } from "node:path";
24
+ import type { SignalBucket, StagedSignalRow } from "../signals.ts";
25
+ import { SIGNAL_BUCKETS } from "../signals.ts";
26
+
27
+ export type SignalSourceShape = "pull" | "push";
28
+ export type SignalSourceAuth = "none" | "api_key" | "oauth";
29
+
30
+ /** Everything a source connector needs for one `fetch`, supplied by the CLI. */
31
+ export type SignalSourceContext = {
32
+ /** Accounts to scope a pull. May be empty (a file/spool source ignores it). */
33
+ watchlist: { domain: string }[];
34
+ /** Evidence keywords for job/listing-style sources; may be empty. */
35
+ keywords: string[];
36
+ now: Date;
37
+ /** Injectable fetch for tests; global `fetch` by default. */
38
+ fetchImpl?: typeof fetch;
39
+ /**
40
+ * Credential-ladder lookup. Returns a usable secret for `provider`, or null
41
+ * when nothing is configured (the connector then returns []). Secrets NEVER
42
+ * arrive via argv — only through this.
43
+ */
44
+ getApiKey?: (provider: string) => Promise<string | null>;
45
+ /** Non-secret per-connector knobs from `--connector-opt k=v` (paths, queries). */
46
+ options?: Record<string, string>;
47
+ };
48
+
49
+ export type SignalSourceConnector = {
50
+ id: string;
51
+ /** Default bucket this source feeds (a row may still override it). */
52
+ bucket: SignalBucket;
53
+ shape: SignalSourceShape;
54
+ auth: SignalSourceAuth;
55
+ /**
56
+ * Produce staged rows now. Resilient by contract: the connector's own
57
+ * failures (offline, non-2xx, malformed payload, missing key) resolve to []
58
+ * rather than throwing, so one source's outage never aborts a multi-source
59
+ * `signals fetch`. Validation/evidence-gating of the returned rows happens
60
+ * centrally in `stagedRowToSignal`.
61
+ */
62
+ fetch(ctx: SignalSourceContext): Promise<StagedSignalRow[]>;
63
+ };
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // file — local JSON / JSONL intake (also the webhook landing-zone reader)
67
+
68
+ /**
69
+ * Read staged rows from a local path — the webhook landing-zone reader. The path
70
+ * (from `options.path`/`options.file`) may be:
71
+ * - a single FILE: a JSON array, or newline-delimited JSON (one row per line);
72
+ * - a DIRECTORY (the conventional spool): every `*.jsonl` / `*.json` file in
73
+ * it is read and concatenated (sorted by name), so multiple receivers can
74
+ * each append their own file (`rb2b.jsonl`, `hubspot.jsonl`) and they all
75
+ * land in one fetch.
76
+ * No auth. This is how every push platform (RB2B, Trigify, HubSpot webhooks)
77
+ * reaches the CLI: a receiver appends a row to the spool, this reads it on the
78
+ * next `signals fetch`. The CLI defaults the path to the conventional spool dir
79
+ * (`signalsSpoolDir`) when `--connector file` is used with no path; a bare
80
+ * library call with no path is inactive (returns []).
81
+ *
82
+ * Resilient: a missing path / unreadable file yields [] (an empty source, not a
83
+ * crash), and one unreadable file in a spool directory is skipped. A file that
84
+ * IS present but malformed throws — a corrupt spool is a real error to surface,
85
+ * not silent data loss. The central `stagedRowToSignal` gate validates rows.
86
+ */
87
+ export const fileSource: SignalSourceConnector = {
88
+ id: "file",
89
+ bucket: "company",
90
+ shape: "pull",
91
+ auth: "none",
92
+ async fetch(ctx) {
93
+ const path = ctx.options?.path ?? ctx.options?.file;
94
+ if (!path) return []; // no spool configured (the CLI fills the default dir)
95
+ const abs = resolve(process.cwd(), path);
96
+ let isDir: boolean;
97
+ try {
98
+ isDir = statSync(abs).isDirectory();
99
+ } catch {
100
+ return []; // missing path: an empty source, not a crash
101
+ }
102
+ const files = isDir ? spoolFilesIn(abs) : [abs];
103
+ const rows: Record<string, unknown>[] = [];
104
+ for (const file of files) {
105
+ let raw: string;
106
+ try {
107
+ raw = readFileSync(file, "utf8");
108
+ } catch {
109
+ continue; // one unreadable file in a spool dir must not sink the rest
110
+ }
111
+ const trimmed = raw.trim();
112
+ if (!trimmed) continue;
113
+ const parsed = trimmed.startsWith("[") ? parseJsonArray(trimmed, file) : parseJsonl(trimmed, file);
114
+ rows.push(...parsed);
115
+ }
116
+ return rows.map((row) => coerceRow(row, this.bucket));
117
+ },
118
+ };
119
+
120
+ /** Spool files in a landing-zone directory: `*.jsonl` / `*.json`, name-sorted. */
121
+ function spoolFilesIn(dir: string): string[] {
122
+ let names: string[];
123
+ try {
124
+ names = readdirSync(dir);
125
+ } catch {
126
+ return [];
127
+ }
128
+ return names
129
+ .filter((name) => name.endsWith(".jsonl") || name.endsWith(".json"))
130
+ .sort()
131
+ .map((name) => join(dir, name));
132
+ }
133
+
134
+ function parseJsonArray(raw: string, path: string): Record<string, unknown>[] {
135
+ let parsed: unknown;
136
+ try {
137
+ parsed = JSON.parse(raw);
138
+ } catch (error) {
139
+ throw new Error(`file source ${path}: not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
140
+ }
141
+ if (!Array.isArray(parsed)) throw new Error(`file source ${path}: expected a JSON array of staged signal rows.`);
142
+ return parsed.map((entry, index) => {
143
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
144
+ throw new Error(`file source ${path}: row ${index} is not an object.`);
145
+ }
146
+ return entry as Record<string, unknown>;
147
+ });
148
+ }
149
+
150
+ function parseJsonl(raw: string, path: string): Record<string, unknown>[] {
151
+ const out: Record<string, unknown>[] = [];
152
+ const lines = raw.split("\n");
153
+ lines.forEach((line, index) => {
154
+ const t = line.trim();
155
+ if (!t) return;
156
+ let parsed: unknown;
157
+ try {
158
+ parsed = JSON.parse(t);
159
+ } catch (error) {
160
+ throw new Error(`file source ${path}: line ${index} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
161
+ }
162
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
163
+ throw new Error(`file source ${path}: line ${index} is not a JSON object.`);
164
+ }
165
+ out.push(parsed as Record<string, unknown>);
166
+ });
167
+ return out;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // serpapi-news — funding/company signals from a news REST query (API key)
172
+
173
+ const SERPAPI_BASE_URL = "https://serpapi.com";
174
+
175
+ /**
176
+ * Pull recent news per watchlist account and stage funding/company signals. One
177
+ * query per account (`q="<domain>"`, Google News engine); each result becomes a
178
+ * row whose verbatim `quote` is the headline (+ source), so the downstream judge
179
+ * can ground a why-now on a real, linkable article. API key via the credential
180
+ * ladder (provider "serpapi"); no key -> [] (the source is simply inactive).
181
+ *
182
+ * Bucket defaults to `funding`; override per run with `--connector-opt bucket=company`.
183
+ */
184
+ export const serpapiNewsSource: SignalSourceConnector = {
185
+ id: "serpapi-news",
186
+ bucket: "funding",
187
+ shape: "pull",
188
+ auth: "api_key",
189
+ async fetch(ctx) {
190
+ const apiKey = ctx.getApiKey ? await ctx.getApiKey("serpapi") : null;
191
+ if (!apiKey) return [];
192
+ const fetchImpl = ctx.fetchImpl ?? fetch;
193
+ const base = (ctx.options?.apiBaseUrl ?? SERPAPI_BASE_URL).replace(/\/$/, "");
194
+ const bucket = pickBucket(ctx.options?.bucket, this.bucket);
195
+ const out: StagedSignalRow[] = [];
196
+ for (const account of ctx.watchlist) {
197
+ const domain = account.domain.trim();
198
+ if (!domain) continue;
199
+ try {
200
+ const url =
201
+ `${base}/search.json?engine=google_news&q=${encodeURIComponent(`"${domain}"`)}` +
202
+ `&api_key=${encodeURIComponent(apiKey)}`;
203
+ const response = await fetchImpl(url, { headers: { Accept: "application/json" } });
204
+ if (!response.ok) continue;
205
+ const data = (await response.json()) as {
206
+ news_results?: Array<{ title?: string; link?: string; source?: { name?: string } | string; date?: string }>;
207
+ };
208
+ for (const item of data.news_results ?? []) {
209
+ const title = typeof item.title === "string" ? item.title.trim() : "";
210
+ if (!title) continue; // no headline -> no verbatim evidence -> drop
211
+ const sourceName =
212
+ typeof item.source === "string" ? item.source : item.source?.name ?? "";
213
+ const quote = sourceName ? `${title} — ${sourceName}` : title;
214
+ out.push({
215
+ bucket,
216
+ accountDomain: domain,
217
+ trigger: `news: ${title}`,
218
+ quote,
219
+ sourceUrl: typeof item.link === "string" ? item.link : "",
220
+ });
221
+ }
222
+ } catch {
223
+ continue; // one account's outage must not sink the run
224
+ }
225
+ }
226
+ return out;
227
+ },
228
+ };
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // hubspot-forms — first-party demand from recent HubSpot form submissions
232
+
233
+ const HUBSPOT_BASE_URL = "https://api.hubapi.com";
234
+
235
+ /**
236
+ * Stage `demand` signals from recent HubSpot form submissions — the first real
237
+ * `demand`-bucket producer (a form fill is first-party demand). Reuses the
238
+ * EXISTING HubSpot credential via the ladder (provider "hubspot"); no separate
239
+ * login. Each submission whose email carries a company domain becomes a row
240
+ * whose verbatim `quote` is the form name + submitted email (the evidence a rep
241
+ * can verify). Submissions without a corporate domain (free-mail) are dropped —
242
+ * no account to attach demand to.
243
+ *
244
+ * Phase 1 is a pull over the Forms submissions API; the form-submission webhook
245
+ * (push) lands in Phase 2 via the spool + `file` source.
246
+ */
247
+ export const hubspotFormsSource: SignalSourceConnector = {
248
+ id: "hubspot-forms",
249
+ bucket: "demand",
250
+ shape: "pull",
251
+ auth: "oauth",
252
+ async fetch(ctx) {
253
+ const token = ctx.getApiKey ? await ctx.getApiKey("hubspot") : null;
254
+ if (!token) return [];
255
+ const fetchImpl = ctx.fetchImpl ?? fetch;
256
+ const base = (ctx.options?.apiBaseUrl ?? HUBSPOT_BASE_URL).replace(/\/$/, "");
257
+ const out: StagedSignalRow[] = [];
258
+ let forms: Array<{ guid?: string; id?: string; name?: string }> = [];
259
+ try {
260
+ const response = await fetchImpl(`${base}/marketing/v3/forms`, {
261
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
262
+ });
263
+ if (!response.ok) return [];
264
+ const data = (await response.json()) as { results?: typeof forms };
265
+ forms = data.results ?? [];
266
+ } catch {
267
+ return [];
268
+ }
269
+ for (const form of forms) {
270
+ const formId = form.guid ?? form.id;
271
+ if (!formId) continue;
272
+ const formName = typeof form.name === "string" && form.name ? form.name : "form";
273
+ try {
274
+ const response = await fetchImpl(
275
+ `${base}/form-integrations/v1/submissions/forms/${encodeURIComponent(formId)}?limit=50`,
276
+ { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } },
277
+ );
278
+ if (!response.ok) continue;
279
+ const data = (await response.json()) as {
280
+ results?: Array<{ values?: Array<{ name?: string; value?: string }>; submittedAt?: number }>;
281
+ };
282
+ for (const submission of data.results ?? []) {
283
+ const email = fieldValue(submission.values, "email");
284
+ const domain = corporateDomain(email);
285
+ if (!domain) continue; // free-mail / no email: no account to attach to
286
+ const submittedAt =
287
+ typeof submission.submittedAt === "number"
288
+ ? new Date(submission.submittedAt).toISOString()
289
+ : undefined;
290
+ out.push({
291
+ bucket: "demand",
292
+ accountDomain: domain,
293
+ trigger: `form: ${formName}`,
294
+ quote: `Submitted "${formName}" — ${email}`,
295
+ sourceUrl: "",
296
+ ...(submittedAt ? { firstSeen: submittedAt } : {}),
297
+ });
298
+ }
299
+ } catch {
300
+ continue;
301
+ }
302
+ }
303
+ return out;
304
+ },
305
+ };
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Registry
309
+
310
+ const CONNECTORS: SignalSourceConnector[] = [fileSource, serpapiNewsSource, hubspotFormsSource];
311
+
312
+ const REGISTRY = new Map<string, SignalSourceConnector>(CONNECTORS.map((c) => [c.id, c]));
313
+
314
+ /** All registered source connectors (stable order). */
315
+ export function listSignalSources(): SignalSourceConnector[] {
316
+ return [...CONNECTORS];
317
+ }
318
+
319
+ /** Resolve a connector by id, or null when unknown. */
320
+ export function getSignalSource(id: string): SignalSourceConnector | null {
321
+ return REGISTRY.get(id) ?? null;
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Helpers
326
+
327
+ function coerceRow(row: Record<string, unknown>, defaultBucket: SignalBucket): StagedSignalRow {
328
+ // Fill the bucket from the connector default when a file row omits it; the
329
+ // central validator still rejects an unknown bucket and an empty quote.
330
+ const bucket = row.bucket === undefined ? defaultBucket : (row.bucket as SignalBucket);
331
+ return { ...(row as StagedSignalRow), bucket };
332
+ }
333
+
334
+ function pickBucket(raw: string | undefined, fallback: SignalBucket): SignalBucket {
335
+ if (raw && SIGNAL_BUCKETS.includes(raw as SignalBucket)) return raw as SignalBucket;
336
+ return fallback;
337
+ }
338
+
339
+ function fieldValue(values: Array<{ name?: string; value?: string }> | undefined, name: string): string {
340
+ for (const field of values ?? []) {
341
+ if (field.name === name && typeof field.value === "string") return field.value.trim();
342
+ }
343
+ return "";
344
+ }
345
+
346
+ const FREE_MAIL = new Set([
347
+ "gmail.com",
348
+ "yahoo.com",
349
+ "hotmail.com",
350
+ "outlook.com",
351
+ "icloud.com",
352
+ "aol.com",
353
+ "proton.me",
354
+ "protonmail.com",
355
+ ]);
356
+
357
+ /** Company domain from an email, or "" for free-mail / no email. */
358
+ function corporateDomain(email: string): string {
359
+ if (!email.includes("@")) return "";
360
+ const domain = email.split("@").at(-1)!.trim().toLowerCase();
361
+ if (!domain || FREE_MAIL.has(domain)) return "";
362
+ return domain;
363
+ }
@@ -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/draft.ts CHANGED
@@ -305,9 +305,6 @@ export function draft(opts: {
305
305
  const minScore = opts.minScore ?? 80;
306
306
  const channel: DraftChannel = opts.channel ?? "task";
307
307
  const freshnessDays = opts.freshnessDays ?? DEFAULT_FRESHNESS_DAYS;
308
- // `task`/`email`/`linkedin` all write a CRM task through the same gate; the
309
- // object the task hangs off is the contact when known, else the account.
310
- const objectType: GtmObjectType = "contact";
311
308
 
312
309
  const hot = opts.decisions
313
310
  .filter((d) => d.decision === "send" && d.score >= minScore)
@@ -349,11 +346,35 @@ export function draft(opts: {
349
346
  continue;
350
347
  }
351
348
 
349
+ // Resolve a REAL CRM target from the judge decision: a contact id (preferred
350
+ // — that's who we message), else the account id. A domain-only decision
351
+ // (the account isn't in the CRM yet) cannot hang a task off a record — reject
352
+ // it with the fix, instead of forging a contact-typed op carrying a domain.
353
+ let objectType: GtmObjectType;
354
+ let objectId: string;
355
+ let targetNote: string;
356
+ if (decision.contact?.id) {
357
+ objectType = "contact";
358
+ objectId = decision.contact.id;
359
+ targetNote = `contact ${decision.contact.email ?? decision.contact.id}`;
360
+ } else if (decision.accountId) {
361
+ objectType = "account";
362
+ objectId = decision.accountId;
363
+ targetNote = `account ${domain}`;
364
+ } else {
365
+ rejected.push({
366
+ accountDomain: domain,
367
+ opener,
368
+ reason: `account ${domain} is not in the CRM — acquire it first (enrich acquire), then judge with a snapshot, before drafting`,
369
+ });
370
+ continue;
371
+ }
372
+
352
373
  const stale = isStaleTrigger(signal.firstSeen, now, freshnessDays);
353
374
  const ev = draftEvidence(signal, nowIso);
354
375
  evidence.push(ev);
355
376
 
356
- const reasonBase = `Signal-grounded opener for ${domain}: "${signal.trigger}"`;
377
+ const reasonBase = `Signal-grounded opener for ${domain} → ${targetNote}: "${signal.trigger}"`;
357
378
  const reason = stale
358
379
  ? `${reasonBase} [staleTrigger: grounding signal first seen ${signal.firstSeen}, older than ${freshnessDays}d — could this have gone out last month unchanged?]`
359
380
  : reasonBase;
@@ -361,7 +382,7 @@ export function draft(opts: {
361
382
  operations.push({
362
383
  id: draftOperationId(channel, domain),
363
384
  objectType,
364
- objectId: domain,
385
+ objectId,
365
386
  operation: "create_task",
366
387
  field: "follow_up_task",
367
388
  beforeValue: null,
package/src/enrich.ts CHANGED
@@ -92,6 +92,12 @@ export type AcquireCreateMap = {
92
92
  matchKey: string;
93
93
  /** Optional source path to a company name; the connector resolves-or-creates it. */
94
94
  associateCompanyFrom?: string;
95
+ /**
96
+ * Optional source path to the company domain. With it (or a derivable email
97
+ * domain) the connector resolves the account by domain and stamps the domain
98
+ * on it — so the lead's account is signal-watchable, not just a text field.
99
+ */
100
+ associateCompanyDomainFrom?: string;
95
101
  };
96
102
 
97
103
  /**
@@ -392,6 +398,8 @@ export function builtinAcquirePreset(source: string | undefined): EnrichConfig |
392
398
  company: "companyName",
393
399
  email: "email",
394
400
  },
401
+ associateCompanyFrom: "companyName",
402
+ associateCompanyDomainFrom: "companyDomain",
395
403
  },
396
404
  },
397
405
  },
@@ -418,6 +426,8 @@ export function builtinAcquirePreset(source: string | undefined): EnrichConfig |
418
426
  company: "companyName",
419
427
  hs_linkedin_url: "linkedin",
420
428
  },
429
+ associateCompanyFrom: "companyName",
430
+ associateCompanyDomainFrom: "companyDomain",
421
431
  },
422
432
  },
423
433
  },
@@ -733,6 +743,27 @@ function isEmptyValue(value: unknown): boolean {
733
743
  return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
734
744
  }
735
745
 
746
+ /** Bare registrable host from a domain or URL ("https://www.x.com/a" → "x.com"). */
747
+ function normalizeCompanyDomain(value: unknown): string | undefined {
748
+ if (typeof value !== "string" || !value.trim()) return undefined;
749
+ const host = value
750
+ .trim()
751
+ .toLowerCase()
752
+ .replace(/^https?:\/\//, "")
753
+ .replace(/^www\./, "")
754
+ .replace(/\/.*$/, "")
755
+ .replace(/\s+/g, "");
756
+ return host && host.includes(".") ? host : undefined;
757
+ }
758
+
759
+ /** The domain of a work email ("vp@acme.com" → "acme.com"), else undefined. */
760
+ function domainFromEmail(value: unknown): string | undefined {
761
+ if (typeof value !== "string") return undefined;
762
+ const at = value.lastIndexOf("@");
763
+ if (at < 0) return undefined;
764
+ return normalizeCompanyDomain(value.slice(at + 1));
765
+ }
766
+
736
767
  /** Values compare as trimmed strings; numbers compare numerically. */
737
768
  function sameValue(a: unknown, b: unknown): boolean {
738
769
  if (isEmptyValue(a) && isEmptyValue(b)) return true;
@@ -1130,6 +1161,20 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1130
1161
  const companyValue = sourceValueAt(record.payload, createMap.associateCompanyFrom);
1131
1162
  if (!isEmptyValue(companyValue)) associateCompanyName = String(companyValue);
1132
1163
  }
1164
+ // The company domain makes the lead's account signal-watchable. Prefer the
1165
+ // configured source path; fall back to the work email's domain (free, and
1166
+ // for B2B leads the email domain IS the company domain).
1167
+ let associateCompanyDomain: string | undefined;
1168
+ if (associateCompanyName) {
1169
+ const fromPath = createMap.associateCompanyDomainFrom
1170
+ ? sourceValueAt(record.payload, createMap.associateCompanyDomainFrom)
1171
+ : undefined;
1172
+ const candidate = !isEmptyValue(fromPath)
1173
+ ? String(fromPath)
1174
+ : domainFromEmail(sourceValueAt(record.payload, "email"));
1175
+ const normalized = normalizeCompanyDomain(candidate);
1176
+ if (normalized) associateCompanyDomain = normalized;
1177
+ }
1133
1178
 
1134
1179
  const recordEvidence = evidenceFor(
1135
1180
  source,
@@ -1162,8 +1207,13 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1162
1207
  source,
1163
1208
  estCostUsd: costPerRecord,
1164
1209
  associateCompanyName,
1210
+ ...(associateCompanyDomain ? { associateCompanyDomain } : {}),
1165
1211
  ...(ownerId ? { ownerId, assignedBy } : {}),
1166
1212
  };
1213
+ // Surface the account link in the dry-run so it is not a silent side-effect.
1214
+ const accountNote = associateCompanyName
1215
+ ? ` Resolves/creates its account "${associateCompanyName}"${associateCompanyDomain ? ` (${associateCompanyDomain})` : ""} and links the lead.`
1216
+ : "";
1167
1217
  operations.push({
1168
1218
  id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
1169
1219
  objectType: canonicalObjectType(record.objectType),
@@ -1173,7 +1223,7 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1173
1223
  afterValue: payload,
1174
1224
  reason:
1175
1225
  `${source} sourced net-new ${record.objectType} "${describeSourceRecord(record)}" ` +
1176
- `(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.`,
1226
+ `(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.${accountNote}`,
1177
1227
  sourceRuleOrPolicy: `acquire:${source}`,
1178
1228
  riskLevel: "medium",
1179
1229
  approvalRequired: true,