fullstackgtm 0.40.0 → 0.42.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.
@@ -489,6 +489,117 @@ export function createSalesforceConnector(options) {
489
489
  providerData: { id: contactId, created: true },
490
490
  };
491
491
  }
492
+ /**
493
+ * Batch create_record for contacts: bulk resolve-first (SOQL IN), then create
494
+ * the misses via the Composite sObject Collections API (allOrNone:false,
495
+ * 200/call), instead of two round-trips per lead. A record-level failure in
496
+ * the collection (e.g. a duplicate rule) falls back to per-record createRecord
497
+ * so one bad row never sinks the rest. Returns one result per input op.
498
+ */
499
+ async function applyCreateContactsBatch(operations) {
500
+ const byId = new Map();
501
+ const groups = new Map();
502
+ for (const op of operations) {
503
+ const matchKey = op.afterValue?.matchKey || "email";
504
+ const searchField = mappedField(mappings, "contacts", matchKey, SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey);
505
+ let arr = groups.get(searchField);
506
+ if (!arr)
507
+ groups.set(searchField, (arr = []));
508
+ arr.push(op);
509
+ }
510
+ for (const [searchField, ops] of groups) {
511
+ // 1. Bulk resolve-first via SOQL IN (chunked; values SOQL-escaped).
512
+ const existing = new Map();
513
+ const uniqueValues = [
514
+ ...new Set(ops.map((op) => String(op.afterValue.matchValue ?? "").trim()).filter(Boolean)),
515
+ ];
516
+ for (let i = 0; i < uniqueValues.length; i += 200) {
517
+ const inList = uniqueValues
518
+ .slice(i, i + 200)
519
+ .map((v) => `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`)
520
+ .join(",");
521
+ const rows = await query(`SELECT Id, ${searchField} FROM Contact WHERE ${searchField} IN (${inList})`);
522
+ for (const row of rows) {
523
+ const v = row?.[searchField];
524
+ if (typeof v === "string" && row.Id)
525
+ existing.set(v.toLowerCase(), String(row.Id));
526
+ }
527
+ }
528
+ // 2. Partition: existing / already-this-run / dup-in-batch / to-create.
529
+ const toCreate = [];
530
+ const seenInBatch = new Set();
531
+ for (const op of ops) {
532
+ const payload = op.afterValue;
533
+ const matchKey = payload.matchKey || "email";
534
+ const matchValue = String(payload.matchValue ?? "").trim();
535
+ if (!matchValue) {
536
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." });
537
+ continue;
538
+ }
539
+ const lower = matchValue.toLowerCase();
540
+ const dedupeKey = `${matchKey}:${lower}`;
541
+ const already = createdContactsByMatch.get(dedupeKey);
542
+ if (already) {
543
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: already, existing: true } });
544
+ continue;
545
+ }
546
+ const existingId = existing.get(lower);
547
+ if (existingId) {
548
+ createdContactsByMatch.set(dedupeKey, existingId);
549
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existingId}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } });
550
+ continue;
551
+ }
552
+ if (seenInBatch.has(lower)) {
553
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} duplicated within this batch; not duplicating.` });
554
+ continue;
555
+ }
556
+ seenInBatch.add(lower);
557
+ toCreate.push(op);
558
+ }
559
+ // 3. Bulk create via Composite sObject Collections (allOrNone:false →
560
+ // per-record results in request order).
561
+ for (let i = 0; i < toCreate.length; i += 200) {
562
+ const chunk = toCreate.slice(i, i + 200);
563
+ const records = chunk.map((op) => {
564
+ const payload = op.afterValue;
565
+ const fields = { ...payload.properties };
566
+ if (payload.ownerId) {
567
+ const ownerField = mappedField(mappings, "contacts", "ownerId", SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts.ownerId ?? "OwnerId");
568
+ if (!fields[ownerField])
569
+ fields[ownerField] = String(payload.ownerId);
570
+ }
571
+ return { attributes: { type: "Contact" }, ...fields };
572
+ });
573
+ let results;
574
+ try {
575
+ results = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
576
+ method: "POST",
577
+ body: JSON.stringify({ allOrNone: false, records }),
578
+ }));
579
+ }
580
+ catch {
581
+ for (const op of chunk)
582
+ byId.set(op.id, await createRecord(op));
583
+ continue;
584
+ }
585
+ for (let j = 0; j < chunk.length; j++) {
586
+ const op = chunk[j];
587
+ const res = Array.isArray(results) ? results[j] : undefined;
588
+ const payload = op.afterValue;
589
+ const matchKey = payload.matchKey || "email";
590
+ const matchValue = String(payload.matchValue).trim();
591
+ if (res?.success && res.id) {
592
+ createdContactsByMatch.set(`${matchKey}:${matchValue.toLowerCase()}`, String(res.id));
593
+ byId.set(op.id, { operationId: op.id, status: "applied", detail: `Created contact ${matchKey}=${matchValue} (${res.id}).`, providerData: { id: String(res.id), created: true } });
594
+ }
595
+ else {
596
+ byId.set(op.id, await createRecord(op)); // record-level failure → isolate per record
597
+ }
598
+ }
599
+ }
600
+ }
601
+ return operations.map((op) => byId.get(op.id) ?? { operationId: op.id, status: "skipped", detail: "create_record was not processed." });
602
+ }
492
603
  async function applyOperation(operation) {
493
604
  try {
494
605
  switch (operation.operation) {
@@ -568,6 +679,7 @@ export function createSalesforceConnector(options) {
568
679
  fetchSnapshot,
569
680
  fetchChanges,
570
681
  applyOperation,
682
+ applyCreateContactsBatch,
571
683
  readField,
572
684
  };
573
685
  }
@@ -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
+ }>>;
package/dist/draft.js ADDED
@@ -0,0 +1,333 @@
1
+ import { forcedToolCall } from "./llm.js";
2
+ import { isGroundedSpan } from "./judge.js";
3
+ import { normalizeAccountDomain } from "./signals.js";
4
+ export const DRAFT_CHANNELS = ["email", "linkedin", "task"];
5
+ // ---------------------------------------------------------------------------
6
+ // Stable hashing (FNV-1a) — duplicated to keep this file importable without the
7
+ // audit engine (the judge.ts/signals.ts/market.ts/enrich.ts precedent).
8
+ function fnv1a(value) {
9
+ let hash = 0x811c9dc5;
10
+ for (let i = 0; i < value.length; i += 1) {
11
+ hash ^= value.charCodeAt(i);
12
+ hash = Math.imul(hash, 0x01000193);
13
+ }
14
+ return (hash >>> 0).toString(16).padStart(8, "0");
15
+ }
16
+ /** Stable patch-operation id for a draft op (mirrors enrich's `op_enr_<hash>`). */
17
+ export function draftOperationId(channel, accountDomain) {
18
+ return `op_draft_${fnv1a(`${channel}:${accountDomain}`)}`;
19
+ }
20
+ /** Stable evidence id for the signal a draft is grounded in. */
21
+ export function draftEvidenceId(signalId) {
22
+ return `ev_draft_${fnv1a(signalId)}`;
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Freshness / stale-trigger
26
+ const DAY_MS = 24 * 60 * 60 * 1000;
27
+ /**
28
+ * Default freshness window for the self-discard rule. A draft whose grounding
29
+ * signal's `firstSeen` is older than this is flagged `staleTrigger` so the human
30
+ * reviewer catches a manufactured-relevance opener before approving it. 14 days
31
+ * mirrors the judge's `FRESH_SIGNAL_DAYS`.
32
+ */
33
+ export const DEFAULT_FRESHNESS_DAYS = 14;
34
+ export function isStaleTrigger(firstSeen, now, windowDays = DEFAULT_FRESHNESS_DAYS) {
35
+ const seen = Date.parse(firstSeen);
36
+ if (!Number.isFinite(seen))
37
+ return true; // an undateable trigger can't be proven fresh — treat as stale
38
+ return now.getTime() - seen > windowDays * DAY_MS;
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // Voice rules / opener hygiene (the editable prompt's mechanically-checkable contract)
42
+ export const DEFAULT_DRAFT_PROMPT = `You are writing ONE cold opener for a single hot account, grounded in a real, fresh trigger.
43
+ The opener's FIRST LINE must quote the trigger back in the buyer's own words — copy a verbatim span of the why-now below into line 1. Never open with "Hi {{firstName}}" or any greeting placeholder.
44
+ Rules (the operator owns these — edit this prompt to change them):
45
+ - Line 1: open on the real trigger, in the buyer's words. It MUST contain a verbatim span of the why-now quote.
46
+ - One sentence tying the trigger to ONE problem you solve. Specific, not generic.
47
+ - End with ONE low-friction ask. No fake urgency, no "circling back".
48
+ - No em dashes. No "Hi {{firstName}}". Keep it short — a real trigger does not need nine sentences.`;
49
+ /**
50
+ * Strip em dashes (voice rule), collapse runs of whitespace WITHIN a line while
51
+ * preserving line breaks (line 1 grounding depends on the first line surviving
52
+ * intact), and trim trailing whitespace. Returns the cleaned opener.
53
+ */
54
+ export function sanitizeOpener(opener) {
55
+ return opener
56
+ .replace(/—/g, "-")
57
+ .split("\n")
58
+ .map((line) => line.replace(/[ \t]+/g, " ").trimEnd())
59
+ .join("\n")
60
+ .replace(/\n{3,}/g, "\n\n")
61
+ .trim();
62
+ }
63
+ /** The first non-empty line of an opener — the line the evidence gate checks. */
64
+ export function firstLine(opener) {
65
+ for (const line of opener.split("\n")) {
66
+ if (line.trim())
67
+ return line.trim();
68
+ }
69
+ return "";
70
+ }
71
+ /**
72
+ * Does the opener's first line contain a verbatim ≥12-char normalized span of the
73
+ * decision's whyNow? This is the self-discard gate: a draft that doesn't ground
74
+ * line 1 in the trigger is rejected, not saved.
75
+ */
76
+ export function firstLineGroundedInTrigger(opener, whyNow) {
77
+ return isGroundedSpan(whyNow, firstLine(opener));
78
+ }
79
+ /** Reject openers that lead with a banned greeting placeholder. */
80
+ export function hasBannedGreeting(opener) {
81
+ return /\bhi\s+\{\{\s*firstname\s*\}\}/i.test(opener) || /^\s*hi\s+\{\{/i.test(opener);
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Evidence builder (evidenceFor idiom)
85
+ /**
86
+ * Build a `GtmEvidence` from the signal a draft is grounded in — the trigger that
87
+ * justified the opener. `sourceSystem` is "web" for fetched signals (ATS boards
88
+ * via global fetch) and "manual" for ingested ones; `text` is the VERBATIM signal
89
+ * quote (the evidence anchor reused all the way from `signals fetch`).
90
+ */
91
+ export function draftEvidence(signal, capturedAt) {
92
+ const fetched = signal.source !== "ingest";
93
+ return {
94
+ id: draftEvidenceId(signal.id),
95
+ sourceSystem: fetched ? "web" : "manual",
96
+ sourceObjectType: "signal",
97
+ sourceObjectId: signal.id,
98
+ title: `${signal.bucket} signal for ${signal.accountDomain}: ${signal.trigger}`,
99
+ text: signal.quote,
100
+ capturedAt,
101
+ metadata: {
102
+ bucket: signal.bucket,
103
+ trigger: signal.trigger,
104
+ sourceUrl: signal.sourceUrl,
105
+ firstSeen: signal.firstSeen,
106
+ signalSource: signal.source,
107
+ },
108
+ };
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // Opener authoring: deterministic stub + gated LLM path
112
+ /**
113
+ * The no-key, honestly-degraded baseline. Emits a clearly-labeled template stub
114
+ * — NEVER fake authored copy. Line 1 is the verbatim whyNow (so the first-line
115
+ * evidence gate passes by construction), followed by the play (if any) as the
116
+ * "what to say" scaffold the operator fills in once they add a key.
117
+ */
118
+ export function deterministicOpener(decision) {
119
+ const whyNow = decision.whyNow.trim();
120
+ const play = decision.play.trim();
121
+ const tail = play ? `\n→ ${play}` : "";
122
+ return `[[DRAFT - no LLM key]] ${whyNow}${tail}`;
123
+ }
124
+ export const DRAFT_SCHEMA = {
125
+ type: "object",
126
+ required: ["opener"],
127
+ properties: {
128
+ opener: {
129
+ type: "string",
130
+ description: "The full cold opener. The FIRST line MUST contain a verbatim span copied from the why-now quote. " +
131
+ "One problem sentence, one low-friction ask. No greeting, no 'Hi {{firstName}}', no em dashes.",
132
+ },
133
+ },
134
+ };
135
+ /**
136
+ * The LLM drafter for one account. Forced tool call with `DRAFT_SCHEMA` + the
137
+ * editable prompt; then the FIRST-LINE evidence gate: line 1 must contain a
138
+ * verbatim ≥12-char span of the decision's whyNow. Retry once with corrective
139
+ * feedback (the judge/market idiom), then — rather than store an ungrounded
140
+ * opener — fall back to the deterministic stub. The brain NEVER ships an opener
141
+ * whose first line isn't grounded in the trigger.
142
+ */
143
+ export async function draftOpenerLlm(decision, signal, promptTemplate, llm) {
144
+ const model = llm.model ?? "claude-haiku-4-5";
145
+ const producedBy = `llm:${llm.provider}:${model}`;
146
+ const basePrompt = `${promptTemplate}
147
+
148
+ Account: ${decision.accountDomain}
149
+ Why now (your FIRST line must contain a verbatim span of THIS): "${decision.whyNow}"
150
+ The trigger quote (verbatim source): "${signal.quote}"${decision.play ? `\nA play the judge suggested (do not copy verbatim): ${decision.play}` : ""}`;
151
+ const attempt = async (feedback) => {
152
+ const result = (await forcedToolCall(feedback ? `${basePrompt}\n${feedback}` : basePrompt, "draft_opener", DRAFT_SCHEMA, model, llm));
153
+ const opener = sanitizeOpener((result.opener ?? "").trim());
154
+ if (!opener)
155
+ return null;
156
+ if (hasBannedGreeting(opener))
157
+ return null;
158
+ if (!firstLineGroundedInTrigger(opener, decision.whyNow))
159
+ return null; // gate failed
160
+ return opener;
161
+ };
162
+ const first = await attempt("");
163
+ if (first)
164
+ return { opener: first, producedBy, grounded: true };
165
+ const retried = await attempt("\nYour previous opener's FIRST line did NOT contain a verbatim span of the why-now quote " +
166
+ "(or it used a banned greeting). Copy a span character-for-character from the why-now into line 1 and rewrite.");
167
+ if (retried)
168
+ return { opener: retried, producedBy, grounded: true };
169
+ // Both attempts ungrounded — never store an ungrounded opener. Fall back to the
170
+ // labeled deterministic stub (grounded by construction), but stamp the path that
171
+ // ran so the operator can see the model was reached and gated.
172
+ return { opener: deterministicOpener(decision), producedBy, grounded: false };
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // Orchestration: judge decisions → drafts → governed plan
176
+ /**
177
+ * Author one opener per hot account and stage them as a `needs_approval` plan of
178
+ * `create_task` PatchOperations, each carrying a `GtmEvidence` (the signal quote)
179
+ * on `plan.evidence` and referenced via `op.evidenceIds`. Read-only: builds the
180
+ * plan in memory; the caller persists it via `createFilePlanStore().save(plan)`
181
+ * only on `--save`. NEVER sends.
182
+ *
183
+ * - Takes decisions with `decision === "send"` and `score >= minScore`.
184
+ * - Picks the credited signal whose quote grounds the whyNow as the trigger
185
+ * anchor (the evidence the opener traces back to).
186
+ * - Authors the opener: LLM (gated, first-line evidence) when `llm` is provided,
187
+ * else the labeled deterministic stub.
188
+ * - First-line evidence gate: an opener whose line 1 doesn't ground in the whyNow
189
+ * is REJECTED (surfaced in `rejected`, never an op). The deterministic stub and
190
+ * the LLM fallback are grounded by construction, so they always pass.
191
+ * - Stale-trigger: a draft whose grounding signal is older than the freshness
192
+ * window gets a `staleTrigger` warning appended to the op `reason`.
193
+ */
194
+ export function draft(opts) {
195
+ const now = opts.now ?? new Date();
196
+ const nowIso = now.toISOString();
197
+ const minScore = opts.minScore ?? 80;
198
+ const channel = opts.channel ?? "task";
199
+ const freshnessDays = opts.freshnessDays ?? DEFAULT_FRESHNESS_DAYS;
200
+ // `task`/`email`/`linkedin` all write a CRM task through the same gate; the
201
+ // object the task hangs off is the contact when known, else the account.
202
+ const objectType = "contact";
203
+ const hot = opts.decisions
204
+ .filter((d) => d.decision === "send" && d.score >= minScore)
205
+ .sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
206
+ const operations = [];
207
+ const evidence = [];
208
+ const drafts = [];
209
+ const rejected = [];
210
+ for (const decision of hot) {
211
+ const domain = normalizeAccountDomain(decision.accountDomain);
212
+ const signal = creditedTriggerSignal(decision, opts.signalsById);
213
+ if (!signal) {
214
+ // No credited signal quote grounds the whyNow — we cannot trace this opener
215
+ // to a trigger, so we cannot draft it. Surface, never emit.
216
+ rejected.push({
217
+ accountDomain: domain,
218
+ opener: "",
219
+ reason: "no credited signal quote grounds the why-now — cannot anchor an opener",
220
+ });
221
+ continue;
222
+ }
223
+ const authored = opts.openers?.get(domain);
224
+ const opener = authored ? authored.opener : deterministicOpener(decision);
225
+ const producedBy = authored ? authored.producedBy : "deterministic";
226
+ // First-line evidence gate — the self-discard rule. The deterministic stub and
227
+ // the LLM fallback are grounded by construction; an LLM opener that reached here
228
+ // already passed in draftOpenerLlm, but we re-check defensively (a caller could
229
+ // inject any opener).
230
+ if (!firstLineGroundedInTrigger(opener, decision.whyNow)) {
231
+ rejected.push({
232
+ accountDomain: domain,
233
+ opener,
234
+ reason: "first line does not contain a verbatim span of the why-now trigger",
235
+ });
236
+ continue;
237
+ }
238
+ const stale = isStaleTrigger(signal.firstSeen, now, freshnessDays);
239
+ const ev = draftEvidence(signal, nowIso);
240
+ evidence.push(ev);
241
+ const reasonBase = `Signal-grounded opener for ${domain}: "${signal.trigger}"`;
242
+ const reason = stale
243
+ ? `${reasonBase} [staleTrigger: grounding signal first seen ${signal.firstSeen}, older than ${freshnessDays}d — could this have gone out last month unchanged?]`
244
+ : reasonBase;
245
+ operations.push({
246
+ id: draftOperationId(channel, domain),
247
+ objectType,
248
+ objectId: domain,
249
+ operation: "create_task",
250
+ field: "follow_up_task",
251
+ beforeValue: null,
252
+ afterValue: opener.slice(0, 255),
253
+ reason,
254
+ sourceRuleOrPolicy: `draft:${channel}`,
255
+ riskLevel: "low",
256
+ approvalRequired: true,
257
+ rollback: "Close or delete the created task; nothing was sent.",
258
+ evidenceIds: [ev.id],
259
+ });
260
+ drafts.push({
261
+ accountDomain: domain,
262
+ opener,
263
+ decision,
264
+ signal,
265
+ producedBy,
266
+ staleTrigger: stale,
267
+ });
268
+ }
269
+ const plan = {
270
+ id: `draft_${fnv1a(`${channel}:${nowIso}:${operations.map((o) => o.id).join(",")}`)}`,
271
+ title: `Draft openers (${channel}) — ${operations.length} hot account(s)`,
272
+ createdAt: nowIso,
273
+ status: operations.length > 0 ? "needs_approval" : "draft",
274
+ dryRun: true,
275
+ summary: `${operations.length} drafted opener(s) staged as create_task proposals` +
276
+ `${rejected.length ? `; ${rejected.length} rejected (ungrounded first line)` : ""}. ` +
277
+ "A draft is a proposal — nothing is sent until plans approve -> apply.",
278
+ findings: [],
279
+ evidence,
280
+ operations,
281
+ };
282
+ return { plan, drafts, rejected };
283
+ }
284
+ /**
285
+ * Pick the credited signal whose quote grounds the decision's whyNow — the
286
+ * trigger the opener traces back to. Prefers a signal whose quote verbatim
287
+ * contains the whyNow span; falls back to the first resolvable credited signal so
288
+ * the evidence chain is never empty when a credit exists.
289
+ */
290
+ export function creditedTriggerSignal(decision, signalsById) {
291
+ let firstResolved;
292
+ for (const id of decision.evidence) {
293
+ const signal = signalsById.get(id);
294
+ if (!signal)
295
+ continue;
296
+ if (!firstResolved)
297
+ firstResolved = signal;
298
+ if (isGroundedSpan(decision.whyNow, signal.quote))
299
+ return signal;
300
+ }
301
+ return firstResolved;
302
+ }
303
+ /**
304
+ * Author openers for the hot decisions via the LLM path (or the deterministic
305
+ * stub when no `llm` is provided), returning the map `draft()` consumes. Kept
306
+ * separate from `draft()` so the plan-assembly stays pure/synchronous and the
307
+ * async authoring is testable in isolation.
308
+ */
309
+ export async function authorOpeners(opts) {
310
+ const minScore = opts.minScore ?? 80;
311
+ const out = new Map();
312
+ for (const decision of opts.decisions) {
313
+ if (decision.decision !== "send" || decision.score < minScore)
314
+ continue;
315
+ const domain = normalizeAccountDomain(decision.accountDomain);
316
+ const signal = creditedTriggerSignal(decision, opts.signalsById);
317
+ if (!signal)
318
+ continue;
319
+ if (opts.llm && opts.promptTemplate) {
320
+ const { opener, producedBy, grounded } = await draftOpenerLlm(decision, signal, opts.promptTemplate, opts.llm);
321
+ out.set(domain, { opener, producedBy, grounded, signalId: signal.id });
322
+ }
323
+ else {
324
+ out.set(domain, {
325
+ opener: deterministicOpener(decision),
326
+ producedBy: "deterministic",
327
+ grounded: true,
328
+ signalId: signal.id,
329
+ });
330
+ }
331
+ }
332
+ return out;
333
+ }
package/dist/index.d.ts CHANGED
@@ -39,4 +39,9 @@ export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
39
39
  export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, type FetchText, type FetchBytes, type ResolveUrl, type DiscoveredVendor, type DiscoverCompetitorsOptions, } from "./marketSourcing.ts";
40
40
  export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, type CronExpression, type CrontabIo, type ScheduleEntry, type ScheduleProvider, type ScheduleRunRecord, type ScheduleRunStore, type ScheduleRunTrigger, type ScheduleStore, } from "./schedule.ts";
41
41
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
42
+ export { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupKey, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, parseSignalsConfig, recencyFactor, SIGNAL_BUCKETS, SIGNALS_CONFIG_FILE_NAME, signalId, signalRunId, signalsDir, signalWeight, type Signal, type SignalBucket, type SignalBucketConfig, type SignalOutcome, type SignalOutcomeResult, type SignalRun, type SignalsConfig, type SignalStore, } from "./signals.ts";
43
+ export { fetchAtsJobs, snippetFor, type AtsBoardSource, type AtsJob, } from "./connectors/atsBoards.ts";
44
+ export { scoreBand, judgeRunId, normalizeSpan, isGroundedSpan, scoreAccount, accountRecentlyTouched, bestContactForAccount, deterministicDecision, deterministicWhyNow, JUDGE_SCHEMA, DEFAULT_JUDGE_PROMPT, judgeDecisionLlm, judgeSignals, judgeDir, createFileJudgeStore, type JudgeDecisionKind, type JudgeDecision, type JudgeRun, type JudgeBand, type AccountScore, type JudgeStore, } from "./judge.ts";
45
+ export { draft, authorOpeners, draftOpenerLlm, deterministicOpener, creditedTriggerSignal, draftEvidence, sanitizeOpener, firstLine, firstLineGroundedInTrigger, hasBannedGreeting, isStaleTrigger, draftOperationId, draftEvidenceId, DRAFT_CHANNELS, DRAFT_SCHEMA, DEFAULT_DRAFT_PROMPT, DEFAULT_FRESHNESS_DAYS, type Draft, type RejectedDraft, type DraftResult, type DraftChannel, } from "./draft.ts";
46
+ export { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, HOT_SCORE, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, type Confusion, type EvalJudgeFn, type GoldenHistory, type GoldenRow, type GradeResult, type OutcomeCalibration, } from "./judgeEval.ts";
42
47
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -39,3 +39,8 @@ export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
39
39
  export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, } from "./marketSourcing.js";
40
40
  export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
41
41
  export { suggestValues } from "./suggest.js";
42
+ export { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupKey, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, parseSignalsConfig, recencyFactor, SIGNAL_BUCKETS, SIGNALS_CONFIG_FILE_NAME, signalId, signalRunId, signalsDir, signalWeight, } from "./signals.js";
43
+ export { fetchAtsJobs, snippetFor, } from "./connectors/atsBoards.js";
44
+ export { scoreBand, judgeRunId, normalizeSpan, isGroundedSpan, scoreAccount, accountRecentlyTouched, bestContactForAccount, deterministicDecision, deterministicWhyNow, JUDGE_SCHEMA, DEFAULT_JUDGE_PROMPT, judgeDecisionLlm, judgeSignals, judgeDir, createFileJudgeStore, } from "./judge.js";
45
+ export { draft, authorOpeners, draftOpenerLlm, deterministicOpener, creditedTriggerSignal, draftEvidence, sanitizeOpener, firstLine, firstLineGroundedInTrigger, hasBannedGreeting, isStaleTrigger, draftOperationId, draftEvidenceId, DRAFT_CHANNELS, DRAFT_SCHEMA, DEFAULT_DRAFT_PROMPT, DEFAULT_FRESHNESS_DAYS, } from "./draft.js";
46
+ export { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, HOT_SCORE, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, } from "./judgeEval.js";