fullstackgtm 0.28.3 → 0.30.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.30.0] — 2026-06-17
9
+
10
+ Connector fixes — the two concrete defects a real HubSpot/Salesforce shop hits.
11
+
12
+ ### Added
13
+
14
+ - **Salesforce `dedupe`/`merge_records` now works** (Accounts and Contacts) via
15
+ the SOAP `merge()` call — REST has no merge resource, but the OAuth token
16
+ doubles as the SOAP session id, so no extra auth. Groups larger than three are
17
+ merged in batches (master + 2 per call); failures are reported honestly with
18
+ what merged before the stop. **Opportunities remain unmergeable** (Salesforce
19
+ exposes no opportunity merge anywhere) and are refused with that explanation.
20
+ Merges still flow through the approval gate and the irreversible-op drift guard
21
+ (the survivor/loser existence check runs before the SOAP call). Exercised by
22
+ unit tests; validate against a sandbox before wiring into automation.
23
+
24
+ ### Fixed
25
+
26
+ - **HubSpot closed/won detection is now pipeline-aware.** `isClosed`/`isWon`
27
+ were derived by substring-matching the stage against `closedwon`/`closedlost`,
28
+ so custom pipelines (opaque stage ids) read every deal as open and flooded
29
+ stale-deal / past-close findings with false positives. The connector now reads
30
+ the pipeline stage metadata (`/crm/v3/pipelines/deals`: `isClosed` +
31
+ `probability`) once per snapshot and derives closed/won from that, falling back
32
+ to the substring heuristic only when the pipelines read is unavailable.
33
+
34
+ ## [0.29.0] — 2026-06-16
35
+
36
+ ### Added
37
+
38
+ - **`suggestMarketConfig` and its taxonomy types (`SeedVendor`,
39
+ `SuggestTaxonomyOptions`, `SuggestTaxonomyResult`) are now exported from the
40
+ package root.** The cold-start claim-taxonomy bootstrap behind `market init
41
+ --auto` (shipped in 0.26.0) is now usable as a library API, not just the CLI —
42
+ a consumer can run it with an injected `fetchPage` (e.g. a browser-rendered
43
+ capture) and compose it with the already-exported `captureMarket` /
44
+ `classifyMarket` / `marketMapToHtml`. No behaviour change to the CLI.
45
+
8
46
  ## [0.28.3] — 2026-06-16
9
47
 
10
48
  Fix broken links in the published mirror. CONTRIBUTING.md and CLAUDE.md
package/README.md CHANGED
@@ -281,9 +281,9 @@ Connectors differ in what the provider's API allows — stated up front so nothi
281
281
  | Field writes (`set_field`, `clear_field`, `link_record`) | ✅ | ✅ | — |
282
282
  | `create_task` | ✅ | ✅ | — |
283
283
  | `archive_record` | ✅ | ✅ | — |
284
- | `merge_records` (`dedupe`) | ✅ | ❌ **not supported** | — |
284
+ | `merge_records` (`dedupe`) | ✅ | ✅ Account/Contact (SOAP); Opportunity | — |
285
285
 
