fullstackgtm 0.43.0 → 0.44.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +18 -7
  3. package/dist/cli.js +634 -56
  4. package/dist/connectors/outboxChannel.d.ts +64 -0
  5. package/dist/connectors/outboxChannel.js +170 -0
  6. package/dist/connectors/prospectSources.d.ts +22 -0
  7. package/dist/connectors/prospectSources.js +43 -0
  8. package/dist/connectors/signalSources.d.ts +105 -0
  9. package/dist/connectors/signalSources.js +316 -0
  10. package/dist/connectors/theirstack.d.ts +84 -0
  11. package/dist/connectors/theirstack.js +125 -0
  12. package/dist/icp.d.ts +47 -3
  13. package/dist/icp.js +105 -11
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -0
  16. package/dist/init.js +3 -0
  17. package/dist/judgeEval.d.ts +7 -0
  18. package/dist/judgeEval.js +8 -1
  19. package/dist/runReport.d.ts +26 -0
  20. package/dist/runReport.js +15 -0
  21. package/dist/schedule.js +18 -4
  22. package/dist/signals.d.ts +54 -0
  23. package/dist/signals.js +64 -0
  24. package/dist/tam.d.ts +225 -0
  25. package/dist/tam.js +470 -0
  26. package/docs/api.md +18 -4
  27. package/docs/outbox-format.md +92 -0
  28. package/docs/recipes.md +37 -0
  29. package/docs/roadmap-to-1.0.md +31 -1
  30. package/docs/signal-spool-format.md +162 -0
  31. package/docs/tam.md +195 -0
  32. package/llms.txt +68 -5
  33. package/package.json +1 -1
  34. package/skills/fullstackgtm/SKILL.md +3 -2
  35. package/src/cli.ts +714 -51
  36. package/src/connectors/outboxChannel.ts +202 -0
  37. package/src/connectors/prospectSources.ts +57 -0
  38. package/src/connectors/signalSources.ts +363 -0
  39. package/src/connectors/theirstack.ts +170 -0
  40. package/src/icp.ts +113 -11
  41. package/src/index.ts +32 -0
  42. package/src/init.ts +3 -0
  43. package/src/judgeEval.ts +8 -1
  44. package/src/runReport.ts +39 -0
  45. package/src/schedule.ts +20 -4
  46. package/src/signals.ts +90 -0
  47. package/src/tam.ts +654 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Outbox channel connector — the governed SEND-side terminus of the outbound
