fullstackgtm 0.34.0 → 0.38.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 (59) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/INSTALL_FOR_AGENTS.md +8 -0
  3. package/README.md +39 -0
  4. package/dist/acquireLinkedIn.d.ts +41 -0
  5. package/dist/acquireLinkedIn.js +57 -0
  6. package/dist/acquireMeter.d.ts +67 -0
  7. package/dist/acquireMeter.js +145 -0
  8. package/dist/acquireSeen.d.ts +5 -0
  9. package/dist/acquireSeen.js +54 -0
  10. package/dist/assign.d.ts +83 -0
  11. package/dist/assign.js +146 -0
  12. package/dist/bin.js +14 -2
  13. package/dist/cli.js +817 -26
  14. package/dist/connectors/hubspot.js +140 -0
  15. package/dist/connectors/linkedin.d.ts +78 -0
  16. package/dist/connectors/linkedin.js +199 -0
  17. package/dist/connectors/prospectSources.d.ts +91 -0
  18. package/dist/connectors/prospectSources.js +227 -0
  19. package/dist/enrich.d.ts +107 -0
  20. package/dist/enrich.js +315 -5
  21. package/dist/format.d.ts +3 -1
  22. package/dist/format.js +14 -2
  23. package/dist/health.d.ts +71 -0
  24. package/dist/health.js +172 -0
  25. package/dist/icp.d.ts +96 -0
  26. package/dist/icp.js +256 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/mappings.js +3 -0
  30. package/dist/reassign.d.ts +11 -2
  31. package/dist/reassign.js +13 -6
  32. package/dist/runReport.d.ts +9 -0
  33. package/dist/runReport.js +66 -0
  34. package/dist/types.d.ts +25 -1
  35. package/docs/api.md +53 -3
  36. package/docs/architecture.md +11 -1
  37. package/docs/dx-punch-list.md +87 -0
  38. package/docs/linkedin-connector-spec.md +87 -0
  39. package/llms.txt +38 -1
  40. package/package.json +1 -1
  41. package/skills/fullstackgtm/SKILL.md +5 -3
  42. package/src/acquireLinkedIn.ts +83 -0
  43. package/src/acquireMeter.ts +186 -0
  44. package/src/acquireSeen.ts +57 -0
  45. package/src/assign.ts +193 -0
  46. package/src/bin.ts +17 -4
  47. package/src/cli.ts +965 -25
  48. package/src/connectors/hubspot.ts +145 -0
  49. package/src/connectors/linkedin.ts +272 -0
  50. package/src/connectors/prospectSources.ts +324 -0
  51. package/src/enrich.ts +411 -5
  52. package/src/format.ts +20 -2
  53. package/src/health.ts +238 -0
  54. package/src/icp.ts +313 -0
  55. package/src/index.ts +18 -0
  56. package/src/mappings.ts +3 -0
  57. package/src/reassign.ts +24 -8
  58. package/src/runReport.ts +76 -0
  59. package/src/types.ts +32 -0
