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
@@ -406,18 +406,35 @@ export function createHubspotConnector(options) {
406
406
  providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
407
407
  };
408
408
  }
409
- /** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
410
- async function searchCompaniesByName(name) {
409
+ /** Exact-value company lookup (by `name` or `domain`) for resolve-first creates. */
410
+ async function searchCompaniesBy(property, value) {
411
411
  const data = await request(`/crm/v3/objects/companies/search`, {
412
412
  method: "POST",
413
413
  body: JSON.stringify({
414
- filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
415
- properties: ["name"],
414
+ filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
415
+ properties: [property],
416
416
  limit: 3,
417
417
  }),
418
418
  });
419
419
  return (data?.results ?? []).map((row) => String(row.id));
420
420
  }
421
+ const searchCompaniesByName = (name) => searchCompaniesBy("name", name);
422
+ /** Stamp `domain` on a company only when it has none (fill-blank, never clobber). */
423
+ async function fillCompanyDomainIfEmpty(companyId, domain) {
424
+ try {
425
+ const data = await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}?properties=domain`);
426
+ const current = data?.properties?.domain;
427
+ if (typeof current === "string" && current.trim())
428
+ return; // already has one — leave it
429
+ await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}`, {
430
+ method: "PATCH",
431
+ body: JSON.stringify({ properties: { domain } }),
432
+ });
433
+ }
434
+ catch {
435
+ // Best-effort: a failed domain fill must not sink the contact create.
436
+ }
437
+ }
421
438
  /** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
422
439
  async function searchContactsBy(property, value) {
423
440
  const data = await request(`/crm/v3/objects/contacts/search`, {
@@ -431,39 +448,55 @@ export function createHubspotConnector(options) {
431
448
  return (data?.results ?? []).map((row) => String(row.id));
432
449
  }
433
450
  /**
434
- * Resolve a company by exact name, creating it on a confirmed miss. Returns
435
- * the company id, or null on ambiguity (≥2 existing) so the caller skips the
436
- * association rather than guessing. Same-run safe via createdCompaniesByName.
451
+ * Resolve a company to a real account record, creating it on a confirmed miss.
452
+ * Matches by DOMAIN first (the accurate key) and falls back to exact name;
453
+ * creates with the domain stamped, and fills the domain on a name-matched
454
+ * account that lacks one — so the account is signal-watchable. Returns null on
455
+ * ambiguity (≥2 matches) so the caller skips the association rather than
456
+ * guessing. Same-run safe via createdCompaniesByName.
437
457
  */
