fullstackgtm 0.34.0 → 0.37.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 +91 -0
- package/README.md +25 -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/cli.js +732 -21
- package/dist/connectors/hubspot.js +135 -0
- package/dist/connectors/prospectSources.d.ts +91 -0
- package/dist/connectors/prospectSources.js +227 -0
- package/dist/enrich.d.ts +87 -0
- package/dist/enrich.js +188 -4
- 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 +1 -0
- package/dist/index.js +1 -0
- package/dist/mappings.js +3 -0
- package/dist/types.d.ts +17 -1
- package/docs/api.md +29 -2
- package/docs/architecture.md +8 -1
- package/docs/dx-punch-list.md +87 -0
- package/llms.txt +16 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/acquireMeter.ts +186 -0
- package/src/acquireSeen.ts +57 -0
- package/src/cli.ts +870 -20
- package/src/connectors/hubspot.ts +140 -0
- package/src/connectors/prospectSources.ts +324 -0
- package/src/enrich.ts +275 -3
- package/src/format.ts +20 -2
- package/src/health.ts +238 -0
- package/src/icp.ts +313 -0
- package/src/index.ts +8 -0
- package/src/mappings.ts +3 -0
- package/src/types.ts +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,97 @@ 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.37.0] — 2026-06-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`enrich acquire` — net-new, ICP-targeted, deduped, metered lead generation
|
|
13
|
+
into the CRM.** Where `enrich append/refresh` fill blanks on records that
|
|
14
|
+
already exist, `acquire` discovers net-new people, resolves their work email,
|
|
15
|
+
and proposes governed `create_record` operations through the existing
|
|
16
|
+
dry-run → approve → apply gate. Never auto-writes.
|
|
17
|
+
- **`create_record` operation** + resolve-first contact creation in the
|
|
18
|
+
HubSpot connector (re-checks the dedupe key at apply, never double-creates).
|
|
19
|
+
- **Acquire meter** (`acquireMeter.ts`): per-profile windowed budget capping
|
|
20
|
+
both record count and provider spend (per-day + per-month, whichever binds
|
|
21
|
+
first); charged only for creates that land at apply.
|
|
22
|
+
- **ICP artifact** (`icp.ts`) + **`icp interview` / `icp set` / `icp show`**:
|
|
23
|
+
one profile drives per-provider discovery filters (Explorium + pipe0/Crustdata)
|
|
24
|
+
AND fit-scores every prospect — only above-threshold leads are proposed.
|
|
25
|
+
`icp interview` emits a question spec an agent drives with AskUserQuestion.
|
|
26
|
+
- **API prospect sources** (`connectors/prospectSources.ts`): Explorium
|
|
27
|
+
discovery, pipe0 work-email waterfall (chunked, surfaces upstream errors),
|
|
28
|
+
and pipe0/Crustdata people search.
|
|
29
|
+
- **No paying for dupes**: pre-email dedup against the live CRM snapshot +
|
|
30
|
+
a cross-run **seen cache** (`acquireSeen.ts`) drop already-known prospects
|
|
31
|
+
*before* the paid email step. **LinkedIn URL (`hs_linkedin_url`) is now read
|
|
32
|
+
into the snapshot** as the strong dedup key (safe everywhere — HubSpot
|
|
33
|
+
ignores unknown properties); created contacts write it back, so dedup
|
|
34
|
+
strengthens over time. A recommendation fires when the CRM has none.
|
|
35
|
+
- **Zero-config preset**: `enrich acquire` works with only an `icp.json` and
|
|
36
|
+
`login` — sensible defaults for budget, provider, and create-mapping.
|
|
37
|
+
- **`login pipe0` / `login explorium`** credential flow.
|
|
38
|
+
|
|
39
|
+
### Also
|
|
40
|
+
|
|
41
|
+
- Built-in **clay enrich preset** + `enrich ingest <csv> --source clay --input`
|
|
42
|
+
one-shot (stage → match in one command).
|
|
43
|
+
|
|
44
|
+
## [0.36.0] — 2026-06-19
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- **Engagement workspace — a per-client CRM health timeline.** The profile dir
|
|
49
|
+
(`$FSGTM_HOME[/profiles/<name>]`) becomes a continuous record, not just a place
|
|
50
|
+
for credentials and plans: every `audit --save` now stamps a deterministic
|
|
51
|
+
hygiene score and a snapshot onto the profile, so a consultant working
|
|
52
|
+
`--profile <client>` accrues that org's health over time from the verb they
|
|
53
|
+
already run.
|
|
54
|
+
- **`fullstackgtm health [--json]`** (new, read-only) rolls up the timeline:
|
|
55
|
+
current score, the change since the last audit, a dated trend, and per-rule
|
|
56
|
+
finding deltas. Empty timeline prints a pointer, never an error.
|
|
57
|
+
- **Deterministic score** = `100 / (1 + severity-weighted findings per record)`
|
|
58
|
+
(info ×1, warning ×3, critical ×10): 0 findings ⇒ 100; the same findings over
|
|
59
|
+
fewer clean records score lower. No LLM — stable in CI, comparable run-over-run.
|
|
60
|
+
- New library exports `computeHealth`, `summarizeHealth`, `healthToMarkdown`
|
|
61
|
+
and the `HealthEntry` / `HealthRollup` / `HealthRuleDelta` types.
|
|
62
|
+
- State is profile-scoped and owner-only (0600): `health.jsonl` (append-only)
|
|
63
|
+
and `snapshots/<planId>.json`, alongside `plans/` under the same secured dir.
|
|
64
|
+
|
|
65
|
+
## [0.35.0] — 2026-06-19
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **Progressive-disclosure help.** The front door is now a lifecycle-grouped map
|
|
70
|
+
(Setup · Detect · Prevent · Remediate · Calls · Govern · Market · Schedule) of
|
|
71
|
+
~one line per verb instead of a 194-line wall — bare invoke and `--help` print
|
|
72
|
+
it. `<verb> --help` gives focused per-command help (summary, synopsis, key
|
|
73
|
+
options, the verb's lifecycle phase, see-also) rather than the whole surface.
|
|
74
|
+
New `help [command] [--full]` command; `--full` always escapes to the complete
|
|
75
|
+
reference. `call`/`market`/`enrich`/`bulk-update`/`schedule` keep their own
|
|
76
|
+
richer help.
|
|
77
|
+
- **`audit` next-step guidance.** After a human-readable audit, the CLI prints a
|
|
78
|
+
context-aware next step on stderr (so stdout stays clean for pipes/`--out`):
|
|
79
|
+
the demo points to `report --demo` and `login`; a saved live audit chains
|
|
80
|
+
`suggest → plans approve → apply` with the real plan id; a clean snapshot
|
|
81
|
+
points to `resolve`/`schedule`. Suppressed under `--json`.
|
|
82
|
+
- **`audit --full`** and **`patchPlanToMarkdown(plan, { summary })`** — a summary
|
|
83
|
+
view (header + Findings-by-Rule table + operation count) for read-side audits.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- **`audit` defaults to the summary view** (≈24 lines on the demo vs ≈1,470
|
|
88
|
+
before); `--full` opts into the per-operation dump. The default stays full for
|
|
89
|
+
write-preview renders (`bulk-update`, `fix`, `dedupe`, `plans show`, MCP), where
|
|
90
|
+
you approve specific operations and want every operation's detail. The `--json`
|
|
91
|
+
machine output is unchanged.
|
|
92
|
+
- **Plan footer reframed** from "This prototype is dry-run only…" to the actual
|
|
93
|
+
safety invariants ("Dry-run plan — read-only. No provider write happens until
|
|
94
|
+
you approve specific operations and run `apply`… These safety invariants are
|
|
95
|
+
not beta."), matching the README and agent skill.
|
|
96
|
+
|
|
97
|
+
See [docs/dx-punch-list.md](./docs/dx-punch-list.md) for the audit that drove this.
|
|
98
|
+
|
|
8
99
|
## [0.34.0] — 2026-06-18
|
|
9
100
|
|
|
10
101
|
### Added
|
package/README.md
CHANGED
|
@@ -169,6 +169,31 @@ fullstackgtm enrich status --runs # last run per sourc
|
|
|
169
169
|
|
|
170
170
|
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
171
|
|
|
172
|
+
## Acquire: net-new leads, ICP-targeted and dupe-safe by default
|
|
173
|
+
|
|
174
|
+
`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.
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
fullstackgtm icp interview # emit the ICP question spec; an agent asks it (AskUserQuestion)
|
|
178
|
+
fullstackgtm icp set answers.json --name "RevOps ICP" # write icp.json (firmographics + persona + fit threshold)
|
|
179
|
+
echo "$PIPE0_API_KEY" | fullstackgtm login pipe0 # discovery + work-email provider, stored 0600
|
|
180
|
+
|
|
181
|
+
fullstackgtm enrich acquire --provider hubspot # zero-config: ICP → discover → score → dedup → dry-run diff
|
|
182
|
+
fullstackgtm enrich acquire --provider hubspot --max 25 --save # cap the batch, persist the needs_approval plan
|
|
183
|
+
fullstackgtm plans approve <id> --operations all
|
|
184
|
+
fullstackgtm apply --plan-id <id> --provider hubspot # the only step that creates contacts
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
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.
|
|
188
|
+
|
|
189
|
+
**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:
|
|
190
|
+
|
|
191
|
+
- **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.
|
|
192
|
+
- **A cross-run seen cache** remembers everyone already resolved, so re-running the same ICP costs nothing for known people.
|
|
193
|
+
- 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).
|
|
194
|
+
|
|
195
|
+
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.
|
|
196
|
+
|
|
172
197
|
## Schedules: declare a cadence once, keep the governance contract under automation
|
|
173
198
|
|
|
174
199
|
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):
|
|
@@ -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
|
+
}
|