286
- **Salesforce merge** has no REST resource it exists only in the SOAP API / Apex (Lead, Contact, Account, Case; max 3 records). This connector refuses a Salesforce `merge_records` operation honestly rather than half-merging; on Salesforce, deduplicate in the UI (or via SOAP/Apex), or use `bulk-update --archive` for the non-survivors. Native Salesforce merge is tracked future work, not a silent gap.
286
+ **Salesforce merge** uses the SOAP `merge()` call (REST has no merge resource), so `dedupe` works on Salesforce **Accounts and Contacts** — the OAuth token doubles as the SOAP session, and groups larger than three records are merged in batches (master + 2 per call). **Opportunities cannot be merged** (Salesforce exposes no opportunity merge in the API or the UI) — for duplicate opportunities, pick a survivor and close/archive the rest. As always, merges are irreversible and go through the same approval gate + drift guard as every other write. (Validate against a sandbox first if you're wiring it into automation — the SOAP path is exercised by unit tests but a live Salesforce org is the real proof.)
287
287
 
288
288
  ### HubSpot: create a private app (~2 minutes, needs super-admin)
289
289
 
@@ -54,6 +54,37 @@ export function createHubspotConnector(options) {
54
54
  const text = await response.text();
55
55
  return text ? JSON.parse(text) : null;
56
56
  }
57
+ /**
58
+ * Map every deal stage id → its actual closed/won semantics, read from the
59
+ * pipeline definitions. Custom pipelines use opaque stage ids (e.g.
60
+ * "1234567"), so deriving closed/won by substring-matching the stage value
61
+ * against "closedwon"/"closedlost" mis-reads every custom-pipeline deal as
62
+ * open. The pipeline's own stage metadata is authoritative: `isClosed` marks
63
+ * a closed stage and `probability` 1.0 = won, 0.0 = lost. Best-effort — if
64
+ * the read is unavailable (e.g. missing scope) callers fall back to the
65
+ * substring heuristic so a partial-scope token still produces a snapshot.
66
+ */
67
+ async function fetchDealStageClose() {
68
+ const map = new Map();
69
+ try {
70
+ const data = await request("/crm/v3/pipelines/deals");
71
+ for (const pipeline of data?.results ?? []) {
72
+ for (const stage of pipeline?.stages ?? []) {
73
+ if (!stage?.id)
74
+ continue;
75
+ const meta = stage.metadata ?? {};
76
+ const isClosed = String(meta.isClosed).toLowerCase() === "true";
77
+ const isWon = isClosed && Number(meta.probability) === 1;
78
+ map.set(String(stage.id), { isClosed, isWon });
79
+ }
80
+ }
81
+ }
82
+ catch {
83
+ // Pipeline metadata unavailable — leave the map empty; the caller falls
84
+ // back to the substring heuristic.
85
+ }
86
+ return map;
87
+ }
57
88
  async function list(path) {
58
89
  const results = [];
59
90
  let after;
@@ -137,6 +168,7 @@ export function createHubspotConnector(options) {
137
168
  };
138
169
  });
139
170
  const dealProperties = `${mappedFields(mappings, "deals", HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals).join(",")},${PROVENANCE_PROPERTIES}`;
171
+ const stageClose = await fetchDealStageClose();
140
172
  const hubspotDeals = await fetchObjects("deals", dealProperties, true);
141
173
  const deals = hubspotDeals
142
174
  .filter((deal) => deal.id)