438
- async function resolveOrCreateCompanyByName(name, opId) {
439
- const nameKey = name.toLowerCase();
440
- const already = createdCompaniesByName.get(nameKey);
458
+ async function resolveOrCreateCompany(name, domain, opId) {
459
+ const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
460
+ const already = createdCompaniesByName.get(cacheKey);
441
461
  if (already)
442
462
  return already;
443
- const matches = await searchCompaniesByName(name);
444
- if (matches.length > 1)
445
- return null;
446
- if (matches.length === 1) {
447
- createdCompaniesByName.set(nameKey, matches[0]);
448
- return matches[0];
463
+ // 1. Domain-first match (accurate; an account matched here already has it).
464
+ if (domain) {
465
+ const byDomain = await searchCompaniesBy("domain", domain);
466
+ if (byDomain.length > 1)
467
+ return null;
468
+ if (byDomain.length === 1) {
469
+ createdCompaniesByName.set(cacheKey, byDomain[0]);
470
+ return byDomain[0];
471
+ }
449
472
  }
473
+ // 2. Exact-name match; stamp the domain if the account has none.
474
+ const byName = await searchCompaniesByName(name);
475
+ if (byName.length > 1)
476
+ return null;
477
+ if (byName.length === 1) {
478
+ if (domain)
479
+ await fillCompanyDomainIfEmpty(byName[0], domain);
480
+ createdCompaniesByName.set(cacheKey, byName[0]);
481
+ return byName[0];
482
+ }
483
+ // 3. Create with name + domain (provenance is best-effort).
484
+ const props = { name, ...(domain ? { domain } : {}) };
450
485
  let created;
451
486
  try {
452
487
  created = await request(`/crm/v3/objects/companies`, {
453
488
  method: "POST",
454
- body: JSON.stringify({
455
- properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
456
- }),
489
+ body: JSON.stringify({ properties: { ...props, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` } }),
457
490
  });
458
491
  }
459
492
  catch {
460
493
  created = await request(`/crm/v3/objects/companies`, {
461
494
  method: "POST",
462
- body: JSON.stringify({ properties: { name } }),
495
+ body: JSON.stringify({ properties: props }),
463
496
  });
464
497
  }
465
498
  const id = String(created.id);
466
- createdCompaniesByName.set(nameKey, id);
499
+ createdCompaniesByName.set(cacheKey, id);
467
500
  return id;
468
501
  }
469
502
  /**
@@ -487,7 +520,7 @@ export function createHubspotConnector(options) {
487
520
  return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
488
521
  }
489
522
  if (operation.objectType === "account") {
490
- const id = await resolveOrCreateCompanyByName(matchValue, operation.id);
523
+ const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
491
524
  if (id === null) {
492
525
  return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
493
526
  }
@@ -534,10 +567,11 @@ export function createHubspotConnector(options) {
534
567
  let companyNote = "";
535
568
  if (payload.associateCompanyName) {
536
569
  try {
537
- const companyId = await resolveOrCreateCompanyByName(payload.associateCompanyName, operation.id);
570
+ const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
538
571
  if (companyId) {
539
572
  await request(`/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
540
- companyNote = ` Linked to company "${payload.associateCompanyName}" (${companyId}).`;
573
+ const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
574
+ companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
541
575
  }
542
576
  else {
543
577
  companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Outbox channel connector — the governed SEND-side terminus of the outbound
3
+ * loop (signal → judge → draft → approve → apply → outbox), and the mirror image
4
+ * of the webhook spool. It is a `GtmConnector` whose `applyOperation` RENDERS an
5
+ * approved drafted opener to a local outbox artifact instead of writing a CRM —
6
+ * so it reuses the entire governed apply machinery (approval set, integrity/HMAC
7
+ * verification, idempotency, run recording) with no change to the apply engine.
8
+ *
9
+ * THE INVARIANT IT KEEPS: the CLI **transmits nothing**. Rendering an approved
10
+ * opener to `<home>/signals/outbox/<channel>.jsonl` is a local file write, never
11
+ * an SMTP/API connection. A downstream sender (the hosted product, or the
12
+ * operator's own MTA) drains the outbox and does the actual transmission. This
13
+ * is the send-side half of the open-core boundary: the governed artifact + its
14
+ * format are open; always-on transmission infrastructure is hosted/opt-in.
15
+ *
16
+ * Governance: it only renders ops that came from `draft` (a `create_task` op
17
+ * whose policy is `draft:<channel>`); any other op is `skipped` (it is not a
18
+ * general CRM writer). Idempotent on the operation id — re-applying an approved
19
+ * plan never duplicates an outbox row. Read paths (`fetchSnapshot`) intentionally
20
+ * throw: a channel has no snapshot, and `applyPatchPlan` never calls it for a
21
+ * draft plan (no guards/filter/irreversible ops → no snapshot needed).
22
+ */
23
+ import type { GtmConnector } from "../types.ts";
24
+ /** One approved, ready-to-send touch — a governed send INTENT, not a sent message. */
25
+ export type OutboxEntry = {
26
+ /** Idempotency key = the source operation id (stable per draft op). */
27
+ id: string;
28
+ /** email | linkedin | task — from the draft op's `draft:<channel>` policy. */
29
+ channel: string;
30
+ /** The CRM target the opener is addressed to (a sender resolves id → address). */
31
+ objectType: string;
32
+ objectId: string;
33
+ /** The APPROVED opener, verbatim as it was signed in the plan op. */
34
+ body: string;
35
+ /** The draft op's human-readable reason (carries the account + trigger). */
36
+ reason?: string;
37
+ /** Evidence ids the opener was grounded in (the verbatim signal quote). */
38
+ evidenceIds: string[];
39
+ /** ISO 8601 — when the CLI rendered this to the outbox (NOT a send time). */
40
+ renderedAt: string;
41
+ };
42
+ /** Channel ids this build can render to. */
43
+ export declare const CHANNELS: readonly ["outbox"];
44
+ export type OutboxChannelOptions = {
45
+ /** Outbox directory; defaults to the profile-scoped `signalsOutboxDir()`. */
46
+ outboxDir?: string;
47
+ /** Injectable clock for deterministic tests. */
48
+ now?: () => Date;
49
+ };
50
+ /**
51
+ * Build the outbox channel connector. With no `outboxDir`, writes to the
52
+ * profile-scoped conventional outbox and locks the home down (0700/0600) like
53
+ * the signal store; with an explicit `outboxDir` (tests), it writes there
54
+ * without touching the real home.
55
+ */
56
+ export declare function createOutboxChannelConnector(options?: OutboxChannelOptions): GtmConnector;
57
+ /** Resolve a channel connector by id (mirrors the source-connector registry). */
58
+ export declare function createChannelConnector(id: string, options?: OutboxChannelOptions): GtmConnector;
59
+ /**
60
+ * Read every outbox entry across all channel files in `dir` (default: the
61
+ * conventional outbox), newest-appended last. The reader a downstream sender (or
62
+ * a future `signals outbox` command) uses to drain the queue.
63
+ */
64
+ export declare function listOutbox(dir?: string): OutboxEntry[];
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Outbox channel connector — the governed SEND-side terminus of the outbound
3
+ * loop (signal → judge → draft → approve → apply → outbox), and the mirror image
4
+ * of the webhook spool. It is a `GtmConnector` whose `applyOperation` RENDERS an
5
+ * approved drafted opener to a local outbox artifact instead of writing a CRM —
6
+ * so it reuses the entire governed apply machinery (approval set, integrity/HMAC
7
+ * verification, idempotency, run recording) with no change to the apply engine.
8
+ *
9
+ * THE INVARIANT IT KEEPS: the CLI **transmits nothing**. Rendering an approved
10
+ * opener to `<home>/signals/outbox/<channel>.jsonl` is a local file write, never
11
+ * an SMTP/API connection. A downstream sender (the hosted product, or the
12
+ * operator's own MTA) drains the outbox and does the actual transmission. This
13
+ * is the send-side half of the open-core boundary: the governed artifact + its
14
+ * format are open; always-on transmission infrastructure is hosted/opt-in.
15
+ *
16
+ * Governance: it only renders ops that came from `draft` (a `create_task` op
17
+ * whose policy is `draft:<channel>`); any other op is `skipped` (it is not a
18
+ * general CRM writer). Idempotent on the operation id — re-applying an approved
19
+ * plan never duplicates an outbox row. Read paths (`fetchSnapshot`) intentionally
20
+ * throw: a channel has no snapshot, and `applyPatchPlan` never calls it for a
21
+ * draft plan (no guards/filter/irreversible ops → no snapshot needed).
22
+ */
23
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { ensureSecureHomeDir, writeSecureFile } from "../credentials.js";
26
+ import { signalsOutboxDir } from "../signals.js";
27
+ /** Channel ids this build can render to. */
28
+ export const CHANNELS = ["outbox"];
29
+ const DRAFT_POLICY_PREFIX = "draft:";
30
+ /**
31
+ * Build the outbox channel connector. With no `outboxDir`, writes to the
32
+ * profile-scoped conventional outbox and locks the home down (0700/0600) like
33
+ * the signal store; with an explicit `outboxDir` (tests), it writes there
34
+ * without touching the real home.
35
+ */
36
+ export function createOutboxChannelConnector(options = {}) {
37
+ const usingDefaultHome = options.outboxDir === undefined;
38
+ const dir = options.outboxDir ?? signalsOutboxDir();
39
+ const now = options.now ?? (() => new Date());
40
+ function fileFor(channel) {
41
+ if (!/^[\w.-]+$/.test(channel))
42
+ throw new Error(`Invalid outbox channel name: ${channel}`);
43
+ return join(dir, `${channel}.jsonl`);
44
+ }
45
+ /** Existing entry ids in a channel file (for idempotent re-apply). */
46
+ function existingIds(channel) {
47
+ const ids = new Set();
48
+ let raw;
49
+ try {
50
+ raw = readFileSync(fileFor(channel), "utf8");
51
+ }
52
+ catch {
53
+ return ids; // no file yet
54
+ }
55
+ for (const line of raw.split("\n")) {
56
+ const t = line.trim();
57
+ if (!t)
58
+ continue;
59
+ try {
60
+ const id = JSON.parse(t).id;
61
+ if (typeof id === "string")
62
+ ids.add(id);
63
+ }
64
+ catch {
65
+ // Skip a corrupt line rather than fail the whole apply.
66
+ }
67
+ }
68
+ return ids;
69
+ }
70
+ function append(channel, entry) {
71
+ if (usingDefaultHome)
72
+ ensureSecureHomeDir();
73
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ const path = fileFor(channel);
75
+ // Owner-only like the signal store: outbox carries opener text + CRM ids.
76
+ if (!existsSync(path))
77
+ writeSecureFile(path, "");
78
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
79
+ }
80
+ async function applyOperation(operation) {
81
+ const policy = String(operation.sourceRuleOrPolicy ?? "");
82
+ if (operation.operation !== "create_task" || !policy.startsWith(DRAFT_POLICY_PREFIX)) {
83
+ return {
84
+ operationId: operation.id,
85
+ status: "skipped",
86
+ detail: "The outbox channel only renders drafted openers (create_task ops from `draft`). " +
87
+ "Apply other operations through a CRM connector (--provider hubspot|salesforce).",
88
+ };
89
+ }
90
+ const channel = policy.slice(DRAFT_POLICY_PREFIX.length) || "task";
91
+ // Idempotent: a re-applied approved plan must not duplicate the row.
92
+ if (existingIds(channel).has(operation.id)) {
93
+ return {
94
+ operationId: operation.id,
95
+ status: "applied",
96
+ detail: `Already in outbox (${channel}.jsonl); idempotent — not duplicated. Nothing transmitted.`,
97
+ };
98
+ }
99
+ const entry = {
100
+ id: operation.id,
101
+ channel,
102
+ objectType: operation.objectType,
103
+ objectId: operation.objectId,
104
+ body: typeof operation.afterValue === "string" ? operation.afterValue : String(operation.afterValue ?? ""),
105
+ ...(operation.reason ? { reason: operation.reason } : {}),
106
+ evidenceIds: operation.evidenceIds ?? [],
107
+ renderedAt: now().toISOString(),
108
+ };
109
+ append(channel, entry);
110
+ return {
111
+ operationId: operation.id,
112
+ status: "applied",
113
+ detail: `Rendered to outbox (${channel}.jsonl) for a downstream sender. Nothing was transmitted.`,
114
+ };
115
+ }
116
+ async function fetchSnapshot() {
117
+ // A channel has no readable state. apply never calls this for a draft plan
118
+ // (no guards/filter/irreversible ops); make the misuse explicit if it does.
119
+ throw new Error("The outbox channel has no snapshot to read — it is a send-side render target. " +
120
+ "Use it only to `apply` an approved draft plan (`apply --plan-id <id> --channel outbox`).");
121
+ }
122
+ return {
123
+ provider: "outbox",
124
+ fetchSnapshot,
125
+ applyOperation,
126
+ };
127
+ }
128
+ /** Resolve a channel connector by id (mirrors the source-connector registry). */
129
+ export function createChannelConnector(id, options = {}) {
130
+ if (id === "outbox")
131
+ return createOutboxChannelConnector(options);
132
+ throw new Error(`Unknown channel: ${id} (one of: ${CHANNELS.join(", ")}).`);
133
+ }
134
+ /**
135
+ * Read every outbox entry across all channel files in `dir` (default: the
136
+ * conventional outbox), newest-appended last. The reader a downstream sender (or
137
+ * a future `signals outbox` command) uses to drain the queue.
138
+ */
139
+ export function listOutbox(dir) {
140
+ const outboxDir = dir ?? signalsOutboxDir();
141
+ let names;
142
+ try {
143
+ names = readdirSync(outboxDir).filter((name) => name.endsWith(".jsonl")).sort();
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ const out = [];
149
+ for (const name of names) {
150
+ let raw;
151
+ try {
152
+ raw = readFileSync(join(outboxDir, name), "utf8");
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ for (const line of raw.split("\n")) {
158
+ const t = line.trim();
159
+ if (!t)
160
+ continue;
161
+ try {
162
+ out.push(JSON.parse(t));
163
+ }
164
+ catch {
165
+ // Skip corrupt lines.
166
+ }
167
+ }
168
+ }
169
+ return out;
170
+ }
@@ -56,6 +56,28 @@ export declare function fetchPipe0CrustdataProspects(opts: {
56
56
  apiBaseUrl?: string;
57
57
  fetchImpl?: FetchImpl;
58
58
  }): Promise<Prospect[]>;
59
+ export declare const EXPLORIUM_BUSINESS_COUNT_CAP = 60000;
60
+ export type BusinessCountProbe = {
61
+ /** matching companies (the account universe); == cap when saturated. */
62
+ total: number;
63
+ /** true when total hit EXPLORIUM_BUSINESS_COUNT_CAP — treat total as a lower bound. */
64
+ capped: boolean;
65
+ };
66
+ /**
67
+ * Count the COMPANIES (accounts) matching an ICP firmographic via Explorium
68
+ * /v1/businesses. Returns the real total (capped at 60k — `capped` flags the
69
+ * ceiling) or null if the response carries no `total_results`. Use
70
+ * `icpToExploriumBusinessFilters` to build `filters` (firmographic field names
71
+ * differ from /v1/prospects).
72
+ */
73
+ export declare function probeExploriumBusinessCount(opts: {
74
+ apiKey: string;
75
+ filters: Record<string, {
76
+ values?: string[];
77
+ }>;
78
+ apiBaseUrl?: string;
79
+ fetchImpl?: FetchImpl;
80
+ }): Promise<BusinessCountProbe | null>;
59
81
  export declare function pipe0ResolveWorkEmails(opts: {
60
82
  apiKey: string;
61
83
  prospects: Prospect[];
@@ -85,6 +85,49 @@ function strField(field) {
85
85
  const v = field?.value;
86
86
  return typeof v === "string" && v.trim() ? v : undefined;
87
87
  }
88
+ // ---------------------------------------------------------------------------
89
+ // TAM universe count — "how many ACCOUNTS match this ICP firmographic?"
90
+ //
91
+ // Verified empirically (2026-06-25) against the live providers, because the
92
+ // obvious endpoints lie about totals:
93
+ // - Explorium /v1/prospects `total_results` == the PAGE size (1→1, 10→10), not
94
+ // the universe — useless for sizing. So is /v1/businesses for tiny pages? No:
95
+ // - Explorium /v1/businesses `total_results` IS a real COUNT of matching
96
+ // companies (US+10000-emp → 9,754; Liechtenstein+10000 → 11), capped at
97
+ // EXPLORIUM_BUSINESS_COUNT_CAP. At/above the cap it saturates at exactly that
98
+ // number, so the caller must treat a capped reading as a FLOOR, not a count.
99
+ // - pipe0/Crustdata people search returns only a pagination cursor, NO total —
100
+ // it cannot size a universe at all (callers use it for discovery, not counting).
101
+ // So the TAM count source is Explorium /v1/businesses (a company/account count).
102
+ export const EXPLORIUM_BUSINESS_COUNT_CAP = 60_000;
103
+ /**
104
+ * Count the COMPANIES (accounts) matching an ICP firmographic via Explorium
105
+ * /v1/businesses. Returns the real total (capped at 60k — `capped` flags the
106
+ * ceiling) or null if the response carries no `total_results`. Use
107
+ * `icpToExploriumBusinessFilters` to build `filters` (firmographic field names
108
+ * differ from /v1/prospects).
109
+ */
110
+ export async function probeExploriumBusinessCount(opts) {
111
+ const fetchImpl = opts.fetchImpl ?? fetch;
112
+ const base = (opts.apiBaseUrl ?? "https://api.explorium.ai").replace(/\/$/, "");
113
+ const response = await fetchImpl(`${base}/v1/businesses`, {
114
+ method: "POST",
115
+ headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
116
+ // page_size 1: we want the envelope's total_results, not the rows. CRITICAL:
117
+ // do NOT send `size` — Explorium caps total_results to `size` when present
118
+ // (verified: size:1 → total_results:1; omitted → the real count, e.g. 19,058).
119
+ body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
120
+ });
121
+ if (!response.ok) {
122
+ throw new Error(`Explorium /v1/businesses count failed: HTTP ${response.status} ${await safeText(response)}`);
123
+ }
124
+ const body = (await response.json());
125
+ const total = body.total_results;
126
+ if (typeof total !== "number" || !Number.isFinite(total) || total < 0)
127
+ return null;
128
+ const rounded = Math.round(total);
129
+ return { total: rounded, capped: rounded >= EXPLORIUM_BUSINESS_COUNT_CAP };
130
+ }
88
131
  function normalizeLinkedin(value) {
89
132
  if (!value)
90
133
  return undefined;
@@ -37,29 +37,53 @@ export function createSalesforceConnector(options) {
37
37
  // confirmed miss is created, and an ambiguous name (>1 match) is refused.
38
38
  // Caches within the run so the same name is never created twice — shared by
39
39
  // `link_record`'s create:<Name> path and `create_record`'s company linking.
40
- async function resolveOrCreateAccountByName(name) {
41
- const nameKey = name.toLowerCase();
42
- const cached = createdAccountsByName.get(nameKey);
40
+ async function resolveOrCreateAccount(name, domain) {
41
+ const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
42
+ const cached = createdAccountsByName.get(cacheKey);
43
43
  if (cached)
44
44
  return { id: cached, createdNew: false };
45
- const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
46
- const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
47
- if (matches.length > 1)
48
- return { ambiguous: matches.length };
45
+ const esc = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
46
+ // 1. Domain-first match on Website (the accurate key; Website often stores a
47
+ // full URL, so match by substring).
48
+ if (domain) {
49
+ const byDomain = await query(`SELECT Id FROM Account WHERE Website LIKE '%${esc(domain)}%' LIMIT 3`);
50
+ if (byDomain.length > 1)
51
+ return { ambiguous: byDomain.length };
52
+ if (byDomain.length === 1) {
53
+ const id = String(byDomain[0].Id);
54
+ createdAccountsByName.set(cacheKey, id);
55
+ return { id, createdNew: false };
56
+ }
57
+ }
58
+ // 2. Exact-name match; stamp Website if the account has none (fill-blank).
59
+ const byName = await query(`SELECT Id, Website FROM Account WHERE Name = '${esc(name)}' LIMIT 3`);
60
+ if (byName.length > 1)
61
+ return { ambiguous: byName.length };
49
62
  let id;
50
63
  let createdNew = false;
51
- if (matches.length === 1) {
52
- id = String(matches[0].Id);
64
+ if (byName.length === 1) {
65
+ id = String(byName[0].Id);
66
+ if (domain && !stringOrUndefined(byName[0].Website)) {
67
+ try {
68
+ await request(`/services/data/${apiVersion}/sobjects/Account/${encodeURIComponent(id)}`, {
69
+ method: "PATCH",
70
+ body: JSON.stringify({ Website: domain }),
71
+ });
72
+ }
73
+ catch {
74
+ // best-effort fill — a failed Website stamp must not sink the create.
75
+ }
76
+ }
53
77
  }
54
78
  else {
55
79
  const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
56
80
  method: "POST",
57
- body: JSON.stringify({ Name: name }),
81
+ body: JSON.stringify({ Name: name, ...(domain ? { Website: domain } : {}) }),
58
82
  });
59
83
  id = String(created.id);
60
84
  createdNew = true;
61
85
  }
62
- createdAccountsByName.set(nameKey, id);
86
+ createdAccountsByName.set(cacheKey, id);
63
87
  return { id, createdNew };
64
88
  }
65
89
  async function request(path, init = {}) {
@@ -427,7 +451,7 @@ export function createSalesforceConnector(options) {
427
451
  return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
428
452
  }
429
453
  if (operation.objectType === "account") {
430
- const resolved = await resolveOrCreateAccountByName(matchValue);
454
+ const resolved = await resolveOrCreateAccount(matchValue);
431
455
  if ("ambiguous" in resolved) {
432
456
  return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
433
457
  }
@@ -465,7 +489,7 @@ export function createSalesforceConnector(options) {
465
489
  let companyNote = "";
466
490
  if (payload.associateCompanyName) {
467
491
  try {
468
- const resolved = await resolveOrCreateAccountByName(payload.associateCompanyName);
492
+ const resolved = await resolveOrCreateAccount(payload.associateCompanyName, payload.associateCompanyDomain);
469
493
  if ("ambiguous" in resolved) {
470
494
  companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
471
495
  }
@@ -474,7 +498,8 @@ export function createSalesforceConnector(options) {
474
498
  method: "PATCH",
475
499
  body: JSON.stringify({ AccountId: resolved.id }),
476
500
  });
477
- companyNote = ` Linked to account "${payload.associateCompanyName}" (${resolved.id}).`;
501
+ const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
502
+ companyNote = ` Linked to account "${payload.associateCompanyName}"${domainNote} (${resolved.id}).`;
478
503
  }
479
504
  }
480
505
  catch (error) {
@@ -617,7 +642,7 @@ export function createSalesforceConnector(options) {
617
642
  if (!name) {
618
643
  return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
619
644
  }
620
- const resolved = await resolveOrCreateAccountByName(name);
645
+ const resolved = await resolveOrCreateAccount(name);
621
646
  if ("ambiguous" in resolved) {
622
647
  return {
623
648
  operationId: operation.id,
@@ -0,0 +1,105 @@
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 type { SignalBucket, StagedSignalRow } from "../signals.ts";
22
+ export type SignalSourceShape = "pull" | "push";
23
+ export type SignalSourceAuth = "none" | "api_key" | "oauth";
24
+ /** Everything a source connector needs for one `fetch`, supplied by the CLI. */
25
+ export type SignalSourceContext = {
26
+ /** Accounts to scope a pull. May be empty (a file/spool source ignores it). */
27
+ watchlist: {
28
+ domain: string;
29
+ }[];
30
+ /** Evidence keywords for job/listing-style sources; may be empty. */
31
+ keywords: string[];
32
+ now: Date;
33
+ /** Injectable fetch for tests; global `fetch` by default. */
34
+ fetchImpl?: typeof fetch;
35
+ /**
36
+ * Credential-ladder lookup. Returns a usable secret for `provider`, or null
37
+ * when nothing is configured (the connector then returns []). Secrets NEVER
38
+ * arrive via argv — only through this.
39
+ */
40
+ getApiKey?: (provider: string) => Promise<string | null>;
41
+ /** Non-secret per-connector knobs from `--connector-opt k=v` (paths, queries). */
42
+ options?: Record<string, string>;
43
+ };
44
+ export type SignalSourceConnector = {
45
+ id: string;
46
+ /** Default bucket this source feeds (a row may still override it). */
47
+ bucket: SignalBucket;
48
+ shape: SignalSourceShape;
49
+ auth: SignalSourceAuth;
50
+ /**
51
+ * Produce staged rows now. Resilient by contract: the connector's own
52
+ * failures (offline, non-2xx, malformed payload, missing key) resolve to []
53
+ * rather than throwing, so one source's outage never aborts a multi-source
54
+ * `signals fetch`. Validation/evidence-gating of the returned rows happens
55
+ * centrally in `stagedRowToSignal`.
56
+ */
57
+ fetch(ctx: SignalSourceContext): Promise<StagedSignalRow[]>;
58
+ };
59
+ /**
60
+ * Read staged rows from a local path — the webhook landing-zone reader. The path
61
+ * (from `options.path`/`options.file`) may be:
62
+ * - a single FILE: a JSON array, or newline-delimited JSON (one row per line);
63
+ * - a DIRECTORY (the conventional spool): every `*.jsonl` / `*.json` file in
64
+ * it is read and concatenated (sorted by name), so multiple receivers can
65
+ * each append their own file (`rb2b.jsonl`, `hubspot.jsonl`) and they all
66
+ * land in one fetch.
67
+ * No auth. This is how every push platform (RB2B, Trigify, HubSpot webhooks)
68
+ * reaches the CLI: a receiver appends a row to the spool, this reads it on the
69
+ * next `signals fetch`. The CLI defaults the path to the conventional spool dir
70
+ * (`signalsSpoolDir`) when `--connector file` is used with no path; a bare
71
+ * library call with no path is inactive (returns []).
72
+ *
73
+ * Resilient: a missing path / unreadable file yields [] (an empty source, not a
74
+ * crash), and one unreadable file in a spool directory is skipped. A file that
75
+ * IS present but malformed throws — a corrupt spool is a real error to surface,
76
+ * not silent data loss. The central `stagedRowToSignal` gate validates rows.
77
+ */
78
+ export declare const fileSource: SignalSourceConnector;
79
+ /**
80
+ * Pull recent news per watchlist account and stage funding/company signals. One
81
+ * query per account (`q="<domain>"`, Google News engine); each result becomes a
82
+ * row whose verbatim `quote` is the headline (+ source), so the downstream judge
83
+ * can ground a why-now on a real, linkable article. API key via the credential
84
+ * ladder (provider "serpapi"); no key -> [] (the source is simply inactive).
85
+ *
86
+ * Bucket defaults to `funding`; override per run with `--connector-opt bucket=company`.
87
+ */
88
+ export declare const serpapiNewsSource: SignalSourceConnector;
89
+ /**
90
+ * Stage `demand` signals from recent HubSpot form submissions — the first real
91
+ * `demand`-bucket producer (a form fill is first-party demand). Reuses the
92
+ * EXISTING HubSpot credential via the ladder (provider "hubspot"); no separate
93
+ * login. Each submission whose email carries a company domain becomes a row
94
+ * whose verbatim `quote` is the form name + submitted email (the evidence a rep
95
+ * can verify). Submissions without a corporate domain (free-mail) are dropped —
96
+ * no account to attach demand to.
97
+ *
98
+ * Phase 1 is a pull over the Forms submissions API; the form-submission webhook
99
+ * (push) lands in Phase 2 via the spool + `file` source.
100
+ */
101
+ export declare const hubspotFormsSource: SignalSourceConnector;
102
+ /** All registered source connectors (stable order). */
103
+ export declare function listSignalSources(): SignalSourceConnector[];
104
+ /** Resolve a connector by id, or null when unknown. */
105
+ export declare function getSignalSource(id: string): SignalSourceConnector | null;