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