@@ -13,6 +13,7 @@ import type {
13
13
  RecordProvenance,
14
14
  CanonicalGtmSnapshot,
15
15
  CanonicalUser,
16
+ CreateRecordPayload,
16
17
  GtmConnector,
17
18
  GtmObjectType,
18
19
  PatchOperation,
@@ -59,6 +60,10 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
59
60
  // search API is eventually consistent, so a just-created company is
60
61
  // invisible to search — this map is the authoritative same-run record.
61
62
  const createdCompaniesByName = new Map<string, string>();
63
+ // Same-run dedup for `create_record` contact creates, keyed by lowercased
64
+ // match value (email): HubSpot search is eventually consistent, so a contact
65
+ // created earlier in this apply run is invisible to a later search.
66
+ const createdContactsByMatch = new Map<string, string>();
62
67
 
63
68
  async function request(path: string, init: RequestInit = {}): Promise<any> {
64
69
  const token = await options.getAccessToken();
@@ -220,6 +225,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
220
225
  email: stringOrUndefined(readMapped(props, "contacts", "email", "email")),
221
226
  phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
222
227
  title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
228
+ linkedin: stringOrUndefined(readMapped(props, "contacts", "linkedin", "hs_linkedin_url")),
223
229
  ownerId: stringOrUndefined(
224
230
  readMapped(props, "contacts", "ownerId", "hubspot_owner_id"),
225
231
  ),
@@ -513,6 +519,143 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
513
519
  return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
514
520
  }
515
521
 
522
+ /** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
523
+ async function searchContactsBy(property: string, value: string): Promise<string[]> {
524
+ const data = await request(`/crm/v3/objects/contacts/search`, {
525
+ method: "POST",
526
+ body: JSON.stringify({
527
+ filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
528
+ properties: [property],
529
+ limit: 3,
530
+ }),
531
+ });
532
+ return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
533
+ }
534
+
535
+ /**
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.
539
+ */
540
+ async function resolveOrCreateCompanyByName(name: string, opId: string): Promise<string | null> {
541
+ const nameKey = name.toLowerCase();
542
+ const already = createdCompaniesByName.get(nameKey);
543
+ 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];
549
+ }
550
+ let created;
551
+ try {
552
+ created = await request(`/crm/v3/objects/companies`, {
553
+ method: "POST",
554
+ body: JSON.stringify({
555
+ properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
556
+ }),
557
+ });
558
+ } catch {
559
+ created = await request(`/crm/v3/objects/companies`, {
560
+ method: "POST",
561
+ body: JSON.stringify({ properties: { name } }),
562
+ });
563
+ }
564
+ const id = String(created.id);
565
+ createdCompaniesByName.set(nameKey, id);
566
+ return id;
567
+ }
568
+
569
+ /**
570
+ * Create a NET-NEW record (a sourced lead). Resolve-first: re-checks the
571
+ * dedupe key against the live CRM (the plan-time snapshot can be stale) and
572
+ * creates ONLY on a confirmed miss — a record a concurrent writer already
573
+ * added is returned as `skipped`, never duplicated. Contacts can be linked
574
+ * to a resolved-or-created company in the same step.
575
+ */
576
+ async function createRecord(operation: PatchOperation): Promise<PatchOperationResult> {
577
+ const payload = operation.afterValue as CreateRecordPayload | undefined;
578
+ if (!payload || typeof payload !== "object" || !payload.properties) {
579
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
580
+ }
581
+ const objectPath = OBJECT_PATHS[operation.objectType];
582
+ if (operation.objectType !== "contact" && operation.objectType !== "account") {
583
+ return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
584
+ }
585
+ const matchValue = String(payload.matchValue ?? "").trim();
586
+ if (!matchValue) {
587
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
588
+ }
589
+
590
+ if (operation.objectType === "account") {
591
+ const id = await resolveOrCreateCompanyByName(matchValue, operation.id);
592
+ if (id === null) {
593
+ return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
594
+ }
595
+ return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
596
+ }
597
+
598
+ // contact — resolve-first on the dedupe key (email by default)
599
+ const matchKey = payload.matchKey || "email";
600
+ const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
601
+ if (createdContactsByMatch.has(matchKeyLower)) {
602
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
603
+ }
604
+ const existing = await searchContactsBy(matchKey, matchValue);
605
+ if (existing.length > 0) {
606
+ createdContactsByMatch.set(matchKeyLower, existing[0]);
607
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
608
+ }
609
+
610
+ const properties: Record<string, string> = { ...payload.properties };
611
+ // Stamp ownership at create time so the lead is never born ownerless. The
612
+ // canonical ownerId maps to HubSpot's standard owner property.
613
+ if (payload.ownerId && !properties.hubspot_owner_id) {
614
+ properties.hubspot_owner_id = String(payload.ownerId);
615
+ }
616
+ let created;
617
+ try {
618
+ created = await request(`/crm/v3/objects/${objectPath}`, {
619
+ method: "POST",
620
+ body: JSON.stringify({ properties: { ...properties, hs_object_source_detail_2: `fullstackgtm acquire (${operation.id})` } }),
621
+ });
622
+ } catch {
623
+ // Some portals reject writes to source-detail properties — the provenance
624
+ // stamp is best-effort, the create is not.
625
+ created = await request(`/crm/v3/objects/${objectPath}`, {
626
+ method: "POST",
627
+ body: JSON.stringify({ properties }),
628
+ });
629
+ }
630
+ const contactId = String(created.id);
631
+ createdContactsByMatch.set(matchKeyLower, contactId);
632
+
633
+ let companyNote = "";
634
+ if (payload.associateCompanyName) {
635
+ try {
636
+ const companyId = await resolveOrCreateCompanyByName(payload.associateCompanyName, operation.id);
637
+ if (companyId) {
638
+ await request(
639
+ `/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
640
+ { method: "PUT" },
641
+ );
642
+ companyNote = ` Linked to company "${payload.associateCompanyName}" (${companyId}).`;
643
+ } else {
644
+ companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
645
+ }
646
+ } catch (error) {
647
+ // Association is best-effort — the contact create already succeeded.
648
+ companyNote = ` (company link failed: ${error instanceof Error ? error.message : String(error)})`;
649
+ }
650
+ }
651
+ return {
652
+ operationId: operation.id,
653
+ status: "applied",
654
+ detail: `Created contact ${matchKey}=${matchValue} (${contactId}).${companyNote}`,
655
+ providerData: { id: contactId, created: true },
656
+ };
657
+ }
658
+
516
659
  async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
517
660
  const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
518
661
  if (associationTypeId === undefined) {
@@ -707,6 +850,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
707
850
  return await linkRecord(operation);
708
851
  case "create_task":
709
852
  return await createTask(operation);
853
+ case "create_record":
854
+ return await createRecord(operation);
710
855
  case "merge_records":
711
856
  return await mergeRecords(operation);
712
857
  case "archive_record":
@@ -0,0 +1,272 @@
1
+ /**
2
+ * LinkedIn connector — Phase 1 discovery (read-only).
3
+ *
4
+ * A provider-agnostic surface for pulling prospects out of LinkedIn via a
5
+ * rented execution layer (default: HeyReach; Unipile is a drop-in alternative).
6
+ * Deliberately decoupled from the acquire spine — the `Icp`→filter and
7
+ * `LinkedInProspect`→`Prospect` bridges live in the `enrich acquire --source
8
+ * linkedin` wiring, not here — so this layer can land and be tested on its own.
9
+ *
10
+ * Phase 2 (future) extends `LinkedInProvider` with send actions
11
+ * (connect/message) routed through the plan/apply gate. Spec:
12
+ * docs/linkedin-connector-spec.md. Nothing here ever writes or sends.
13
+ */
14
+
15
+ export type LinkedInProspect = {
16
+ firstName: string;
17
+ lastName: string;
18
+ fullName?: string;
19
+ headline?: string;
20
+ jobTitle?: string;
21
+ company?: string;
22
+ /** Canonical LinkedIn profile URL — the identity key dedup uses downstream. */
23
+ profileUrl: string;
24
+ location?: string;
25
+ email?: string;
26
+ emailStatus?: string;
27
+ /** The raw provider payload, preserved for debugging and later mapping. */
28
+ raw?: unknown;
29
+ };
30
+
31
+ export type LinkedInSource = {
32
+ /** Provider-native id of a prospect source (HeyReach: a lead-list id). */
33
+ id: string;
34
+ name: string;
35
+ /** Approximate prospect count, when the provider reports it. */
36
+ count?: number;
37
+ };
38
+
39
+ export type FetchProspectsOptions = {
40
+ /** Which source to read (HeyReach lead-list id); provider may have a default. */
41
+ sourceId?: string;
42
+ /** Hard cap on prospects returned; also bounds pagination. */
43
+ max?: number;
44
+ };
45
+
46
+ export type ConnectionStatus = { ok: boolean; detail: string };
47
+
48
+ /**
49
+ * Provider-agnostic LinkedIn discovery surface (Phase 1). Read-only: it
50
+ * enumerates prospect sources and pulls normalized prospects, never sends.
51
+ */
52
+ export interface LinkedInProvider {
53
+ readonly name: string;
54
+ /** Cheap auth/connectivity check; never throws — returns ok:false instead. */
55
+ checkConnection(): Promise<ConnectionStatus>;
56
+ /** Enumerate prospect sources (HeyReach lead lists). */
57
+ listSources(): Promise<LinkedInSource[]>;
58
+ /** Pull prospects from a source, normalized + capped. */
59
+ fetchProspects(options?: FetchProspectsOptions): Promise<LinkedInProspect[]>;
60
+ }
61
+
62
+ // ── HeyReach adapter ────────────────────────────────────────────────────────
63
+ // Base + auth verified 2026-06-20: POST endpoints, `X-API-KEY` header, 300
64
+ // req/min (429). Confirmed live: /auth/CheckApiKey, /list/GetAll. The
65
+ // leads-from-list path has a documented gap in HeyReach's public docs and is
66
+ // marked to reconcile at live validation (step 3, needs a real key).
67
+
68
+ export const HEYREACH_BASE = "https://api.heyreach.io/api/public";
69
+ const HEYREACH_MAX_PAGE = 100;
70
+
71
+ export type HeyReachProviderOptions = {
72
+ apiKey: string;
73
+ fetchImpl?: typeof fetch;
74
+ baseUrl?: string;
75
+ /** Lead-list id used when fetchProspects is called without an explicit sourceId. */
76
+ defaultListId?: string;
77
+ /** Page size for paginated reads (≤ server max). Lower in tests. */
78
+ pageSize?: number;
79
+ };
80
+
81
+ export function createHeyReachProvider(options: HeyReachProviderOptions): LinkedInProvider {
82
+ const apiKey = options.apiKey;
83
+ if (!apiKey) {
84
+ throw new Error("HeyReach API key is required (`login heyreach` or HEYREACH_API_KEY).");
85
+ }
86
+ const fetchImpl = options.fetchImpl ?? fetch;
87
+ const baseUrl = (options.baseUrl ?? HEYREACH_BASE).replace(/\/$/, "");
88
+ const pageSize = Math.max(1, Math.min(options.pageSize ?? HEYREACH_MAX_PAGE, HEYREACH_MAX_PAGE));
89
+
90
+ async function call(path: string, method: "GET" | "POST", body?: unknown): Promise<any> {
91
+ let response: Response;
92
+ try {
93
+ response = await fetchImpl(`${baseUrl}${path}`, {
94
+ method,
95
+ // Secret travels in the header, never the URL (no leak via logs/history).
96
+ headers: {
97
+ "X-API-KEY": apiKey,
98
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
99
+ },
100
+ body: body !== undefined ? JSON.stringify(body) : undefined,
101
+ });
102
+ } catch {
103
+ throw new Error(`Cannot reach HeyReach at ${baseUrl}${path}. Check network access.`);
104
+ }
105
+ if (response.status === 429) {
106
+ const retry = response.headers.get("Retry-After");
107
+ throw new Error(
108
+ `HeyReach rate limit (429)${retry ? `; retry after ${retry}s` : ""}. The cap is 300 requests/min.`,
109
+ );
110
+ }
111
+ if (!response.ok) {
112
+ await response.text().catch(() => undefined);
113
+ throw new Error(`HeyReach API error ${response.status} on ${path}. Check the API key and request.`);
114
+ }
115
+ if (response.status === 204) return undefined;
116
+ return response.json().catch(() => undefined);
117
+ }
118
+
119
+ return {
120
+ name: "heyreach",
121
+
122
+ async checkConnection() {
123
+ try {
124
+ await call("/auth/CheckApiKey", "GET");
125
+ return { ok: true, detail: "HeyReach API key valid." };
126
+ } catch (error) {
127
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
128
+ }
129
+ },
130
+
131
+ async listSources() {
132
+ const data = await call("/list/GetAll", "POST", { offset: 0, limit: HEYREACH_MAX_PAGE });
133
+ return toArray(data).map((item: any) => ({
134
+ id: String(item?.id ?? item?.listId ?? ""),
135
+ name: String(item?.name ?? item?.title ?? "(unnamed list)"),
136
+ count:
137
+ typeof item?.totalItemsCount === "number"
138
+ ? item.totalItemsCount
139
+ : typeof item?.count === "number"
140
+ ? item.count
141
+ : undefined,
142
+ }));
143
+ },
144
+
145
+ async fetchProspects(opts = {}) {
146
+ const listId = opts.sourceId ?? options.defaultListId;
147
+ if (!listId) {
148
+ throw new Error(
149
+ "HeyReach fetchProspects needs a sourceId (lead-list id) or a configured defaultListId — list them with listSources().",
150
+ );
151
+ }
152
+ const max = opts.max ?? Number.POSITIVE_INFINITY;
153
+ const out: LinkedInProspect[] = [];
154
+ let offset = 0;
155
+ while (out.length < max) {
156
+ const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
157
+ const items = toArray(data);
158
+ if (items.length === 0) break;
159
+ for (const item of items) {
160
+ out.push(normalizeHeyReachLead(item));
161
+ if (out.length >= max) break;
162
+ }
163
+ if (items.length < pageSize) break;
164
+ offset += items.length;
165
+ }
166
+ return out;
167
+ },
168
+ };
169
+ }
170
+
171
+ /** First value that is a non-empty string, else undefined (treats "" as absent). */
172
+ function firstNonEmpty(...values: unknown[]): string | undefined {
173
+ for (const value of values) {
174
+ if (typeof value === "string" && value.trim() !== "") return value;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /** HeyReach wraps list payloads as `{ items: [...] }` on some routes, bare arrays on others. */
180
+ function toArray(data: any): any[] {
181
+ if (Array.isArray(data)) return data;
182
+ if (Array.isArray(data?.items)) return data.items;
183
+ return [];
184
+ }
185
+
186
+ /** Map a HeyReach lead (nested `profile` or flat) into the normalized shape. */
187
+ export function normalizeHeyReachLead(raw: any): LinkedInProspect {
188
+ const p = (raw && typeof raw === "object" && raw.profile) || raw || {};
189
+ const first = p.firstName ?? raw?.firstName ?? "";
190
+ const last = p.lastName ?? raw?.lastName ?? "";
191
+ const full = (p.fullName ?? raw?.fullName ?? `${first} ${last}`.trim()) || undefined;
192
+ return {
193
+ firstName: String(first ?? ""),
194
+ lastName: String(last ?? ""),
195
+ fullName: full,
196
+ headline: p.headline ?? raw?.headline ?? undefined,
197
+ jobTitle: p.position ?? p.jobTitle ?? raw?.position ?? undefined,
198
+ company: p.companyName ?? p.company ?? raw?.companyName ?? undefined,
199
+ profileUrl: String(p.profileUrl ?? p.linkedInUrl ?? raw?.profileUrl ?? raw?.linkedin_url ?? ""),
200
+ location: p.location ?? raw?.location ?? undefined,
201
+ // HeyReach exposes the raw `emailAddress` plus its own enrichment outputs
202
+ // (`enrichedEmailAddress`, `customEmailAddress`) — verified against the live
203
+ // /list/GetLeadsFromList schema 2026-06-20. Unresolved emails come back as
204
+ // "", so pick the first NON-EMPTY value (?? would keep the empty string).
205
+ email: firstNonEmpty(p.emailAddress, p.enrichedEmailAddress, p.customEmailAddress, p.email, raw?.emailAddress),
206
+ emailStatus: p.emailStatus ?? raw?.emailStatus ?? undefined,
207
+ raw,
208
+ };
209
+ }
210
+
211
+ // ── Fake provider (tests / dry-run with no key, no network) ─────────────────
212
+
213
+ export const FAKE_LINKEDIN_PROSPECTS: LinkedInProspect[] = [
214
+ {
215
+ firstName: "Dana",
216
+ lastName: "Okafor",
217
+ fullName: "Dana Okafor",
218
+ headline: "VP RevOps",
219
+ jobTitle: "VP Revenue Operations",
220
+ company: "Northwind Logistics",
221
+ profileUrl: "https://www.linkedin.com/in/dana-okafor",
222
+ location: "Chicago, IL",
223
+ email: "dana@northwind.example",
224
+ emailStatus: "verified",
225
+ },
226
+ {
227
+ firstName: "Priya",
228
+ lastName: "Raman",
229
+ fullName: "Priya Raman",
230
+ headline: "Head of Sales Ops",
231
+ jobTitle: "Head of Sales Operations",
232
+ company: "Cobalt Health",
233
+ profileUrl: "https://www.linkedin.com/in/priya-raman",
234
+ location: "Austin, TX",
235
+ },
236
+ {
237
+ firstName: "Marco",
238
+ lastName: "Bianchi",
239
+ fullName: "Marco Bianchi",
240
+ headline: "RevOps Manager",
241
+ jobTitle: "Revenue Operations Manager",
242
+ company: "Ferro Systems",
243
+ profileUrl: "https://www.linkedin.com/in/marco-bianchi",
244
+ location: "Remote",
245
+ email: "marco@ferro.example",
246
+ emailStatus: "guessed",
247
+ },
248
+ ];
249
+
250
+ export type FakeLinkedInOptions = {
251
+ prospects?: LinkedInProspect[];
252
+ sources?: LinkedInSource[];
253
+ connection?: ConnectionStatus;
254
+ };
255
+
256
+ export function createFakeLinkedInProvider(opts: FakeLinkedInOptions = {}): LinkedInProvider {
257
+ const prospects = opts.prospects ?? FAKE_LINKEDIN_PROSPECTS;
258
+ const sources = opts.sources ?? [{ id: "list_demo", name: "Demo ICP list", count: prospects.length }];
259
+ return {
260
+ name: "fake",
261
+ async checkConnection() {
262
+ return opts.connection ?? { ok: true, detail: "fake LinkedIn provider" };
263
+ },
264
+ async listSources() {
265
+ return sources.map((s) => ({ ...s }));
266
+ },
267
+ async fetchProspects(options = {}) {
268
+ const capped = options.max != null ? prospects.slice(0, options.max) : prospects;
269
+ return capped.map((p) => ({ ...p }));
270
+ },
271
+ };
272
+ }