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.
- package/CHANGELOG.md +141 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +39 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/acquireMeter.d.ts +67 -0
- package/dist/acquireMeter.js +145 -0
- package/dist/acquireSeen.d.ts +5 -0
- package/dist/acquireSeen.js +54 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +817 -26
- package/dist/connectors/hubspot.js +140 -0
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/connectors/prospectSources.d.ts +91 -0
- package/dist/connectors/prospectSources.js +227 -0
- package/dist/enrich.d.ts +107 -0
- package/dist/enrich.js +315 -5
- package/dist/format.d.ts +3 -1
- package/dist/format.js +14 -2
- package/dist/health.d.ts +71 -0
- package/dist/health.js +172 -0
- package/dist/icp.d.ts +96 -0
- package/dist/icp.js +256 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mappings.js +3 -0
- package/dist/reassign.d.ts +11 -2
- package/dist/reassign.js +13 -6
- package/dist/runReport.d.ts +9 -0
- package/dist/runReport.js +66 -0
- package/dist/types.d.ts +25 -1
- package/docs/api.md +53 -3
- package/docs/architecture.md +11 -1
- package/docs/dx-punch-list.md +87 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +38 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +5 -3
- package/src/acquireLinkedIn.ts +83 -0
- package/src/acquireMeter.ts +186 -0
- package/src/acquireSeen.ts +57 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +965 -25
- package/src/connectors/hubspot.ts +145 -0
- package/src/connectors/linkedin.ts +272 -0
- package/src/connectors/prospectSources.ts +324 -0
- package/src/enrich.ts +411 -5
- package/src/format.ts +20 -2
- package/src/health.ts +238 -0
- package/src/icp.ts +313 -0
- package/src/index.ts +18 -0
- package/src/mappings.ts +3 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,147 @@ 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
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.38.0] — 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **CLI run observability — paired CLIs report each run to the hosted dashboard.**
|
|
15
|
+
When the CLI is paired (`login --via <hosted url>`), a best-effort run record —
|
|
16
|
+
command, status (`success`/`partial`/`error`), duration, headline counts, and
|
|
17
|
+
structured events (plan saved, meter charged) — is POSTed to the deployment's
|
|
18
|
+
run timeline after each command (`runReport.ts`: `reportCounts` / `reportEvent`
|
|
19
|
+
/ `flushRunReport`). Opt-in by pairing (an unpaired CLI sends nothing), 4s-capped,
|
|
20
|
+
swallows every error, and never affects the command's exit code; setup/inspection
|
|
21
|
+
verbs (`login`, `doctor`, `help`, `--version`) are skipped. The hosted dashboard
|
|
22
|
+
re-maps onto these runs (`/dashboard/runs`).
|
|
23
|
+
|
|
24
|
+
- **Lead assignment — acquired leads are never born ownerless.** A shared
|
|
25
|
+
`AssignmentPolicy` (`assign.ts`: `fixed` / `round-robin` / `territory` /
|
|
26
|
+
`account-owner`) routes an owner onto every record, consumed in two places:
|
|
27
|
+
- **`enrich acquire`** stamps the resolved owner into each `create_record`
|
|
28
|
+
payload (`EnrichConfig.acquire.assign`); the HubSpot connector maps it to
|
|
29
|
+
`hubspot_owner_id` at create time. Round-robin distributes by the within-run
|
|
30
|
+
index (deterministic, no persisted cursor); every result is gated by the
|
|
31
|
+
snapshot's active owners, so an unknown/inactive owner collapses to
|
|
32
|
+
unassigned rather than writing a bad owner. The CLI defaults to the portal's
|
|
33
|
+
sole active owner when no policy is set (or `--assign-owner <id>`), and warns
|
|
34
|
+
+ leaves leads unassigned when several owners exist and no policy says how to
|
|
35
|
+
route. `AcquireCounts` now reports `assigned` / `unassigned`.
|
|
36
|
+
- **`reassign --assign-unowned --to <ownerId>`** claims every existing
|
|
37
|
+
ownerless record (`ownerId:empty`) for an owner — the backfill twin of
|
|
38
|
+
acquire's create-time assignment.
|
|
39
|
+
|
|
40
|
+
- **`enrich acquire --source linkedin` — governed LinkedIn lead acquisition
|
|
41
|
+
(Phase 1, discovery + dry-run).** LinkedIn is a new discovery source on the
|
|
42
|
+
existing acquire spine: it reads a pre-built **HeyReach** lead list
|
|
43
|
+
(`--list <id>` or `acquire.discovery.linkedin.listId`), normalizes leads to
|
|
44
|
+
the canonical `Prospect` type, then reuses ICP scoring, pre-email dedup, the
|
|
45
|
+
acquire meter, and the dry-run → approve → apply gate unchanged. The LinkedIn
|
|
46
|
+
profile URL is the match key; cost is `$0`/record (the list is already built).
|
|
47
|
+
- **`LinkedInProvider`** interface (`connectors/linkedin.ts`) with a default
|
|
48
|
+
**HeyReach** adapter (`HEYREACH_BASE`, `X-API-KEY`) and a
|
|
49
|
+
`FakeLinkedInProvider` so the whole pipeline runs with no key and no network.
|
|
50
|
+
`createLinkedInProvider` rejects unknown providers (`heyreach` only for now;
|
|
51
|
+
`unipile` is the planned alternative behind the same interface).
|
|
52
|
+
- Auth: `login heyreach` or `HEYREACH_API_KEY`.
|
|
53
|
+
- **Read-only / no sending.** Phase 1 is discovery only — no
|
|
54
|
+
`linkedin_connect` / `linkedin_message` operations, no webhook ingestion
|
|
55
|
+
(Phase 2). Without `--save` nothing is written and there is zero send/ToS
|
|
56
|
+
exposure.
|
|
57
|
+
|
|
58
|
+
## [0.37.0] — 2026-06-19
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **`enrich acquire` — net-new, ICP-targeted, deduped, metered lead generation
|
|
63
|
+
into the CRM.** Where `enrich append/refresh` fill blanks on records that
|
|
64
|
+
already exist, `acquire` discovers net-new people, resolves their work email,
|
|
65
|
+
and proposes governed `create_record` operations through the existing
|
|
66
|
+
dry-run → approve → apply gate. Never auto-writes.
|
|
67
|
+
- **`create_record` operation** + resolve-first contact creation in the
|
|
68
|
+
HubSpot connector (re-checks the dedupe key at apply, never double-creates).
|
|
69
|
+
- **Acquire meter** (`acquireMeter.ts`): per-profile windowed budget capping
|
|
70
|
+
both record count and provider spend (per-day + per-month, whichever binds
|
|
71
|
+
first); charged only for creates that land at apply.
|
|
72
|
+
- **ICP artifact** (`icp.ts`) + **`icp interview` / `icp set` / `icp show`**:
|
|
73
|
+
one profile drives per-provider discovery filters (Explorium + pipe0/Crustdata)
|
|
74
|
+
AND fit-scores every prospect — only above-threshold leads are proposed.
|
|
75
|
+
`icp interview` emits a question spec an agent drives with AskUserQuestion.
|
|
76
|
+
- **API prospect sources** (`connectors/prospectSources.ts`): Explorium
|
|
77
|
+
discovery, pipe0 work-email waterfall (chunked, surfaces upstream errors),
|
|
78
|
+
and pipe0/Crustdata people search.
|
|
79
|
+
- **No paying for dupes**: pre-email dedup against the live CRM snapshot +
|
|
80
|
+
a cross-run **seen cache** (`acquireSeen.ts`) drop already-known prospects
|
|
81
|
+
*before* the paid email step. **LinkedIn URL (`hs_linkedin_url`) is now read
|
|
82
|
+
into the snapshot** as the strong dedup key (safe everywhere — HubSpot
|
|
83
|
+
ignores unknown properties); created contacts write it back, so dedup
|
|
84
|
+
strengthens over time. A recommendation fires when the CRM has none.
|
|
85
|
+
- **Zero-config preset**: `enrich acquire` works with only an `icp.json` and
|
|
86
|
+
`login` — sensible defaults for budget, provider, and create-mapping.
|
|
87
|
+
- **`login pipe0` / `login explorium`** credential flow.
|
|
88
|
+
|
|
89
|
+
### Also
|
|
90
|
+
|
|
91
|
+
- Built-in **clay enrich preset** + `enrich ingest <csv> --source clay --input`
|
|
92
|
+
one-shot (stage → match in one command).
|
|
93
|
+
|
|
94
|
+
## [0.36.0] — 2026-06-19
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- **Engagement workspace — a per-client CRM health timeline.** The profile dir
|
|
99
|
+
(`$FSGTM_HOME[/profiles/<name>]`) becomes a continuous record, not just a place
|
|
100
|
+
for credentials and plans: every `audit --save` now stamps a deterministic
|
|
101
|
+
hygiene score and a snapshot onto the profile, so a consultant working
|
|
102
|
+
`--profile <client>` accrues that org's health over time from the verb they
|
|
103
|
+
already run.
|
|
104
|
+
- **`fullstackgtm health [--json]`** (new, read-only) rolls up the timeline:
|
|
105
|
+
current score, the change since the last audit, a dated trend, and per-rule
|
|
106
|
+
finding deltas. Empty timeline prints a pointer, never an error.
|
|
107
|
+
- **Deterministic score** = `100 / (1 + severity-weighted findings per record)`
|
|
108
|
+
(info ×1, warning ×3, critical ×10): 0 findings ⇒ 100; the same findings over
|
|
109
|
+
fewer clean records score lower. No LLM — stable in CI, comparable run-over-run.
|
|
110
|
+
- New library exports `computeHealth`, `summarizeHealth`, `healthToMarkdown`
|
|
111
|
+
and the `HealthEntry` / `HealthRollup` / `HealthRuleDelta` types.
|
|
112
|
+
- State is profile-scoped and owner-only (0600): `health.jsonl` (append-only)
|
|
113
|
+
and `snapshots/<planId>.json`, alongside `plans/` under the same secured dir.
|
|
114
|
+
|
|
115
|
+
## [0.35.0] — 2026-06-19
|
|
116
|
+
|
|
117
|
+
### Added
|
|
118
|
+
|
|
119
|
+
- **Progressive-disclosure help.** The front door is now a lifecycle-grouped map
|
|
120
|
+
(Setup · Detect · Prevent · Remediate · Calls · Govern · Market · Schedule) of
|
|
121
|
+
~one line per verb instead of a 194-line wall — bare invoke and `--help` print
|
|
122
|
+
it. `<verb> --help` gives focused per-command help (summary, synopsis, key
|
|
123
|
+
options, the verb's lifecycle phase, see-also) rather than the whole surface.
|
|
124
|
+
New `help [command] [--full]` command; `--full` always escapes to the complete
|
|
125
|
+
reference. `call`/`market`/`enrich`/`bulk-update`/`schedule` keep their own
|
|
126
|
+
richer help.
|
|
127
|
+
- **`audit` next-step guidance.** After a human-readable audit, the CLI prints a
|
|
128
|
+
context-aware next step on stderr (so stdout stays clean for pipes/`--out`):
|
|
129
|
+
the demo points to `report --demo` and `login`; a saved live audit chains
|
|
130
|
+
`suggest → plans approve → apply` with the real plan id; a clean snapshot
|
|
131
|
+
points to `resolve`/`schedule`. Suppressed under `--json`.
|
|
132
|
+
- **`audit --full`** and **`patchPlanToMarkdown(plan, { summary })`** — a summary
|
|
133
|
+
view (header + Findings-by-Rule table + operation count) for read-side audits.
|
|
134
|
+
|
|
135
|
+
### Changed
|
|
136
|
+
|
|
137
|
+
- **`audit` defaults to the summary view** (≈24 lines on the demo vs ≈1,470
|
|
138
|
+
before); `--full` opts into the per-operation dump. The default stays full for
|
|
139
|
+
write-preview renders (`bulk-update`, `fix`, `dedupe`, `plans show`, MCP), where
|
|
140
|
+
you approve specific operations and want every operation's detail. The `--json`
|
|
141
|
+
machine output is unchanged.
|
|
142
|
+
- **Plan footer reframed** from "This prototype is dry-run only…" to the actual
|
|
143
|
+
safety invariants ("Dry-run plan — read-only. No provider write happens until
|
|
144
|
+
you approve specific operations and run `apply`… These safety invariants are
|
|
145
|
+
not beta."), matching the README and agent skill.
|
|
146
|
+
|
|
147
|
+
See [docs/dx-punch-list.md](./docs/dx-punch-list.md) for the audit that drove this.
|
|
148
|
+
|
|
8
149
|
## [0.34.0] — 2026-06-18
|
|
9
150
|
|
|
10
151
|
### Added
|
package/INSTALL_FOR_AGENTS.md
CHANGED
|
@@ -69,6 +69,14 @@ the environment, or have the human run `echo "$KEY" | fullstackgtm login apollo`
|
|
|
69
69
|
once. Without it, `enrich ingest <file> --source clay` still stages push-style
|
|
70
70
|
data keyless.
|
|
71
71
|
|
|
72
|
+
`enrich acquire` differs from `append`/`refresh`: instead of filling blanks on
|
|
73
|
+
existing records it creates net-new, ICP-targeted leads via governed
|
|
74
|
+
`create_record` plans (resolve-first dedupe re-checks the key at apply, never
|
|
75
|
+
double-creates) and is capped by a per-profile windowed meter bounding record
|
|
76
|
+
count and provider spend per day and per month. It still flows through
|
|
77
|
+
dry-run → approve → apply and never auto-writes — expect it to refuse rather
|
|
78
|
+
than silently exceed the meter.
|
|
79
|
+
|
|
72
80
|
Provider prerequisites (what the human must create, and which scopes) are in
|
|
73
81
|
the README's **"Connect your CRM"** section: HubSpot needs a private app with
|
|
74
82
|
four `crm.objects.*.read` scopes (plus write scopes only for `apply`);
|
package/README.md
CHANGED
|
@@ -128,6 +128,7 @@ fullstackgtm bulk-update deal --where "stage=closedwon" --where "amount:empty" \
|
|
|
128
128
|
--set amount=from:account.annualrevenue --save # per-record derived values; empty sources skipped, never guessed
|
|
129
129
|
fullstackgtm dedupe account --key domain --keep richest --save # one merge_records op per duplicate group
|
|
130
130
|
fullstackgtm reassign --from 411 --to 902 --except-deal-stage closing --save # ownership handoff playbook
|
|
131
|
+
fullstackgtm reassign --assign-unowned --to 902 --save # claim every ownerless record for an owner
|
|
131
132
|
fullstackgtm fix --rule missing-deal-owner --provider hubspot --yes # audit one rule → suggest → approve → apply, one command
|
|
132
133
|
```
|
|
133
134
|
|
|
@@ -169,6 +170,42 @@ fullstackgtm enrich status --runs # last run per sourc
|
|
|
169
170
|
|
|
170
171
|
Matching is deterministic: ordered keys, unique hit wins, zero hits falls through to the next key, and multiple hits are never guessed away — they skip (recorded with candidate ids) or flow into the existing `suggest` → `plans approve` chain as `requires_human_record_selection` placeholders. The MVP conflict policy is `never`: enrich only fills blank fields, and `refresh` only re-touches fields its own run-store ledger proves it stamped (per-record/per-field `enrichedAt`, profile-scoped, never written into your portal as custom properties). The `system-only` and `always` rungs of the ladder are phase 2 and are refused explicitly, not silently accepted. Recurring execution belongs to the scheduler — enrich owns no cron logic.
|
|
171
172
|
|
|
173
|
+
## Acquire: net-new leads, ICP-targeted and dupe-safe by default
|
|
174
|
+
|
|
175
|
+
`enrich append/refresh` fill blanks on records you already have. **`enrich acquire`** creates *net-new* ones — and routes them through the same dry-run → approve → apply gate, so prospecting can never silently write to your CRM. It discovers people, resolves their work email, scores them against your ICP, dedupes against your CRM, and proposes governed `create_record` operations.
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
fullstackgtm icp interview # emit the ICP question spec; an agent asks it (AskUserQuestion)
|
|
179
|
+
fullstackgtm icp set answers.json --name "RevOps ICP" # write icp.json (firmographics + persona + fit threshold)
|
|
180
|
+
echo "$PIPE0_API_KEY" | fullstackgtm login pipe0 # discovery + work-email provider, stored 0600
|
|
181
|
+
|
|
182
|
+
fullstackgtm enrich acquire --provider hubspot # zero-config: ICP → discover → score → dedup → dry-run diff
|
|
183
|
+
fullstackgtm enrich acquire --provider hubspot --max 25 --save # cap the batch, persist the needs_approval plan
|
|
184
|
+
fullstackgtm plans approve <id> --operations all
|
|
185
|
+
fullstackgtm apply --plan-id <id> --provider hubspot # the only step that creates contacts
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The **ICP** (`icp.json`) is the single targeting artifact: it generates each provider's discovery filters (Explorium, pipe0/Crustdata) *and* fit-scores every discovered prospect — only above-threshold leads are proposed. Develop one by interview; the CLI can't run `AskUserQuestion` itself, so `icp interview` emits the spec and an agent (Claude Code / Codex) drives it.
|
|
189
|
+
|
|
190
|
+
**You don't pay to re-discover dupes.** Before the (credit-spending) email step, acquire drops prospects already in your CRM and any seen in a prior run:
|
|
191
|
+
|
|
192
|
+
- **Pre-email CRM dedup** matches the live snapshot. The LinkedIn URL (`hs_linkedin_url`, read into the snapshot by default — safe everywhere, HubSpot ignores unknown properties) is the strong key; name+domain is the fallback. Created contacts get their LinkedIn URL written back, so coverage — and dedup precision — grows over time. If your CRM has no LinkedIn URLs, acquire says so and recommends populating it.
|
|
193
|
+
- **A cross-run seen cache** remembers everyone already resolved, so re-running the same ICP costs nothing for known people.
|
|
194
|
+
- Two more checkpoints stay precise: the email-level match at plan build, and **resolve-first at apply** (re-checks the live CRM, never double-creates, never charges the meter).
|
|
195
|
+
|
|
196
|
+
Every run is **metered**: a per-profile budget caps both record count and provider spend (per-day + per-month, whichever binds first), charged only for creates that actually land. `enrich acquire` runs with just an `icp.json` and a `login` — sensible defaults for budget, provider, and create-mapping ship in the box.
|
|
197
|
+
|
|
198
|
+
**Discovery sources** are zero-config presets behind `--source`: `explorium` and `pipe0` run ICP-driven people search, while `linkedin` (Phase 1 — discovery + dry-run) reads a pre-built **HeyReach** lead list:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
echo "$HEYREACH_API_KEY" | fullstackgtm login heyreach # or set HEYREACH_API_KEY
|
|
202
|
+
fullstackgtm enrich acquire --source linkedin --list <listId> --provider hubspot # score → dedup → dry-run diff
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
LinkedIn is just another discovery source on the same scored → deduped → metered → dry-run→approve→apply spine; the LinkedIn profile URL is the match key and the list costs `$0`/record. **It never sends** — connect/message operations are out of scope for Phase 1, so without `--save` there is zero send/ToS exposure.
|
|
206
|
+
|
|
207
|
+
**Leads are never born ownerless.** An `acquire.assign` policy stamps an owner onto every created lead at create time (mapped to HubSpot `hubspot_owner_id`), routed by one of four strategies — `fixed`, `round-robin` (distributed deterministically, no rotation state to drift), `territory` (by geo / industry / size / department / title), or `account-owner` (inherit the matched company's owner). With no policy and a single-owner portal, acquire defaults every lead to that owner (or pass `--assign-owner <id>`); with multiple owners and no policy it warns and leaves them unassigned rather than guess. To clear *existing* ownerless records, `reassign --assign-unowned --to <ownerId>` applies the same intent as a backfill.
|
|
208
|
+
|
|
172
209
|
## Schedules: declare a cadence once, keep the governance contract under automation
|
|
173
210
|
|
|
174
211
|
Everything the CLI produces is accurate the moment it runs and silently stale afterward. The **schedule layer** is the horizontal fix — any read/plan-side command on a cron cadence, one component, every verb (no feature owns its own cron logic):
|
|
@@ -246,6 +283,8 @@ fullstackgtm login --via https://gtm.yourco.com
|
|
|
246
283
|
|
|
247
284
|
An admin connects HubSpot **once** in the hosted dashboard (the org's OAuth tokens live encrypted in the deployment). Pairing a CLI is a device-flow handshake: the CLI prints a code, an admin or manager approves it in the dashboard, and the CLI receives a long-lived broker token (stored hashed server-side, revocable per CLI). From then on, every provider command silently exchanges the broker token for a short-lived CRM access token minted from the org's stored sync credentials — and inherits the org's field mappings. No one pastes CRM tokens; revoking a laptop is one row.
|
|
248
285
|
|
|
286
|
+
**Run observability (paired CLIs).** Once paired, each command best-effort reports a run record — command, status (`success`/`partial`/`error`), duration, headline counts (e.g. ops emitted, leads created, est. cost), and structured events (plan saved, meter charged) — to the deployment's **run timeline** in the dashboard. It's opt-in by pairing (an unpaired CLI sends nothing), a 4-second-capped POST that swallows every error, and never changes the command's exit code. Setup/inspection verbs (`login`, `doctor`, `help`, `--version`) are skipped.
|
|
287
|
+
|
|
249
288
|
### Individuals: no deployment needed
|
|
250
289
|
|
|
251
290
|
```bash
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn → acquire bridge (Phase 1, step 2).
|
|
3
|
+
*
|
|
4
|
+
* Connects the provider-agnostic LinkedIn discovery layer (connectors/linkedin.ts)
|
|
5
|
+
* to the `enrich acquire` spine: pull prospects from a LinkedIn source, map them
|
|
6
|
+
* to the canonical `Prospect`, and hand them to the existing ICP-score → dedup →
|
|
7
|
+
* create_record → meter → dry-run/apply pipeline unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Kept out of cli.ts on purpose: the only remaining wiring is a ~3-line
|
|
10
|
+
* discovery branch in `acquireFromApi` (call `createLinkedInProvider` +
|
|
11
|
+
* `discoverLinkedInProspects`), a `builtinAcquirePreset('linkedin')` entry, the
|
|
12
|
+
* source allowlist/help, and a `login heyreach` credential. That lands once
|
|
13
|
+
* cli.ts is clean of other in-flight work — see docs/linkedin-connector-spec.md.
|
|
14
|
+
*
|
|
15
|
+
* HeyReach reads a pre-populated lead LIST (not an ICP-driven search), and the
|
|
16
|
+
* natural identity key is the LinkedIn profile URL — so acquire's matchKey is
|
|
17
|
+
* `linkedin`, which means the spine skips pipe0 email resolution automatically.
|
|
18
|
+
*/
|
|
19
|
+
import { type HeyReachProviderOptions, type LinkedInProspect, type LinkedInProvider } from "./connectors/linkedin.ts";
|
|
20
|
+
import type { Prospect } from "./connectors/prospectSources.ts";
|
|
21
|
+
/** Map a normalized LinkedIn prospect into the canonical acquire `Prospect`. */
|
|
22
|
+
export declare function linkedInProspectToProspect(lp: LinkedInProspect): Prospect;
|
|
23
|
+
export type DiscoverLinkedInOptions = {
|
|
24
|
+
/** Which source (HeyReach lead-list id) to read; provider may have a default. */
|
|
25
|
+
sourceId?: string;
|
|
26
|
+
/** Hard cap on prospects pulled. */
|
|
27
|
+
max?: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Discovery branch for `acquireFromApi`: read prospects from a LinkedIn source
|
|
31
|
+
* and return them as canonical `Prospect`s. Read-only — never sends. The
|
|
32
|
+
* provider is injected so this is fully testable against the fake (no key).
|
|
33
|
+
*/
|
|
34
|
+
export declare function discoverLinkedInProspects(provider: LinkedInProvider, options?: DiscoverLinkedInOptions): Promise<Prospect[]>;
|
|
35
|
+
export type CreateLinkedInProviderOptions = Omit<HeyReachProviderOptions, "apiKey">;
|
|
36
|
+
/**
|
|
37
|
+
* Build the execution provider for a LinkedIn acquire source. HeyReach is the
|
|
38
|
+
* default; `unipile` is the planned alternative (same interface). Called by the
|
|
39
|
+
* cli acquire branch with the stored/env API key.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createLinkedInProvider(source: string, apiKey: string, options?: CreateLinkedInProviderOptions): LinkedInProvider;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn → acquire bridge (Phase 1, step 2).
|
|
3
|
+
*
|
|
4
|
+
* Connects the provider-agnostic LinkedIn discovery layer (connectors/linkedin.ts)
|
|
5
|
+
* to the `enrich acquire` spine: pull prospects from a LinkedIn source, map them
|
|
6
|
+
* to the canonical `Prospect`, and hand them to the existing ICP-score → dedup →
|
|
7
|
+
* create_record → meter → dry-run/apply pipeline unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Kept out of cli.ts on purpose: the only remaining wiring is a ~3-line
|
|
10
|
+
* discovery branch in `acquireFromApi` (call `createLinkedInProvider` +
|
|
11
|
+
* `discoverLinkedInProspects`), a `builtinAcquirePreset('linkedin')` entry, the
|
|
12
|
+
* source allowlist/help, and a `login heyreach` credential. That lands once
|
|
13
|
+
* cli.ts is clean of other in-flight work — see docs/linkedin-connector-spec.md.
|
|
14
|
+
*
|
|
15
|
+
* HeyReach reads a pre-populated lead LIST (not an ICP-driven search), and the
|
|
16
|
+
* natural identity key is the LinkedIn profile URL — so acquire's matchKey is
|
|
17
|
+
* `linkedin`, which means the spine skips pipe0 email resolution automatically.
|
|
18
|
+
*/
|
|
19
|
+
import { createHeyReachProvider, } from "./connectors/linkedin.js";
|
|
20
|
+
/** Map a normalized LinkedIn prospect into the canonical acquire `Prospect`. */
|
|
21
|
+
export function linkedInProspectToProspect(lp) {
|
|
22
|
+
return {
|
|
23
|
+
firstName: lp.firstName || undefined,
|
|
24
|
+
lastName: lp.lastName || undefined,
|
|
25
|
+
fullName: lp.fullName,
|
|
26
|
+
// Title drives ICP scoring; fall back to the headline when no title is set.
|
|
27
|
+
jobTitle: lp.jobTitle ?? lp.headline,
|
|
28
|
+
companyName: lp.company,
|
|
29
|
+
linkedin: lp.profileUrl || undefined,
|
|
30
|
+
email: lp.email,
|
|
31
|
+
// The profile URL is LinkedIn's stable native id — use it for traceability.
|
|
32
|
+
sourceId: lp.profileUrl || undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Discovery branch for `acquireFromApi`: read prospects from a LinkedIn source
|
|
37
|
+
* and return them as canonical `Prospect`s. Read-only — never sends. The
|
|
38
|
+
* provider is injected so this is fully testable against the fake (no key).
|
|
39
|
+
*/
|
|
40
|
+
export async function discoverLinkedInProspects(provider, options = {}) {
|
|
41
|
+
const raw = await provider.fetchProspects({ sourceId: options.sourceId, max: options.max });
|
|
42
|
+
return raw.map(linkedInProspectToProspect);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build the execution provider for a LinkedIn acquire source. HeyReach is the
|
|
46
|
+
* default; `unipile` is the planned alternative (same interface). Called by the
|
|
47
|
+
* cli acquire branch with the stored/env API key.
|
|
48
|
+
*/
|
|
49
|
+
export function createLinkedInProvider(source, apiKey, options = {}) {
|
|
50
|
+
switch (source) {
|
|
51
|
+
case "linkedin":
|
|
52
|
+
case "heyreach":
|
|
53
|
+
return createHeyReachProvider({ apiKey, ...options });
|
|
54
|
+
default:
|
|
55
|
+
throw new Error(`Unknown LinkedIn execution provider "${source}" (supported: heyreach).`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Budget declared in enrich.config.json under `acquire.budget`. */
|
|
2
|
+
export type AcquireBudget = {
|
|
3
|
+
records?: {
|
|
4
|
+
perDay?: number;
|
|
5
|
+
perMonth?: number;
|
|
6
|
+
};
|
|
7
|
+
spend?: {
|
|
8
|
+
perDay?: number;
|
|
9
|
+
perMonth?: number;
|
|
10
|
+
currency?: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export type AcquireMeterState = {
|
|
14
|
+
/** UTC day bucket, YYYY-MM-DD. */
|
|
15
|
+
day: string;
|
|
16
|
+
/** UTC month bucket, YYYY-MM. */
|
|
17
|
+
month: string;
|
|
18
|
+
records: {
|
|
19
|
+
day: number;
|
|
20
|
+
month: number;
|
|
21
|
+
};
|
|
22
|
+
spendUsd: {
|
|
23
|
+
day: number;
|
|
24
|
+
month: number;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export type AcquireRemaining = {
|
|
28
|
+
/** Remaining headroom per window; null = no budget set for that dimension. */
|
|
29
|
+
records: {
|
|
30
|
+
day: number | null;
|
|
31
|
+
month: number | null;
|
|
32
|
+
};
|
|
33
|
+
spendUsd: {
|
|
34
|
+
day: number | null;
|
|
35
|
+
month: number | null;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The single number that matters: how many MORE records may be created right
|
|
39
|
+
* now, given every budget dimension and the per-record cost. null = unlimited
|
|
40
|
+
* (no budget constrains creation). 0 = budget exhausted.
|
|
41
|
+
*/
|
|
42
|
+
maxRecords: number | null;
|
|
43
|
+
};
|
|
44
|
+
export declare function dayKey(now: Date): string;
|
|
45
|
+
export declare function monthKey(now: Date): string;
|
|
46
|
+
export declare function acquireMeterPath(baseDir?: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Roll the window counters forward: a new UTC day zeroes the day buckets, a
|
|
49
|
+
* new UTC month zeroes the month buckets. Returns a fresh object; never
|
|
50
|
+
* mutates the input.
|
|
51
|
+
*/
|
|
52
|
+
export declare function rollWindows(state: AcquireMeterState, now: Date): AcquireMeterState;
|
|
53
|
+
/** Load the meter from disk, rolling stale windows forward. Missing = empty. */
|
|
54
|
+
export declare function loadMeter(now: Date, baseDir?: string): AcquireMeterState;
|
|
55
|
+
export declare function saveMeter(state: AcquireMeterState, baseDir?: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* Compute remaining headroom and the max additional records creatable now.
|
|
58
|
+
* `costPerRecord` (USD) converts the spend budget into a record ceiling; when
|
|
59
|
+
* it is 0 the spend budget cannot constrain record count.
|
|
60
|
+
*/
|
|
61
|
+
export declare function remaining(state: AcquireMeterState, budget: AcquireBudget | undefined, costPerRecord: number, now: Date): AcquireRemaining;
|
|
62
|
+
/**
|
|
63
|
+
* Record a successful batch of creates against the budget and persist.
|
|
64
|
+
* Returns the updated state. Call AFTER the writes land, so a failed apply
|
|
65
|
+
* never charges the meter.
|
|
66
|
+
*/
|
|
67
|
+
export declare function recordConsumption(now: Date, recordsCreated: number, spendUsd: number, baseDir?: string): AcquireMeterState;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The acquire meter — a persistent, windowed budget for net-new lead creation.
|
|
3
|
+
*
|
|
4
|
+
* `enrich acquire` proposes `create_record` operations; `apply` writes them.
|
|
5
|
+
* Both gate on this meter so a profile can never create more leads — or spend
|
|
6
|
+
* more on enrichment — than its declared budget, per day AND per month
|
|
7
|
+
* (whichever limit is hit first). The meter is the volume control; a human
|
|
8
|
+
* still approves the plan (the CLI never auto-writes). State is per-profile,
|
|
9
|
+
* stored under the credential home so one client's budget never bleeds into
|
|
10
|
+
* another's.
|
|
11
|
+
*
|
|
12
|
+
* Pure where it counts: window math and `remaining()` take an explicit `now`
|
|
13
|
+
* and operate on a passed-in state, so they are deterministic and testable.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { credentialsDir } from "./credentials.js";
|
|
18
|
+
export function dayKey(now) {
|
|
19
|
+
return now.toISOString().slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
export function monthKey(now) {
|
|
22
|
+
return now.toISOString().slice(0, 7);
|
|
23
|
+
}
|
|
24
|
+
export function acquireMeterPath(baseDir) {
|
|
25
|
+
return join(baseDir ?? credentialsDir(), "acquire", "meter.json");
|
|
26
|
+
}
|
|
27
|
+
function emptyState(now) {
|
|
28
|
+
return {
|
|
29
|
+
day: dayKey(now),
|
|
30
|
+
month: monthKey(now),
|
|
31
|
+
records: { day: 0, month: 0 },
|
|
32
|
+
spendUsd: { day: 0, month: 0 },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Roll the window counters forward: a new UTC day zeroes the day buckets, a
|
|
37
|
+
* new UTC month zeroes the month buckets. Returns a fresh object; never
|
|
38
|
+
* mutates the input.
|
|
39
|
+
*/
|
|
40
|
+
export function rollWindows(state, now) {
|
|
41
|
+
const today = dayKey(now);
|
|
42
|
+
const thisMonth = monthKey(now);
|
|
43
|
+
return {
|
|
44
|
+
day: today,
|
|
45
|
+
month: thisMonth,
|
|
46
|
+
records: {
|
|
47
|
+
day: state.day === today ? state.records.day : 0,
|
|
48
|
+
month: state.month === thisMonth ? state.records.month : 0,
|
|
49
|
+
},
|
|
50
|
+
spendUsd: {
|
|
51
|
+
day: state.day === today ? state.spendUsd.day : 0,
|
|
52
|
+
month: state.month === thisMonth ? state.spendUsd.month : 0,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Load the meter from disk, rolling stale windows forward. Missing = empty. */
|
|
57
|
+
export function loadMeter(now, baseDir) {
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = readFileSync(acquireMeterPath(baseDir), "utf8");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return emptyState(now);
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(raw);
|
|
67
|
+
const state = {
|
|
68
|
+
day: typeof parsed.day === "string" ? parsed.day : dayKey(now),
|
|
69
|
+
month: typeof parsed.month === "string" ? parsed.month : monthKey(now),
|
|
70
|
+
records: {
|
|
71
|
+
day: Number(parsed.records?.day) || 0,
|
|
72
|
+
month: Number(parsed.records?.month) || 0,
|
|
73
|
+
},
|
|
74
|
+
spendUsd: {
|
|
75
|
+
day: Number(parsed.spendUsd?.day) || 0,
|
|
76
|
+
month: Number(parsed.spendUsd?.month) || 0,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
return rollWindows(state, now);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return emptyState(now);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function saveMeter(state, baseDir) {
|
|
86
|
+
const path = acquireMeterPath(baseDir);
|
|
87
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
88
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
89
|
+
}
|
|
90
|
+
function headroom(limit, used) {
|
|
91
|
+
if (limit === undefined)
|
|
92
|
+
return null;
|
|
93
|
+
return Math.max(0, limit - used);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Compute remaining headroom and the max additional records creatable now.
|
|
97
|
+
* `costPerRecord` (USD) converts the spend budget into a record ceiling; when
|
|
98
|
+
* it is 0 the spend budget cannot constrain record count.
|
|
99
|
+
*/
|
|
100
|
+
export function remaining(state, budget, costPerRecord, now) {
|
|
101
|
+
const rolled = rollWindows(state, now);
|
|
102
|
+
const records = {
|
|
103
|
+
day: headroom(budget?.records?.perDay, rolled.records.day),
|
|
104
|
+
month: headroom(budget?.records?.perMonth, rolled.records.month),
|
|
105
|
+
};
|
|
106
|
+
const spendUsd = {
|
|
107
|
+
day: headroom(budget?.spend?.perDay, rolled.spendUsd.day),
|
|
108
|
+
month: headroom(budget?.spend?.perMonth, rolled.spendUsd.month),
|
|
109
|
+
};
|
|
110
|
+
const ceilings = [];
|
|
111
|
+
if (records.day !== null)
|
|
112
|
+
ceilings.push(records.day);
|
|
113
|
+
if (records.month !== null)
|
|
114
|
+
ceilings.push(records.month);
|
|
115
|
+
if (costPerRecord > 0) {
|
|
116
|
+
if (spendUsd.day !== null)
|
|
117
|
+
ceilings.push(Math.floor(spendUsd.day / costPerRecord));
|
|
118
|
+
if (spendUsd.month !== null)
|
|
119
|
+
ceilings.push(Math.floor(spendUsd.month / costPerRecord));
|
|
120
|
+
}
|
|
121
|
+
const maxRecords = ceilings.length === 0 ? null : Math.max(0, Math.min(...ceilings));
|
|
122
|
+
return { records, spendUsd, maxRecords };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Record a successful batch of creates against the budget and persist.
|
|
126
|
+
* Returns the updated state. Call AFTER the writes land, so a failed apply
|
|
127
|
+
* never charges the meter.
|
|
128
|
+
*/
|
|
129
|
+
export function recordConsumption(now, recordsCreated, spendUsd, baseDir) {
|
|
130
|
+
const state = loadMeter(now, baseDir);
|
|
131
|
+
const next = {
|
|
132
|
+
day: state.day,
|
|
133
|
+
month: state.month,
|
|
134
|
+
records: {
|
|
135
|
+
day: state.records.day + recordsCreated,
|
|
136
|
+
month: state.records.month + recordsCreated,
|
|
137
|
+
},
|
|
138
|
+
spendUsd: {
|
|
139
|
+
day: state.spendUsd.day + spendUsd,
|
|
140
|
+
month: state.spendUsd.month + spendUsd,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
saveMeter(next, baseDir);
|
|
144
|
+
return next;
|
|
145
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function acquireSeenPath(baseDir?: string): string;
|
|
2
|
+
/** Load the set of seen keys (empty if none/corrupt). */
|
|
3
|
+
export declare function loadSeen(baseDir?: string): Set<string>;
|
|
4
|
+
/** Merge keys into the cache with a `now` timestamp, prune to cap, persist. */
|
|
5
|
+
export declare function recordSeen(keys: string[], now: Date, baseDir?: string): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The acquire "seen" cache — cross-run memory of prospects already processed,
|
|
3
|
+
* so re-running the same ICP doesn't re-pay the (expensive) email-resolution
|
|
4
|
+
* step for people we resolved last time. Keyed by stable prospect identity
|
|
5
|
+
* (LinkedIn URL, name+domain, email); a hit on ANY key means "skip before
|
|
6
|
+
* paying". Per-profile, stored under the credential home.
|
|
7
|
+
*
|
|
8
|
+
* Distinct from the meter (spend budget) and from the live-CRM dedup (the
|
|
9
|
+
* snapshot match + apply-time resolve-first): this avoids RE-spending across
|
|
10
|
+
* runs; those avoid double-creating within/against the CRM.
|
|
11
|
+
*
|
|
12
|
+
* Zero deps. Stored as { key: lastSeenISO } so the cache can be pruned by age.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { credentialsDir } from "./credentials.js";
|
|
17
|
+
/** Keep the newest N keys; bounds the file for very large/long-running fills. */
|
|
18
|
+
const SEEN_CAP = 50_000;
|
|
19
|
+
export function acquireSeenPath(baseDir) {
|
|
20
|
+
return join(baseDir ?? credentialsDir(), "acquire", "seen.json");
|
|
21
|
+
}
|
|
22
|
+
/** Load the set of seen keys (empty if none/corrupt). */
|
|
23
|
+
export function loadSeen(baseDir) {
|
|
24
|
+
try {
|
|
25
|
+
const store = JSON.parse(readFileSync(acquireSeenPath(baseDir), "utf8"));
|
|
26
|
+
return new Set(Object.keys(store));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return new Set();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Merge keys into the cache with a `now` timestamp, prune to cap, persist. */
|
|
33
|
+
export function recordSeen(keys, now, baseDir) {
|
|
34
|
+
if (keys.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
let store = {};
|
|
37
|
+
try {
|
|
38
|
+
store = JSON.parse(readFileSync(acquireSeenPath(baseDir), "utf8"));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
store = {};
|
|
42
|
+
}
|
|
43
|
+
const iso = now.toISOString();
|
|
44
|
+
for (const key of keys)
|
|
45
|
+
store[key] = iso;
|
|
46
|
+
let entries = Object.entries(store);
|
|
47
|
+
if (entries.length > SEEN_CAP) {
|
|
48
|
+
entries = entries.sort((a, b) => (a[1] < b[1] ? 1 : -1)).slice(0, SEEN_CAP);
|
|
49
|
+
store = Object.fromEntries(entries);
|
|
50
|
+
}
|
|
51
|
+
const path = acquireSeenPath(baseDir);
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
writeFileSync(path, `${JSON.stringify(store)}\n`, "utf8");
|
|
54
|
+
}
|