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.
@@ -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 response = await llmFetch(fetchImpl, ANTHROPIC_URL, {
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 response = await llmFetch(fetchImpl, OPENAI_URL, {
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, suggest, report, doctor — " +
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;