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.
@@ -0,0 +1,186 @@
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.ts";
18
+
19
+ /** Budget declared in enrich.config.json under `acquire.budget`. */
20
+ export type AcquireBudget = {
21
+ records?: { perDay?: number; perMonth?: number };
22
+ spend?: { perDay?: number; perMonth?: number; currency?: string };
23
+ };
24
+
25
+ export type AcquireMeterState = {
26
+ /** UTC day bucket, YYYY-MM-DD. */
27
+ day: string;
28
+ /** UTC month bucket, YYYY-MM. */
29
+ month: string;
30
+ records: { day: number; month: number };
31
+ spendUsd: { day: number; month: number };
32
+ };
33
+
34
+ export type AcquireRemaining = {
35
+ /** Remaining headroom per window; null = no budget set for that dimension. */
36
+ records: { day: number | null; month: number | null };
37
+ spendUsd: { day: number | null; month: number | null };
38
+ /**
39
+ * The single number that matters: how many MORE records may be created right
40
+ * now, given every budget dimension and the per-record cost. null = unlimited
41
+ * (no budget constrains creation). 0 = budget exhausted.
42
+ */
43
+ maxRecords: number | null;
44
+ };
45
+
46
+ export function dayKey(now: Date): string {
47
+ return now.toISOString().slice(0, 10);
48
+ }
49
+
50
+ export function monthKey(now: Date): string {
51
+ return now.toISOString().slice(0, 7);
52
+ }
53
+
54
+ export function acquireMeterPath(baseDir?: string): string {
55
+ return join(baseDir ?? credentialsDir(), "acquire", "meter.json");
56
+ }
57
+
58
+ function emptyState(now: Date): AcquireMeterState {
59
+ return {
60
+ day: dayKey(now),
61
+ month: monthKey(now),
62
+ records: { day: 0, month: 0 },
63
+ spendUsd: { day: 0, month: 0 },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Roll the window counters forward: a new UTC day zeroes the day buckets, a
69
+ * new UTC month zeroes the month buckets. Returns a fresh object; never
70
+ * mutates the input.
71
+ */
72
+ export function rollWindows(state: AcquireMeterState, now: Date): AcquireMeterState {
73
+ const today = dayKey(now);
74
+ const thisMonth = monthKey(now);
75
+ return {
76
+ day: today,
77
+ month: thisMonth,
78
+ records: {
79
+ day: state.day === today ? state.records.day : 0,
80
+ month: state.month === thisMonth ? state.records.month : 0,
81
+ },
82
+ spendUsd: {
83
+ day: state.day === today ? state.spendUsd.day : 0,
84
+ month: state.month === thisMonth ? state.spendUsd.month : 0,
85
+ },
86
+ };
87
+ }
88
+
89
+ /** Load the meter from disk, rolling stale windows forward. Missing = empty. */
90
+ export function loadMeter(now: Date, baseDir?: string): AcquireMeterState {
91
+ let raw: string;
92
+ try {
93
+ raw = readFileSync(acquireMeterPath(baseDir), "utf8");
94
+ } catch {
95
+ return emptyState(now);
96
+ }
97
+ try {
98
+ const parsed = JSON.parse(raw) as Partial<AcquireMeterState>;
99
+ const state: AcquireMeterState = {
100
+ day: typeof parsed.day === "string" ? parsed.day : dayKey(now),
101
+ month: typeof parsed.month === "string" ? parsed.month : monthKey(now),
102
+ records: {
103
+ day: Number(parsed.records?.day) || 0,
104
+ month: Number(parsed.records?.month) || 0,
105
+ },
106
+ spendUsd: {
107
+ day: Number(parsed.spendUsd?.day) || 0,
108
+ month: Number(parsed.spendUsd?.month) || 0,
109
+ },
110
+ };
111
+ return rollWindows(state, now);
112
+ } catch {
113
+ return emptyState(now);
114
+ }
115
+ }
116
+
117
+ export function saveMeter(state: AcquireMeterState, baseDir?: string): void {
118
+ const path = acquireMeterPath(baseDir);
119
+ mkdirSync(dirname(path), { recursive: true });
120
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
121
+ }
122
+
123
+ function headroom(limit: number | undefined, used: number): number | null {
124
+ if (limit === undefined) return null;
125
+ return Math.max(0, limit - used);
126
+ }
127
+
128
+ /**
129
+ * Compute remaining headroom and the max additional records creatable now.
130
+ * `costPerRecord` (USD) converts the spend budget into a record ceiling; when
131
+ * it is 0 the spend budget cannot constrain record count.
132
+ */
133
+ export function remaining(
134
+ state: AcquireMeterState,
135
+ budget: AcquireBudget | undefined,
136
+ costPerRecord: number,
137
+ now: Date,
138
+ ): AcquireRemaining {
139
+ const rolled = rollWindows(state, now);
140
+ const records = {
141
+ day: headroom(budget?.records?.perDay, rolled.records.day),
142
+ month: headroom(budget?.records?.perMonth, rolled.records.month),
143
+ };
144
+ const spendUsd = {
145
+ day: headroom(budget?.spend?.perDay, rolled.spendUsd.day),
146
+ month: headroom(budget?.spend?.perMonth, rolled.spendUsd.month),
147
+ };
148
+
149
+ const ceilings: number[] = [];
150
+ if (records.day !== null) ceilings.push(records.day);
151
+ if (records.month !== null) ceilings.push(records.month);
152
+ if (costPerRecord > 0) {
153
+ if (spendUsd.day !== null) ceilings.push(Math.floor(spendUsd.day / costPerRecord));
154
+ if (spendUsd.month !== null) ceilings.push(Math.floor(spendUsd.month / costPerRecord));
155
+ }
156
+ const maxRecords = ceilings.length === 0 ? null : Math.max(0, Math.min(...ceilings));
157
+ return { records, spendUsd, maxRecords };
158
+ }
159
+
160
+ /**
161
+ * Record a successful batch of creates against the budget and persist.
162
+ * Returns the updated state. Call AFTER the writes land, so a failed apply
163
+ * never charges the meter.
164
+ */
165
+ export function recordConsumption(
166
+ now: Date,
167
+ recordsCreated: number,
168
+ spendUsd: number,
169
+ baseDir?: string,
170
+ ): AcquireMeterState {
171
+ const state = loadMeter(now, baseDir);
172
+ const next: AcquireMeterState = {
173
+ day: state.day,
174
+ month: state.month,
175
+ records: {
176
+ day: state.records.day + recordsCreated,
177
+ month: state.records.month + recordsCreated,
178
+ },
179
+ spendUsd: {
180
+ day: state.spendUsd.day + spendUsd,
181
+ month: state.spendUsd.month + spendUsd,
182
+ },
183
+ };
184
+ saveMeter(next, baseDir);
185
+ return next;
186
+ }
@@ -0,0 +1,57 @@
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.ts";
17
+
18
+ type SeenStore = Record<string, string>;
19
+
20
+ /** Keep the newest N keys; bounds the file for very large/long-running fills. */
21
+ const SEEN_CAP = 50_000;
22
+
23
+ export function acquireSeenPath(baseDir?: string): string {
24
+ return join(baseDir ?? credentialsDir(), "acquire", "seen.json");
25
+ }
26
+
27
+ /** Load the set of seen keys (empty if none/corrupt). */
28
+ export function loadSeen(baseDir?: string): Set<string> {
29
+ try {
30
+ const store = JSON.parse(readFileSync(acquireSeenPath(baseDir), "utf8")) as SeenStore;
31
+ return new Set(Object.keys(store));
32
+ } catch {
33
+ return new Set();
34
+ }
35
+ }
36
+
37
+ /** Merge keys into the cache with a `now` timestamp, prune to cap, persist. */
38
+ export function recordSeen(keys: string[], now: Date, baseDir?: string): void {
39
+ if (keys.length === 0) return;
40
+ let store: SeenStore = {};
41
+ try {
42
+ store = JSON.parse(readFileSync(acquireSeenPath(baseDir), "utf8")) as SeenStore;
43
+ } catch {
44
+ store = {};
45
+ }
46
+ const iso = now.toISOString();
47
+ for (const key of keys) store[key] = iso;
48
+
49
+ let entries = Object.entries(store);
50
+ if (entries.length > SEEN_CAP) {
51
+ entries = entries.sort((a, b) => (a[1] < b[1] ? 1 : -1)).slice(0, SEEN_CAP);
52
+ store = Object.fromEntries(entries);
53
+ }
54
+ const path = acquireSeenPath(baseDir);
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ writeFileSync(path, `${JSON.stringify(store)}\n`, "utf8");
57
+ }