fullstackgtm 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +30 -1
- package/dist/cli.js +615 -6
- package/dist/connectors/atsBoards.d.ts +60 -0
- package/dist/connectors/atsBoards.js +179 -0
- package/dist/connectors/prospectSources.d.ts +7 -5
- package/dist/connectors/prospectSources.js +82 -47
- package/dist/draft.d.ts +182 -0
- package/dist/draft.js +333 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/judge.d.ts +209 -0
- package/dist/judge.js +490 -0
- package/dist/judgeEval.d.ts +141 -0
- package/dist/judgeEval.js +331 -0
- package/dist/llm.d.ts +13 -0
- package/dist/llm.js +18 -2
- package/dist/schedule.js +11 -1
- package/dist/signals.d.ts +197 -0
- package/dist/signals.js +515 -0
- package/package.json +1 -1
- package/src/cli.ts +696 -7
- package/src/connectors/atsBoards.ts +242 -0
- package/src/connectors/prospectSources.ts +94 -43
- package/src/draft.ts +463 -0
- package/src/index.ts +94 -0
- package/src/judge.ts +661 -0
- package/src/judgeEval.ts +459 -0
- package/src/llm.ts +31 -2
- package/src/schedule.ts +11 -1
- package/src/signals.ts +685 -0
package/dist/signals.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, appendFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
|
|
4
|
+
export const SIGNAL_BUCKETS = ["demand", "funding", "job", "company", "social"];
|
|
5
|
+
/**
|
|
6
|
+
* Zero-config preset. `signals fetch` works with no `signals.config.json`
|
|
7
|
+
* (preset-first, like enrich). The four free buckets carry the system; `demand`
|
|
8
|
+
* is schema-reserved (privileged source, unbuilt) so its `sources` stay empty.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_SIGNALS_CONFIG = {
|
|
11
|
+
watchlist: {},
|
|
12
|
+
buckets: {
|
|
13
|
+
demand: { sources: [], weight: 2.0 },
|
|
14
|
+
funding: { sources: ["news"], weight: 1.6 },
|
|
15
|
+
job: { sources: ["greenhouse", "lever", "ashby"], keywords: [], weight: 1.0 },
|
|
16
|
+
company: { sources: ["web"], weight: 1.0 },
|
|
17
|
+
social: { sources: ["web"], weight: 0.4 },
|
|
18
|
+
},
|
|
19
|
+
dedupWindowDays: 30,
|
|
20
|
+
repostedRoleBoost: 0.5,
|
|
21
|
+
};
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Domain normalization — mirrors enrich's normalizeKeyValue("domain", ...)
|
|
24
|
+
// (strip protocol/www/path, lowercased) so a signal account key lines up with
|
|
25
|
+
// the canonical snapshot / enrich matcher.
|
|
26
|
+
export function normalizeAccountDomain(value) {
|
|
27
|
+
const text = String(value ?? "").trim().toLowerCase();
|
|
28
|
+
if (!text)
|
|
29
|
+
return "";
|
|
30
|
+
return text
|
|
31
|
+
.replace(/^https?:\/\//, "")
|
|
32
|
+
.replace(/^www\./, "")
|
|
33
|
+
.replace(/\/.*$/, "");
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Stable hashing (FNV-1a) — mirrors enrich.ts fnv1a, duplicated to keep this
|
|
37
|
+
// file importable without the audit engine (the market.ts/enrich.ts precedent).
|
|
38
|
+
function fnv1a(value) {
|
|
39
|
+
let hash = 0x811c9dc5;
|
|
40
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
41
|
+
hash ^= value.charCodeAt(i);
|
|
42
|
+
hash = Math.imul(hash, 0x01000193);
|
|
43
|
+
}
|
|
44
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
45
|
+
}
|
|
46
|
+
/** Dedup key: a signal is "the same change" iff (account, bucket, trigger) match. */
|
|
47
|
+
export function dedupKey(signal) {
|
|
48
|
+
return `${signal.accountDomain}|${signal.bucket}|${signal.trigger}`;
|
|
49
|
+
}
|
|
50
|
+
export function signalId(signal) {
|
|
51
|
+
return `sig_${fnv1a(dedupKey(signal))}`;
|
|
52
|
+
}
|
|
53
|
+
function outcomeId(o) {
|
|
54
|
+
return `out_${fnv1a(`${o.accountDomain}|${o.touchId ?? ""}|${o.result}|${o.recordedAt}`)}`;
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Config parsing — strict, up-front, names the offending entry (enrich pattern).
|
|
58
|
+
function fail(message) {
|
|
59
|
+
throw new Error(`signals config: ${message}`);
|
|
60
|
+
}
|
|
61
|
+
export function parseSignalsConfig(raw) {
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(raw);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
fail(`not valid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
68
|
+
}
|
|
69
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
70
|
+
fail("expected a JSON object with watchlist, buckets, dedupWindowDays, repostedRoleBoost");
|
|
71
|
+
}
|
|
72
|
+
const config = parsed;
|
|
73
|
+
// watchlist
|
|
74
|
+
const watchlist = config.watchlist ?? {};
|
|
75
|
+
if (typeof watchlist !== "object" || Array.isArray(watchlist)) {
|
|
76
|
+
fail('"watchlist" must be an object (source / domains / boards)');
|
|
77
|
+
}
|
|
78
|
+
if (watchlist.source !== undefined && typeof watchlist.source !== "string") {
|
|
79
|
+
fail('watchlist.source must be a string (e.g. "crm:open-pipeline")');
|
|
80
|
+
}
|
|
81
|
+
if (watchlist.domains !== undefined) {
|
|
82
|
+
if (!Array.isArray(watchlist.domains) || watchlist.domains.some((d) => typeof d !== "string")) {
|
|
83
|
+
fail("watchlist.domains must be an array of domain strings");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (watchlist.boards !== undefined) {
|
|
87
|
+
if (typeof watchlist.boards !== "object" || Array.isArray(watchlist.boards)) {
|
|
88
|
+
fail("watchlist.boards must be an object mapping domain -> board token");
|
|
89
|
+
}
|
|
90
|
+
for (const [domain, tok] of Object.entries(watchlist.boards)) {
|
|
91
|
+
if (typeof tok !== "string" || !tok)
|
|
92
|
+
fail(`watchlist.boards["${domain}"] must be a non-empty board token`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// buckets — fill missing buckets from the default so a partial config is valid.
|
|
96
|
+
const buckets = {};
|
|
97
|
+
const rawBuckets = (config.buckets ?? {});
|
|
98
|
+
if (typeof rawBuckets !== "object" || Array.isArray(rawBuckets)) {
|
|
99
|
+
fail('"buckets" must be an object keyed by bucket name');
|
|
100
|
+
}
|
|
101
|
+
for (const key of Object.keys(rawBuckets)) {
|
|
102
|
+
if (!SIGNAL_BUCKETS.includes(key)) {
|
|
103
|
+
fail(`unknown bucket "${key}" (use: ${SIGNAL_BUCKETS.join(", ")})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const bucket of SIGNAL_BUCKETS) {
|
|
107
|
+
const provided = rawBuckets[bucket];
|
|
108
|
+
if (provided === undefined) {
|
|
109
|
+
buckets[bucket] = { ...DEFAULT_SIGNALS_CONFIG.buckets[bucket] };
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (typeof provided !== "object" || Array.isArray(provided))
|
|
113
|
+
fail(`buckets.${bucket} must be an object`);
|
|
114
|
+
if (!Array.isArray(provided.sources) || provided.sources.some((s) => typeof s !== "string")) {
|
|
115
|
+
fail(`buckets.${bucket}.sources must be an array of source strings`);
|
|
116
|
+
}
|
|
117
|
+
if (provided.weight !== undefined && (!Number.isFinite(provided.weight) || provided.weight < 0)) {
|
|
118
|
+
fail(`buckets.${bucket}.weight must be a non-negative number`);
|
|
119
|
+
}
|
|
120
|
+
if (provided.keywords !== undefined) {
|
|
121
|
+
if (!Array.isArray(provided.keywords) || provided.keywords.some((k) => typeof k !== "string")) {
|
|
122
|
+
fail(`buckets.${bucket}.keywords must be an array of strings`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
buckets[bucket] = {
|
|
126
|
+
sources: provided.sources,
|
|
127
|
+
weight: provided.weight ?? DEFAULT_SIGNALS_CONFIG.buckets[bucket].weight,
|
|
128
|
+
...(provided.keywords !== undefined ? { keywords: provided.keywords } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const dedupWindowDays = config.dedupWindowDays ?? DEFAULT_SIGNALS_CONFIG.dedupWindowDays;
|
|
132
|
+
if (!Number.isFinite(dedupWindowDays) || dedupWindowDays <= 0) {
|
|
133
|
+
fail(`dedupWindowDays must be a positive number (got ${JSON.stringify(config.dedupWindowDays)})`);
|
|
134
|
+
}
|
|
135
|
+
const repostedRoleBoost = config.repostedRoleBoost ?? DEFAULT_SIGNALS_CONFIG.repostedRoleBoost;
|
|
136
|
+
if (!Number.isFinite(repostedRoleBoost) || repostedRoleBoost < 0) {
|
|
137
|
+
fail(`repostedRoleBoost must be a non-negative number (got ${JSON.stringify(config.repostedRoleBoost)})`);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
watchlist: {
|
|
141
|
+
...(watchlist.source !== undefined ? { source: watchlist.source } : {}),
|
|
142
|
+
...(watchlist.domains !== undefined ? { domains: watchlist.domains } : {}),
|
|
143
|
+
...(watchlist.boards !== undefined ? { boards: watchlist.boards } : {}),
|
|
144
|
+
},
|
|
145
|
+
buckets,
|
|
146
|
+
dedupWindowDays,
|
|
147
|
+
repostedRoleBoost,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export const SIGNALS_CONFIG_FILE_NAME = "signals.config.json";
|
|
151
|
+
export function loadSignalsConfig(path) {
|
|
152
|
+
let raw;
|
|
153
|
+
try {
|
|
154
|
+
raw = readFileSync(path, "utf8");
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new Error(`No signals config at ${path}. Create ${SIGNALS_CONFIG_FILE_NAME} (watchlist/buckets/` +
|
|
158
|
+
"dedupWindowDays/repostedRoleBoost — see docs/signals.md) or run with the zero-config defaults.");
|
|
159
|
+
}
|
|
160
|
+
return parseSignalsConfig(raw);
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Recency + weight
|
|
164
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
165
|
+
/**
|
|
166
|
+
* Recency decay over the dedup window: a signal seen today is 1.0; one at the
|
|
167
|
+
* window edge is ~`floor`. Linear, clamped, deterministic — no surprise from
|
|
168
|
+
* an exponential's tail. Older than the window → `floor`.
|
|
169
|
+
*/
|
|
170
|
+
export function recencyFactor(firstSeen, now, windowDays) {
|
|
171
|
+
const seen = Date.parse(firstSeen);
|
|
172
|
+
if (!Number.isFinite(seen))
|
|
173
|
+
return 1;
|
|
174
|
+
const ageDays = Math.max(0, (now.getTime() - seen) / DAY_MS);
|
|
175
|
+
const floor = 0.4;
|
|
176
|
+
if (windowDays <= 0)
|
|
177
|
+
return 1;
|
|
178
|
+
const decayed = 1 - (1 - floor) * Math.min(1, ageDays / windowDays);
|
|
179
|
+
return Math.max(floor, decayed);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Final signal weight = bucketWeight × recency (+ repost boost when reposted).
|
|
183
|
+
* Rounded to 4 dp so the same inputs always serialize the same string (the
|
|
184
|
+
* deterministic-store property the tests and dedup ledger rely on).
|
|
185
|
+
*/
|
|
186
|
+
export function signalWeight(opts) {
|
|
187
|
+
const base = opts.bucketWeight * recencyFactor(opts.firstSeen, opts.now, opts.windowDays);
|
|
188
|
+
const boosted = opts.reposted ? base + opts.repostedRoleBoost : base;
|
|
189
|
+
return Math.round(boosted * 10000) / 10000;
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Build signals from ATS jobs
|
|
193
|
+
/**
|
|
194
|
+
* Map a board's open roles to `job` signals for one account, computing weights
|
|
195
|
+
* and applying the reposted heuristic against `priorSignals` (the store's prior
|
|
196
|
+
* job signals for this account). A role is REPOSTED when the store saw a
|
|
197
|
+
* same-title job signal inside `dedupWindowDays` under a DIFFERENT role id (the
|
|
198
|
+
* old req closed, a new req opened with the same title) — the "first hire fell
|
|
199
|
+
* through, more pain now" signal the article calls out.
|
|
200
|
+
*
|
|
201
|
+
* A role only becomes a signal if its quote can carry a configured keyword
|
|
202
|
+
* verbatim (the evidence anchor). With no keywords configured, every open role
|
|
203
|
+
* qualifies and the quote is title + leading JD snippet.
|
|
204
|
+
*/
|
|
205
|
+
export function buildSignalsFromAts(rawJobs, account, config, opts = {}) {
|
|
206
|
+
const now = opts.now ?? new Date();
|
|
207
|
+
const nowIso = now.toISOString();
|
|
208
|
+
const windowDays = config.dedupWindowDays;
|
|
209
|
+
const bucketWeight = config.buckets.job.weight;
|
|
210
|
+
const keywords = config.buckets.job.keywords ?? [];
|
|
211
|
+
const accountDomain = normalizeAccountDomain(account.domain);
|
|
212
|
+
// Prior same-title job signals (within window) indexed by title key, with the
|
|
213
|
+
// set of role ids seen — a different role id under a known title = reposted.
|
|
214
|
+
const priorByTitle = new Map();
|
|
215
|
+
for (const prior of opts.priorSignals ?? []) {
|
|
216
|
+
if (prior.bucket !== "job" || prior.accountDomain !== accountDomain)
|
|
217
|
+
continue;
|
|
218
|
+
const titleKey = roleTitleKey(prior.trigger);
|
|
219
|
+
const entry = priorByTitle.get(titleKey) ?? { roleKeys: new Set(), withinWindow: false };
|
|
220
|
+
if (prior.roleKey)
|
|
221
|
+
entry.roleKeys.add(prior.roleKey);
|
|
222
|
+
if (withinWindow(prior.firstSeen, now, windowDays))
|
|
223
|
+
entry.withinWindow = true;
|
|
224
|
+
priorByTitle.set(titleKey, entry);
|
|
225
|
+
}
|
|
226
|
+
const signals = [];
|
|
227
|
+
for (const job of rawJobs) {
|
|
228
|
+
const title = job.title.trim();
|
|
229
|
+
if (!title)
|
|
230
|
+
continue;
|
|
231
|
+
// Quote must carry a configured keyword verbatim; with no keywords, any role
|
|
232
|
+
// qualifies. Reject a role whose JD contains none of the keywords — the
|
|
233
|
+
// judge/drafter could not ground a why-now on it.
|
|
234
|
+
const quote = jobQuote(title, job.jdSnippet, keywords);
|
|
235
|
+
if (quote === null)
|
|
236
|
+
continue;
|
|
237
|
+
const titleKey = roleTitleKey(title);
|
|
238
|
+
const prior = priorByTitle.get(titleKey);
|
|
239
|
+
const reposted = Boolean(prior && prior.withinWindow && job.firstSeenKey != null && !prior.roleKeys.has(job.firstSeenKey));
|
|
240
|
+
const trigger = reposted ? `reposted: ${title}` : `hiring: ${title}`;
|
|
241
|
+
const base = { accountDomain, bucket: "job", trigger };
|
|
242
|
+
signals.push({
|
|
243
|
+
id: signalId(base),
|
|
244
|
+
accountDomain,
|
|
245
|
+
bucket: "job",
|
|
246
|
+
trigger,
|
|
247
|
+
quote,
|
|
248
|
+
sourceUrl: job.url,
|
|
249
|
+
firstSeen: nowIso,
|
|
250
|
+
weight: signalWeight({
|
|
251
|
+
bucketWeight,
|
|
252
|
+
firstSeen: nowIso,
|
|
253
|
+
now,
|
|
254
|
+
windowDays,
|
|
255
|
+
reposted,
|
|
256
|
+
repostedRoleBoost: config.repostedRoleBoost,
|
|
257
|
+
}),
|
|
258
|
+
...(reposted ? { reposted: true } : {}),
|
|
259
|
+
source: job.source,
|
|
260
|
+
judgedBy: null,
|
|
261
|
+
roleKey: job.firstSeenKey,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return signals;
|
|
265
|
+
}
|
|
266
|
+
/** Normalize a role title for same-role matching (collapse spacing, lowercase). */
|
|
267
|
+
function roleTitleKey(triggerOrTitle) {
|
|
268
|
+
return triggerOrTitle
|
|
269
|
+
.replace(/^(reposted|hiring):\s*/i, "")
|
|
270
|
+
.trim()
|
|
271
|
+
.toLowerCase()
|
|
272
|
+
.replace(/\s+/g, " ");
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Build the verbatim evidence quote for a job signal. The keyword (if any) must
|
|
276
|
+
* appear verbatim in the title or JD snippet; returns null when keywords are
|
|
277
|
+
* configured but none match (so the role is dropped, not faked).
|
|
278
|
+
*/
|
|
279
|
+
function jobQuote(title, jdSnippet, keywords) {
|
|
280
|
+
const snippet = jdSnippet.trim();
|
|
281
|
+
const composed = snippet ? `${title} — ${snippet}` : title;
|
|
282
|
+
const active = keywords.map((k) => k.trim()).filter(Boolean);
|
|
283
|
+
if (active.length === 0)
|
|
284
|
+
return composed;
|
|
285
|
+
const hay = composed.toLowerCase();
|
|
286
|
+
const matched = active.some((k) => hay.includes(k.toLowerCase()));
|
|
287
|
+
return matched ? composed : null;
|
|
288
|
+
}
|
|
289
|
+
function withinWindow(iso, now, windowDays) {
|
|
290
|
+
const t = Date.parse(iso);
|
|
291
|
+
if (!Number.isFinite(t))
|
|
292
|
+
return false;
|
|
293
|
+
return now.getTime() - t <= windowDays * DAY_MS;
|
|
294
|
+
}
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Dedup
|
|
297
|
+
/**
|
|
298
|
+
* Split candidate signals into fresh vs. deduped against prior signals. A
|
|
299
|
+
* candidate is deduped when a prior signal shares its `dedupKey`
|
|
300
|
+
* (account|bucket|trigger) AND that prior was seen inside `windowDays` — the
|
|
301
|
+
* store's dedup ledger role. A reposted role carries a DIFFERENT trigger
|
|
302
|
+
* ("reposted: X" vs "hiring: X"), so it is correctly fresh. Deterministic.
|
|
303
|
+
*/
|
|
304
|
+
export function dedupeSignals(candidates, priorSignals, windowDays, now) {
|
|
305
|
+
const recentKeys = new Set();
|
|
306
|
+
for (const prior of priorSignals) {
|
|
307
|
+
if (withinWindow(prior.firstSeen, now, windowDays))
|
|
308
|
+
recentKeys.add(dedupKey(prior));
|
|
309
|
+
}
|
|
310
|
+
const fresh = [];
|
|
311
|
+
const deduped = [];
|
|
312
|
+
const seenThisRun = new Set();
|
|
313
|
+
for (const candidate of candidates) {
|
|
314
|
+
const key = dedupKey(candidate);
|
|
315
|
+
if (recentKeys.has(key) || seenThisRun.has(key)) {
|
|
316
|
+
deduped.push(candidate);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
seenThisRun.add(key);
|
|
320
|
+
fresh.push(candidate);
|
|
321
|
+
}
|
|
322
|
+
return { fresh, deduped };
|
|
323
|
+
}
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Learn loop: bucket weights from outcomes (Beta-smoothed booked-meeting rate)
|
|
326
|
+
/**
|
|
327
|
+
* Recompute a per-bucket weight multiplier from the outcome ledger: the
|
|
328
|
+
* booked-meeting rate of touches credited to each bucket, Beta-smoothed against
|
|
329
|
+
* the declared default so a single reply does not swing a bucket. With NO
|
|
330
|
+
* outcomes, weights == config defaults (graceful cold start). Deterministic:
|
|
331
|
+
* same ledger → same weights.
|
|
332
|
+
*
|
|
333
|
+
* Mechanic: a bucket's declared default `w` acts as the prior mean. We observe
|
|
334
|
+
* `booked` booked meetings out of `total` credited touches and form a
|
|
335
|
+
* Beta(α,β)-style posterior mean with prior strength `priorStrength`, then scale
|
|
336
|
+
* the default by (posteriorRate / priorRate). priorRate is a fixed baseline
|
|
337
|
+
* booked-rate the default is calibrated against, so a bucket that books above
|
|
338
|
+
* baseline gets heavier and one that dies gets cut — the config value is never
|
|
339
|
+
* overwritten, only this overlay moves.
|
|
340
|
+
*/
|
|
341
|
+
export function computeWeights(config, outcomes, signalsById) {
|
|
342
|
+
const PRIOR_STRENGTH = 4; // pseudo-touches; damps small samples
|
|
343
|
+
const PRIOR_RATE = 0.2; // baseline booked-meeting rate the defaults assume
|
|
344
|
+
// Per-bucket booked / total over credited touches. A touch books if its
|
|
345
|
+
// result is "meeting". Credit flows to the bucket of each credited signal.
|
|
346
|
+
const booked = blankCounts();
|
|
347
|
+
const total = blankCounts();
|
|
348
|
+
for (const outcome of outcomes) {
|
|
349
|
+
const buckets = creditedBuckets(outcome, signalsById);
|
|
350
|
+
const isBooked = outcome.result === "meeting" ? 1 : 0;
|
|
351
|
+
for (const bucket of buckets) {
|
|
352
|
+
total[bucket] += 1;
|
|
353
|
+
booked[bucket] += isBooked;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const weights = {};
|
|
357
|
+
for (const bucket of SIGNAL_BUCKETS) {
|
|
358
|
+
const declared = config.buckets[bucket].weight;
|
|
359
|
+
const n = total[bucket];
|
|
360
|
+
if (n === 0) {
|
|
361
|
+
weights[bucket] = declared; // cold start: exactly the declared default
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const posteriorRate = (booked[bucket] + PRIOR_STRENGTH * PRIOR_RATE) / (n + PRIOR_STRENGTH);
|
|
365
|
+
const scaled = declared * (posteriorRate / PRIOR_RATE);
|
|
366
|
+
weights[bucket] = Math.round(scaled * 10000) / 10000;
|
|
367
|
+
}
|
|
368
|
+
return weights;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Which buckets an outcome credits. When the credited signal objects are known
|
|
372
|
+
* (`signalsById`), use their real buckets; otherwise fall back to parsing the
|
|
373
|
+
* `sig_` ids' bucket is not recoverable, so credit nothing (the cold-start /
|
|
374
|
+
* unknown path leaves weights at defaults rather than mis-crediting).
|
|
375
|
+
*/
|
|
376
|
+
function creditedBuckets(outcome, signalsById) {
|
|
377
|
+
if (!signalsById)
|
|
378
|
+
return [];
|
|
379
|
+
const buckets = new Set();
|
|
380
|
+
for (const id of outcome.creditedSignals) {
|
|
381
|
+
const signal = signalsById.get(id);
|
|
382
|
+
if (signal)
|
|
383
|
+
buckets.add(signal.bucket);
|
|
384
|
+
}
|
|
385
|
+
return [...buckets];
|
|
386
|
+
}
|
|
387
|
+
function blankCounts() {
|
|
388
|
+
return { demand: 0, funding: 0, job: 0, company: 0, social: 0 };
|
|
389
|
+
}
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Store: per-label JSON runs + append-only outcomes.jsonl, profile-scoped.
|
|
392
|
+
export function signalsDir(baseDir) {
|
|
393
|
+
return join(baseDir ?? credentialsDir(), "signals");
|
|
394
|
+
}
|
|
395
|
+
export function signalRunId(runLabel) {
|
|
396
|
+
return `sigr_${fnv1a(runLabel)}`;
|
|
397
|
+
}
|
|
398
|
+
export function createFileSignalStore(directory) {
|
|
399
|
+
const dir = directory ?? signalsDir();
|
|
400
|
+
const runsDir = join(dir, "runs");
|
|
401
|
+
const outcomesPath = join(dir, "outcomes.jsonl");
|
|
402
|
+
function fileFor(runLabel) {
|
|
403
|
+
if (!/^[\w.-]+$/.test(runLabel))
|
|
404
|
+
throw new Error(`Invalid run label: ${runLabel}`);
|
|
405
|
+
return join(runsDir, `${runLabel}.json`);
|
|
406
|
+
}
|
|
407
|
+
function read(runLabel) {
|
|
408
|
+
try {
|
|
409
|
+
return JSON.parse(readFileSync(fileFor(runLabel), "utf8"));
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function write(run) {
|
|
416
|
+
// Run files carry account domains + source quotes/URLs; keep them owner-only
|
|
417
|
+
// like plan/enrich files (and lock the home down even before any login).
|
|
418
|
+
if (!directory)
|
|
419
|
+
ensureSecureHomeDir();
|
|
420
|
+
mkdirSync(runsDir, { recursive: true, mode: 0o700 });
|
|
421
|
+
writeSecureFile(fileFor(run.runLabel), `${JSON.stringify(run, null, 2)}\n`);
|
|
422
|
+
return run;
|
|
423
|
+
}
|
|
424
|
+
function listRunsSync() {
|
|
425
|
+
let names = [];
|
|
426
|
+
try {
|
|
427
|
+
names = readdirSync(runsDir).filter((name) => name.endsWith(".json"));
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return [];
|
|
431
|
+
}
|
|
432
|
+
return names
|
|
433
|
+
.map((name) => read(name.slice(0, -".json".length)))
|
|
434
|
+
.filter((run) => run !== null)
|
|
435
|
+
.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
436
|
+
}
|
|
437
|
+
function readOutcomes() {
|
|
438
|
+
let raw;
|
|
439
|
+
try {
|
|
440
|
+
raw = readFileSync(outcomesPath, "utf8");
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
const out = [];
|
|
446
|
+
for (const line of raw.split("\n")) {
|
|
447
|
+
const trimmed = line.trim();
|
|
448
|
+
if (!trimmed)
|
|
449
|
+
continue;
|
|
450
|
+
try {
|
|
451
|
+
out.push(JSON.parse(trimmed));
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// Skip a corrupt line rather than fail the whole ledger read.
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
async appendRun(run) {
|
|
461
|
+
if (read(run.runLabel)) {
|
|
462
|
+
throw new Error(`Signal run "${run.runLabel}" already exists — runs are append-only; use a new run label`);
|
|
463
|
+
}
|
|
464
|
+
return write(run);
|
|
465
|
+
},
|
|
466
|
+
async updateRun(run) {
|
|
467
|
+
const existing = read(run.runLabel);
|
|
468
|
+
if (!existing)
|
|
469
|
+
throw new Error(`No signal run "${run.runLabel}" to update`);
|
|
470
|
+
if (existing.id !== run.id) {
|
|
471
|
+
throw new Error(`Signal run "${run.runLabel}" belongs to a different run id (${existing.id})`);
|
|
472
|
+
}
|
|
473
|
+
return write(run);
|
|
474
|
+
},
|
|
475
|
+
async getRun(runLabel) {
|
|
476
|
+
return read(runLabel);
|
|
477
|
+
},
|
|
478
|
+
async listRuns() {
|
|
479
|
+
return listRunsSync();
|
|
480
|
+
},
|
|
481
|
+
async latestRun() {
|
|
482
|
+
const runs = listRunsSync();
|
|
483
|
+
return runs.length ? runs[runs.length - 1] : null;
|
|
484
|
+
},
|
|
485
|
+
async appendOutcome(outcome) {
|
|
486
|
+
if (!directory)
|
|
487
|
+
ensureSecureHomeDir();
|
|
488
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
489
|
+
// Append-only ledger: one JSON object per line, never rewritten.
|
|
490
|
+
if (!existsSync(outcomesPath))
|
|
491
|
+
writeSecureFile(outcomesPath, "");
|
|
492
|
+
appendFileSync(outcomesPath, `${JSON.stringify(outcome)}\n`, { mode: 0o600 });
|
|
493
|
+
return outcome;
|
|
494
|
+
},
|
|
495
|
+
async listOutcomes() {
|
|
496
|
+
return readOutcomes();
|
|
497
|
+
},
|
|
498
|
+
async allSignals() {
|
|
499
|
+
return listRunsSync().flatMap((run) => run.signals);
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/** Construct a SignalOutcome with a stable id and a recordedAt stamp. */
|
|
504
|
+
export function makeOutcome(input) {
|
|
505
|
+
const recordedAt = input.recordedAt ?? new Date().toISOString();
|
|
506
|
+
const accountDomain = normalizeAccountDomain(input.accountDomain);
|
|
507
|
+
return {
|
|
508
|
+
id: outcomeId({ accountDomain, touchId: input.touchId, result: input.result, recordedAt }),
|
|
509
|
+
accountDomain,
|
|
510
|
+
...(input.touchId !== undefined ? { touchId: input.touchId } : {}),
|
|
511
|
+
result: input.result,
|
|
512
|
+
recordedAt,
|
|
513
|
+
creditedSignals: input.creditedSignals ?? [],
|
|
514
|
+
};
|
|
515
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
|