fullstackgtm 0.42.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.
- package/CHANGELOG.md +273 -0
- package/README.md +18 -7
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +718 -60
- package/dist/connectors/hubspot.js +58 -24
- package/dist/connectors/outboxChannel.d.ts +64 -0
- package/dist/connectors/outboxChannel.js +170 -0
- package/dist/connectors/prospectSources.d.ts +22 -0
- package/dist/connectors/prospectSources.js +43 -0
- package/dist/connectors/salesforce.js +40 -15
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +316 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/draft.js +27 -5
- package/dist/enrich.d.ts +6 -0
- package/dist/enrich.js +47 -1
- package/dist/health.d.ts +12 -0
- package/dist/health.js +29 -2
- package/dist/icp.d.ts +47 -3
- package/dist/icp.js +105 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +143 -0
- package/dist/judge.d.ts +36 -3
- package/dist/judge.js +46 -10
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.js +18 -4
- package/dist/signals.d.ts +58 -0
- package/dist/signals.js +65 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +12 -0
- package/docs/api.md +26 -4
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +195 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +162 -0
- package/docs/tam.md +195 -0
- package/llms.txt +89 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +17 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +809 -55
- package/src/connectors/hubspot.ts +55 -23
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +39 -14
- package/src/connectors/signalSources.ts +363 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/draft.ts +26 -5
- package/src/enrich.ts +51 -1
- package/src/health.ts +47 -2
- package/src/icp.ts +113 -11
- package/src/index.ts +42 -0
- package/src/init.ts +166 -0
- package/src/judge.ts +85 -11
- package/src/judgeEval.ts +8 -1
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +20 -4
- package/src/signals.ts +95 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
|
@@ -406,18 +406,35 @@ export function createHubspotConnector(options) {
|
|
|
406
406
|
providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
|
|
407
407
|
};
|
|
408
408
|
}
|
|
409
|
-
/** Exact-
|
|
410
|
-
async function
|
|
409
|
+
/** Exact-value company lookup (by `name` or `domain`) for resolve-first creates. */
|
|
410
|
+
async function searchCompaniesBy(property, value) {
|
|
411
411
|
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
412
412
|
method: "POST",
|
|
413
413
|
body: JSON.stringify({
|
|
414
|
-
filterGroups: [{ filters: [{ propertyName:
|
|
415
|
-
properties: [
|
|
414
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
|
|
415
|
+
properties: [property],
|
|
416
416
|
limit: 3,
|
|
417
417
|
}),
|
|
418
418
|
});
|
|
419
419
|
return (data?.results ?? []).map((row) => String(row.id));
|
|
420
420
|
}
|
|
421
|
+
const searchCompaniesByName = (name) => searchCompaniesBy("name", name);
|
|
422
|
+
/** Stamp `domain` on a company only when it has none (fill-blank, never clobber). */
|
|
423
|
+
async function fillCompanyDomainIfEmpty(companyId, domain) {
|
|
424
|
+
try {
|
|
425
|
+
const data = await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}?properties=domain`);
|
|
426
|
+
const current = data?.properties?.domain;
|
|
427
|
+
if (typeof current === "string" && current.trim())
|
|
428
|
+
return; // already has one — leave it
|
|
429
|
+
await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}`, {
|
|
430
|
+
method: "PATCH",
|
|
431
|
+
body: JSON.stringify({ properties: { domain } }),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// Best-effort: a failed domain fill must not sink the contact create.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
421
438
|
/** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
|
|
422
439
|
async function searchContactsBy(property, value) {
|
|
423
440
|
const data = await request(`/crm/v3/objects/contacts/search`, {
|
|
@@ -431,39 +448,55 @@ export function createHubspotConnector(options) {
|
|
|
431
448
|
return (data?.results ?? []).map((row) => String(row.id));
|
|
432
449
|
}
|
|
433
450
|
/**
|
|
434
|
-
* Resolve a company
|
|
435
|
-
*
|
|
436
|
-
*
|
|
451
|
+
* Resolve a company to a real account record, creating it on a confirmed miss.
|
|
452
|
+
* Matches by DOMAIN first (the accurate key) and falls back to exact name;
|
|
453
|
+
* creates with the domain stamped, and fills the domain on a name-matched
|
|
454
|
+
* account that lacks one — so the account is signal-watchable. Returns null on
|
|
455
|
+
* ambiguity (≥2 matches) so the caller skips the association rather than
|
|
456
|
+
* guessing. Same-run safe via createdCompaniesByName.
|
|
437
457
|
*/
|
|
438
|
-
async function
|
|
439
|
-
const
|
|
440
|
-
const already = createdCompaniesByName.get(
|
|
458
|
+
async function resolveOrCreateCompany(name, domain, opId) {
|
|
459
|
+
const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
|
|
460
|
+
const already = createdCompaniesByName.get(cacheKey);
|
|
441
461
|
if (already)
|
|
442
462
|
return already;
|
|
443
|
-
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
463
|
+
// 1. Domain-first match (accurate; an account matched here already has it).
|
|
464
|
+
if (domain) {
|
|
465
|
+
const byDomain = await searchCompaniesBy("domain", domain);
|
|
466
|
+
if (byDomain.length > 1)
|
|
467
|
+
return null;
|
|
468
|
+
if (byDomain.length === 1) {
|
|
469
|
+
createdCompaniesByName.set(cacheKey, byDomain[0]);
|
|
470
|
+
return byDomain[0];
|
|
471
|
+
}
|
|
449
472
|
}
|
|
473
|
+
// 2. Exact-name match; stamp the domain if the account has none.
|
|
474
|
+
const byName = await searchCompaniesByName(name);
|
|
475
|
+
if (byName.length > 1)
|
|
476
|
+
return null;
|
|
477
|
+
if (byName.length === 1) {
|
|
478
|
+
if (domain)
|
|
479
|
+
await fillCompanyDomainIfEmpty(byName[0], domain);
|
|
480
|
+
createdCompaniesByName.set(cacheKey, byName[0]);
|
|
481
|
+
return byName[0];
|
|
482
|
+
}
|
|
483
|
+
// 3. Create with name + domain (provenance is best-effort).
|
|
484
|
+
const props = { name, ...(domain ? { domain } : {}) };
|
|
450
485
|
let created;
|
|
451
486
|
try {
|
|
452
487
|
created = await request(`/crm/v3/objects/companies`, {
|
|
453
488
|
method: "POST",
|
|
454
|
-
body: JSON.stringify({
|
|
455
|
-
properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
|
|
456
|
-
}),
|
|
489
|
+
body: JSON.stringify({ properties: { ...props, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` } }),
|
|
457
490
|
});
|
|
458
491
|
}
|
|
459
492
|
catch {
|
|
460
493
|
created = await request(`/crm/v3/objects/companies`, {
|
|
461
494
|
method: "POST",
|
|
462
|
-
body: JSON.stringify({ properties:
|
|
495
|
+
body: JSON.stringify({ properties: props }),
|
|
463
496
|
});
|
|
464
497
|
}
|
|
465
498
|
const id = String(created.id);
|
|
466
|
-
createdCompaniesByName.set(
|
|
499
|
+
createdCompaniesByName.set(cacheKey, id);
|
|
467
500
|
return id;
|
|
468
501
|
}
|
|
469
502
|
/**
|
|
@@ -487,7 +520,7 @@ export function createHubspotConnector(options) {
|
|
|
487
520
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
488
521
|
}
|
|
489
522
|
if (operation.objectType === "account") {
|
|
490
|
-
const id = await
|
|
523
|
+
const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
|
|
491
524
|
if (id === null) {
|
|
492
525
|
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
|
|
493
526
|
}
|
|
@@ -534,10 +567,11 @@ export function createHubspotConnector(options) {
|
|
|
534
567
|
let companyNote = "";
|
|
535
568
|
if (payload.associateCompanyName) {
|
|
536
569
|
try {
|
|
537
|
-
const companyId = await
|
|
570
|
+
const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
|
|
538
571
|
if (companyId) {
|
|
539
572
|
await request(`/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
|
|
540
|
-
|
|
573
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
574
|
+
companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
|
|
541
575
|
}
|
|
542
576
|
else {
|
|
543
577
|
companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
import type { GtmConnector } from "../types.ts";
|
|
24
|
+
/** One approved, ready-to-send touch — a governed send INTENT, not a sent message. */
|
|
25
|
+
export type OutboxEntry = {
|
|
26
|
+
/** Idempotency key = the source operation id (stable per draft op). */
|
|
27
|
+
id: string;
|
|
28
|
+
/** email | linkedin | task — from the draft op's `draft:<channel>` policy. */
|
|
29
|
+
channel: string;
|
|
30
|
+
/** The CRM target the opener is addressed to (a sender resolves id → address). */
|
|
31
|
+
objectType: string;
|
|
32
|
+
objectId: string;
|
|
33
|
+
/** The APPROVED opener, verbatim as it was signed in the plan op. */
|
|
34
|
+
body: string;
|
|
35
|
+
/** The draft op's human-readable reason (carries the account + trigger). */
|
|
36
|
+
reason?: string;
|
|
37
|
+
/** Evidence ids the opener was grounded in (the verbatim signal quote). */
|
|
38
|
+
evidenceIds: string[];
|
|
39
|
+
/** ISO 8601 — when the CLI rendered this to the outbox (NOT a send time). */
|
|
40
|
+
renderedAt: string;
|
|
41
|
+
};
|
|
42
|
+
/** Channel ids this build can render to. */
|
|
43
|
+
export declare const CHANNELS: readonly ["outbox"];
|
|
44
|
+
export type OutboxChannelOptions = {
|
|
45
|
+
/** Outbox directory; defaults to the profile-scoped `signalsOutboxDir()`. */
|
|
46
|
+
outboxDir?: string;
|
|
47
|
+
/** Injectable clock for deterministic tests. */
|
|
48
|
+
now?: () => Date;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Build the outbox channel connector. With no `outboxDir`, writes to the
|
|
52
|
+
* profile-scoped conventional outbox and locks the home down (0700/0600) like
|
|
53
|
+
* the signal store; with an explicit `outboxDir` (tests), it writes there
|
|
54
|
+
* without touching the real home.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createOutboxChannelConnector(options?: OutboxChannelOptions): GtmConnector;
|
|
57
|
+
/** Resolve a channel connector by id (mirrors the source-connector registry). */
|
|
58
|
+
export declare function createChannelConnector(id: string, options?: OutboxChannelOptions): GtmConnector;
|
|
59
|
+
/**
|
|
60
|
+
* Read every outbox entry across all channel files in `dir` (default: the
|
|
61
|
+
* conventional outbox), newest-appended last. The reader a downstream sender (or
|
|
62
|
+
* a future `signals outbox` command) uses to drain the queue.
|
|
63
|
+
*/
|
|
64
|
+
export declare function listOutbox(dir?: string): OutboxEntry[];
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { ensureSecureHomeDir, writeSecureFile } from "../credentials.js";
|
|
26
|
+
import { signalsOutboxDir } from "../signals.js";
|
|
27
|
+
/** Channel ids this build can render to. */
|
|
28
|
+
export const CHANNELS = ["outbox"];
|
|
29
|
+
const DRAFT_POLICY_PREFIX = "draft:";
|
|
30
|
+
/**
|
|
31
|
+
* Build the outbox channel connector. With no `outboxDir`, writes to the
|
|
32
|
+
* profile-scoped conventional outbox and locks the home down (0700/0600) like
|
|
33
|
+
* the signal store; with an explicit `outboxDir` (tests), it writes there
|
|
34
|
+
* without touching the real home.
|
|
35
|
+
*/
|
|
36
|
+
export function createOutboxChannelConnector(options = {}) {
|
|
37
|
+
const usingDefaultHome = options.outboxDir === undefined;
|
|
38
|
+
const dir = options.outboxDir ?? signalsOutboxDir();
|
|
39
|
+
const now = options.now ?? (() => new Date());
|
|
40
|
+
function fileFor(channel) {
|
|
41
|
+
if (!/^[\w.-]+$/.test(channel))
|
|
42
|
+
throw new Error(`Invalid outbox channel name: ${channel}`);
|
|
43
|
+
return join(dir, `${channel}.jsonl`);
|
|
44
|
+
}
|
|
45
|
+
/** Existing entry ids in a channel file (for idempotent re-apply). */
|
|
46
|
+
function existingIds(channel) {
|
|
47
|
+
const ids = new Set();
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = readFileSync(fileFor(channel), "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return ids; // no file yet
|
|
54
|
+
}
|
|
55
|
+
for (const line of raw.split("\n")) {
|
|
56
|
+
const t = line.trim();
|
|
57
|
+
if (!t)
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
const id = JSON.parse(t).id;
|
|
61
|
+
if (typeof id === "string")
|
|
62
|
+
ids.add(id);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Skip a corrupt line rather than fail the whole apply.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return ids;
|
|
69
|
+
}
|
|
70
|
+
function append(channel, entry) {
|
|
71
|
+
if (usingDefaultHome)
|
|
72
|
+
ensureSecureHomeDir();
|
|
73
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
74
|
+
const path = fileFor(channel);
|
|
75
|
+
// Owner-only like the signal store: outbox carries opener text + CRM ids.
|
|
76
|
+
if (!existsSync(path))
|
|
77
|
+
writeSecureFile(path, "");
|
|
78
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
async function applyOperation(operation) {
|
|
81
|
+
const policy = String(operation.sourceRuleOrPolicy ?? "");
|
|
82
|
+
if (operation.operation !== "create_task" || !policy.startsWith(DRAFT_POLICY_PREFIX)) {
|
|
83
|
+
return {
|
|
84
|
+
operationId: operation.id,
|
|
85
|
+
status: "skipped",
|
|
86
|
+
detail: "The outbox channel only renders drafted openers (create_task ops from `draft`). " +
|
|
87
|
+
"Apply other operations through a CRM connector (--provider hubspot|salesforce).",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const channel = policy.slice(DRAFT_POLICY_PREFIX.length) || "task";
|
|
91
|
+
// Idempotent: a re-applied approved plan must not duplicate the row.
|
|
92
|
+
if (existingIds(channel).has(operation.id)) {
|
|
93
|
+
return {
|
|
94
|
+
operationId: operation.id,
|
|
95
|
+
status: "applied",
|
|
96
|
+
detail: `Already in outbox (${channel}.jsonl); idempotent — not duplicated. Nothing transmitted.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const entry = {
|
|
100
|
+
id: operation.id,
|
|
101
|
+
channel,
|
|
102
|
+
objectType: operation.objectType,
|
|
103
|
+
objectId: operation.objectId,
|
|
104
|
+
body: typeof operation.afterValue === "string" ? operation.afterValue : String(operation.afterValue ?? ""),
|
|
105
|
+
...(operation.reason ? { reason: operation.reason } : {}),
|
|
106
|
+
evidenceIds: operation.evidenceIds ?? [],
|
|
107
|
+
renderedAt: now().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
append(channel, entry);
|
|
110
|
+
return {
|
|
111
|
+
operationId: operation.id,
|
|
112
|
+
status: "applied",
|
|
113
|
+
detail: `Rendered to outbox (${channel}.jsonl) for a downstream sender. Nothing was transmitted.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function fetchSnapshot() {
|
|
117
|
+
// A channel has no readable state. apply never calls this for a draft plan
|
|
118
|
+
// (no guards/filter/irreversible ops); make the misuse explicit if it does.
|
|
119
|
+
throw new Error("The outbox channel has no snapshot to read — it is a send-side render target. " +
|
|
120
|
+
"Use it only to `apply` an approved draft plan (`apply --plan-id <id> --channel outbox`).");
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
provider: "outbox",
|
|
124
|
+
fetchSnapshot,
|
|
125
|
+
applyOperation,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Resolve a channel connector by id (mirrors the source-connector registry). */
|
|
129
|
+
export function createChannelConnector(id, options = {}) {
|
|
130
|
+
if (id === "outbox")
|
|
131
|
+
return createOutboxChannelConnector(options);
|
|
132
|
+
throw new Error(`Unknown channel: ${id} (one of: ${CHANNELS.join(", ")}).`);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Read every outbox entry across all channel files in `dir` (default: the
|
|
136
|
+
* conventional outbox), newest-appended last. The reader a downstream sender (or
|
|
137
|
+
* a future `signals outbox` command) uses to drain the queue.
|
|
138
|
+
*/
|
|
139
|
+
export function listOutbox(dir) {
|
|
140
|
+
const outboxDir = dir ?? signalsOutboxDir();
|
|
141
|
+
let names;
|
|
142
|
+
try {
|
|
143
|
+
names = readdirSync(outboxDir).filter((name) => name.endsWith(".jsonl")).sort();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const name of names) {
|
|
150
|
+
let raw;
|
|
151
|
+
try {
|
|
152
|
+
raw = readFileSync(join(outboxDir, name), "utf8");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
for (const line of raw.split("\n")) {
|
|
158
|
+
const t = line.trim();
|
|
159
|
+
if (!t)
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
out.push(JSON.parse(t));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Skip corrupt lines.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
@@ -56,6 +56,28 @@ export declare function fetchPipe0CrustdataProspects(opts: {
|
|
|
56
56
|
apiBaseUrl?: string;
|
|
57
57
|
fetchImpl?: FetchImpl;
|
|
58
58
|
}): Promise<Prospect[]>;
|
|
59
|
+
export declare const EXPLORIUM_BUSINESS_COUNT_CAP = 60000;
|
|
60
|
+
export type BusinessCountProbe = {
|
|
61
|
+
/** matching companies (the account universe); == cap when saturated. */
|
|
62
|
+
total: number;
|
|
63
|
+
/** true when total hit EXPLORIUM_BUSINESS_COUNT_CAP — treat total as a lower bound. */
|
|
64
|
+
capped: boolean;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Count the COMPANIES (accounts) matching an ICP firmographic via Explorium
|
|
68
|
+
* /v1/businesses. Returns the real total (capped at 60k — `capped` flags the
|
|
69
|
+
* ceiling) or null if the response carries no `total_results`. Use
|
|
70
|
+
* `icpToExploriumBusinessFilters` to build `filters` (firmographic field names
|
|
71
|
+
* differ from /v1/prospects).
|
|
72
|
+
*/
|
|
73
|
+
export declare function probeExploriumBusinessCount(opts: {
|
|
74
|
+
apiKey: string;
|
|
75
|
+
filters: Record<string, {
|
|
76
|
+
values?: string[];
|
|
77
|
+
}>;
|
|
78
|
+
apiBaseUrl?: string;
|
|
79
|
+
fetchImpl?: FetchImpl;
|
|
80
|
+
}): Promise<BusinessCountProbe | null>;
|
|
59
81
|
export declare function pipe0ResolveWorkEmails(opts: {
|
|
60
82
|
apiKey: string;
|
|
61
83
|
prospects: Prospect[];
|
|
@@ -85,6 +85,49 @@ function strField(field) {
|
|
|
85
85
|
const v = field?.value;
|
|
86
86
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
87
87
|
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// TAM universe count — "how many ACCOUNTS match this ICP firmographic?"
|
|
90
|
+
//
|
|
91
|
+
// Verified empirically (2026-06-25) against the live providers, because the
|
|
92
|
+
// obvious endpoints lie about totals:
|
|
93
|
+
// - Explorium /v1/prospects `total_results` == the PAGE size (1→1, 10→10), not
|
|
94
|
+
// the universe — useless for sizing. So is /v1/businesses for tiny pages? No:
|
|
95
|
+
// - Explorium /v1/businesses `total_results` IS a real COUNT of matching
|
|
96
|
+
// companies (US+10000-emp → 9,754; Liechtenstein+10000 → 11), capped at
|
|
97
|
+
// EXPLORIUM_BUSINESS_COUNT_CAP. At/above the cap it saturates at exactly that
|
|
98
|
+
// number, so the caller must treat a capped reading as a FLOOR, not a count.
|
|
99
|
+
// - pipe0/Crustdata people search returns only a pagination cursor, NO total —
|
|
100
|
+
// it cannot size a universe at all (callers use it for discovery, not counting).
|
|
101
|
+
// So the TAM count source is Explorium /v1/businesses (a company/account count).
|
|
102
|
+
export const EXPLORIUM_BUSINESS_COUNT_CAP = 60_000;
|
|
103
|
+
/**
|
|
104
|
+
* Count the COMPANIES (accounts) matching an ICP firmographic via Explorium
|
|
105
|
+
* /v1/businesses. Returns the real total (capped at 60k — `capped` flags the
|
|
106
|
+
* ceiling) or null if the response carries no `total_results`. Use
|
|
107
|
+
* `icpToExploriumBusinessFilters` to build `filters` (firmographic field names
|
|
108
|
+
* differ from /v1/prospects).
|
|
109
|
+
*/
|
|
110
|
+
export async function probeExploriumBusinessCount(opts) {
|
|
111
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
112
|
+
const base = (opts.apiBaseUrl ?? "https://api.explorium.ai").replace(/\/$/, "");
|
|
113
|
+
const response = await fetchImpl(`${base}/v1/businesses`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
|
|
116
|
+
// page_size 1: we want the envelope's total_results, not the rows. CRITICAL:
|
|
117
|
+
// do NOT send `size` — Explorium caps total_results to `size` when present
|
|
118
|
+
// (verified: size:1 → total_results:1; omitted → the real count, e.g. 19,058).
|
|
119
|
+
body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
|
|
120
|
+
});
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new Error(`Explorium /v1/businesses count failed: HTTP ${response.status} ${await safeText(response)}`);
|
|
123
|
+
}
|
|
124
|
+
const body = (await response.json());
|
|
125
|
+
const total = body.total_results;
|
|
126
|
+
if (typeof total !== "number" || !Number.isFinite(total) || total < 0)
|
|
127
|
+
return null;
|
|
128
|
+
const rounded = Math.round(total);
|
|
129
|
+
return { total: rounded, capped: rounded >= EXPLORIUM_BUSINESS_COUNT_CAP };
|
|
130
|
+
}
|
|
88
131
|
function normalizeLinkedin(value) {
|
|
89
132
|
if (!value)
|
|
90
133
|
return undefined;
|
|
@@ -37,29 +37,53 @@ export function createSalesforceConnector(options) {
|
|
|
37
37
|
// confirmed miss is created, and an ambiguous name (>1 match) is refused.
|
|
38
38
|
// Caches within the run so the same name is never created twice — shared by
|
|
39
39
|
// `link_record`'s create:<Name> path and `create_record`'s company linking.
|
|
40
|
-
async function
|
|
41
|
-
const
|
|
42
|
-
const cached = createdAccountsByName.get(
|
|
40
|
+
async function resolveOrCreateAccount(name, domain) {
|
|
41
|
+
const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
|
|
42
|
+
const cached = createdAccountsByName.get(cacheKey);
|
|
43
43
|
if (cached)
|
|
44
44
|
return { id: cached, createdNew: false };
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
const esc = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
46
|
+
// 1. Domain-first match on Website (the accurate key; Website often stores a
|
|
47
|
+
// full URL, so match by substring).
|
|
48
|
+
if (domain) {
|
|
49
|
+
const byDomain = await query(`SELECT Id FROM Account WHERE Website LIKE '%${esc(domain)}%' LIMIT 3`);
|
|
50
|
+
if (byDomain.length > 1)
|
|
51
|
+
return { ambiguous: byDomain.length };
|
|
52
|
+
if (byDomain.length === 1) {
|
|
53
|
+
const id = String(byDomain[0].Id);
|
|
54
|
+
createdAccountsByName.set(cacheKey, id);
|
|
55
|
+
return { id, createdNew: false };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// 2. Exact-name match; stamp Website if the account has none (fill-blank).
|
|
59
|
+
const byName = await query(`SELECT Id, Website FROM Account WHERE Name = '${esc(name)}' LIMIT 3`);
|
|
60
|
+
if (byName.length > 1)
|
|
61
|
+
return { ambiguous: byName.length };
|
|
49
62
|
let id;
|
|
50
63
|
let createdNew = false;
|
|
51
|
-
if (
|
|
52
|
-
id = String(
|
|
64
|
+
if (byName.length === 1) {
|
|
65
|
+
id = String(byName[0].Id);
|
|
66
|
+
if (domain && !stringOrUndefined(byName[0].Website)) {
|
|
67
|
+
try {
|
|
68
|
+
await request(`/services/data/${apiVersion}/sobjects/Account/${encodeURIComponent(id)}`, {
|
|
69
|
+
method: "PATCH",
|
|
70
|
+
body: JSON.stringify({ Website: domain }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// best-effort fill — a failed Website stamp must not sink the create.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
53
77
|
}
|
|
54
78
|
else {
|
|
55
79
|
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
56
80
|
method: "POST",
|
|
57
|
-
body: JSON.stringify({ Name: name }),
|
|
81
|
+
body: JSON.stringify({ Name: name, ...(domain ? { Website: domain } : {}) }),
|
|
58
82
|
});
|
|
59
83
|
id = String(created.id);
|
|
60
84
|
createdNew = true;
|
|
61
85
|
}
|
|
62
|
-
createdAccountsByName.set(
|
|
86
|
+
createdAccountsByName.set(cacheKey, id);
|
|
63
87
|
return { id, createdNew };
|
|
64
88
|
}
|
|
65
89
|
async function request(path, init = {}) {
|
|
@@ -427,7 +451,7 @@ export function createSalesforceConnector(options) {
|
|
|
427
451
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
428
452
|
}
|
|
429
453
|
if (operation.objectType === "account") {
|
|
430
|
-
const resolved = await
|
|
454
|
+
const resolved = await resolveOrCreateAccount(matchValue);
|
|
431
455
|
if ("ambiguous" in resolved) {
|
|
432
456
|
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
|
|
433
457
|
}
|
|
@@ -465,7 +489,7 @@ export function createSalesforceConnector(options) {
|
|
|
465
489
|
let companyNote = "";
|
|
466
490
|
if (payload.associateCompanyName) {
|
|
467
491
|
try {
|
|
468
|
-
const resolved = await
|
|
492
|
+
const resolved = await resolveOrCreateAccount(payload.associateCompanyName, payload.associateCompanyDomain);
|
|
469
493
|
if ("ambiguous" in resolved) {
|
|
470
494
|
companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
471
495
|
}
|
|
@@ -474,7 +498,8 @@ export function createSalesforceConnector(options) {
|
|
|
474
498
|
method: "PATCH",
|
|
475
499
|
body: JSON.stringify({ AccountId: resolved.id }),
|
|
476
500
|
});
|
|
477
|
-
|
|
501
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
502
|
+
companyNote = ` Linked to account "${payload.associateCompanyName}"${domainNote} (${resolved.id}).`;
|
|
478
503
|
}
|
|
479
504
|
}
|
|
480
505
|
catch (error) {
|
|
@@ -617,7 +642,7 @@ export function createSalesforceConnector(options) {
|
|
|
617
642
|
if (!name) {
|
|
618
643
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
619
644
|
}
|
|
620
|
-
const resolved = await
|
|
645
|
+
const resolved = await resolveOrCreateAccount(name);
|
|
621
646
|
if ("ambiguous" in resolved) {
|
|
622
647
|
return {
|
|
623
648
|
operationId: operation.id,
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
import type { SignalBucket, StagedSignalRow } from "../signals.ts";
|
|
22
|
+
export type SignalSourceShape = "pull" | "push";
|
|
23
|
+
export type SignalSourceAuth = "none" | "api_key" | "oauth";
|
|
24
|
+
/** Everything a source connector needs for one `fetch`, supplied by the CLI. */
|
|
25
|
+
export type SignalSourceContext = {
|
|
26
|
+
/** Accounts to scope a pull. May be empty (a file/spool source ignores it). */
|
|
27
|
+
watchlist: {
|
|
28
|
+
domain: string;
|
|
29
|
+
}[];
|
|
30
|
+
/** Evidence keywords for job/listing-style sources; may be empty. */
|
|
31
|
+
keywords: string[];
|
|
32
|
+
now: Date;
|
|
33
|
+
/** Injectable fetch for tests; global `fetch` by default. */
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
/**
|
|
36
|
+
* Credential-ladder lookup. Returns a usable secret for `provider`, or null
|
|
37
|
+
* when nothing is configured (the connector then returns []). Secrets NEVER
|
|
38
|
+
* arrive via argv — only through this.
|
|
39
|
+
*/
|
|
40
|
+
getApiKey?: (provider: string) => Promise<string | null>;
|
|
41
|
+
/** Non-secret per-connector knobs from `--connector-opt k=v` (paths, queries). */
|
|
42
|
+
options?: Record<string, string>;
|
|
43
|
+
};
|
|
44
|
+
export type SignalSourceConnector = {
|
|
45
|
+
id: string;
|
|
46
|
+
/** Default bucket this source feeds (a row may still override it). */
|
|
47
|
+
bucket: SignalBucket;
|
|
48
|
+
shape: SignalSourceShape;
|
|
49
|
+
auth: SignalSourceAuth;
|
|
50
|
+
/**
|
|
51
|
+
* Produce staged rows now. Resilient by contract: the connector's own
|
|
52
|
+
* failures (offline, non-2xx, malformed payload, missing key) resolve to []
|
|
53
|
+
* rather than throwing, so one source's outage never aborts a multi-source
|
|
54
|
+
* `signals fetch`. Validation/evidence-gating of the returned rows happens
|
|
55
|
+
* centrally in `stagedRowToSignal`.
|
|
56
|
+
*/
|
|
57
|
+
fetch(ctx: SignalSourceContext): Promise<StagedSignalRow[]>;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Read staged rows from a local path — the webhook landing-zone reader. The path
|
|
61
|
+
* (from `options.path`/`options.file`) may be:
|
|
62
|
+
* - a single FILE: a JSON array, or newline-delimited JSON (one row per line);
|
|
63
|
+
* - a DIRECTORY (the conventional spool): every `*.jsonl` / `*.json` file in
|
|
64
|
+
* it is read and concatenated (sorted by name), so multiple receivers can
|
|
65
|
+
* each append their own file (`rb2b.jsonl`, `hubspot.jsonl`) and they all
|
|
66
|
+
* land in one fetch.
|
|
67
|
+
* No auth. This is how every push platform (RB2B, Trigify, HubSpot webhooks)
|
|
68
|
+
* reaches the CLI: a receiver appends a row to the spool, this reads it on the
|
|
69
|
+
* next `signals fetch`. The CLI defaults the path to the conventional spool dir
|
|
70
|
+
* (`signalsSpoolDir`) when `--connector file` is used with no path; a bare
|
|
71
|
+
* library call with no path is inactive (returns []).
|
|
72
|
+
*
|
|
73
|
+
* Resilient: a missing path / unreadable file yields [] (an empty source, not a
|
|
74
|
+
* crash), and one unreadable file in a spool directory is skipped. A file that
|
|
75
|
+
* IS present but malformed throws — a corrupt spool is a real error to surface,
|
|
76
|
+
* not silent data loss. The central `stagedRowToSignal` gate validates rows.
|
|
77
|
+
*/
|
|
78
|
+
export declare const fileSource: SignalSourceConnector;
|
|
79
|
+
/**
|
|
80
|
+
* Pull recent news per watchlist account and stage funding/company signals. One
|
|
81
|
+
* query per account (`q="<domain>"`, Google News engine); each result becomes a
|
|
82
|
+
* row whose verbatim `quote` is the headline (+ source), so the downstream judge
|
|
83
|
+
* can ground a why-now on a real, linkable article. API key via the credential
|
|
84
|
+
* ladder (provider "serpapi"); no key -> [] (the source is simply inactive).
|
|
85
|
+
*
|
|
86
|
+
* Bucket defaults to `funding`; override per run with `--connector-opt bucket=company`.
|
|
87
|
+
*/
|
|
88
|
+
export declare const serpapiNewsSource: SignalSourceConnector;
|
|
89
|
+
/**
|
|
90
|
+
* Stage `demand` signals from recent HubSpot form submissions — the first real
|
|
91
|
+
* `demand`-bucket producer (a form fill is first-party demand). Reuses the
|
|
92
|
+
* EXISTING HubSpot credential via the ladder (provider "hubspot"); no separate
|
|
93
|
+
* login. Each submission whose email carries a company domain becomes a row
|
|
94
|
+
* whose verbatim `quote` is the form name + submitted email (the evidence a rep
|
|
95
|
+
* can verify). Submissions without a corporate domain (free-mail) are dropped —
|
|
96
|
+
* no account to attach demand to.
|
|
97
|
+
*
|
|
98
|
+
* Phase 1 is a pull over the Forms submissions API; the form-submission webhook
|
|
99
|
+
* (push) lands in Phase 2 via the spool + `file` source.
|
|
100
|
+
*/
|
|
101
|
+
export declare const hubspotFormsSource: SignalSourceConnector;
|
|
102
|
+
/** All registered source connectors (stable order). */
|
|
103
|
+
export declare function listSignalSources(): SignalSourceConnector[];
|
|
104
|
+
/** Resolve a connector by id, or null when unknown. */
|
|
105
|
+
export declare function getSignalSource(id: string): SignalSourceConnector | null;
|