@@ -145,13 +177,15 @@ export function createHubspotConnector(options) {
145
177
  const companyId = deal.associations?.companies?.results?.[0]?.id;
146
178
  const stage = stringOrUndefined(readMapped(props, "deals", "stage", "dealstage"));
147
179
  const normalizedStage = (stage ?? "").toLowerCase();
148
- const isWon = normalizedStage.includes("closedwon");
149
- const isClosed = isWon || normalizedStage.includes("closedlost");
150
- const forecastCategory = isWon
151
- ? "closed_won"
152
- : normalizedStage.includes("closedlost")
153
- ? "closed_lost"
154
- : "pipeline";
180
+ // Prefer the pipeline stage's actual closed/won metadata; fall back to
181
+ // the substring heuristic only when the stage isn't in the pipeline map
182
+ // (pipelines unreadable, or a deal on a deleted stage).
183
+ const stageMeta = stage ? stageClose.get(stage) : undefined;
184
+ const isWon = stageMeta ? stageMeta.isWon : normalizedStage.includes("closedwon");
185
+ const isClosed = stageMeta
186
+ ? stageMeta.isClosed
187
+ : isWon || normalizedStage.includes("closedlost");
188
+ const forecastCategory = isWon ? "closed_won" : isClosed ? "closed_lost" : "pipeline";
155
189
  const lastActivityAt = stringOrUndefined(readMapped(props, "deals", "lastActivityAt", "hs_last_sales_activity_timestamp"));
156
190
  return {
157
191
  id: String(deal.id),
@@ -5,6 +5,12 @@ const SOBJECT_TYPES = {
5
5
  contact: "Contact",
6
6
  deal: "Opportunity",
7
7
  };
8
+ // SObjects the SOAP merge() call supports. Opportunity is deliberately absent —
9
+ // Salesforce has no opportunity merge at all (API or UI).
10
+ const SOAP_MERGEABLE = {
11
+ account: "Account",
12
+ contact: "Contact",
13
+ };
8
14
  const MAPPING_OBJECT_TYPES = {
9
15
  account: "accounts",
10
16
  contact: "contacts",
@@ -282,6 +288,97 @@ export function createSalesforceConnector(options) {
282
288
  detail: `Deleted ${sobjectType}/${operation.objectId}.`,
283
289
  };
284
290
  }
291
+ function escapeXml(value) {
292
+ return value
293
+ .replace(/&/g, "&")
294
+ .replace(/</g, "&lt;")
295
+ .replace(/>/g, "&gt;")
296
+ .replace(/"/g, "&quot;")
297
+ .replace(/'/g, "&apos;");
298
+ }
299
+ /**
300
+ * One SOAP merge() call: master + up to 2 records to merge. Salesforce has no
301
+ * REST merge resource, but the Partner SOAP API's merge() works for Account,
302
+ * Contact, Lead, and Case, and accepts the OAuth access token as the SOAP
303
+ * session id. Returns ok/detail (the SOAP fault/error message is operational
304
+ * — MALFORMED_ID, entity-locked — not a data echo, so it's surfaced, capped).
305
+ */
306
+ async function soapMerge(sobjectType, masterId, loserIds) {
307
+ const connection = await options.getConnection();
308
+ const version = apiVersion.replace(/^v/, ""); // SOAP path uses "59.0", not "v59.0"
309
+ const url = `${connection.instanceUrl.replace(/\/$/, "")}/services/Soap/u/${version}`;
310
+ const toMerge = loserIds.map((id) => `<urn:recordToMergeIds>${escapeXml(id)}</urn:recordToMergeIds>`).join("");
311
+ const envelope = `<?xml version="1.0" encoding="UTF-8"?>` +
312
+ `<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:partner.soap.sforce.com" xmlns:urn1="urn:sobject.partner.soap.sforce.com">` +
313
+ `<soapenv:Header><urn:SessionHeader><urn:sessionId>${escapeXml(connection.accessToken)}</urn:sessionId></urn:SessionHeader></soapenv:Header>` +
314
+ `<soapenv:Body><urn:merge><urn:request>` +
315
+ `<urn:masterRecord><urn1:type>${sobjectType}</urn1:type><urn1:Id>${escapeXml(masterId)}</urn1:Id></urn:masterRecord>` +
316
+ toMerge +
317
+ `</urn:request></urn:merge></soapenv:Body></soapenv:Envelope>`;
318
+ let response;
319
+ try {
320
+ response = await fetchImpl(url, {
321
+ method: "POST",
322
+ headers: { "Content-Type": "text/xml; charset=UTF-8", SOAPAction: '""' },
323
+ body: envelope,
324
+ });
325
+ }
326
+ catch (error) {
327
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
328
+ return { ok: false, detail: `Cannot reach the Salesforce SOAP endpoint${cause}.` };
329
+ }
330
+ const text = await response.text().catch(() => "");
331
+ if (!response.ok) {
332
+ const fault = /<faultstring>([\s\S]*?)<\/faultstring>/.exec(text)?.[1]?.trim();
333
+ return { ok: false, detail: `Salesforce SOAP merge failed (HTTP ${response.status})${fault ? `: ${fault.slice(0, 200)}` : ""}.` };
334
+ }
335
+ if (!/<success>\s*true\s*<\/success>/i.test(text)) {
336
+ const message = /<message>([\s\S]*?)<\/message>/.exec(text)?.[1]?.trim();
337
+ return { ok: false, detail: `Salesforce rejected the merge${message ? `: ${message.slice(0, 200)}` : ""}.` };
338
+ }
339
+ return { ok: true };
340
+ }
341
+ async function mergeRecords(operation) {
342
+ const sobjectType = SOAP_MERGEABLE[operation.objectType];
343
+ if (!sobjectType) {
344
+ return {
345
+ operationId: operation.id,
346
+ status: "skipped",
347
+ detail: operation.objectType === "deal"
348
+ ? "Salesforce opportunities cannot be merged (no merge exists in the API or the UI). Pick a survivor and close/archive the duplicate opportunities instead."
349
+ : `Salesforce merge supports Account and Contact only (not ${operation.objectType}).`,
350
+ };
351
+ }
352
+ const master = operation.objectId;
353
+ const group = Array.isArray(operation.beforeValue) ? operation.beforeValue.map(String) : [];
354
+ const losers = group.filter((id) => id !== master);
355
+ if (losers.length === 0) {
356
+ return { operationId: operation.id, status: "skipped", detail: "Nothing to merge — no records besides the survivor." };
357
+ }
358
+ // SOAP merge() takes the master + at most 2 records per call; batch larger
359
+ // duplicate groups. A mid-batch failure is reported with what was merged so
360
+ // far (merges are irreversible).
361
+ const merged = [];
362
+ for (let i = 0; i < losers.length; i += 2) {
363
+ const batch = losers.slice(i, i + 2);
364
+ const result = await soapMerge(sobjectType, master, batch);
365
+ if (!result.ok) {
366
+ return {
367
+ operationId: operation.id,
368
+ status: "failed",
369
+ detail: `${result.detail} Merged ${merged.length} of ${losers.length} into ${master} before failing — IRREVERSIBLE; the remaining duplicates were not merged.`,
370
+ providerData: { survivorId: master, mergedRecordIds: merged },
371
+ };
372
+ }
373
+ merged.push(...batch);
374
+ }
375
+ return {
376
+ operationId: operation.id,
377
+ status: "applied",
378
+ detail: `Merged ${merged.length} ${sobjectType} record(s) into ${master} (irreversible).`,
379
+ providerData: { survivorId: master, mergedRecordIds: merged },
380
+ };
381
+ }
285
382
  async function applyOperation(operation) {
286
383
  try {
287
384
  switch (operation.operation) {
@@ -341,14 +438,7 @@ export function createSalesforceConnector(options) {
341
438
  case "create_task":
342
439
  return await createTask(operation);
343
440
  case "merge_records":
344
- // Salesforce merge exists only in the SOAP API and Apex (Lead,
345
- // Contact, Account, Case; max 3 records) — there is no REST merge
346
- // resource. Surface that honestly instead of half-merging.
347
- return {
348
- operationId: operation.id,
349
- status: "skipped",
350
- detail: "Salesforce merge requires the SOAP API or Apex (Lead/Contact/Account/Case only) — this REST connector cannot merge. Merge in the Salesforce UI, or archive the duplicates explicitly.",
351
- };
441
+ return await mergeRecords(operation);
352
442
  case "archive_record":
353
443
  return await archiveRecord(operation);
354
444
  default:
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ export { sampleSnapshot } from "./sampleData.ts";
27
27
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
28
28
  export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
29
29
  export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketAxis, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, type ScaleSignal, type SpanVerificationFailure, } from "./market.ts";
30
+ export { suggestMarketConfig, type SeedVendor, type SuggestTaxonomyOptions, type SuggestTaxonomyResult, } from "./marketTaxonomy.ts";
30
31
  export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
31
32
  export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type ClassifyMarketResult, type MarketWorksheet, } from "./marketClassify.ts";
32
33
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ export { sampleSnapshot } from "./sampleData.js";
27
27
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
28
28
  export { resolveRecord } from "./resolve.js";
29
29
  export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
30
+ export { suggestMarketConfig, } from "./marketTaxonomy.js";
30
31
  export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
31
32
  export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
32
33
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
@@ -148,11 +148,6 @@ against the 0.28 surface: still accurate, still open.
148
148
  Found by exercising the published package as a fresh RevOps user with a real
149
149
  CRM (2026-06):
150
150
 
151
- - **Pipeline-aware closed-deal detection (HubSpot).** `isClosed`/`isWon` are
152
- derived by substring-matching the raw `dealstage` value against
153
- `closedwon`/`closedlost`. Custom pipelines use opaque stage ids, so closed
154
- deals read as open and flood stale-deal/past-close findings. Fix: resolve
155
- stage metadata from `/crm/v3/pipelines` once per snapshot.
156
151
  - **Rate-limit resilience.** No 429/retry/backoff handling anywhere; a
157
152
  mid-size portal snapshot is ~1,000+ sequential page requests and one
158
153
  transient failure aborts the run. Fix: honor `Retry-After`, retry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.28.3",
3
+ "version": "0.30.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -88,6 +88,36 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
88
88
  return text ? JSON.parse(text) : null;
89
89
  }
90
90
 
91
+ /**
92
+ * Map every deal stage id → its actual closed/won semantics, read from the
93
+ * pipeline definitions. Custom pipelines use opaque stage ids (e.g.
94
+ * "1234567"), so deriving closed/won by substring-matching the stage value
95
+ * against "closedwon"/"closedlost" mis-reads every custom-pipeline deal as
96
+ * open. The pipeline's own stage metadata is authoritative: `isClosed` marks
97
+ * a closed stage and `probability` 1.0 = won, 0.0 = lost. Best-effort — if
98
+ * the read is unavailable (e.g. missing scope) callers fall back to the
99
+ * substring heuristic so a partial-scope token still produces a snapshot.
100
+ */
101
+ async function fetchDealStageClose(): Promise<Map<string, { isClosed: boolean; isWon: boolean }>> {
102
+ const map = new Map<string, { isClosed: boolean; isWon: boolean }>();
103
+ try {
104
+ const data = await request("/crm/v3/pipelines/deals");
105
+ for (const pipeline of data?.results ?? []) {
106
+ for (const stage of pipeline?.stages ?? []) {
107
+ if (!stage?.id) continue;
108
+ const meta = stage.metadata ?? {};
109
+ const isClosed = String(meta.isClosed).toLowerCase() === "true";
110
+ const isWon = isClosed && Number(meta.probability) === 1;
111
+ map.set(String(stage.id), { isClosed, isWon });
112
+ }
113
+ }
114
+ } catch {
115
+ // Pipeline metadata unavailable — leave the map empty; the caller falls
116
+ // back to the substring heuristic.
117
+ }
118
+ return map;
119
+ }
120
+
91
121
  async function list(path: string): Promise<any[]> {
92
122
  const results: any[] = [];
93
123
  let after: string | undefined;
@@ -204,6 +234,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
204
234
  "deals",
205
235
  HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals,
206
236
  ).join(",")},${PROVENANCE_PROPERTIES}`;
237
+ const stageClose = await fetchDealStageClose();
207
238
  const hubspotDeals = await fetchObjects("deals", dealProperties, true);
208
239
  const deals: CanonicalDeal[] = hubspotDeals
209
240
  .filter((deal) => deal.id)
@@ -212,13 +243,15 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
212
243
  const companyId = deal.associations?.companies?.results?.[0]?.id;
213
244
  const stage = stringOrUndefined(readMapped(props, "deals", "stage", "dealstage"));
214
245
  const normalizedStage = (stage ?? "").toLowerCase();
215
- const isWon = normalizedStage.includes("closedwon");
216
- const isClosed = isWon || normalizedStage.includes("closedlost");
217
- const forecastCategory = isWon
218
- ? "closed_won"
219
- : normalizedStage.includes("closedlost")
220
- ? "closed_lost"
221
- : "pipeline";
246
+ // Prefer the pipeline stage's actual closed/won metadata; fall back to
247
+ // the substring heuristic only when the stage isn't in the pipeline map
248
+ // (pipelines unreadable, or a deal on a deleted stage).
249
+ const stageMeta = stage ? stageClose.get(stage) : undefined;
250
+ const isWon = stageMeta ? stageMeta.isWon : normalizedStage.includes("closedwon");
251
+ const isClosed = stageMeta
252
+ ? stageMeta.isClosed
253
+ : isWon || normalizedStage.includes("closedlost");
254
+ const forecastCategory = isWon ? "closed_won" : isClosed ? "closed_lost" : "pipeline";
222
255
  const lastActivityAt = stringOrUndefined(
223
256
  readMapped(props, "deals", "lastActivityAt", "hs_last_sales_activity_timestamp"),
224
257
  );
@@ -42,6 +42,13 @@ const SOBJECT_TYPES: Partial<Record<GtmObjectType, string>> = {
42
42
  deal: "Opportunity",
43
43
  };
44
44
 
45
+ // SObjects the SOAP merge() call supports. Opportunity is deliberately absent —
46
+ // Salesforce has no opportunity merge at all (API or UI).
47
+ const SOAP_MERGEABLE: Partial<Record<GtmObjectType, string>> = {
48
+ account: "Account",
49
+ contact: "Contact",
50
+ };
51
+
45
52
  const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType, "owners">>> = {
46
53
  account: "accounts",
47
54
  contact: "contacts",
@@ -378,6 +385,105 @@ export function createSalesforceConnector(
378
385
  };
379
386
  }
380
387
 
388
+ function escapeXml(value: string): string {
389
+ return value
390
+ .replace(/&/g, "&amp;")
391
+ .replace(/</g, "&lt;")
392
+ .replace(/>/g, "&gt;")
393
+ .replace(/"/g, "&quot;")
394
+ .replace(/'/g, "&apos;");
395
+ }
396
+
397
+ /**
398
+ * One SOAP merge() call: master + up to 2 records to merge. Salesforce has no
399
+ * REST merge resource, but the Partner SOAP API's merge() works for Account,
400
+ * Contact, Lead, and Case, and accepts the OAuth access token as the SOAP
401
+ * session id. Returns ok/detail (the SOAP fault/error message is operational
402
+ * — MALFORMED_ID, entity-locked — not a data echo, so it's surfaced, capped).
403
+ */
404
+ async function soapMerge(
405
+ sobjectType: string,
406
+ masterId: string,
407
+ loserIds: string[],
408
+ ): Promise<{ ok: boolean; detail?: string }> {
409
+ const connection = await options.getConnection();
410
+ const version = apiVersion.replace(/^v/, ""); // SOAP path uses "59.0", not "v59.0"
411
+ const url = `${connection.instanceUrl.replace(/\/$/, "")}/services/Soap/u/${version}`;
412
+ const toMerge = loserIds.map((id) => `<urn:recordToMergeIds>${escapeXml(id)}</urn:recordToMergeIds>`).join("");
413
+ const envelope =
414
+ `<?xml version="1.0" encoding="UTF-8"?>` +
415
+ `<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:partner.soap.sforce.com" xmlns:urn1="urn:sobject.partner.soap.sforce.com">` +
416
+ `<soapenv:Header><urn:SessionHeader><urn:sessionId>${escapeXml(connection.accessToken)}</urn:sessionId></urn:SessionHeader></soapenv:Header>` +
417
+ `<soapenv:Body><urn:merge><urn:request>` +
418
+ `<urn:masterRecord><urn1:type>${sobjectType}</urn1:type><urn1:Id>${escapeXml(masterId)}</urn1:Id></urn:masterRecord>` +
419
+ toMerge +
420
+ `</urn:request></urn:merge></soapenv:Body></soapenv:Envelope>`;
421
+ let response: Response;
422
+ try {
423
+ response = await fetchImpl(url, {
424
+ method: "POST",
425
+ headers: { "Content-Type": "text/xml; charset=UTF-8", SOAPAction: '""' },
426
+ body: envelope,
427
+ });
428
+ } catch (error) {
429
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
430
+ return { ok: false, detail: `Cannot reach the Salesforce SOAP endpoint${cause}.` };
431
+ }
432
+ const text = await response.text().catch(() => "");
433
+ if (!response.ok) {
434
+ const fault = /<faultstring>([\s\S]*?)<\/faultstring>/.exec(text)?.[1]?.trim();
435
+ return { ok: false, detail: `Salesforce SOAP merge failed (HTTP ${response.status})${fault ? `: ${fault.slice(0, 200)}` : ""}.` };
436
+ }
437
+ if (!/<success>\s*true\s*<\/success>/i.test(text)) {
438
+ const message = /<message>([\s\S]*?)<\/message>/.exec(text)?.[1]?.trim();
439
+ return { ok: false, detail: `Salesforce rejected the merge${message ? `: ${message.slice(0, 200)}` : ""}.` };
440
+ }
441
+ return { ok: true };
442
+ }
443
+
444
+ async function mergeRecords(operation: PatchOperation): Promise<PatchOperationResult> {
445
+ const sobjectType = SOAP_MERGEABLE[operation.objectType];
446
+ if (!sobjectType) {
447
+ return {
448
+ operationId: operation.id,
449
+ status: "skipped",
450
+ detail:
451
+ operation.objectType === "deal"
452
+ ? "Salesforce opportunities cannot be merged (no merge exists in the API or the UI). Pick a survivor and close/archive the duplicate opportunities instead."
453
+ : `Salesforce merge supports Account and Contact only (not ${operation.objectType}).`,
454
+ };
455
+ }
456
+ const master = operation.objectId;
457
+ const group = Array.isArray(operation.beforeValue) ? (operation.beforeValue as unknown[]).map(String) : [];
458
+ const losers = group.filter((id) => id !== master);
459
+ if (losers.length === 0) {
460
+ return { operationId: operation.id, status: "skipped", detail: "Nothing to merge — no records besides the survivor." };
461
+ }
462
+ // SOAP merge() takes the master + at most 2 records per call; batch larger
463
+ // duplicate groups. A mid-batch failure is reported with what was merged so
464
+ // far (merges are irreversible).
465
+ const merged: string[] = [];
466
+ for (let i = 0; i < losers.length; i += 2) {
467
+ const batch = losers.slice(i, i + 2);
468
+ const result = await soapMerge(sobjectType, master, batch);
469
+ if (!result.ok) {
470
+ return {
471
+ operationId: operation.id,
472
+ status: "failed",
473
+ detail: `${result.detail} Merged ${merged.length} of ${losers.length} into ${master} before failing — IRREVERSIBLE; the remaining duplicates were not merged.`,
474
+ providerData: { survivorId: master, mergedRecordIds: merged },
475
+ };
476
+ }
477
+ merged.push(...batch);
478
+ }
479
+ return {
480
+ operationId: operation.id,
481
+ status: "applied",
482
+ detail: `Merged ${merged.length} ${sobjectType} record(s) into ${master} (irreversible).`,
483
+ providerData: { survivorId: master, mergedRecordIds: merged },
484
+ };
485
+ }
486
+
381
487
  async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
382
488
  try {
383
489
  switch (operation.operation) {
@@ -438,15 +544,7 @@ export function createSalesforceConnector(
438
544
  case "create_task":
439
545
  return await createTask(operation);
440
546
  case "merge_records":
441
- // Salesforce merge exists only in the SOAP API and Apex (Lead,
442
- // Contact, Account, Case; max 3 records) — there is no REST merge
443
- // resource. Surface that honestly instead of half-merging.
444
- return {
445
- operationId: operation.id,
446
- status: "skipped",
447
- detail:
448
- "Salesforce merge requires the SOAP API or Apex (Lead/Contact/Account/Case only) — this REST connector cannot merge. Merge in the Salesforce UI, or archive the duplicates explicitly.",
449
- };
547
+ return await mergeRecords(operation);
450
548
  case "archive_record":
451
549
  return await archiveRecord(operation);
452
550
  default:
package/src/index.ts CHANGED
@@ -226,6 +226,12 @@ export {
226
226
  type ScaleSignal,
227
227
  type SpanVerificationFailure,
228
228
  } from "./market.ts";
229
+ export {
230
+ suggestMarketConfig,
231
+ type SeedVendor,
232
+ type SuggestTaxonomyOptions,
233
+ type SuggestTaxonomyResult,
234
+ } from "./marketTaxonomy.ts";
229
235
  export {
230
236
  assessAxes,
231
237
  axesReportToText,