3
+ * loop (signal → judge → draft → approve → apply → outbox), and the mirror image
4
+ * of the webhook spool. It is a `GtmConnector` whose `applyOperation` RENDERS an
5
+ * approved drafted opener to a local outbox artifact instead of writing a CRM —
6
+ * so it reuses the entire governed apply machinery (approval set, integrity/HMAC
7
+ * verification, idempotency, run recording) with no change to the apply engine.
8
+ *
9
+ * THE INVARIANT IT KEEPS: the CLI **transmits nothing**. Rendering an approved
10
+ * opener to `<home>/signals/outbox/<channel>.jsonl` is a local file write, never
11
+ * an SMTP/API connection. A downstream sender (the hosted product, or the
12
+ * operator's own MTA) drains the outbox and does the actual transmission. This
13
+ * is the send-side half of the open-core boundary: the governed artifact + its
14
+ * format are open; always-on transmission infrastructure is hosted/opt-in.
15
+ *
16
+ * Governance: it only renders ops that came from `draft` (a `create_task` op
17
+ * whose policy is `draft:<channel>`); any other op is `skipped` (it is not a
18
+ * general CRM writer). Idempotent on the operation id — re-applying an approved
19
+ * plan never duplicates an outbox row. Read paths (`fetchSnapshot`) intentionally
20
+ * throw: a channel has no snapshot, and `applyPatchPlan` never calls it for a
21
+ * draft plan (no guards/filter/irreversible ops → no snapshot needed).
22
+ */
23
+
24
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { ensureSecureHomeDir, writeSecureFile } from "../credentials.ts";
27
+ import { signalsOutboxDir } from "../signals.ts";
28
+ import type { CanonicalGtmSnapshot, GtmConnector, PatchOperation, PatchOperationResult } from "../types.ts";
29
+
30
+ /** One approved, ready-to-send touch — a governed send INTENT, not a sent message. */
31
+ export type OutboxEntry = {
32
+ /** Idempotency key = the source operation id (stable per draft op). */
33
+ id: string;
34
+ /** email | linkedin | task — from the draft op's `draft:<channel>` policy. */
35
+ channel: string;
36
+ /** The CRM target the opener is addressed to (a sender resolves id → address). */
37
+ objectType: string;
38
+ objectId: string;
39
+ /** The APPROVED opener, verbatim as it was signed in the plan op. */
40
+ body: string;
41
+ /** The draft op's human-readable reason (carries the account + trigger). */
42
+ reason?: string;
43
+ /** Evidence ids the opener was grounded in (the verbatim signal quote). */
44
+ evidenceIds: string[];
45
+ /** ISO 8601 — when the CLI rendered this to the outbox (NOT a send time). */
46
+ renderedAt: string;
47
+ };
48
+
49
+ /** Channel ids this build can render to. */
50
+ export const CHANNELS = ["outbox"] as const;
51
+
52
+ export type OutboxChannelOptions = {
53
+ /** Outbox directory; defaults to the profile-scoped `signalsOutboxDir()`. */
54
+ outboxDir?: string;
55
+ /** Injectable clock for deterministic tests. */
56
+ now?: () => Date;
57
+ };
58
+
59
+ const DRAFT_POLICY_PREFIX = "draft:";
60
+
61
+ /**
62
+ * Build the outbox channel connector. With no `outboxDir`, writes to the
63
+ * profile-scoped conventional outbox and locks the home down (0700/0600) like
64
+ * the signal store; with an explicit `outboxDir` (tests), it writes there
65
+ * without touching the real home.
66
+ */
67
+ export function createOutboxChannelConnector(options: OutboxChannelOptions = {}): GtmConnector {
68
+ const usingDefaultHome = options.outboxDir === undefined;
69
+ const dir = options.outboxDir ?? signalsOutboxDir();
70
+ const now = options.now ?? (() => new Date());
71
+
72
+ function fileFor(channel: string): string {
73
+ if (!/^[\w.-]+$/.test(channel)) throw new Error(`Invalid outbox channel name: ${channel}`);
74
+ return join(dir, `${channel}.jsonl`);
75
+ }
76
+
77
+ /** Existing entry ids in a channel file (for idempotent re-apply). */
78
+ function existingIds(channel: string): Set<string> {
79
+ const ids = new Set<string>();
80
+ let raw: string;
81
+ try {
82
+ raw = readFileSync(fileFor(channel), "utf8");
83
+ } catch {
84
+ return ids; // no file yet
85
+ }
86
+ for (const line of raw.split("\n")) {
87
+ const t = line.trim();
88
+ if (!t) continue;
89
+ try {
90
+ const id = (JSON.parse(t) as { id?: unknown }).id;
91
+ if (typeof id === "string") ids.add(id);
92
+ } catch {
93
+ // Skip a corrupt line rather than fail the whole apply.
94
+ }
95
+ }
96
+ return ids;
97
+ }
98
+
99
+ function append(channel: string, entry: OutboxEntry): void {
100
+ if (usingDefaultHome) ensureSecureHomeDir();
101
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
102
+ const path = fileFor(channel);
103
+ // Owner-only like the signal store: outbox carries opener text + CRM ids.
104
+ if (!existsSync(path)) writeSecureFile(path, "");
105
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
106
+ }
107
+
108
+ async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
109
+ const policy = String(operation.sourceRuleOrPolicy ?? "");
110
+ if (operation.operation !== "create_task" || !policy.startsWith(DRAFT_POLICY_PREFIX)) {
111
+ return {
112
+ operationId: operation.id,
113
+ status: "skipped",
114
+ detail:
115
+ "The outbox channel only renders drafted openers (create_task ops from `draft`). " +
116
+ "Apply other operations through a CRM connector (--provider hubspot|salesforce).",
117
+ };
118
+ }
119
+ const channel = policy.slice(DRAFT_POLICY_PREFIX.length) || "task";
120
+
121
+ // Idempotent: a re-applied approved plan must not duplicate the row.
122
+ if (existingIds(channel).has(operation.id)) {
123
+ return {
124
+ operationId: operation.id,
125
+ status: "applied",
126
+ detail: `Already in outbox (${channel}.jsonl); idempotent — not duplicated. Nothing transmitted.`,
127
+ };
128
+ }
129
+
130
+ const entry: OutboxEntry = {
131
+ id: operation.id,
132
+ channel,
133
+ objectType: operation.objectType,
134
+ objectId: operation.objectId,
135
+ body: typeof operation.afterValue === "string" ? operation.afterValue : String(operation.afterValue ?? ""),
136
+ ...(operation.reason ? { reason: operation.reason } : {}),
137
+ evidenceIds: operation.evidenceIds ?? [],
138
+ renderedAt: now().toISOString(),
139
+ };
140
+ append(channel, entry);
141
+ return {
142
+ operationId: operation.id,
143
+ status: "applied",
144
+ detail: `Rendered to outbox (${channel}.jsonl) for a downstream sender. Nothing was transmitted.`,
145
+ };
146
+ }
147
+
148
+ async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
149
+ // A channel has no readable state. apply never calls this for a draft plan
150
+ // (no guards/filter/irreversible ops); make the misuse explicit if it does.
151
+ throw new Error(
152
+ "The outbox channel has no snapshot to read — it is a send-side render target. " +
153
+ "Use it only to `apply` an approved draft plan (`apply --plan-id <id> --channel outbox`).",
154
+ );
155
+ }
156
+
157
+ return {
158
+ provider: "outbox",
159
+ fetchSnapshot,
160
+ applyOperation,
161
+ };
162
+ }
163
+
164
+ /** Resolve a channel connector by id (mirrors the source-connector registry). */
165
+ export function createChannelConnector(id: string, options: OutboxChannelOptions = {}): GtmConnector {
166
+ if (id === "outbox") return createOutboxChannelConnector(options);
167
+ throw new Error(`Unknown channel: ${id} (one of: ${CHANNELS.join(", ")}).`);
168
+ }
169
+
170
+ /**
171
+ * Read every outbox entry across all channel files in `dir` (default: the
172
+ * conventional outbox), newest-appended last. The reader a downstream sender (or
173
+ * a future `signals outbox` command) uses to drain the queue.
174
+ */
175
+ export function listOutbox(dir?: string): OutboxEntry[] {
176
+ const outboxDir = dir ?? signalsOutboxDir();
177
+ let names: string[];
178
+ try {
179
+ names = readdirSync(outboxDir).filter((name) => name.endsWith(".jsonl")).sort();
180
+ } catch {
181
+ return [];
182
+ }
183
+ const out: OutboxEntry[] = [];
184
+ for (const name of names) {
185
+ let raw: string;
186
+ try {
187
+ raw = readFileSync(join(outboxDir, name), "utf8");
188
+ } catch {
189
+ continue;
190
+ }
191
+ for (const line of raw.split("\n")) {
192
+ const t = line.trim();
193
+ if (!t) continue;
194
+ try {
195
+ out.push(JSON.parse(t) as OutboxEntry);
196
+ } catch {
197
+ // Skip corrupt lines.
198
+ }
199
+ }
200
+ }
201
+ return out;
202
+ }
@@ -166,6 +166,63 @@ function strField(field: { value?: unknown } | undefined): string | undefined {
166
166
  return typeof v === "string" && v.trim() ? v : undefined;
167
167
  }
