fullstackgtm 0.39.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.
@@ -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(/&amp;/g, "&")
169
+ .replace(/&lt;/g, "<")
170
+ .replace(/&gt;/g, ">")
171
+ .replace(/&quot;/g, '"')
172
+ .replace(/&#39;|&apos;/g, "'")
173
+ .replace(/&nbsp;/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 emailByKey = new Map();
110
- for (let i = 0; i < resolvable.length; i += chunkSize) {
111
- const chunk = resolvable.slice(i, i + chunkSize);
112
- const input = chunk.map((p) => ({
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
- let body;
117
- try {
118
- const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
119
- method: "POST",
120
- headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
121
- body: JSON.stringify({ pipes: [{ pipe_id: "person:workemail:waterfall@1" }], input }),
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
- emailByKey.set(personKey(fieldValue(fields.name), domain), email);
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 domainByName = new Map();
181
- for (let i = 0; i < uniqueNames.length; i += chunkSize) {
182
- const chunk = uniqueNames.slice(i, i + chunkSize);
183
- try {
184
- const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
185
- method: "POST",
186
- headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
187
- body: JSON.stringify({
188
- pipes: [{ pipe_id: "company:identity@1" }],
189
- input: chunk.map((company_name) => ({ company_name })),
190
- }),
191
- });
192
- if (!response.ok)
193
- continue;
194
- const body = (await response.json());
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;
@@ -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
+ }>>;