fullstackgtm 0.40.0 → 0.41.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 +40 -0
- package/README.md +30 -1
- package/dist/cli.js +615 -6
- package/dist/connectors/atsBoards.d.ts +60 -0
- package/dist/connectors/atsBoards.js +179 -0
- package/dist/connectors/prospectSources.d.ts +7 -5
- package/dist/connectors/prospectSources.js +82 -47
- package/dist/draft.d.ts +182 -0
- package/dist/draft.js +333 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/judge.d.ts +209 -0
- package/dist/judge.js +490 -0
- package/dist/judgeEval.d.ts +141 -0
- package/dist/judgeEval.js +331 -0
- package/dist/llm.d.ts +13 -0
- package/dist/llm.js +18 -2
- package/dist/schedule.js +11 -1
- package/dist/signals.d.ts +197 -0
- package/dist/signals.js +515 -0
- package/package.json +1 -1
- package/src/cli.ts +696 -7
- package/src/connectors/atsBoards.ts +242 -0
- package/src/connectors/prospectSources.ts +94 -43
- package/src/draft.ts +463 -0
- package/src/index.ts +94 -0
- package/src/judge.ts +661 -0
- package/src/judgeEval.ts +459 -0
- package/src/llm.ts +31 -2
- package/src/schedule.ts +11 -1
- package/src/signals.ts +685 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ATS job-board adapters for the `signals` layer — the free, no-auth example of
|
|
3
|
+
* "watch an account for hiring motion." Each public board JSON endpoint turns a
|
|
4
|
+
* company's board token into a list of open roles; the signals layer maps each
|
|
5
|
+
* role to a `job` signal whose `quote` is the role title plus a JD snippet that
|
|
6
|
+
* VERBATIM contains a configured keyword (so the downstream judge/drafter can
|
|
7
|
+
* gate a why-now on something a human can verify).
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the `prospectSources.ts` raw-fetch idiom exactly: zero runtime deps
|
|
10
|
+
* (global fetch only, injectable for tests), a base-url override per provider,
|
|
11
|
+
* and a per-request `try { } catch { continue }` so one provider's outage never
|
|
12
|
+
* sinks a multi-source fetch. No SDK, no key.
|
|
13
|
+
*
|
|
14
|
+
* Governance: read-only. These adapters fetch public board JSON and return open
|
|
15
|
+
* roles. They write nothing — the signals layer (and only with `--save`) is the
|
|
16
|
+
* one that persists, and it never touches the CRM.
|
|
17
|
+
*/
|
|
18
|
+
export type AtsBoardSource = "greenhouse" | "lever" | "ashby";
|
|
19
|
+
/** One open role, normalized across the three providers. */
|
|
20
|
+
export type AtsJob = {
|
|
21
|
+
/** Role title, verbatim from the board. */
|
|
22
|
+
title: string;
|
|
23
|
+
/**
|
|
24
|
+
* A short JD snippet. When `keywords` are supplied, this is a window around
|
|
25
|
+
* the first matching keyword (kept verbatim so the keyword survives into the
|
|
26
|
+
* evidence quote); otherwise it is the leading slice of the description.
|
|
27
|
+
*/
|
|
28
|
+
jdSnippet: string;
|
|
29
|
+
/** Canonical posting URL (the signal's sourceUrl). */
|
|
30
|
+
url: string;
|
|
31
|
+
/**
|
|
32
|
+
* Stable per-role identity within a board (provider id when present, else the
|
|
33
|
+
* title). Used by the reposted-role heuristic to tell "same role, gone, back"
|
|
34
|
+
* from "two roles that happen to share a title".
|
|
35
|
+
*/
|
|
36
|
+
firstSeenKey: string;
|
|
37
|
+
};
|
|
38
|
+
type FetchImpl = typeof fetch;
|
|
39
|
+
/**
|
|
40
|
+
* Fetch open roles for one board. Network failures (offline, non-2xx, malformed
|
|
41
|
+
* JSON) yield `[]` rather than throwing, matching the per-source resilience of
|
|
42
|
+
* the prospect sources — a single board's outage must not abort a watchlist run.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fetchAtsJobs(opts: {
|
|
45
|
+
source: AtsBoardSource;
|
|
46
|
+
boardToken: string;
|
|
47
|
+
accountDomain: string;
|
|
48
|
+
keywords?: string[];
|
|
49
|
+
fetchImpl?: FetchImpl;
|
|
50
|
+
apiBaseUrl?: string;
|
|
51
|
+
}): Promise<AtsJob[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Pick a verbatim JD snippet. If a keyword matches the description (case-
|
|
54
|
+
* insensitive), return a window centered on the first match — kept verbatim so
|
|
55
|
+
* the keyword text survives into the signal quote and the evidence gate holds.
|
|
56
|
+
* With no keywords (or no match), fall back to the leading slice. Returns "" for
|
|
57
|
+
* an empty description so the caller can decide whether the role still qualifies.
|
|
58
|
+
*/
|
|
59
|
+
export declare function snippetFor(description: string, keywords: string[]): string;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ATS job-board adapters for the `signals` layer — the free, no-auth example of
|
|
3
|
+
* "watch an account for hiring motion." Each public board JSON endpoint turns a
|
|
4
|
+
* company's board token into a list of open roles; the signals layer maps each
|
|
5
|
+
* role to a `job` signal whose `quote` is the role title plus a JD snippet that
|
|
6
|
+
* VERBATIM contains a configured keyword (so the downstream judge/drafter can
|
|
7
|
+
* gate a why-now on something a human can verify).
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the `prospectSources.ts` raw-fetch idiom exactly: zero runtime deps
|
|
10
|
+
* (global fetch only, injectable for tests), a base-url override per provider,
|
|
11
|
+
* and a per-request `try { } catch { continue }` so one provider's outage never
|
|
12
|
+
* sinks a multi-source fetch. No SDK, no key.
|
|
13
|
+
*
|
|
14
|
+
* Governance: read-only. These adapters fetch public board JSON and return open
|
|
15
|
+
* roles. They write nothing — the signals layer (and only with `--save`) is the
|
|
16
|
+
* one that persists, and it never touches the CRM.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_BASE_URLS = {
|
|
19
|
+
greenhouse: "https://boards-api.greenhouse.io",
|
|
20
|
+
lever: "https://api.lever.co",
|
|
21
|
+
ashby: "https://api.ashbyhq.com",
|
|
22
|
+
};
|
|
23
|
+
/** JD snippet length — long enough to carry a verbatim keyword window. */
|
|
24
|
+
const SNIPPET_LEN = 240;
|
|
25
|
+
/**
|
|
26
|
+
* Fetch open roles for one board. Network failures (offline, non-2xx, malformed
|
|
27
|
+
* JSON) yield `[]` rather than throwing, matching the per-source resilience of
|
|
28
|
+
* the prospect sources — a single board's outage must not abort a watchlist run.
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchAtsJobs(opts) {
|
|
31
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
32
|
+
const base = (opts.apiBaseUrl ?? DEFAULT_BASE_URLS[opts.source]).replace(/\/$/, "");
|
|
33
|
+
const token = opts.boardToken.trim();
|
|
34
|
+
if (!token)
|
|
35
|
+
return [];
|
|
36
|
+
const keywords = opts.keywords ?? [];
|
|
37
|
+
try {
|
|
38
|
+
if (opts.source === "greenhouse") {
|
|
39
|
+
return await fetchGreenhouse(fetchImpl, base, token, keywords);
|
|
40
|
+
}
|
|
41
|
+
if (opts.source === "lever") {
|
|
42
|
+
return await fetchLever(fetchImpl, base, token, keywords);
|
|
43
|
+
}
|
|
44
|
+
return await fetchAshby(fetchImpl, base, token, keywords);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Offline / 5xx / malformed payload: an empty board, never a crash.
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Greenhouse — GET /v1/boards/<token>/jobs?content=true
|
|
53
|
+
async function fetchGreenhouse(fetchImpl, base, token, keywords) {
|
|
54
|
+
const response = await fetchImpl(`${base}/v1/boards/${encodeURIComponent(token)}/jobs?content=true`, {
|
|
55
|
+
headers: { Accept: "application/json" },
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok)
|
|
58
|
+
return [];
|
|
59
|
+
const data = (await response.json());
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const job of data.jobs ?? []) {
|
|
62
|
+
const title = strOrEmpty(job.title);
|
|
63
|
+
if (!title)
|
|
64
|
+
continue;
|
|
65
|
+
const description = decodeHtmlish(strOrEmpty(job.content));
|
|
66
|
+
out.push({
|
|
67
|
+
title,
|
|
68
|
+
jdSnippet: snippetFor(description, keywords),
|
|
69
|
+
url: strOrEmpty(job.absolute_url),
|
|
70
|
+
firstSeenKey: job.id != null ? String(job.id) : title,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Lever — GET /v0/postings/<token>?mode=json (returns a bare array)
|
|
77
|
+
async function fetchLever(fetchImpl, base, token, keywords) {
|
|
78
|
+
const response = await fetchImpl(`${base}/v0/postings/${encodeURIComponent(token)}?mode=json`, {
|
|
79
|
+
headers: { Accept: "application/json" },
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok)
|
|
82
|
+
return [];
|
|
83
|
+
const data = (await response.json());
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const posting of Array.isArray(data) ? data : []) {
|
|
86
|
+
const title = strOrEmpty(posting.text);
|
|
87
|
+
if (!title)
|
|
88
|
+
continue;
|
|
89
|
+
const description = strOrEmpty(posting.descriptionPlain) || decodeHtmlish(strOrEmpty(posting.description));
|
|
90
|
+
out.push({
|
|
91
|
+
title,
|
|
92
|
+
jdSnippet: snippetFor(description, keywords),
|
|
93
|
+
url: strOrEmpty(posting.hostedUrl),
|
|
94
|
+
firstSeenKey: strOrEmpty(posting.id) || title,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Ashby — POST /posting-api/job-board/<token> with { jobBoardName }
|
|
101
|
+
async function fetchAshby(fetchImpl, base, token, keywords) {
|
|
102
|
+
const response = await fetchImpl(`${base}/posting-api/job-board/${encodeURIComponent(token)}`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
105
|
+
body: JSON.stringify({ jobBoardName: token, includeCompensation: false }),
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok)
|
|
108
|
+
return [];
|
|
109
|
+
const data = (await response.json());
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const job of data.jobs ?? []) {
|
|
112
|
+
const title = strOrEmpty(job.title);
|
|
113
|
+
if (!title)
|
|
114
|
+
continue;
|
|
115
|
+
const description = strOrEmpty(job.descriptionPlain) || decodeHtmlish(strOrEmpty(job.descriptionHtml));
|
|
116
|
+
out.push({
|
|
117
|
+
title,
|
|
118
|
+
jdSnippet: snippetFor(description, keywords),
|
|
119
|
+
url: strOrEmpty(job.jobUrl) || strOrEmpty(job.applyUrl),
|
|
120
|
+
firstSeenKey: strOrEmpty(job.id) || title,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Snippet selection: a verbatim window around the first matching keyword.
|
|
127
|
+
/**
|
|
128
|
+
* Pick a verbatim JD snippet. If a keyword matches the description (case-
|
|
129
|
+
* insensitive), return a window centered on the first match — kept verbatim so
|
|
130
|
+
* the keyword text survives into the signal quote and the evidence gate holds.
|
|
131
|
+
* With no keywords (or no match), fall back to the leading slice. Returns "" for
|
|
132
|
+
* an empty description so the caller can decide whether the role still qualifies.
|
|
133
|
+
*/
|
|
134
|
+
export function snippetFor(description, keywords) {
|
|
135
|
+
const text = description.trim();
|
|
136
|
+
if (!text)
|
|
137
|
+
return "";
|
|
138
|
+
for (const keyword of keywords) {
|
|
139
|
+
const needle = keyword.trim();
|
|
140
|
+
if (!needle)
|
|
141
|
+
continue;
|
|
142
|
+
const idx = text.toLowerCase().indexOf(needle.toLowerCase());
|
|
143
|
+
if (idx === -1)
|
|
144
|
+
continue;
|
|
145
|
+
const pad = Math.max(0, Math.floor((SNIPPET_LEN - needle.length) / 2));
|
|
146
|
+
let start = Math.max(0, idx - pad);
|
|
147
|
+
let end = Math.min(text.length, idx + needle.length + pad);
|
|
148
|
+
// Snap to word boundaries so the window doesn't start/end mid-word, but
|
|
149
|
+
// never past the keyword itself (the verbatim span must stay intact).
|
|
150
|
+
while (start > 0 && /\S/.test(text[start - 1]) && start < idx)
|
|
151
|
+
start += 1;
|
|
152
|
+
while (end < text.length && /\S/.test(text[end]) && end > idx + needle.length)
|
|
153
|
+
end -= 1;
|
|
154
|
+
return text.slice(start, end).trim();
|
|
155
|
+
}
|
|
156
|
+
return text.slice(0, SNIPPET_LEN).trim();
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Minimal HTML-ish cleanup for board `content`/`description` HTML: drop tags and
|
|
160
|
+
* decode the handful of entities Greenhouse/Ashby emit. Dependency-free; good
|
|
161
|
+
* enough to surface a readable, keyword-matchable snippet (the quote does not
|
|
162
|
+
* need to be byte-perfect HTML, only verbatim text the keyword appears in).
|
|
163
|
+
*/
|
|
164
|
+
function decodeHtmlish(raw) {
|
|
165
|
+
return raw
|
|
166
|
+
.replace(/<\s*br\s*\/?\s*>/gi, " ")
|
|
167
|
+
.replace(/<\/?[^>]+>/g, " ")
|
|
168
|
+
.replace(/&/g, "&")
|
|
169
|
+
.replace(/</g, "<")
|
|
170
|
+
.replace(/>/g, ">")
|
|
171
|
+
.replace(/"/g, '"')
|
|
172
|
+
.replace(/'|'/g, "'")
|
|
173
|
+
.replace(/ /g, " ")
|
|
174
|
+
.replace(/\s+/g, " ")
|
|
175
|
+
.trim();
|
|
176
|
+
}
|
|
177
|
+
function strOrEmpty(value) {
|
|
178
|
+
return typeof value === "string" ? value.trim() : "";
|
|
179
|
+
}
|
|
@@ -56,11 +56,6 @@ export declare function fetchPipe0CrustdataProspects(opts: {
|
|
|
56
56
|
apiBaseUrl?: string;
|
|
57
57
|
fetchImpl?: FetchImpl;
|
|
58
58
|
}): Promise<Prospect[]>;
|
|
59
|
-
/**
|
|
60
|
-
* Resolve real work emails for people via pipe0's waterfall. Input rows need a
|
|
61
|
-
* full name + a company domain (or name). Returns the prospects with `email`
|
|
62
|
-
* filled where the waterfall found one; rows with no hit are returned unchanged.
|
|
63
|
-
*/
|
|
64
59
|
export declare function pipe0ResolveWorkEmails(opts: {
|
|
65
60
|
apiKey: string;
|
|
66
61
|
prospects: Prospect[];
|
|
@@ -70,6 +65,11 @@ export declare function pipe0ResolveWorkEmails(opts: {
|
|
|
70
65
|
* batch rate-limit (a throttled chunk returns work_email status "failed"
|
|
71
66
|
* for every row, so one big batch is all-or-nothing). Default 3. */
|
|
72
67
|
chunkSize?: number;
|
|
68
|
+
/** Chunks resolved in parallel, bounded to respect pipe0's rate limit. Default
|
|
69
|
+
* 3 — paired with pipe0Post's backoff this cuts wall-clock from O(n) serial
|
|
70
|
+
* calls without throttling away coverage (higher defaults tanked the hit-rate
|
|
71
|
+
* at scale). */
|
|
72
|
+
concurrency?: number;
|
|
73
73
|
}): Promise<Prospect[]>;
|
|
74
74
|
/** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
|
|
75
75
|
export declare function hostFromUrl(url: string | undefined): string | undefined;
|
|
@@ -87,6 +87,8 @@ export declare function pipe0ResolveCompanyDomains(opts: {
|
|
|
87
87
|
apiBaseUrl?: string;
|
|
88
88
|
fetchImpl?: FetchImpl;
|
|
89
89
|
chunkSize?: number;
|
|
90
|
+
/** Chunks resolved in parallel (bounded). Default 3. */
|
|
91
|
+
concurrency?: number;
|
|
90
92
|
}): Promise<Prospect[]>;
|
|
91
93
|
/**
|
|
92
94
|
* Stable identity keys for a prospect (prefixed by kind). A match on ANY key
|
|
@@ -98,44 +98,83 @@ function normalizeLinkedin(value) {
|
|
|
98
98
|
* full name + a company domain (or name). Returns the prospects with `email`
|
|
99
99
|
* filled where the waterfall found one; rows with no hit are returned unchanged.
|
|
100
100
|
*/
|
|
101
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
102
|
+
/** Run `count` async tasks with at most `limit` in flight. Order-independent. */
|
|
103
|
+
async function runWithConcurrency(count, limit, worker) {
|
|
104
|
+
const out = new Array(count);
|
|
105
|
+
let next = 0;
|
|
106
|
+
const runners = Array.from({ length: Math.max(1, Math.min(limit, count || 1)) }, async () => {
|
|
107
|
+
for (let i = next++; i < count; i = next++)
|
|
108
|
+
out[i] = await worker(i);
|
|
109
|
+
});
|
|
110
|
+
await Promise.all(runners);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* POST a pipe0 sync run with exponential backoff on transient failure
|
|
115
|
+
* (throttle/5xx/network). pipe0's waterfall throttles under burst — a 429'd
|
|
116
|
+
* chunk returns all-failed — so concurrency must be paired with real backoff,
|
|
117
|
+
* not a single retry, or coverage collapses at scale (a parallel run without
|
|
118
|
+
* this dropped the work-email hit-rate from ~79% to ~17%). Retries 500ms →
|
|
119
|
+
* 1s → 2s → 4s (capped) so a throttled chunk lands on a later attempt instead
|
|
120
|
+
* of being lost.
|
|
121
|
+
*/
|
|
122
|
+
async function pipe0Post(fetchImpl, base, apiKey, payload, maxAttempts = 5) {
|
|
123
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
128
|
+
body: JSON.stringify(payload),
|
|
129
|
+
});
|
|
130
|
+
if (response.ok)
|
|
131
|
+
return (await response.json());
|
|
132
|
+
// non-ok (429/5xx) — fall through to backoff + retry
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// network error — fall through to backoff + retry
|
|
136
|
+
}
|
|
137
|
+
if (attempt < maxAttempts - 1)
|
|
138
|
+
await sleep(Math.min(8000, 500 * 2 ** attempt));
|
|
139
|
+
}
|
|
140
|
+
return undefined; // still failing after backoff — caller keeps the other chunks
|
|
141
|
+
}
|
|
101
142
|
export async function pipe0ResolveWorkEmails(opts) {
|
|
102
143
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
103
144
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
104
145
|
const chunkSize = Math.max(1, opts.chunkSize ?? 3);
|
|
146
|
+
const concurrency = Math.max(1, opts.concurrency ?? 3);
|
|
105
147
|
// Only rows with the waterfall's required inputs (name + company_domain|name).
|
|
106
148
|
const resolvable = opts.prospects.filter((p) => p.fullName && (p.companyDomain || p.companyName));
|
|
107
149
|
if (resolvable.length === 0)
|
|
108
150
|
return opts.prospects;
|
|
109
|
-
const
|
|
110
|
-
for (let i = 0; i < resolvable.length; i += chunkSize)
|
|
111
|
-
|
|
112
|
-
|
|
151
|
+
const chunks = [];
|
|
152
|
+
for (let i = 0; i < resolvable.length; i += chunkSize)
|
|
153
|
+
chunks.push(resolvable.slice(i, i + chunkSize));
|
|
154
|
+
const perChunk = await runWithConcurrency(chunks.length, concurrency, async (ci) => {
|
|
155
|
+
const input = chunks[ci].map((p) => ({
|
|
113
156
|
name: p.fullName,
|
|
114
157
|
...(p.companyDomain ? { company_domain: p.companyDomain } : { company_name: p.companyName }),
|
|
115
158
|
}));
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
});
|
|
123
|
-
if (!response.ok)
|
|
124
|
-
continue; // throttled/5xx chunk — skip, keep the rest
|
|
125
|
-
body = (await response.json());
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
continue; // network hiccup on one chunk must not sink the whole run
|
|
129
|
-
}
|
|
130
|
-
for (const recordId of body.order ?? []) {
|
|
159
|
+
const body = await pipe0Post(fetchImpl, base, opts.apiKey, {
|
|
160
|
+
pipes: [{ pipe_id: "person:workemail:waterfall@1" }],
|
|
161
|
+
input,
|
|
162
|
+
});
|
|
163
|
+
const entries = [];
|
|
164
|
+
for (const recordId of body?.order ?? []) {
|
|
131
165
|
const fields = body.records?.[recordId]?.fields ?? {};
|
|
132
166
|
const email = fields.work_email?.status === "completed" ? fieldValue(fields.work_email) : undefined;
|
|
133
167
|
if (email) {
|
|
134
168
|
const domain = fieldValue(fields.company_domain) ?? fieldValue(fields.company_name);
|
|
135
|
-
|
|
169
|
+
entries.push([personKey(fieldValue(fields.name), domain), email]);
|
|
136
170
|
}
|
|
137
171
|
}
|
|
138
|
-
|
|
172
|
+
return entries;
|
|
173
|
+
});
|
|
174
|
+
const emailByKey = new Map();
|
|
175
|
+
for (const entries of perChunk)
|
|
176
|
+
for (const [k, v] of entries)
|
|
177
|
+
emailByKey.set(k, v);
|
|
139
178
|
return opts.prospects.map((p) => {
|
|
140
179
|
const email = emailByKey.get(personKey(p.fullName, p.companyDomain ?? p.companyName));
|
|
141
180
|
return email ? { ...p, email } : p;
|
|
@@ -169,6 +208,7 @@ export async function pipe0ResolveCompanyDomains(opts) {
|
|
|
169
208
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
170
209
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
171
210
|
const chunkSize = Math.max(1, opts.chunkSize ?? 5);
|
|
211
|
+
const concurrency = Math.max(1, opts.concurrency ?? 3);
|
|
172
212
|
const uniqueNames = [
|
|
173
213
|
...new Set(opts.prospects
|
|
174
214
|
.filter((p) => !p.companyDomain && p.companyName)
|
|
@@ -177,33 +217,28 @@ export async function pipe0ResolveCompanyDomains(opts) {
|
|
|
177
217
|
];
|
|
178
218
|
if (uniqueNames.length === 0)
|
|
179
219
|
return opts.prospects;
|
|
180
|
-
const
|
|
181
|
-
for (let i = 0; i < uniqueNames.length; i += chunkSize)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
for (const recordId of body.order ?? []) {
|
|
196
|
-
const fields = body.records?.[recordId]?.fields ?? {};
|
|
197
|
-
const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
|
|
198
|
-
const domain = hostFromUrl(fieldValue(fields.company_website_url));
|
|
199
|
-
if (name && domain)
|
|
200
|
-
domainByName.set(name.trim().toLowerCase(), domain);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
continue;
|
|
220
|
+
const chunks = [];
|
|
221
|
+
for (let i = 0; i < uniqueNames.length; i += chunkSize)
|
|
222
|
+
chunks.push(uniqueNames.slice(i, i + chunkSize));
|
|
223
|
+
const perChunk = await runWithConcurrency(chunks.length, concurrency, async (ci) => {
|
|
224
|
+
const body = await pipe0Post(fetchImpl, base, opts.apiKey, {
|
|
225
|
+
pipes: [{ pipe_id: "company:identity@1" }],
|
|
226
|
+
input: chunks[ci].map((company_name) => ({ company_name })),
|
|
227
|
+
});
|
|
228
|
+
const entries = [];
|
|
229
|
+
for (const recordId of body?.order ?? []) {
|
|
230
|
+
const fields = body.records?.[recordId]?.fields ?? {};
|
|
231
|
+
const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
|
|
232
|
+
const domain = hostFromUrl(fieldValue(fields.company_website_url));
|
|
233
|
+
if (name && domain)
|
|
234
|
+
entries.push([name.trim().toLowerCase(), domain]);
|
|
205
235
|
}
|
|
206
|
-
|
|
236
|
+
return entries;
|
|
237
|
+
});
|
|
238
|
+
const domainByName = new Map();
|
|
239
|
+
for (const entries of perChunk)
|
|
240
|
+
for (const [k, v] of entries)
|
|
241
|
+
domainByName.set(k, v);
|
|
207
242
|
return opts.prospects.map((p) => {
|
|
208
243
|
if (p.companyDomain || !p.companyName)
|
|
209
244
|
return p;
|
package/dist/draft.d.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { type LlmCallOptions } from "./llm.ts";
|
|
2
|
+
import { type JudgeDecision } from "./judge.ts";
|
|
3
|
+
import { type Signal } from "./signals.ts";
|
|
4
|
+
import type { GtmEvidence, PatchPlan } from "./types.ts";
|
|
5
|
+
/**
|
|
6
|
+
* The draft layer: turn a judge `send` decision into ONE trigger-grounded opener
|
|
7
|
+
* per hot account, emitted as a governed `create_task` PatchOperation so it flows
|
|
8
|
+
* through the existing `plans approve` → `apply` chain. This is the last thin
|
|
9
|
+
* piece of the brain: signals (when) → judge (who) → DRAFT (what to say) →
|
|
10
|
+
* governed apply (sent only on approval).
|
|
11
|
+
*
|
|
12
|
+
* GOVERNANCE — structural, forever: a draft is a PROPOSAL, never a send. `draft`
|
|
13
|
+
* stages a `needs_approval` PatchPlan of `create_task` ops carrying the opener
|
|
14
|
+
* text; it calls NO email/LinkedIn send API and adds none. `apply` writes the
|
|
15
|
+
* draft into the CRM (as a task for a human/downstream sender to act on); it does
|
|
16
|
+
* not transmit a message. `riskLevel: "low"` (a draft is reversible — it's text
|
|
17
|
+
* in a task) but `approvalRequired: true` ALWAYS, so a draft is never auto-applied
|
|
18
|
+
* even under a scheduled run.
|
|
19
|
+
*
|
|
20
|
+
* EVIDENCE GATE — structural: the opener's FIRST LINE must contain a verbatim
|
|
21
|
+
* normalized-whitespace span (≥12 chars) of the decision's `whyNow` (itself a
|
|
22
|
+
* verbatim span of a credited signal quote, gated by the judge). A draft whose
|
|
23
|
+
* line 1 does not ground in the trigger is REJECTED, not saved — the self-discard
|
|
24
|
+
* rule: "if the message could have gone out last month unchanged, throw it away."
|
|
25
|
+
*
|
|
26
|
+
* NO-KEY PATH — honestly degraded: without an LLM key, `draft` emits a clearly
|
|
27
|
+
* labeled template stub (`[[DRAFT - no LLM key]] ...`) rather than fake authored
|
|
28
|
+
* copy, so an operator without a key sees the structure but is never handed wooden
|
|
29
|
+
* copy passed off as a real draft.
|
|
30
|
+
*/
|
|
31
|
+
export type DraftChannel = "email" | "linkedin" | "task";
|
|
32
|
+
export declare const DRAFT_CHANNELS: DraftChannel[];
|
|
33
|
+
/** A single authored opener for one hot account, before it becomes a patch op. */
|
|
34
|
+
export type Draft = {
|
|
35
|
+
accountDomain: string;
|
|
36
|
+
/** The full opener body. */
|
|
37
|
+
opener: string;
|
|
38
|
+
/** The decision this draft was authored from. */
|
|
39
|
+
decision: JudgeDecision;
|
|
40
|
+
/** The credited signal whose quote grounds the opener (the trigger anchor). */
|
|
41
|
+
signal: Signal;
|
|
42
|
+
/** "deterministic" (the labeled stub) | "llm:<provider>:<model>". */
|
|
43
|
+
producedBy: string;
|
|
44
|
+
/** True when the grounding signal is older than the freshness window. */
|
|
45
|
+
staleTrigger: boolean;
|
|
46
|
+
};
|
|
47
|
+
/** A draft that failed the first-line evidence gate — surfaced, never saved. */
|
|
48
|
+
export type RejectedDraft = {
|
|
49
|
+
accountDomain: string;
|
|
50
|
+
opener: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
};
|
|
53
|
+
export type DraftResult = {
|
|
54
|
+
plan: PatchPlan;
|
|
55
|
+
drafts: Draft[];
|
|
56
|
+
rejected: RejectedDraft[];
|
|
57
|
+
};
|
|
58
|
+
/** Stable patch-operation id for a draft op (mirrors enrich's `op_enr_<hash>`). */
|
|
59
|
+
export declare function draftOperationId(channel: string, accountDomain: string): string;
|
|
60
|
+
/** Stable evidence id for the signal a draft is grounded in. */
|
|
61
|
+
export declare function draftEvidenceId(signalId: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* Default freshness window for the self-discard rule. A draft whose grounding
|
|
64
|
+
* signal's `firstSeen` is older than this is flagged `staleTrigger` so the human
|
|
65
|
+
* reviewer catches a manufactured-relevance opener before approving it. 14 days
|
|
66
|
+
* mirrors the judge's `FRESH_SIGNAL_DAYS`.
|
|
67
|
+
*/
|
|
68
|
+
export declare const DEFAULT_FRESHNESS_DAYS = 14;
|
|
69
|
+
export declare function isStaleTrigger(firstSeen: string, now: Date, windowDays?: number): boolean;
|
|
70
|
+
export declare const DEFAULT_DRAFT_PROMPT = "You are writing ONE cold opener for a single hot account, grounded in a real, fresh trigger.\nThe opener's FIRST LINE must quote the trigger back in the buyer's own words \u2014 copy a verbatim span of the why-now below into line 1. Never open with \"Hi {{firstName}}\" or any greeting placeholder.\nRules (the operator owns these \u2014 edit this prompt to change them):\n- Line 1: open on the real trigger, in the buyer's words. It MUST contain a verbatim span of the why-now quote.\n- One sentence tying the trigger to ONE problem you solve. Specific, not generic.\n- End with ONE low-friction ask. No fake urgency, no \"circling back\".\n- No em dashes. No \"Hi {{firstName}}\". Keep it short \u2014 a real trigger does not need nine sentences.";
|
|
71
|
+
/**
|
|
72
|
+
* Strip em dashes (voice rule), collapse runs of whitespace WITHIN a line while
|
|
73
|
+
* preserving line breaks (line 1 grounding depends on the first line surviving
|
|
74
|
+
* intact), and trim trailing whitespace. Returns the cleaned opener.
|
|
75
|
+
*/
|
|
76
|
+
export declare function sanitizeOpener(opener: string): string;
|
|
77
|
+
/** The first non-empty line of an opener — the line the evidence gate checks. */
|
|
78
|
+
export declare function firstLine(opener: string): string;
|
|
79
|
+
/**
|
|
80
|
+
* Does the opener's first line contain a verbatim ≥12-char normalized span of the
|
|
81
|
+
* decision's whyNow? This is the self-discard gate: a draft that doesn't ground
|
|
82
|
+
* line 1 in the trigger is rejected, not saved.
|
|
83
|
+
*/
|
|
84
|
+
export declare function firstLineGroundedInTrigger(opener: string, whyNow: string): boolean;
|
|
85
|
+
/** Reject openers that lead with a banned greeting placeholder. */
|
|
86
|
+
export declare function hasBannedGreeting(opener: string): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Build a `GtmEvidence` from the signal a draft is grounded in — the trigger that
|
|
89
|
+
* justified the opener. `sourceSystem` is "web" for fetched signals (ATS boards
|
|
90
|
+
* via global fetch) and "manual" for ingested ones; `text` is the VERBATIM signal
|
|
91
|
+
* quote (the evidence anchor reused all the way from `signals fetch`).
|
|
92
|
+
*/
|
|
93
|
+
export declare function draftEvidence(signal: Signal, capturedAt: string): GtmEvidence;
|
|
94
|
+
/**
|
|
95
|
+
* The no-key, honestly-degraded baseline. Emits a clearly-labeled template stub
|
|
96
|
+
* — NEVER fake authored copy. Line 1 is the verbatim whyNow (so the first-line
|
|
97
|
+
* evidence gate passes by construction), followed by the play (if any) as the
|
|
98
|
+
* "what to say" scaffold the operator fills in once they add a key.
|
|
99
|
+
*/
|
|
100
|
+
export declare function deterministicOpener(decision: JudgeDecision): string;
|
|
101
|
+
export declare const DRAFT_SCHEMA: {
|
|
102
|
+
readonly type: "object";
|
|
103
|
+
readonly required: readonly ["opener"];
|
|
104
|
+
readonly properties: {
|
|
105
|
+
readonly opener: {
|
|
106
|
+
readonly type: "string";
|
|
107
|
+
readonly description: string;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The LLM drafter for one account. Forced tool call with `DRAFT_SCHEMA` + the
|
|
113
|
+
* editable prompt; then the FIRST-LINE evidence gate: line 1 must contain a
|
|
114
|
+
* verbatim ≥12-char span of the decision's whyNow. Retry once with corrective
|
|
115
|
+
* feedback (the judge/market idiom), then — rather than store an ungrounded
|
|
116
|
+
* opener — fall back to the deterministic stub. The brain NEVER ships an opener
|
|
117
|
+
* whose first line isn't grounded in the trigger.
|
|
118
|
+
*/
|
|
119
|
+
export declare function draftOpenerLlm(decision: JudgeDecision, signal: Signal, promptTemplate: string, llm: LlmCallOptions): Promise<{
|
|
120
|
+
opener: string;
|
|
121
|
+
producedBy: string;
|
|
122
|
+
grounded: boolean;
|
|
123
|
+
}>;
|
|
124
|
+
/**
|
|
125
|
+
* Author one opener per hot account and stage them as a `needs_approval` plan of
|
|
126
|
+
* `create_task` PatchOperations, each carrying a `GtmEvidence` (the signal quote)
|
|
127
|
+
* on `plan.evidence` and referenced via `op.evidenceIds`. Read-only: builds the
|
|
128
|
+
* plan in memory; the caller persists it via `createFilePlanStore().save(plan)`
|
|
129
|
+
* only on `--save`. NEVER sends.
|
|
130
|
+
*
|
|
131
|
+
* - Takes decisions with `decision === "send"` and `score >= minScore`.
|
|
132
|
+
* - Picks the credited signal whose quote grounds the whyNow as the trigger
|
|
133
|
+
* anchor (the evidence the opener traces back to).
|
|
134
|
+
* - Authors the opener: LLM (gated, first-line evidence) when `llm` is provided,
|
|
135
|
+
* else the labeled deterministic stub.
|
|
136
|
+
* - First-line evidence gate: an opener whose line 1 doesn't ground in the whyNow
|
|
137
|
+
* is REJECTED (surfaced in `rejected`, never an op). The deterministic stub and
|
|
138
|
+
* the LLM fallback are grounded by construction, so they always pass.
|
|
139
|
+
* - Stale-trigger: a draft whose grounding signal is older than the freshness
|
|
140
|
+
* window gets a `staleTrigger` warning appended to the op `reason`.
|
|
141
|
+
*/
|
|
142
|
+
export declare function draft(opts: {
|
|
143
|
+
decisions: JudgeDecision[];
|
|
144
|
+
/** Credited signals by id — supplies the verbatim quote each opener grounds in. */
|
|
145
|
+
signalsById: Map<string, Signal>;
|
|
146
|
+
minScore?: number;
|
|
147
|
+
channel?: DraftChannel;
|
|
148
|
+
/** Pre-authored openers by accountDomain (the LLM path runs in the async caller). */
|
|
149
|
+
openers?: Map<string, {
|
|
150
|
+
opener: string;
|
|
151
|
+
producedBy: string;
|
|
152
|
+
grounded: boolean;
|
|
153
|
+
signalId: string;
|
|
154
|
+
}>;
|
|
155
|
+
freshnessDays?: number;
|
|
156
|
+
now?: Date;
|
|
157
|
+
}): DraftResult;
|
|
158
|
+
/**
|
|
159
|
+
* Pick the credited signal whose quote grounds the decision's whyNow — the
|
|
160
|
+
* trigger the opener traces back to. Prefers a signal whose quote verbatim
|
|
161
|
+
* contains the whyNow span; falls back to the first resolvable credited signal so
|
|
162
|
+
* the evidence chain is never empty when a credit exists.
|
|
163
|
+
*/
|
|
164
|
+
export declare function creditedTriggerSignal(decision: JudgeDecision, signalsById: Map<string, Signal>): Signal | undefined;
|
|
165
|
+
/**
|
|
166
|
+
* Author openers for the hot decisions via the LLM path (or the deterministic
|
|
167
|
+
* stub when no `llm` is provided), returning the map `draft()` consumes. Kept
|
|
168
|
+
* separate from `draft()` so the plan-assembly stays pure/synchronous and the
|
|
169
|
+
* async authoring is testable in isolation.
|
|
170
|
+
*/
|
|
171
|
+
export declare function authorOpeners(opts: {
|
|
172
|
+
decisions: JudgeDecision[];
|
|
173
|
+
signalsById: Map<string, Signal>;
|
|
174
|
+
minScore?: number;
|
|
175
|
+
promptTemplate?: string;
|
|
176
|
+
llm?: LlmCallOptions;
|
|
177
|
+
}): Promise<Map<string, {
|
|
178
|
+
opener: string;
|
|
179
|
+
producedBy: string;
|
|
180
|
+
grounded: boolean;
|
|
181
|
+
signalId: string;
|
|
182
|
+
}>>;
|