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.
- package/CHANGELOG.md +273 -0
- package/README.md +18 -7
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +718 -60
- package/dist/connectors/hubspot.js +58 -24
- package/dist/connectors/outboxChannel.d.ts +64 -0
- package/dist/connectors/outboxChannel.js +170 -0
- package/dist/connectors/prospectSources.d.ts +22 -0
- package/dist/connectors/prospectSources.js +43 -0
- package/dist/connectors/salesforce.js +40 -15
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +316 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/draft.js +27 -5
- package/dist/enrich.d.ts +6 -0
- package/dist/enrich.js +47 -1
- package/dist/health.d.ts +12 -0
- package/dist/health.js +29 -2
- package/dist/icp.d.ts +47 -3
- package/dist/icp.js +105 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +143 -0
- package/dist/judge.d.ts +36 -3
- package/dist/judge.js +46 -10
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.js +18 -4
- package/dist/signals.d.ts +58 -0
- package/dist/signals.js +65 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +12 -0
- package/docs/api.md +26 -4
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +195 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +162 -0
- package/docs/tam.md +195 -0
- package/llms.txt +89 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +17 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +809 -55
- package/src/connectors/hubspot.ts +55 -23
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +39 -14
- package/src/connectors/signalSources.ts +363 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/draft.ts +26 -5
- package/src/enrich.ts +51 -1
- package/src/health.ts +47 -2
- package/src/icp.ts +113 -11
- package/src/index.ts +42 -0
- package/src/init.ts +166 -0
- package/src/judge.ts +85 -11
- package/src/judgeEval.ts +8 -1
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +20 -4
- package/src/signals.ts +95 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
package/llms.txt
CHANGED
|
@@ -15,10 +15,12 @@ at/above `--fail-on`.
|
|
|
15
15
|
- [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
|
|
16
16
|
- [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
|
|
17
17
|
- [Agent skill](https://github.com/fullstackgtm/core/blob/main/skills/fullstackgtm/SKILL.md): compact operating guide, installable via `npx skills add fullstackgtm/core`
|
|
18
|
+
- [Recipes](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md): five composable GTM plays over the primitives (cold-start lead-fill, the trigger→judge→draft outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated outbound) — the CLI ships primitives, your agent is the orchestrator, the package never sends
|
|
18
19
|
- [Architecture](https://github.com/fullstackgtm/core/blob/main/docs/architecture.md): module map + snapshot → audit → plan → apply data flow; "where do I add a rule/connector/operation"
|
|
19
20
|
- [Contributing](https://github.com/fullstackgtm/core/blob/main/CONTRIBUTING.md): dev setup, the open-core mirror model, the release ritual
|
|
20
21
|
- [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
|
|
21
22
|
- [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
|
|
23
|
+
- [TAM mapping](https://github.com/fullstackgtm/core/blob/main/docs/tam.md): size the reachable market from the ICP, then populate it with scheduled governed acquire and track coverage to an ETA
|
|
22
24
|
- [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
|
|
23
25
|
|
|
24
26
|
`fullstackgtm resolve <type>` is the create gate: exit 0 = safe to create,
|
|
@@ -57,6 +59,60 @@ uncertainty; report bubbles ∝ estimated share only when signals exist.
|
|
|
57
59
|
Storage is profile-scoped under `<home>/market/<category>`. MCP:
|
|
58
60
|
`fullstackgtm_market_worksheet`, `fullstackgtm_market_observe`.
|
|
59
61
|
|
|
62
|
+
## Key invariants (TAM mapping)
|
|
63
|
+
|
|
64
|
+
`fullstackgtm tam` sizes the reachable market FROM the ICP and then iteratively
|
|
65
|
+
fills it — not a one-shot headline. `tam estimate` computes a transparent model:
|
|
66
|
+
account universe × ACV = TAM (account/per-logo basis by default; `--acv-basis
|
|
67
|
+
buyer` for per-seat = contacts × ACV), where contacts = accounts ×
|
|
68
|
+
buyers/account is the population target. The account count is `--accounts <n>`
|
|
69
|
+
(assumption), OR `--source theirstack` — the TECHNOGRAPHIC universe: counts
|
|
70
|
+
companies that USE a CRM/MAP (`firmographics.technologies` → TheirStack
|
|
71
|
+
`company_technology_slug_or`) + geo + employee bounds; the real RevOps buying signal
|
|
72
|
+
AND the only source that returns a LIST (`tam accounts --source theirstack` pulls
|
|
73
|
+
real companies name/domain, `--out csv`/`--json`, `--max` caps credit spend; a
|
|
74
|
+
count is cheap but NOT free — limit:0 is rejected so a count returns 1 row ≈ 3
|
|
75
|
+
credits, a list pull ≈ 3/company; `login theirstack`). OR `--source explorium` — a firmographic
|
|
76
|
+
count via `/v1/businesses` (`total_results`), a count ONLY (Explorium 403s on
|
|
77
|
+
list-pull, can't target by CRM-usage; must OMIT `size` or it caps the total; capped
|
|
78
|
+
at 60,000 `EXPLORIUM_BUSINESS_COUNT_CAP` → `provider:explorium (≥60k cap — floor)` +
|
|
79
|
+
⚠). Verified live: people endpoints can't size a market (Explorium `/v1/prospects`
|
|
80
|
+
total == page size; pipe0/Crustdata returns only a cursor — `--source pipe0` for
|
|
81
|
+
estimate is REJECTED, it's a population source). The model always LABELS
|
|
82
|
+
`accountsSource` (provider:theirstack/explorium vs assumption). ACV must be a
|
|
83
|
+
real ANNUAL figure the operator CONFIRMS — `--acv <annual-usd>`, OR `--acv-from-crm
|
|
84
|
+
--deal-period monthly|quarterly|annual` which takes the median closed-won amount and
|
|
85
|
+
ANNUALIZES it (the period is required — a deal can be MRR/quarterly/annual and
|
|
86
|
+
guessing is a 4–12× error; $15k/mo → $180k). NO band defaults, and a bare
|
|
87
|
+
`--provider` does NOT auto-set ACV (HubSpot is the COVERAGE source, not the ACV) —
|
|
88
|
+
`estimate` REFUSES without a confirmed ACV. Buyers/account is `--buyers-per-account`
|
|
89
|
+
OR the CRM's avg-contacts-per-account OR a labeled `assumption:1-buyer`. Both
|
|
90
|
+
`acv.source` and `universe.buyersSource` are labeled (incl. deal count + the
|
|
91
|
+
period×factor, so a thin signal like "1 deal" is visible). Optional
|
|
92
|
+
citable cross-checks (`{claim,valueUsd?,sourceUrl,quote}`) sit beside the
|
|
93
|
+
bottom-up number as a top-down sanity check. `estimate`
|
|
94
|
+
records a coverage BASELINE (CRM counts now) so `status` attributes what the
|
|
95
|
+
campaign added. `tam populate --cron` schedules `enrich acquire --source <s>
|
|
96
|
+
--save` — the ONE create-side verb the schedule allowlist permits, and only in
|
|
97
|
+
its plan-producing `--save` form: each firing queues a needs_approval lead plan,
|
|
98
|
+
the acquire meter is charged only at apply, and apply stays `apply --plan-id`
|
|
99
|
+
(re-checked approved). `tam status` measures coverage on the INTERSECTION of CRM ×
|
|
100
|
+
TAM-ICP, not a raw count: `tam estimate` stores the ICP filter (`model.targeting`),
|
|
101
|
+
and status CLASSIFIES each domain-bearing account into in-TAM / out-of-TAM / unknown
|
|
102
|
+
(checked on size+industry — geo + "uses-CRM" aren't on a CanonicalAccount, so a
|
|
103
|
+
`--reverify` re-enrich is future; the classifier labels what it checked). ONLY
|
|
104
|
+
in-TAM counts toward coverage, so off-ICP accounts loaded from elsewhere can't
|
|
105
|
+
inflate it. Bottom-up vs top-down reconciliation: the in-TAM count is a FLOOR on the
|
|
106
|
+
real universe (those are verified members), so when in-TAM ≥ the estimate, status
|
|
107
|
+
stops reporting a fake 100% and flips to "estimate was a floor — real market is at
|
|
108
|
+
least N (`reconciledUniverse = max(estimate, inTam)`); re-estimate for the headroom".
|
|
109
|
+
`--save` stamps an append-only timeline. `tam report` (markdown) and `status` project
|
|
110
|
+
an ETA on the IN-TAM accounts/day burn across the timeline → days-remaining → fill
|
|
111
|
+
date — and REFUSE to project (honest "not enough history") below two readings, a flat
|
|
112
|
+
rate, or zero elapsed time.
|
|
113
|
+
Coverage + ETA are deterministic (no LLM, byte-stable). Storage profile-scoped
|
|
114
|
+
under `<home>/tam/<name>/` (`model.json` + `coverage.jsonl`, 0600).
|
|
115
|
+
|
|
60
116
|
## Key invariants (governed write verbs)
|
|
61
117
|
|
|
62
118
|
`bulk-update <object> --where … (--set|--archive|--create-task)` filters the
|
|
@@ -117,12 +173,44 @@ injectable `LinkedInProvider` (default HeyReach; `FakeLinkedInProvider` for test
|
|
|
117
173
|
LinkedIn is read-only — Phase 1 has no `linkedin_connect`/`linkedin_message` ops
|
|
118
174
|
and no webhook ingestion (Phase 2). Never auto-writes.
|
|
119
175
|
|
|
176
|
+
## Key invariants (GTM brain — signals / icp / judge / draft)
|
|
177
|
+
|
|
178
|
+
The timing/outbound layer turns "who changed" into one grounded, governed
|
|
179
|
+
opener — and **never sends**. `signals fetch` captures fresh buying triggers
|
|
180
|
+
into a profile-scoped ledger (free Greenhouse/Lever/Ashby ATS scrapers in the
|
|
181
|
+
box; funding/social via `--from`; connected platforms via `--connector`
|
|
182
|
+
file/serpapi-news/hubspot-forms with secrets through the credential ladder; and
|
|
183
|
+
push platforms via the webhook spool — `--connector file` reads the conventional
|
|
184
|
+
landing zone `<home>/signals/spool/*.jsonl`, see docs/signal-spool-format.md),
|
|
185
|
+
keyed by **account domain**, ranked by learned `weights`; it writes NOTHING to
|
|
186
|
+
the CRM. `icp interview|set|show`
|
|
187
|
+
builds `icp.json`; `icp judge` ranks fresh signals into send/nurture/skip — pass
|
|
188
|
+
a snapshot (`--provider`/`--input`/`--demo`) and each decision resolves its CRM
|
|
189
|
+
target: `accountId`, the best `contact {id,email,title}`, and `contacts[]` (all
|
|
190
|
+
in-CRM contacts at the account, titled-first, cap 10, for multi-threading);
|
|
191
|
+
`--with-history` additionally gates memory (don't re-touch a recently-touched
|
|
192
|
+
account) and fit-scoring. `icp eval` is the calibration gate (exit 2 below the
|
|
193
|
+
bar). `draft` emits ONE trigger-grounded opener per hot account as a governed
|
|
194
|
+
`create_task` targeting the resolved `contact.id` (or `accountId`) — a
|
|
195
|
+
domain-only decision (account not yet in the CRM) is REJECTED with "acquire it
|
|
196
|
+
first" (run `enrich acquire`, re-judge with the snapshot). `--channel
|
|
197
|
+
email|linkedin|task` shapes the opener. After `plans approve`, applying the draft
|
|
198
|
+
plan logs the touch as a CRM task (`apply --provider <crm>`) OR renders it to the
|
|
199
|
+
governed outbox (`apply --channel outbox` → `<home>/signals/outbox/<channel>.jsonl`
|
|
200
|
+
for a downstream sender to drain). Either way the CLI **transmits nothing** — the
|
|
201
|
+
outbox is the send-side mirror of the spool; the always-on sender is hosted/opt-in. `signals outcome --account <domain>
|
|
202
|
+
--contact <id> --result replied|...` credits the contact reached so weights
|
|
203
|
+
re-learn. The contact↔account join (`contact.accountId` + `account.domain`) is
|
|
204
|
+
surfaced at every hop, so the loop is contact-coherent end to end — see the
|
|
205
|
+
[recipes](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md).
|
|
206
|
+
|
|
120
207
|
## Key invariants (schedule)
|
|
121
208
|
|
|
122
209
|
`fullstackgtm schedule` is the horizontal scheduler — no feature namespace
|
|
123
210
|
owns cron logic. `add "<command>" --cron "<expr>"` validates the command
|
|
124
211
|
against the read/plan-side allowlist (audit, snapshot, enrich append|refresh,
|
|
125
|
-
market capture|refresh,
|
|
212
|
+
enrich acquire --save, market capture|refresh, signals fetch, icp judge|eval,
|
|
213
|
+
draft, suggest, report, doctor) and the 5-field cron
|
|
126
214
|
expression, but touches no crontab; `install` materializes enabled entries
|
|
127
215
|
into a sentinel-delimited managed block (`# >>> fullstackgtm <profile> >>>`)
|
|
128
216
|
replaced wholesale on re-install, lines outside it untouched; `uninstall`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.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)",
|
|
@@ -62,8 +62,12 @@ credentials AND stored plans per client org.
|
|
|
62
62
|
| `fix --rule <id>` | audit one rule → suggest → approve at the confidence bar → apply only with `--yes` |
|
|
63
63
|
| `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
|
|
64
64
|
| `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
|
|
65
|
-
| `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |
|
|
65
|
+
| `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); links each lead to a domain-stamped, signal-watchable account; `acquire.create.company` acquires accounts (ABM); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |
|
|
66
|
+
| `signals fetch\|list\|outcome\|weights` | Detect-side timing layer: capture fresh buying triggers into a profile-scoped ledger (free Greenhouse/Lever/Ashby ATS in the box; `--from` staged files; `--connector file\|serpapi-news\|hubspot-forms` to pull connected platforms / read the webhook spool `<home>/signals/spool`, secrets via the credential ladder), ranked by learned weights. Writes NOTHING to the CRM; `outcome --account <domain> --contact <id> --result …` re-weights which triggers earn a touch |
|
|
67
|
+
| `icp interview\|set\|show\|judge\|eval` | Build the ICP, then `judge` ranks fresh signals into send/nurture/skip — pass a snapshot (`--provider`/`--input`) and each decision resolves its CRM target (`accountId` + the `contact`/`contacts` to reach). `eval` is the calibration gate (exit 2 below the bar) |
|
|
68
|
+
| `draft [--from-judge latest] [--channel email\|linkedin\|task]` | One trigger-grounded opener per hot account → a governed `create_task` targeting the resolved `contact.id` (or `accountId`); rejects a domain-only decision ("acquire it first"). **Never sends** — after `plans approve`, `apply --provider <crm>` logs it as a CRM task, or `apply --channel outbox` renders it to `<home>/signals/outbox/<channel>.jsonl` for a downstream sender to drain; the CLI transmits nothing |
|
|
66
69
|
| `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
|
|
70
|
+
| `tam estimate\|accounts\|status\|report\|populate` | Size the reachable market FROM the ICP (real account count × real ACV; buyers/account → contact target), then iteratively fill it: `populate` schedules plan-only `enrich acquire --save` (apply stays gated), `status` classifies CRM accounts vs the TAM ICP into **in-TAM / out-of-TAM / unknown** (only in-TAM counts — junk loaded from elsewhere doesn't inflate coverage; checked on size+industry, geo/tech need re-enrich) and flips to "estimate was a FLOOR, revise up" when bottom-up in-CRM exceeds the top-down estimate; `--save` stamps a timeline, `report` projects burn-up + ETA on the in-TAM rate. **`estimate --source theirstack`** counts companies that USE a CRM/MAP (`firmographics.technologies`) — the real RevOps universe — and **`tam accounts --source theirstack`** pulls the actual company LIST (names+domains, `--out csv`) at ~3 credits/company — `--dry-run [--usd-per-credit <r>]` prices the pull + full TAM for 0 credits, `--confirm` gates pulls above `--max-credits` (default 150). `--source explorium` = firmographic count only (no list, 60k cap = floor); else `--accounts`. **ACV must be a real ANNUAL figure you confirm** — `--acv <annual-usd>`, or `--acv-from-crm --deal-period monthly\|quarterly\|annual` (median closed-won, annualized); NO band defaults, a bare `--provider` does NOT set ACV (HubSpot=coverage), refuses without one. Buyers/account = CRM avg-contacts or explicit. Every number labeled with its source |
|
|
67
71
|
| `schedule add\|list\|remove\|enable\|disable\|run\|install\|uninstall\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
|
|
68
72
|
| `report` | Client-ready audit deliverable (markdown or self-contained HTML) |
|
|
69
73
|
| `health [--json]` | Per-profile CRM health timeline: deterministic 0–100 score, trend, per-rule deltas — accrues from `audit --save`, read-only |
|
|
@@ -84,8 +88,20 @@ Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
|
|
|
84
88
|
`fullstackgtm_resolve`, `fullstackgtm_market_worksheet`,
|
|
85
89
|
`fullstackgtm_market_observe`.
|
|
86
90
|
|
|
91
|
+
## Composing the primitives into plays
|
|
92
|
+
|
|
93
|
+
This CLI ships governed **primitives** — there is no `outbound` mega-command.
|
|
94
|
+
**You (the agent) are the orchestrator:** chain these verbs into the play the
|
|
95
|
+
operator wants, surface the one approve gate, and bridge the last mile to the
|
|
96
|
+
sender (**the package never sends**). [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md)
|
|
97
|
+
has five worked plays — cold-start lead-fill, the trigger→judge→draft outbound
|
|
98
|
+
loop, scheduled-continuous, ABM-from-companies, and hygiene-gated outbound.
|
|
99
|
+
`fullstackgtm init` scaffolds a workspace (icp.json + enrich config + a PLAYBOOK
|
|
100
|
+
pointing at those recipes) to start from.
|
|
101
|
+
|
|
87
102
|
## Going deeper
|
|
88
103
|
|
|
104
|
+
- [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md) — five composable GTM plays over the primitives (cold-start, outbound loop, scheduled, ABM, hygiene-gated)
|
|
89
105
|
- [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule, engagement/health)
|
|
90
106
|
- [INSTALL_FOR_AGENTS.md](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md) — deterministic install-and-verify with expected outputs
|
|
91
107
|
- [docs/api.md](https://github.com/fullstackgtm/core/blob/main/docs/api.md) — semver-covered surfaces: canonical model, rule interface, plan/apply contract, connectors, config, CLI, MCP
|
package/src/calls.ts
CHANGED
|
@@ -369,6 +369,15 @@ export type CallDealSuggestion = {
|
|
|
369
369
|
dealName?: string;
|
|
370
370
|
accountId?: string;
|
|
371
371
|
accountName?: string;
|
|
372
|
+
/**
|
|
373
|
+
* HOW the deal's account was matched — `account_domain` (the company-wide,
|
|
374
|
+
* reliable signal) vs `contact_email` (a specific person, possibly stale) vs
|
|
375
|
+
* `both`. An agent needs this to weight the match; the bare confidence hides it.
|
|
376
|
+
*/
|
|
377
|
+
resolvedVia?: "account_domain" | "contact_email" | "both";
|
|
378
|
+
/** The attendee contacts that matched (the email path), so the contact↔account
|
|
379
|
+
* hop is visible, not just the resolved account. */
|
|
380
|
+
matchedContacts?: { id: string; email?: string; accountId?: string }[];
|
|
372
381
|
confidence: "high" | "low" | "none";
|
|
373
382
|
reason: string;
|
|
374
383
|
};
|
|
@@ -393,17 +402,27 @@ export function suggestCallDeal(
|
|
|
393
402
|
}
|
|
394
403
|
|
|
395
404
|
const accountIds = new Set<string>();
|
|
405
|
+
let viaDomain = false;
|
|
406
|
+
let viaEmail = false;
|
|
407
|
+
const matchedContacts: { id: string; email?: string; accountId?: string }[] = [];
|
|
396
408
|
for (const account of snapshot.accounts) {
|
|
397
409
|
const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
|
|
398
|
-
if (domain && domains.has(domain))
|
|
410
|
+
if (domain && domains.has(domain)) {
|
|
411
|
+
accountIds.add(account.id);
|
|
412
|
+
viaDomain = true;
|
|
413
|
+
}
|
|
399
414
|
}
|
|
400
415
|
for (const contact of snapshot.contacts) {
|
|
401
416
|
const email = contact.email?.trim().toLowerCase();
|
|
402
417
|
const at = email ? email.indexOf("@") : -1;
|
|
403
418
|
if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
|
|
404
419
|
accountIds.add(contact.accountId);
|
|
420
|
+
viaEmail = true;
|
|
421
|
+
matchedContacts.push({ id: contact.id, ...(contact.email ? { email: contact.email } : {}), accountId: contact.accountId });
|
|
405
422
|
}
|
|
406
423
|
}
|
|
424
|
+
const resolvedVia = viaDomain && viaEmail ? "both" : viaDomain ? "account_domain" : "contact_email";
|
|
425
|
+
const via = { resolvedVia, ...(matchedContacts.length ? { matchedContacts } : {}) } as const;
|
|
407
426
|
if (accountIds.size === 0) {
|
|
408
427
|
return {
|
|
409
428
|
dealId: null,
|
|
@@ -432,8 +451,9 @@ export function suggestCallDeal(
|
|
|
432
451
|
dealName: top.name,
|
|
433
452
|
accountId: top.accountId,
|
|
434
453
|
accountName: account?.name,
|
|
454
|
+
...via,
|
|
435
455
|
confidence: "high",
|
|
436
|
-
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
|
|
456
|
+
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}" (via ${resolvedVia}).`,
|
|
437
457
|
};
|
|
438
458
|
}
|
|
439
459
|
return {
|
|
@@ -441,8 +461,9 @@ export function suggestCallDeal(
|
|
|
441
461
|
dealName: top.name,
|
|
442
462
|
accountId: top.accountId,
|
|
443
463
|
accountName: account?.name,
|
|
464
|
+
...via,
|
|
444
465
|
confidence: "low",
|
|
445
|
-
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
|
|
466
|
+
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity (matched via ${resolvedVia}). Confirm before writing.`,
|
|
446
467
|
};
|
|
447
468
|
}
|
|
448
469
|
|