168
168
 
169
+ // ---------------------------------------------------------------------------
170
+ // TAM universe count — "how many ACCOUNTS match this ICP firmographic?"
171
+ //
172
+ // Verified empirically (2026-06-25) against the live providers, because the
173
+ // obvious endpoints lie about totals:
174
+ // - Explorium /v1/prospects `total_results` == the PAGE size (1→1, 10→10), not
175
+ // the universe — useless for sizing. So is /v1/businesses for tiny pages? No:
176
+ // - Explorium /v1/businesses `total_results` IS a real COUNT of matching
177
+ // companies (US+10000-emp → 9,754; Liechtenstein+10000 → 11), capped at
178
+ // EXPLORIUM_BUSINESS_COUNT_CAP. At/above the cap it saturates at exactly that
179
+ // number, so the caller must treat a capped reading as a FLOOR, not a count.
180
+ // - pipe0/Crustdata people search returns only a pagination cursor, NO total —
181
+ // it cannot size a universe at all (callers use it for discovery, not counting).
182
+ // So the TAM count source is Explorium /v1/businesses (a company/account count).
183
+
184
+ export const EXPLORIUM_BUSINESS_COUNT_CAP = 60_000;
185
+
186
+ export type BusinessCountProbe = {
187
+ /** matching companies (the account universe); == cap when saturated. */
188
+ total: number;
189
+ /** true when total hit EXPLORIUM_BUSINESS_COUNT_CAP — treat total as a lower bound. */
190
+ capped: boolean;
191
+ };
192
+
193
+ /**
194
+ * Count the COMPANIES (accounts) matching an ICP firmographic via Explorium
195
+ * /v1/businesses. Returns the real total (capped at 60k — `capped` flags the
196
+ * ceiling) or null if the response carries no `total_results`. Use
197
+ * `icpToExploriumBusinessFilters` to build `filters` (firmographic field names
198
+ * differ from /v1/prospects).
199
+ */
200
+ export async function probeExploriumBusinessCount(opts: {
201
+ apiKey: string;
202
+ filters: Record<string, { values?: string[] }>;
203
+ apiBaseUrl?: string;
204
+ fetchImpl?: FetchImpl;
205
+ }): Promise<BusinessCountProbe | null> {
206
+ const fetchImpl = opts.fetchImpl ?? fetch;
207
+ const base = (opts.apiBaseUrl ?? "https://api.explorium.ai").replace(/\/$/, "");
208
+ const response = await fetchImpl(`${base}/v1/businesses`, {
209
+ method: "POST",
210
+ headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
211
+ // page_size 1: we want the envelope's total_results, not the rows. CRITICAL:
212
+ // do NOT send `size` — Explorium caps total_results to `size` when present
213
+ // (verified: size:1 → total_results:1; omitted → the real count, e.g. 19,058).
214
+ body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
215
+ });
216
+ if (!response.ok) {
217
+ throw new Error(`Explorium /v1/businesses count failed: HTTP ${response.status} ${await safeText(response)}`);
218
+ }
219
+ const body = (await response.json()) as { total_results?: unknown };
220
+ const total = body.total_results;
221
+ if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return null;
222
+ const rounded = Math.round(total);
223
+ return { total: rounded, capped: rounded >= EXPLORIUM_BUSINESS_COUNT_CAP };
224
+ }
225
+
169
226
  function normalizeLinkedin(value: string | undefined): string | undefined {
170
227
  if (!value) return undefined;
171
228
  const v = value.trim().replace(/^https?:\/\//i, "").replace(/\/$/, "");
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Source connectors for the `signals` layer — the connection-based intake that
3
+ * generalizes the no-auth ATS adapters (`atsBoards.ts`) and the hand-staged
4
+ * `--from <file.json>` path into one contract: a platform connection in, a list
5
+ * of evidence-bearing staged signal rows out.
6
+ *
7
+ * Design (see docs/spec-connectors-signals-outbound.md):
8
+ * - Zero runtime deps: global `fetch` only, injectable for tests.
9
+ * - Read-only: a source never writes a CRM record and never emits a
10
+ * PatchOperation. `signals fetch` stays read-only re: the CRM.
11
+ * - Verbatim evidence: every row carries a non-empty `quote`; a row that
12
+ * cannot ground a why-now is dropped, never faked. The central
13
+ * `stagedRowToSignal` gate enforces this again.
14
+ * - Secrets via the credential ladder: API keys come from
15
+ * `ctx.getApiKey(provider)` (login store -> env -> broker), NEVER from argv.
16
+ * `ctx.options` carries non-secret knobs only (a file path, a query term).
17
+ * - Per-source resilience: a connector's own failure yields `[]` (logged by
18
+ * the caller), it must never sink a multi-source run — the per-provider
19
+ * try/catch idiom of atsBoards/prospectSources.
20
+ */
21
+
22
+ import { readFileSync, readdirSync, statSync } from "node:fs";
23
+ import { join, resolve } from "node:path";
24
+ import type { SignalBucket, StagedSignalRow } from "../signals.ts";
25
+ import { SIGNAL_BUCKETS } from "../signals.ts";
26
+
27
+ export type SignalSourceShape = "pull" | "push";
28
+ export type SignalSourceAuth = "none" | "api_key" | "oauth";
29
+
30
+ /** Everything a source connector needs for one `fetch`, supplied by the CLI. */
31
+ export type SignalSourceContext = {
32
+ /** Accounts to scope a pull. May be empty (a file/spool source ignores it). */
33
+ watchlist: { domain: string }[];
34
+ /** Evidence keywords for job/listing-style sources; may be empty. */
35
+ keywords: string[];
36
+ now: Date;
37
+ /** Injectable fetch for tests; global `fetch` by default. */
38
+ fetchImpl?: typeof fetch;
39
+ /**
40
+ * Credential-ladder lookup. Returns a usable secret for `provider`, or null
41
+ * when nothing is configured (the connector then returns []). Secrets NEVER
42
+ * arrive via argv — only through this.
43
+ */
44
+ getApiKey?: (provider: string) => Promise<string | null>;
45
+ /** Non-secret per-connector knobs from `--connector-opt k=v` (paths, queries). */
46
+ options?: Record<string, string>;
47
+ };
48
+
49
+ export type SignalSourceConnector = {
50
+ id: string;
51
+ /** Default bucket this source feeds (a row may still override it). */
52
+ bucket: SignalBucket;
53
+ shape: SignalSourceShape;
54
+ auth: SignalSourceAuth;
55
+ /**
56
+ * Produce staged rows now. Resilient by contract: the connector's own
57
+ * failures (offline, non-2xx, malformed payload, missing key) resolve to []
58
+ * rather than throwing, so one source's outage never aborts a multi-source
59
+ * `signals fetch`. Validation/evidence-gating of the returned rows happens
60
+ * centrally in `stagedRowToSignal`.
61
+ */
62
+ fetch(ctx: SignalSourceContext): Promise<StagedSignalRow[]>;
63
+ };
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // file — local JSON / JSONL intake (also the webhook landing-zone reader)
67
+
68
+ /**
69
+ * Read staged rows from a local path — the webhook landing-zone reader. The path
70
+ * (from `options.path`/`options.file`) may be:
71
+ * - a single FILE: a JSON array, or newline-delimited JSON (one row per line);
72
+ * - a DIRECTORY (the conventional spool): every `*.jsonl` / `*.json` file in
73
+ * it is read and concatenated (sorted by name), so multiple receivers can
74
+ * each append their own file (`rb2b.jsonl`, `hubspot.jsonl`) and they all
75
+ * land in one fetch.
76
+ * No auth. This is how every push platform (RB2B, Trigify, HubSpot webhooks)
77
+ * reaches the CLI: a receiver appends a row to the spool, this reads it on the
78
+ * next `signals fetch`. The CLI defaults the path to the conventional spool dir
79
+ * (`signalsSpoolDir`) when `--connector file` is used with no path; a bare
80
+ * library call with no path is inactive (returns []).
81
+ *
82
+ * Resilient: a missing path / unreadable file yields [] (an empty source, not a
83
+ * crash), and one unreadable file in a spool directory is skipped. A file that
84
+ * IS present but malformed throws — a corrupt spool is a real error to surface,
85
+ * not silent data loss. The central `stagedRowToSignal` gate validates rows.
86
+ */
87
+ export const fileSource: SignalSourceConnector = {
88
+ id: "file",
89
+ bucket: "company",
90
+ shape: "pull",
91
+ auth: "none",
92
+ async fetch(ctx) {
93
+ const path = ctx.options?.path ?? ctx.options?.file;
94
+ if (!path) return []; // no spool configured (the CLI fills the default dir)
95
+ const abs = resolve(process.cwd(), path);
96
+ let isDir: boolean;
97
+ try {
98
+ isDir = statSync(abs).isDirectory();
99
+ } catch {
100
+ return []; // missing path: an empty source, not a crash
101
+ }
102
+ const files = isDir ? spoolFilesIn(abs) : [abs];
103
+ const rows: Record<string, unknown>[] = [];
104
+ for (const file of files) {
105
+ let raw: string;
106
+ try {
107
+ raw = readFileSync(file, "utf8");
108
+ } catch {
109
+ continue; // one unreadable file in a spool dir must not sink the rest
110
+ }
111
+ const trimmed = raw.trim();
112
+ if (!trimmed) continue;
113
+ const parsed = trimmed.startsWith("[") ? parseJsonArray(trimmed, file) : parseJsonl(trimmed, file);
114
+ rows.push(...parsed);
115
+ }
116
+ return rows.map((row) => coerceRow(row, this.bucket));
117
+ },
118
+ };
119
+
120
+ /** Spool files in a landing-zone directory: `*.jsonl` / `*.json`, name-sorted. */
121
+ function spoolFilesIn(dir: string): string[] {
122
+ let names: string[];
123
+ try {
124
+ names = readdirSync(dir);
125
+ } catch {
126
+ return [];
127
+ }
128
+ return names
129
+ .filter((name) => name.endsWith(".jsonl") || name.endsWith(".json"))
130
+ .sort()
131
+ .map((name) => join(dir, name));
132
+ }
133
+
134
+ function parseJsonArray(raw: string, path: string): Record<string, unknown>[] {
135
+ let parsed: unknown;
136
+ try {
137
+ parsed = JSON.parse(raw);
138
+ } catch (error) {
139
+ throw new Error(`file source ${path}: not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
140
+ }
141
+ if (!Array.isArray(parsed)) throw new Error(`file source ${path}: expected a JSON array of staged signal rows.`);
142
+ return parsed.map((entry, index) => {
143
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
144
+ throw new Error(`file source ${path}: row ${index} is not an object.`);
145
+ }
146
+ return entry as Record<string, unknown>;
147
+ });
148
+ }
149
+
150
+ function parseJsonl(raw: string, path: string): Record<string, unknown>[] {
151
+ const out: Record<string, unknown>[] = [];
152
+ const lines = raw.split("\n");
153
+ lines.forEach((line, index) => {
154
+ const t = line.trim();
155
+ if (!t) return;
156
+ let parsed: unknown;
157
+ try {
158
+ parsed = JSON.parse(t);
159
+ } catch (error) {
160
+ throw new Error(`file source ${path}: line ${index} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
161
+ }
162
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
163
+ throw new Error(`file source ${path}: line ${index} is not a JSON object.`);
164
+ }
165
+ out.push(parsed as Record<string, unknown>);
166
+ });
167
+ return out;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // serpapi-news — funding/company signals from a news REST query (API key)
172
+
173
+ const SERPAPI_BASE_URL = "https://serpapi.com";
174
+
175
+ /**
176
+ * Pull recent news per watchlist account and stage funding/company signals. One
177
+ * query per account (`q="<domain>"`, Google News engine); each result becomes a
178
+ * row whose verbatim `quote` is the headline (+ source), so the downstream judge
179
+ * can ground a why-now on a real, linkable article. API key via the credential
180
+ * ladder (provider "serpapi"); no key -> [] (the source is simply inactive).
181
+ *
182
+ * Bucket defaults to `funding`; override per run with `--connector-opt bucket=company`.
183
+ */
184
+ export const serpapiNewsSource: SignalSourceConnector = {
185
+ id: "serpapi-news",
186
+ bucket: "funding",
187
+ shape: "pull",
188
+ auth: "api_key",
189
+ async fetch(ctx) {
190
+ const apiKey = ctx.getApiKey ? await ctx.getApiKey("serpapi") : null;
191
+ if (!apiKey) return [];
192
+ const fetchImpl = ctx.fetchImpl ?? fetch;
193
+ const base = (ctx.options?.apiBaseUrl ?? SERPAPI_BASE_URL).replace(/\/$/, "");
194
+ const bucket = pickBucket(ctx.options?.bucket, this.bucket);
195
+ const out: StagedSignalRow[] = [];
196
+ for (const account of ctx.watchlist) {
197
+ const domain = account.domain.trim();
198
+ if (!domain) continue;
199
+ try {
200
+ const url =
201
+ `${base}/search.json?engine=google_news&q=${encodeURIComponent(`"${domain}"`)}` +
202
+ `&api_key=${encodeURIComponent(apiKey)}`;
203
+ const response = await fetchImpl(url, { headers: { Accept: "application/json" } });
204
+ if (!response.ok) continue;
205
+ const data = (await response.json()) as {
206
+ news_results?: Array<{ title?: string; link?: string; source?: { name?: string } | string; date?: string }>;
207
+ };
208
+ for (const item of data.news_results ?? []) {
209
+ const title = typeof item.title === "string" ? item.title.trim() : "";
210
+ if (!title) continue; // no headline -> no verbatim evidence -> drop
211
+ const sourceName =
212
+ typeof item.source === "string" ? item.source : item.source?.name ?? "";
213
+ const quote = sourceName ? `${title} — ${sourceName}` : title;
214
+ out.push({
215
+ bucket,
216
+ accountDomain: domain,
217
+ trigger: `news: ${title}`,
218
+ quote,
219
+ sourceUrl: typeof item.link === "string" ? item.link : "",
220
+ });
221
+ }
222
+ } catch {
223
+ continue; // one account's outage must not sink the run
224
+ }
225
+ }
226
+ return out;
227
+ },
228
+ };
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // hubspot-forms — first-party demand from recent HubSpot form submissions
232
+
233
+ const HUBSPOT_BASE_URL = "https://api.hubapi.com";
234
+
235
+ /**
236
+ * Stage `demand` signals from recent HubSpot form submissions — the first real
237
+ * `demand`-bucket producer (a form fill is first-party demand). Reuses the
238
+ * EXISTING HubSpot credential via the ladder (provider "hubspot"); no separate
239
+ * login. Each submission whose email carries a company domain becomes a row
240
+ * whose verbatim `quote` is the form name + submitted email (the evidence a rep
241
+ * can verify). Submissions without a corporate domain (free-mail) are dropped —
242
+ * no account to attach demand to.
243
+ *
244
+ * Phase 1 is a pull over the Forms submissions API; the form-submission webhook
245
+ * (push) lands in Phase 2 via the spool + `file` source.
246
+ */
247
+ export const hubspotFormsSource: SignalSourceConnector = {
248
+ id: "hubspot-forms",
249
+ bucket: "demand",
250
+ shape: "pull",
251
+ auth: "oauth",
252
+ async fetch(ctx) {
253
+ const token = ctx.getApiKey ? await ctx.getApiKey("hubspot") : null;
254
+ if (!token) return [];
255
+ const fetchImpl = ctx.fetchImpl ?? fetch;
256
+ const base = (ctx.options?.apiBaseUrl ?? HUBSPOT_BASE_URL).replace(/\/$/, "");
257
+ const out: StagedSignalRow[] = [];
258
+ let forms: Array<{ guid?: string; id?: string; name?: string }> = [];
259
+ try {
260
+ const response = await fetchImpl(`${base}/marketing/v3/forms`, {
261
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
262
+ });
263
+ if (!response.ok) return [];
264
+ const data = (await response.json()) as { results?: typeof forms };
265
+ forms = data.results ?? [];
266
+ } catch {
267
+ return [];
268
+ }
269
+ for (const form of forms) {
270
+ const formId = form.guid ?? form.id;
271
+ if (!formId) continue;
272
+ const formName = typeof form.name === "string" && form.name ? form.name : "form";
273
+ try {
274
+ const response = await fetchImpl(
275
+ `${base}/form-integrations/v1/submissions/forms/${encodeURIComponent(formId)}?limit=50`,
276
+ { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } },
277
+ );
278
+ if (!response.ok) continue;
279
+ const data = (await response.json()) as {
280
+ results?: Array<{ values?: Array<{ name?: string; value?: string }>; submittedAt?: number }>;
281
+ };
282
+ for (const submission of data.results ?? []) {
283
+ const email = fieldValue(submission.values, "email");
284
+ const domain = corporateDomain(email);
285
+ if (!domain) continue; // free-mail / no email: no account to attach to
286
+ const submittedAt =
287
+ typeof submission.submittedAt === "number"
288
+ ? new Date(submission.submittedAt).toISOString()
289
+ : undefined;
290
+ out.push({
291
+ bucket: "demand",
292
+ accountDomain: domain,
293
+ trigger: `form: ${formName}`,
294
+ quote: `Submitted "${formName}" — ${email}`,
295
+ sourceUrl: "",
296
+ ...(submittedAt ? { firstSeen: submittedAt } : {}),
297
+ });
298
+ }
299
+ } catch {
300
+ continue;
301
+ }
302
+ }
303
+ return out;
304
+ },
305
+ };
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Registry
309
+
310
+ const CONNECTORS: SignalSourceConnector[] = [fileSource, serpapiNewsSource, hubspotFormsSource];
311
+
312
+ const REGISTRY = new Map<string, SignalSourceConnector>(CONNECTORS.map((c) => [c.id, c]));
313
+
314
+ /** All registered source connectors (stable order). */
315
+ export function listSignalSources(): SignalSourceConnector[] {
316
+ return [...CONNECTORS];
317
+ }
318
+
319
+ /** Resolve a connector by id, or null when unknown. */
320
+ export function getSignalSource(id: string): SignalSourceConnector | null {
321
+ return REGISTRY.get(id) ?? null;
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Helpers
326
+
327
+ function coerceRow(row: Record<string, unknown>, defaultBucket: SignalBucket): StagedSignalRow {
328
+ // Fill the bucket from the connector default when a file row omits it; the
329
+ // central validator still rejects an unknown bucket and an empty quote.
330
+ const bucket = row.bucket === undefined ? defaultBucket : (row.bucket as SignalBucket);
331
+ return { ...(row as StagedSignalRow), bucket };
332
+ }
333
+
334
+ function pickBucket(raw: string | undefined, fallback: SignalBucket): SignalBucket {
335
+ if (raw && SIGNAL_BUCKETS.includes(raw as SignalBucket)) return raw as SignalBucket;
336
+ return fallback;
337
+ }
338
+
339
+ function fieldValue(values: Array<{ name?: string; value?: string }> | undefined, name: string): string {
340
+ for (const field of values ?? []) {
341
+ if (field.name === name && typeof field.value === "string") return field.value.trim();
342
+ }
343
+ return "";
344
+ }
345
+
346
+ const FREE_MAIL = new Set([
347
+ "gmail.com",
348
+ "yahoo.com",
349
+ "hotmail.com",
350
+ "outlook.com",
351
+ "icloud.com",
352
+ "aol.com",
353
+ "proton.me",
354
+ "protonmail.com",
355
+ ]);
356
+
357
+ /** Company domain from an email, or "" for free-mail / no email. */
358
+ function corporateDomain(email: string): string {
359
+ if (!email.includes("@")) return "";
360
+ const domain = email.split("@").at(-1)!.trim().toLowerCase();
361
+ if (!domain || FREE_MAIL.has(domain)) return "";
362
+ return domain;
363
+ }