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.
- package/CHANGELOG.md +57 -0
- package/README.md +30 -1
- package/dist/cli.js +615 -6
- package/dist/connector.js +34 -0
- package/dist/connectors/atsBoards.d.ts +60 -0
- package/dist/connectors/atsBoards.js +179 -0
- package/dist/connectors/hubspot.js +124 -0
- package/dist/connectors/prospectSources.d.ts +7 -5
- package/dist/connectors/prospectSources.js +82 -47
- package/dist/connectors/salesforce.js +112 -0
- 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/dist/types.d.ts +11 -0
- package/docs/api.md +8 -1
- package/package.json +1 -1
- package/src/cli.ts +696 -7
- package/src/connector.ts +37 -0
- package/src/connectors/atsBoards.ts +242 -0
- package/src/connectors/hubspot.ts +125 -0
- package/src/connectors/prospectSources.ts +94 -43
- package/src/connectors/salesforce.ts +113 -0
- 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
- package/src/types.ts +11 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { deterministicDecision, scoreAccount, } from "./judge.js";
|
|
2
|
+
import { DEFAULT_SIGNALS_CONFIG, computeWeights, normalizeAccountDomain, } from "./signals.js";
|
|
3
|
+
/** Hot threshold for the empirical gate — mirrors the judge's strong band (>=80). */
|
|
4
|
+
export const HOT_SCORE = 80;
|
|
5
|
+
/** Default golden-set pass bar (`--min-accuracy`). */
|
|
6
|
+
export const DEFAULT_MIN_ACCURACY = 0.8;
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Grading a judge against a golden set
|
|
9
|
+
/** Collapse the three-way decision to the binary send-vs-not the gate guards. */
|
|
10
|
+
function isSend(decision) {
|
|
11
|
+
return decision === "send";
|
|
12
|
+
}
|
|
13
|
+
function decisionKindOf(result) {
|
|
14
|
+
return typeof result === "string" ? result : result.decision;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run `judgeFn` over each golden row and score agreement on the binary
|
|
18
|
+
* send-vs-skip call (nurture counts as not-send). Deterministic given a
|
|
19
|
+
* deterministic `judgeFn`; the only async is the judge itself (so an LLM judge
|
|
20
|
+
* can be graded with the same harness). Empty `rows` → perfect-but-vacuous
|
|
21
|
+
* scores (accuracy 1) — the CLI guards against an empty set separately.
|
|
22
|
+
*/
|
|
23
|
+
export async function gradeJudge(rows, judgeFn) {
|
|
24
|
+
const confusion = {
|
|
25
|
+
truePositive: 0,
|
|
26
|
+
falsePositive: 0,
|
|
27
|
+
falseNegative: 0,
|
|
28
|
+
trueNegative: 0,
|
|
29
|
+
};
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
const predicted = isSend(decisionKindOf(await judgeFn(row)));
|
|
32
|
+
const expected = row.expectedDecision === "send";
|
|
33
|
+
if (predicted && expected)
|
|
34
|
+
confusion.truePositive += 1;
|
|
35
|
+
else if (predicted && !expected)
|
|
36
|
+
confusion.falsePositive += 1;
|
|
37
|
+
else if (!predicted && expected)
|
|
38
|
+
confusion.falseNegative += 1;
|
|
39
|
+
else
|
|
40
|
+
confusion.trueNegative += 1;
|
|
41
|
+
}
|
|
42
|
+
const total = rows.length;
|
|
43
|
+
const correct = confusion.truePositive + confusion.trueNegative;
|
|
44
|
+
const predictedSend = confusion.truePositive + confusion.falsePositive;
|
|
45
|
+
const actualSend = confusion.truePositive + confusion.falseNegative;
|
|
46
|
+
return {
|
|
47
|
+
accuracy: total === 0 ? 1 : round4(correct / total),
|
|
48
|
+
// A judge that never says send is vacuously precise; one with no positives
|
|
49
|
+
// to find has perfect recall — the standard 0-denominator conventions.
|
|
50
|
+
precision: predictedSend === 0 ? 1 : round4(confusion.truePositive / predictedSend),
|
|
51
|
+
recall: actualSend === 0 ? 1 : round4(confusion.truePositive / actualSend),
|
|
52
|
+
confusion,
|
|
53
|
+
total,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// The default deterministic judge fn (offline, free, no LLM)
|
|
58
|
+
/**
|
|
59
|
+
* The deterministic judge wrapped as an `EvalJudgeFn`: group a row's signals by
|
|
60
|
+
* account, score the (single, by construction) account on timing × fit × memory
|
|
61
|
+
* via `scoreAccount`, and return the deterministic decision. This is the SAME
|
|
62
|
+
* scorer `icp judge` runs key-free, so `gradeJudge(rows, defaultJudgeFn())`
|
|
63
|
+
* grades the real baseline — not a re-implementation. A row with no signals is
|
|
64
|
+
* a skip (nothing changed → nothing to reach out about).
|
|
65
|
+
*/
|
|
66
|
+
export function defaultJudgeFn(opts = {}) {
|
|
67
|
+
const config = opts.config ?? DEFAULT_SIGNALS_CONFIG;
|
|
68
|
+
return (row) => {
|
|
69
|
+
if (row.signals.length === 0)
|
|
70
|
+
return "skip";
|
|
71
|
+
// A golden row is one account; take the first signal's domain as the anchor.
|
|
72
|
+
const domain = normalizeAccountDomain(row.signals[0].accountDomain);
|
|
73
|
+
const signals = row.signals.filter((s) => normalizeAccountDomain(s.accountDomain) === domain);
|
|
74
|
+
const weights = computeWeights(config, [], new Map(signals.map((s) => [s.id, s])));
|
|
75
|
+
const score = scoreAccount({
|
|
76
|
+
accountDomain: domain,
|
|
77
|
+
signals,
|
|
78
|
+
weights,
|
|
79
|
+
icp: opts.icp,
|
|
80
|
+
recentlyTouched: row.history?.recentlyTouched ?? false,
|
|
81
|
+
now: opts.now,
|
|
82
|
+
});
|
|
83
|
+
return deterministicDecision(score).decision;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// The default golden set (ships inline so `icp eval --golden default` is free)
|
|
88
|
+
const GOLD_NOW_ISO = "2026-06-23T12:00:00.000Z";
|
|
89
|
+
function goldSignal(over) {
|
|
90
|
+
const domain = normalizeAccountDomain(over.accountDomain);
|
|
91
|
+
return {
|
|
92
|
+
id: `gold_${domain}_${over.bucket}_${over.trigger}`.replace(/[^\w]+/g, "_").toLowerCase(),
|
|
93
|
+
accountDomain: domain,
|
|
94
|
+
bucket: over.bucket,
|
|
95
|
+
trigger: over.trigger,
|
|
96
|
+
quote: over.quote,
|
|
97
|
+
sourceUrl: "https://example.com/golden",
|
|
98
|
+
firstSeen: over.firstSeen ?? GOLD_NOW_ISO,
|
|
99
|
+
weight: 1,
|
|
100
|
+
source: "ingest",
|
|
101
|
+
judgedBy: null,
|
|
102
|
+
...(over.reposted ? { reposted: true } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A starter labeled set covering the four rubric corners, so `icp eval --golden
|
|
107
|
+
* default` runs offline and the deterministic baseline passes it (>= the default
|
|
108
|
+
* min-accuracy). Each row is a single account.
|
|
109
|
+
*
|
|
110
|
+
* - SEND: a fresh clustered account (funding + reposted live req) — strong band.
|
|
111
|
+
* - SEND: a single high-weight demand/funding signal that still clears the bar.
|
|
112
|
+
* - SKIP: a lone low-intent social blip — weak band, first-class no.
|
|
113
|
+
* - SKIP: a single company-news mention with no in-persona trigger — weak.
|
|
114
|
+
* - SKIP: a hot account TOUCHED within the memory window — don't pile on.
|
|
115
|
+
*/
|
|
116
|
+
export const DEFAULT_GOLDEN_SET = [
|
|
117
|
+
{
|
|
118
|
+
label: "clustered funding + reposted role -> send",
|
|
119
|
+
expectedDecision: "send",
|
|
120
|
+
signals: [
|
|
121
|
+
goldSignal({
|
|
122
|
+
accountDomain: "apexnorth.agency",
|
|
123
|
+
bucket: "funding",
|
|
124
|
+
trigger: "raised Series B",
|
|
125
|
+
quote: "ApexNorth raised a $40M Series B led by Acme Ventures this week.",
|
|
126
|
+
}),
|
|
127
|
+
goldSignal({
|
|
128
|
+
accountDomain: "apexnorth.agency",
|
|
129
|
+
bucket: "job",
|
|
130
|
+
trigger: "reposted: Head of Growth",
|
|
131
|
+
quote: "Head of Growth reopened — own demand gen and marketing ops for the next stage of growth.",
|
|
132
|
+
reposted: true,
|
|
133
|
+
}),
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
label: "fresh funding + live req -> send",
|
|
138
|
+
expectedDecision: "send",
|
|
139
|
+
signals: [
|
|
140
|
+
goldSignal({
|
|
141
|
+
accountDomain: "brightloop.io",
|
|
142
|
+
bucket: "funding",
|
|
143
|
+
trigger: "raised Series A",
|
|
144
|
+
quote: "Brightloop closed a $12M Series A to expand its go-to-market team this quarter.",
|
|
145
|
+
}),
|
|
146
|
+
goldSignal({
|
|
147
|
+
accountDomain: "brightloop.io",
|
|
148
|
+
bucket: "job",
|
|
149
|
+
trigger: "hiring: VP Marketing",
|
|
150
|
+
quote: "VP Marketing — build the demand engine and own pipeline targets from scratch.",
|
|
151
|
+
}),
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
label: "lone social blip -> skip",
|
|
156
|
+
expectedDecision: "skip",
|
|
157
|
+
signals: [
|
|
158
|
+
goldSignal({
|
|
159
|
+
accountDomain: "quietco.io",
|
|
160
|
+
bucket: "social",
|
|
161
|
+
trigger: "liked a post",
|
|
162
|
+
quote: "Their VP of Sales liked a post about pipeline hygiene last Tuesday.",
|
|
163
|
+
}),
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
label: "single low-intent company mention -> skip",
|
|
168
|
+
expectedDecision: "skip",
|
|
169
|
+
signals: [
|
|
170
|
+
goldSignal({
|
|
171
|
+
accountDomain: "stillwater.co",
|
|
172
|
+
bucket: "social",
|
|
173
|
+
trigger: "shared an article",
|
|
174
|
+
quote: "Stillwater's account exec shared an industry article on LinkedIn over the weekend.",
|
|
175
|
+
}),
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
label: "hot but touched within 7 days -> skip (don't pile on)",
|
|
180
|
+
expectedDecision: "skip",
|
|
181
|
+
history: { recentlyTouched: true },
|
|
182
|
+
signals: [
|
|
183
|
+
goldSignal({
|
|
184
|
+
accountDomain: "veridian.com",
|
|
185
|
+
bucket: "social",
|
|
186
|
+
trigger: "liked a post",
|
|
187
|
+
quote: "A Veridian director liked a post about outbound sequencing yesterday afternoon.",
|
|
188
|
+
}),
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
/**
|
|
193
|
+
* Parse a golden set from JSON or JSONL (one row per line). Validates each row
|
|
194
|
+
* up front and names the offending entry (the enrich/signals parse idiom). The
|
|
195
|
+
* literal "default" resolves to `DEFAULT_GOLDEN_SET` (handled by the CLI before
|
|
196
|
+
* reading a file; this parses an actual file's contents).
|
|
197
|
+
*/
|
|
198
|
+
export function parseGoldenSet(raw) {
|
|
199
|
+
const trimmed = raw.trim();
|
|
200
|
+
if (!trimmed)
|
|
201
|
+
throw new Error("golden set: empty file (expected a JSON array or JSONL of rows)");
|
|
202
|
+
let candidates;
|
|
203
|
+
if (trimmed.startsWith("[")) {
|
|
204
|
+
let parsed;
|
|
205
|
+
try {
|
|
206
|
+
parsed = JSON.parse(trimmed);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
throw new Error(`golden set: not valid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
210
|
+
}
|
|
211
|
+
if (!Array.isArray(parsed))
|
|
212
|
+
throw new Error("golden set: expected a JSON array of rows");
|
|
213
|
+
candidates = parsed;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
// JSONL: one row object per non-empty line.
|
|
217
|
+
candidates = [];
|
|
218
|
+
const lines = trimmed.split("\n");
|
|
219
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
220
|
+
const line = lines[i].trim();
|
|
221
|
+
if (!line)
|
|
222
|
+
continue;
|
|
223
|
+
try {
|
|
224
|
+
candidates.push(JSON.parse(line));
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
throw new Error(`golden set: line ${i + 1} is not valid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return candidates.map((row, i) => validateGoldenRow(row, i));
|
|
232
|
+
}
|
|
233
|
+
function validateGoldenRow(row, index) {
|
|
234
|
+
const where = `golden set: row ${index}`;
|
|
235
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
236
|
+
throw new Error(`${where} must be an object with signals + expectedDecision`);
|
|
237
|
+
}
|
|
238
|
+
const r = row;
|
|
239
|
+
if (r.expectedDecision !== "send" && r.expectedDecision !== "skip") {
|
|
240
|
+
throw new Error(`${where}: expectedDecision must be "send" or "skip"`);
|
|
241
|
+
}
|
|
242
|
+
if (!Array.isArray(r.signals)) {
|
|
243
|
+
throw new Error(`${where}: signals must be an array of Signal objects`);
|
|
244
|
+
}
|
|
245
|
+
for (let s = 0; s < r.signals.length; s += 1) {
|
|
246
|
+
const sig = r.signals[s];
|
|
247
|
+
if (!sig || typeof sig !== "object")
|
|
248
|
+
throw new Error(`${where} signal ${s} must be an object`);
|
|
249
|
+
if (typeof sig.accountDomain !== "string" || !sig.accountDomain) {
|
|
250
|
+
throw new Error(`${where} signal ${s}: accountDomain must be a non-empty string`);
|
|
251
|
+
}
|
|
252
|
+
if (typeof sig.bucket !== "string")
|
|
253
|
+
throw new Error(`${where} signal ${s}: bucket must be a string`);
|
|
254
|
+
if (typeof sig.quote !== "string")
|
|
255
|
+
throw new Error(`${where} signal ${s}: quote must be a string`);
|
|
256
|
+
}
|
|
257
|
+
const history = r.history && typeof r.history === "object" && !Array.isArray(r.history)
|
|
258
|
+
? { recentlyTouched: Boolean(r.history.recentlyTouched) }
|
|
259
|
+
: undefined;
|
|
260
|
+
return {
|
|
261
|
+
...(typeof r.label === "string" ? { label: r.label } : {}),
|
|
262
|
+
signals: r.signals,
|
|
263
|
+
...(history ? { history } : {}),
|
|
264
|
+
expectedDecision: r.expectedDecision,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// The empirical gate: do hot scores book more than cold scores?
|
|
269
|
+
/**
|
|
270
|
+
* Grade the judge's decisions against accumulated `signals outcome` results: of
|
|
271
|
+
* the decisions scored hot (>=80) vs cold (<80), which books meetings more
|
|
272
|
+
* often? A decision is matched to outcomes by account domain (and an outcome
|
|
273
|
+
* "books" when its result is "meeting"). Accounts with no recorded outcome are
|
|
274
|
+
* excluded from the denominators. `calibrated` is true iff hot strictly beats
|
|
275
|
+
* cold — absent or inverted correlation is NOT calibrated (the CLI exits
|
|
276
|
+
* nonzero on it). Deterministic.
|
|
277
|
+
*/
|
|
278
|
+
export function gradeAgainstOutcomes(decisions, outcomes) {
|
|
279
|
+
// Per-account: did any credited touch book a meeting? (Domain-level booking.)
|
|
280
|
+
const booked = new Map();
|
|
281
|
+
const sawOutcome = new Map();
|
|
282
|
+
for (const outcome of outcomes) {
|
|
283
|
+
const domain = normalizeAccountDomain(outcome.accountDomain);
|
|
284
|
+
if (!domain)
|
|
285
|
+
continue;
|
|
286
|
+
sawOutcome.set(domain, true);
|
|
287
|
+
if (outcome.result === "meeting")
|
|
288
|
+
booked.set(domain, true);
|
|
289
|
+
else if (!booked.has(domain))
|
|
290
|
+
booked.set(domain, false);
|
|
291
|
+
}
|
|
292
|
+
let hotBooked = 0;
|
|
293
|
+
let hotCount = 0;
|
|
294
|
+
let coldBooked = 0;
|
|
295
|
+
let coldCount = 0;
|
|
296
|
+
// One decision per account; if a domain repeats, the highest score wins the
|
|
297
|
+
// bucket assignment so a single domain isn't double-counted across buckets.
|
|
298
|
+
const scoreByDomain = new Map();
|
|
299
|
+
for (const decision of decisions) {
|
|
300
|
+
const domain = normalizeAccountDomain(decision.accountDomain);
|
|
301
|
+
if (!domain || !sawOutcome.get(domain))
|
|
302
|
+
continue;
|
|
303
|
+
const prior = scoreByDomain.get(domain);
|
|
304
|
+
if (prior === undefined || decision.score > prior)
|
|
305
|
+
scoreByDomain.set(domain, decision.score);
|
|
306
|
+
}
|
|
307
|
+
for (const [domain, score] of scoreByDomain) {
|
|
308
|
+
const didBook = booked.get(domain) === true ? 1 : 0;
|
|
309
|
+
if (score >= HOT_SCORE) {
|
|
310
|
+
hotCount += 1;
|
|
311
|
+
hotBooked += didBook;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
coldCount += 1;
|
|
315
|
+
coldBooked += didBook;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const hotBookRate = hotCount === 0 ? 0 : round4(hotBooked / hotCount);
|
|
319
|
+
const coldBookRate = coldCount === 0 ? 0 : round4(coldBooked / coldCount);
|
|
320
|
+
return {
|
|
321
|
+
hotBookRate,
|
|
322
|
+
coldBookRate,
|
|
323
|
+
calibrated: hotBookRate > coldBookRate,
|
|
324
|
+
hotCount,
|
|
325
|
+
coldCount,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
function round4(value) {
|
|
330
|
+
return Math.round(value * 10000) / 10000;
|
|
331
|
+
}
|
package/dist/llm.d.ts
CHANGED
|
@@ -23,6 +23,19 @@ export type LlmCallOptions = {
|
|
|
23
23
|
apiKey: string;
|
|
24
24
|
model?: string;
|
|
25
25
|
fetchImpl?: typeof fetch;
|
|
26
|
+
/**
|
|
27
|
+
* Override the Anthropic Messages endpoint — point the package at an
|
|
28
|
+
* OpenAI/Anthropic-compatible gateway (GLM-5.2, z.ai, a local proxy) without
|
|
29
|
+
* code changes. An origin (`https://...`) gets `/v1/messages` appended; a base
|
|
30
|
+
* already ending in `/messages` is used verbatim. Threaded from
|
|
31
|
+
* `ANTHROPIC_API_BASE_URL` at the LlmCallOptions-construction layer (cli.ts),
|
|
32
|
+
* never read inside `forcedToolCall`. Unset → default api.anthropic.com.
|
|
33
|
+
*/
|
|
34
|
+
anthropicBaseUrl?: string;
|
|
35
|
+
/** OpenAI equivalent of `anthropicBaseUrl` (e.g. an Ollama OpenAI-compatible
|
|
36
|
+
* endpoint). Origin → `/v1/chat/completions` appended; a base ending in
|
|
37
|
+
* `/chat/completions` is used verbatim. Threaded from `OPENAI_API_BASE_URL`. */
|
|
38
|
+
openaiBaseUrl?: string;
|
|
26
39
|
};
|
|
27
40
|
export type LlmExtractedInsight = ExtractedCallInsight & {
|
|
28
41
|
owner?: string;
|
package/dist/llm.js
CHANGED
|
@@ -6,6 +6,20 @@ export const DEFAULT_MODELS = {
|
|
|
6
6
|
};
|
|
7
7
|
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
8
8
|
const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the effective endpoint, honoring an optional base-URL override.
|
|
11
|
+
* Mirrors the `prospectSources.ts` idiom (`(base ?? default).replace(/\/$/, "")`):
|
|
12
|
+
* trailing-slash-stripped; if the configured base already ends in the known
|
|
13
|
+
* path suffix it is used as-is, otherwise the suffix is appended so callers can
|
|
14
|
+
* pass either a bare origin (`https://glm.example`) or a full endpoint URL.
|
|
15
|
+
* Unset override → the upstream default, so behavior is unchanged.
|
|
16
|
+
*/
|
|
17
|
+
function resolveLlmUrl(override, defaultUrl, pathSuffix) {
|
|
18
|
+
if (!override)
|
|
19
|
+
return defaultUrl;
|
|
20
|
+
const base = override.replace(/\/$/, "");
|
|
21
|
+
return base.endsWith(pathSuffix) ? base : `${base}${pathSuffix}`;
|
|
22
|
+
}
|
|
9
23
|
// Bound cost and context: long calls keep the head and tail.
|
|
10
24
|
const MAX_TRANSCRIPT_CHARS = 28_000;
|
|
11
25
|
export function detectProviderFromKey(apiKey) {
|
|
@@ -273,7 +287,8 @@ export async function classifyCallLlm(transcript, defs, options) {
|
|
|
273
287
|
export async function forcedToolCall(prompt, toolName, schema, model, options) {
|
|
274
288
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
275
289
|
if (options.provider === "anthropic") {
|
|
276
|
-
const
|
|
290
|
+
const anthropicUrl = resolveLlmUrl(options.anthropicBaseUrl, ANTHROPIC_URL, "/v1/messages");
|
|
291
|
+
const response = await llmFetch(fetchImpl, anthropicUrl, {
|
|
277
292
|
method: "POST",
|
|
278
293
|
headers: {
|
|
279
294
|
"x-api-key": options.apiKey,
|
|
@@ -293,7 +308,8 @@ export async function forcedToolCall(prompt, toolName, schema, model, options) {
|
|
|
293
308
|
throw new Error("Anthropic returned no tool call — try again or a different --model.");
|
|
294
309
|
return block.input;
|
|
295
310
|
}
|
|
296
|
-
const
|
|
311
|
+
const openaiUrl = resolveLlmUrl(options.openaiBaseUrl, OPENAI_URL, "/v1/chat/completions");
|
|
312
|
+
const response = await llmFetch(fetchImpl, openaiUrl, {
|
|
297
313
|
method: "POST",
|
|
298
314
|
headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
|
|
299
315
|
body: JSON.stringify({
|
package/dist/schedule.js
CHANGED
|
@@ -31,8 +31,18 @@ const SCHEDULABLE = {
|
|
|
31
31
|
doctor: null,
|
|
32
32
|
enrich: ["append", "refresh"],
|
|
33
33
|
market: ["capture", "refresh"],
|
|
34
|
+
// The GTM brain. `signals fetch` is read-only re: CRM (--save persists only
|
|
35
|
+
// the local signal ledger). `icp judge`/`icp eval` are read-only/grade-only
|
|
36
|
+
// (--save writes only the local judge store, never a plan). `draft` is
|
|
37
|
+
// plan-side — it only stages a needs_approval plan, never applies — so the
|
|
38
|
+
// whole verb is safely schedulable (apply stays `apply --plan-id` only and
|
|
39
|
+
// re-checks `approved` at run time, so a scheduled draft still cannot send).
|
|
40
|
+
signals: ["fetch"],
|
|
41
|
+
icp: ["judge", "eval"],
|
|
42
|
+
draft: null,
|
|
34
43
|
};
|
|
35
|
-
const ALLOWLIST_SUMMARY = "audit, snapshot, enrich append|refresh, market capture|refresh,
|
|
44
|
+
const ALLOWLIST_SUMMARY = "audit, snapshot, enrich append|refresh, market capture|refresh, signals fetch, " +
|
|
45
|
+
"icp judge|eval, draft (stages a plan), suggest, report, doctor — " +
|
|
36
46
|
"plus apply --plan-id <id> (re-checked approved at every firing)";
|
|
37
47
|
/**
|
|
38
48
|
* Validate that an argv resolves to a schedulable fullstackgtm command.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { AtsBoardSource, AtsJob } from "./connectors/atsBoards.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The signals layer: a deterministic, read-only timing ledger. Every other CLI
|
|
4
|
+
* layer answers "is this CRM record correct?"; signals answers "did something
|
|
5
|
+
* change at this account this week?" — the premise of signal-based outbound.
|
|
6
|
+
*
|
|
7
|
+
* `signals fetch` runs a watchlist of accounts through bucketed adapters (ATS
|
|
8
|
+
* job boards are the free, no-auth example), captures each detected change as a
|
|
9
|
+
* `Signal` carrying a VERBATIM source quote and URL, dedups against the store,
|
|
10
|
+
* stamps `firstSeen`, and ranks by a learned-then-defaulted weight. Outcomes
|
|
11
|
+
* feed back via `signals outcome`; `signals weights` drifts the bucket priors
|
|
12
|
+
* toward what actually books meetings.
|
|
13
|
+
*
|
|
14
|
+
* GOVERNANCE — structural, forever: signals is Detect-side. It produces no
|
|
15
|
+
* `PatchOperation`, opens no plan, touches no provider record. `fetch --save`
|
|
16
|
+
* persists a `SignalRun` to the LOCAL signal store only; the write side is
|
|
17
|
+
* downstream and human-gated (`draft` → `plans approve` → `apply`). State stays
|
|
18
|
+
* local — the CLI never writes signal custom properties into the customer's
|
|
19
|
+
* portal. Recurring execution is owned by the horizontal scheduler; signals
|
|
20
|
+
* knows nothing about cron.
|
|
21
|
+
*/
|
|
22
|
+
export type SignalBucket = "demand" | "funding" | "job" | "company" | "social";
|
|
23
|
+
export declare const SIGNAL_BUCKETS: SignalBucket[];
|
|
24
|
+
export type Signal = {
|
|
25
|
+
/** sig_<fnv1a(accountDomain|bucket|trigger)> — stable per detected change. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** Normalized domain (strip protocol/www/path), matching enrich's key logic. */
|
|
28
|
+
accountDomain: string;
|
|
29
|
+
bucket: SignalBucket;
|
|
30
|
+
/** Short human label, e.g. "reposted: Head of Growth" or "hiring: VP Sales". */
|
|
31
|
+
trigger: string;
|
|
32
|
+
/** VERBATIM source text — the evidence anchor reused by judge/drafter. */
|
|
33
|
+
quote: string;
|
|
34
|
+
sourceUrl: string;
|
|
35
|
+
/** ISO 8601. */
|
|
36
|
+
firstSeen: string;
|
|
37
|
+
/** Bucket weight × recency × repost boost, computed at fetch. */
|
|
38
|
+
weight: number;
|
|
39
|
+
reposted?: boolean;
|
|
40
|
+
/** "greenhouse" | "lever" | "ashby" | "ingest". */
|
|
41
|
+
source: string;
|
|
42
|
+
/** judge runLabel that consumed this signal (null until judged). */
|
|
43
|
+
judgedBy?: string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Stable per-role identity within a board (provider id when present, else the
|
|
46
|
+
* title). Lets the reposted heuristic tell "same role, gone, back" (a new req
|
|
47
|
+
* id under the same title) from one continuously-open req. job-bucket only.
|
|
48
|
+
*/
|
|
49
|
+
roleKey?: string;
|
|
50
|
+
};
|
|
51
|
+
export type SignalOutcomeResult = "replied" | "meeting" | "bounced" | "no_reply";
|
|
52
|
+
export type SignalOutcome = {
|
|
53
|
+
id: string;
|
|
54
|
+
accountDomain: string;
|
|
55
|
+
touchId?: string;
|
|
56
|
+
result: SignalOutcomeResult;
|
|
57
|
+
recordedAt: string;
|
|
58
|
+
/** Signal ids credited (from the judge decision). */
|
|
59
|
+
creditedSignals: string[];
|
|
60
|
+
};
|
|
61
|
+
export type SignalBucketConfig = {
|
|
62
|
+
sources: string[];
|
|
63
|
+
keywords?: string[];
|
|
64
|
+
weight: number;
|
|
65
|
+
};
|
|
66
|
+
export type SignalsConfig = {
|
|
67
|
+
watchlist: {
|
|
68
|
+
/** "crm:<segment>" derives domains from a CRM segment at run time. */
|
|
69
|
+
source?: string;
|
|
70
|
+
domains?: string[];
|
|
71
|
+
/** domain -> ATS board token, for boards not resolvable from the domain. */
|
|
72
|
+
boards?: Record<string, string>;
|
|
73
|
+
};
|
|
74
|
+
buckets: Record<SignalBucket, SignalBucketConfig>;
|
|
75
|
+
/** Dedup + reposted-detection window. Default 30. */
|
|
76
|
+
dedupWindowDays: number;
|
|
77
|
+
/** Extra weight for a reposted role (the "first hire fell through" heuristic). */
|
|
78
|
+
repostedRoleBoost: number;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Zero-config preset. `signals fetch` works with no `signals.config.json`
|
|
82
|
+
* (preset-first, like enrich). The four free buckets carry the system; `demand`
|
|
83
|
+
* is schema-reserved (privileged source, unbuilt) so its `sources` stay empty.
|
|
84
|
+
*/
|
|
85
|
+
export declare const DEFAULT_SIGNALS_CONFIG: SignalsConfig;
|
|
86
|
+
export declare function normalizeAccountDomain(value: string | undefined | null): string;
|
|
87
|
+
/** Dedup key: a signal is "the same change" iff (account, bucket, trigger) match. */
|
|
88
|
+
export declare function dedupKey(signal: Pick<Signal, "accountDomain" | "bucket" | "trigger">): string;
|
|
89
|
+
export declare function signalId(signal: Pick<Signal, "accountDomain" | "bucket" | "trigger">): string;
|
|
90
|
+
export declare function parseSignalsConfig(raw: string): SignalsConfig;
|
|
91
|
+
export declare const SIGNALS_CONFIG_FILE_NAME = "signals.config.json";
|
|
92
|
+
export declare function loadSignalsConfig(path: string): SignalsConfig;
|
|
93
|
+
/**
|
|
94
|
+
* Recency decay over the dedup window: a signal seen today is 1.0; one at the
|
|
95
|
+
* window edge is ~`floor`. Linear, clamped, deterministic — no surprise from
|
|
96
|
+
* an exponential's tail. Older than the window → `floor`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function recencyFactor(firstSeen: string, now: Date, windowDays: number): number;
|
|
99
|
+
/**
|
|
100
|
+
* Final signal weight = bucketWeight × recency (+ repost boost when reposted).
|
|
101
|
+
* Rounded to 4 dp so the same inputs always serialize the same string (the
|
|
102
|
+
* deterministic-store property the tests and dedup ledger rely on).
|
|
103
|
+
*/
|
|
104
|
+
export declare function signalWeight(opts: {
|
|
105
|
+
bucketWeight: number;
|
|
106
|
+
firstSeen: string;
|
|
107
|
+
now: Date;
|
|
108
|
+
windowDays: number;
|
|
109
|
+
reposted?: boolean;
|
|
110
|
+
repostedRoleBoost: number;
|
|
111
|
+
}): number;
|
|
112
|
+
/**
|
|
113
|
+
* Map a board's open roles to `job` signals for one account, computing weights
|
|
114
|
+
* and applying the reposted heuristic against `priorSignals` (the store's prior
|
|
115
|
+
* job signals for this account). A role is REPOSTED when the store saw a
|
|
116
|
+
* same-title job signal inside `dedupWindowDays` under a DIFFERENT role id (the
|
|
117
|
+
* old req closed, a new req opened with the same title) — the "first hire fell
|
|
118
|
+
* through, more pain now" signal the article calls out.
|
|
119
|
+
*
|
|
120
|
+
* A role only becomes a signal if its quote can carry a configured keyword
|
|
121
|
+
* verbatim (the evidence anchor). With no keywords configured, every open role
|
|
122
|
+
* qualifies and the quote is title + leading JD snippet.
|
|
123
|
+
*/
|
|
124
|
+
export declare function buildSignalsFromAts(rawJobs: Array<AtsJob & {
|
|
125
|
+
source: AtsBoardSource;
|
|
126
|
+
}>, account: {
|
|
127
|
+
domain: string;
|
|
128
|
+
}, config: SignalsConfig, opts?: {
|
|
129
|
+
now?: Date;
|
|
130
|
+
priorSignals?: Signal[];
|
|
131
|
+
}): Signal[];
|
|
132
|
+
/**
|
|
133
|
+
* Split candidate signals into fresh vs. deduped against prior signals. A
|
|
134
|
+
* candidate is deduped when a prior signal shares its `dedupKey`
|
|
135
|
+
* (account|bucket|trigger) AND that prior was seen inside `windowDays` — the
|
|
136
|
+
* store's dedup ledger role. A reposted role carries a DIFFERENT trigger
|
|
137
|
+
* ("reposted: X" vs "hiring: X"), so it is correctly fresh. Deterministic.
|
|
138
|
+
*/
|
|
139
|
+
export declare function dedupeSignals(candidates: Signal[], priorSignals: Signal[], windowDays: number, now: Date): {
|
|
140
|
+
fresh: Signal[];
|
|
141
|
+
deduped: Signal[];
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Recompute a per-bucket weight multiplier from the outcome ledger: the
|
|
145
|
+
* booked-meeting rate of touches credited to each bucket, Beta-smoothed against
|
|
146
|
+
* the declared default so a single reply does not swing a bucket. With NO
|
|
147
|
+
* outcomes, weights == config defaults (graceful cold start). Deterministic:
|
|
148
|
+
* same ledger → same weights.
|
|
149
|
+
*
|
|
150
|
+
* Mechanic: a bucket's declared default `w` acts as the prior mean. We observe
|
|
151
|
+
* `booked` booked meetings out of `total` credited touches and form a
|
|
152
|
+
* Beta(α,β)-style posterior mean with prior strength `priorStrength`, then scale
|
|
153
|
+
* the default by (posteriorRate / priorRate). priorRate is a fixed baseline
|
|
154
|
+
* booked-rate the default is calibrated against, so a bucket that books above
|
|
155
|
+
* baseline gets heavier and one that dies gets cut — the config value is never
|
|
156
|
+
* overwritten, only this overlay moves.
|
|
157
|
+
*/
|
|
158
|
+
export declare function computeWeights(config: SignalsConfig, outcomes: SignalOutcome[], signalsById?: Map<string, Signal>): Record<SignalBucket, number>;
|
|
159
|
+
export declare function signalsDir(baseDir?: string): string;
|
|
160
|
+
export type SignalRun = {
|
|
161
|
+
id: string;
|
|
162
|
+
runLabel: string;
|
|
163
|
+
startedAt: string;
|
|
164
|
+
completedAt: string | null;
|
|
165
|
+
/** Buckets the run scanned. */
|
|
166
|
+
buckets: SignalBucket[];
|
|
167
|
+
counts: {
|
|
168
|
+
fetched: number;
|
|
169
|
+
new: number;
|
|
170
|
+
deduped: number;
|
|
171
|
+
};
|
|
172
|
+
signals: Signal[];
|
|
173
|
+
};
|
|
174
|
+
export declare function signalRunId(runLabel: string): string;
|
|
175
|
+
export interface SignalStore {
|
|
176
|
+
/** Append a new run; refuses an existing label (runs are append-only). */
|
|
177
|
+
appendRun(run: SignalRun): Promise<SignalRun>;
|
|
178
|
+
/** Update an existing run in place (e.g. stamp judgedBy); id must match. */
|
|
179
|
+
updateRun(run: SignalRun): Promise<SignalRun>;
|
|
180
|
+
getRun(runLabel: string): Promise<SignalRun | null>;
|
|
181
|
+
listRuns(): Promise<SignalRun[]>;
|
|
182
|
+
latestRun(): Promise<SignalRun | null>;
|
|
183
|
+
/** Append-only outcome ledger (outcomes.jsonl). */
|
|
184
|
+
appendOutcome(outcome: SignalOutcome): Promise<SignalOutcome>;
|
|
185
|
+
listOutcomes(): Promise<SignalOutcome[]>;
|
|
186
|
+
/** Every signal across all runs, newest run last (the timing source). */
|
|
187
|
+
allSignals(): Promise<Signal[]>;
|
|
188
|
+
}
|
|
189
|
+
export declare function createFileSignalStore(directory?: string): SignalStore;
|
|
190
|
+
/** Construct a SignalOutcome with a stable id and a recordedAt stamp. */
|
|
191
|
+
export declare function makeOutcome(input: {
|
|
192
|
+
accountDomain: string;
|
|
193
|
+
touchId?: string;
|
|
194
|
+
result: SignalOutcomeResult;
|
|
195
|
+
creditedSignals?: string[];
|
|
196
|
+
recordedAt?: string;
|
|
197
|
+
}): SignalOutcome;
|