fullstackgtm 0.43.0 → 0.45.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 +378 -1
- package/README.md +37 -14
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +80 -0
- package/dist/cli/auth.js +500 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +373 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +788 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +344 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +609 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +454 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +70 -0
- package/dist/cli/shared.js +331 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +121 -0
- package/dist/cli/ui.js +375 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +104 -4556
- package/dist/connector.d.ts +15 -0
- package/dist/connector.js +35 -0
- package/dist/connectors/hubspot.d.ts +3 -1
- package/dist/connectors/hubspot.js +10 -3
- 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.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +264 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/icp.d.ts +52 -4
- package/dist/icp.js +112 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +3 -0
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/marketClassify.d.ts +2 -0
- package/dist/marketClassify.js +7 -1
- package/dist/mcp-bin.js +2 -2
- package/dist/mcp.js +259 -166
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +272 -5
- package/dist/signals.d.ts +54 -0
- package/dist/signals.js +64 -0
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +91 -11
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +37 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +175 -0
- package/docs/tam.md +195 -0
- package/llms.txt +83 -11
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +4 -3
- package/src/audit.ts +27 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +549 -0
- package/src/cli/call.ts +398 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +885 -0
- package/src/cli/fix.ts +372 -0
- package/src/cli/help.ts +664 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +523 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +375 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +426 -0
- package/src/cli.ts +103 -5170
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +314 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/icp.ts +120 -34
- package/src/index.ts +32 -0
- package/src/init.ts +3 -0
- package/src/judge.ts +7 -0
- package/src/judgeEval.ts +8 -1
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +330 -7
- package/src/signals.ts +90 -0
- package/src/spoolFiles.ts +116 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
package/dist/connector.d.ts
CHANGED
|
@@ -24,6 +24,21 @@ export type ApplyPatchPlanOptions = {
|
|
|
24
24
|
* edited mid-apply is conflicted out instead of overwritten. Default 25.
|
|
25
25
|
*/
|
|
26
26
|
recheckEvery?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Per-operation progress as the run executes (presentation only — a
|
|
29
|
+
* throwing callback never affects the run). `completed` counts every
|
|
30
|
+
* resolved operation including skips and conflicts; `total` is the plan's
|
|
31
|
+
* full operation count.
|
|
32
|
+
*/
|
|
33
|
+
onOperation?: (progress: ApplyProgress) => void;
|
|
34
|
+
};
|
|
35
|
+
export type ApplyProgress = {
|
|
36
|
+
completed: number;
|
|
37
|
+
total: number;
|
|
38
|
+
applied: number;
|
|
39
|
+
failed: number;
|
|
40
|
+
conflicts: number;
|
|
41
|
+
skipped: number;
|
|
27
42
|
};
|
|
28
43
|
/**
|
|
29
44
|
* Apply an approved subset of a patch plan through a connector.
|
package/dist/connector.js
CHANGED
|
@@ -253,7 +253,41 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
253
253
|
batched.set(result.operationId, result);
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
|
+
// Progress ticker state: every loop iteration below pushes at most one
|
|
257
|
+
// result; `lastNotified` guards the rare early-throw path so an iteration
|
|
258
|
+
// that pushed nothing reports nothing.
|
|
259
|
+
const progressCounts = { applied: 0, failed: 0, conflicts: 0, skipped: 0 };
|
|
260
|
+
const resultsBefore = results.length;
|
|
261
|
+
let lastNotified = results.length;
|
|
262
|
+
const notifyProgress = () => {
|
|
263
|
+
if (!options.onOperation || results.length === lastNotified)
|
|
264
|
+
return;
|
|
265
|
+
lastNotified = results.length;
|
|
266
|
+
const last = results[results.length - 1];
|
|
267
|
+
if (last.status === "applied")
|
|
268
|
+
progressCounts.applied += 1;
|
|
269
|
+
else if (last.status === "failed")
|
|
270
|
+
progressCounts.failed += 1;
|
|
271
|
+
else if (last.status === "conflict")
|
|
272
|
+
progressCounts.conflicts += 1;
|
|
273
|
+
else
|
|
274
|
+
progressCounts.skipped += 1;
|
|
275
|
+
try {
|
|
276
|
+
options.onOperation({
|
|
277
|
+
completed: results.length - resultsBefore,
|
|
278
|
+
total: plan.operations.length,
|
|
279
|
+
...progressCounts,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// progress is presentation-only
|
|
284
|
+
}
|
|
285
|
+
};
|
|
256
286
|
for (const operation of plan.operations) {
|
|
287
|
+
// Report the previous iteration's result (guarded: no-op if it pushed
|
|
288
|
+
// nothing). One-iteration lag keeps this a two-line hook instead of a
|
|
289
|
+
// notify after all ten push sites; the loop-exit call below flushes the last.
|
|
290
|
+
notifyProgress();
|
|
257
291
|
const batchedResult = batched.get(operation.id);
|
|
258
292
|
if (batchedResult) {
|
|
259
293
|
results.push(batchedResult);
|
|
@@ -342,6 +376,7 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
342
376
|
});
|
|
343
377
|
}
|
|
344
378
|
}
|
|
379
|
+
notifyProgress();
|
|
345
380
|
return {
|
|
346
381
|
planId: plan.id,
|
|
347
382
|
provider: connector.provider,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type FieldMappings } from "../mappings.ts";
|
|
2
|
-
import type { GtmConnector } from "../types.ts";
|
|
2
|
+
import type { GtmConnector, SnapshotProgress } from "../types.ts";
|
|
3
3
|
export type HubspotConnectorOptions = {
|
|
4
4
|
/** Returns a HubSpot access token (private app token or OAuth access token). */
|
|
5
5
|
getAccessToken: () => string | Promise<string>;
|
|
@@ -8,6 +8,8 @@ export type HubspotConnectorOptions = {
|
|
|
8
8
|
apiBaseUrl?: string;
|
|
9
9
|
/** Injectable fetch for testing. */
|
|
10
10
|
fetchImpl?: typeof fetch;
|
|
11
|
+
/** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
|
|
12
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
11
13
|
};
|
|
12
14
|
/**
|
|
13
15
|
* Reference connector for HubSpot.
|
|
@@ -89,7 +89,7 @@ export function createHubspotConnector(options) {
|
|
|
89
89
|
}
|
|
90
90
|
return map;
|
|
91
91
|
}
|
|
92
|
-
async function list(path) {
|
|
92
|
+
async function list(path, onPage) {
|
|
93
93
|
const results = [];
|
|
94
94
|
let after;
|
|
95
95
|
const seen = new Set();
|
|
@@ -104,12 +104,18 @@ export function createHubspotConnector(options) {
|
|
|
104
104
|
const separator = path.includes("?") ? "&" : "?";
|
|
105
105
|
const data = await request(`${path}${after ? `${separator}after=${encodeURIComponent(after)}` : ""}`);
|
|
106
106
|
results.push(...(data.results ?? []));
|
|
107
|
+
try {
|
|
108
|
+
onPage?.(results.length);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// progress is presentation-only; never let it fail a pull
|
|
112
|
+
}
|
|
107
113
|
after = data.paging?.next?.after;
|
|
108
114
|
} while (after);
|
|
109
115
|
return results;
|
|
110
116
|
}
|
|
111
117
|
async function assembleSnapshot(fetchObjects) {
|
|
112
|
-
const owners = await list("/crm/v3/owners?limit=100");
|
|
118
|
+
const owners = await list("/crm/v3/owners?limit=100", (fetched) => options.onProgress?.({ objectType: "user", fetched }));
|
|
113
119
|
const users = owners
|
|
114
120
|
.filter((owner) => owner.id)
|
|
115
121
|
.map((owner) => ({
|
|
@@ -235,7 +241,8 @@ export function createHubspotConnector(options) {
|
|
|
235
241
|
};
|
|
236
242
|
}
|
|
237
243
|
async function fetchSnapshot() {
|
|
238
|
-
|
|
244
|
+
const canonicalType = { companies: "account", contacts: "contact", deals: "deal" };
|
|
245
|
+
return assembleSnapshot((objectType, properties, withAssociations) => list(`/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`, (fetched) => options.onProgress?.({ objectType: canonicalType[objectType], fetched })));
|
|
239
246
|
}
|
|
240
247
|
const MODIFIED_DATE_PROPERTIES = {
|
|
241
248
|
companies: "hs_lastmodifieddate",
|
|
@@ -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;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type FieldMappings } from "../mappings.ts";
|
|
2
|
-
import type { GtmConnector } from "../types.ts";
|
|
2
|
+
import type { GtmConnector, SnapshotProgress } from "../types.ts";
|
|
3
3
|
export type SalesforceConnection = {
|
|
4
4
|
accessToken: string;
|
|
5
5
|
/** e.g. https://yourorg.my.salesforce.com */
|
|
@@ -13,6 +13,8 @@ export type SalesforceConnectorOptions = {
|
|
|
13
13
|
apiVersion?: string;
|
|
14
14
|
/** Injectable fetch for testing. */
|
|
15
15
|
fetchImpl?: typeof fetch;
|
|
16
|
+
/** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
|
|
17
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
16
18
|
};
|
|
17
19
|
/**
|
|
18
20
|
* Reference connector for Salesforce.
|
|
@@ -116,7 +116,7 @@ export function createSalesforceConnector(options) {
|
|
|
116
116
|
const text = await response.text();
|
|
117
117
|
return text ? JSON.parse(text) : null;
|
|
118
118
|
}
|
|
119
|
-
async function query(soql) {
|
|
119
|
+
async function query(soql, onPage) {
|
|
120
120
|
const records = [];
|
|
121
121
|
let next = `/services/data/${apiVersion}/query?q=${encodeURIComponent(soql)}`;
|
|
122
122
|
const seen = new Set();
|
|
@@ -127,6 +127,12 @@ export function createSalesforceConnector(options) {
|
|
|
127
127
|
seen.add(next);
|
|
128
128
|
const data = await request(next);
|
|
129
129
|
records.push(...(data?.records ?? []));
|
|
130
|
+
try {
|
|
131
|
+
onPage?.(records.length);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// progress is presentation-only; never let it fail a pull
|
|
135
|
+
}
|
|
130
136
|
next = data?.nextRecordsUrl ?? undefined;
|
|
131
137
|
}
|
|
132
138
|
return records;
|
|
@@ -138,7 +144,8 @@ export function createSalesforceConnector(options) {
|
|
|
138
144
|
return mappedFields(mappings, objectType, SALESFORCE_DEFAULT_FIELD_MAPPINGS[objectType]).join(", ");
|
|
139
145
|
}
|
|
140
146
|
async function assembleSnapshot(whereClause) {
|
|
141
|
-
const
|
|
147
|
+
const progressFor = (objectType) => (fetched) => options.onProgress?.({ objectType, fetched });
|
|
148
|
+
const sfUsers = await query(`SELECT ${selectFields("owners")} FROM User${whereClause}`, progressFor("user"));
|
|
142
149
|
const users = sfUsers.map((user) => {
|
|
143
150
|
const id = String(readMapped(user, "owners", "id", "Id"));
|
|
144
151
|
const email = stringOrUndefined(readMapped(user, "owners", "email", "Email"));
|
|
@@ -153,7 +160,7 @@ export function createSalesforceConnector(options) {
|
|
|
153
160
|
active: Boolean(readMapped(user, "owners", "isActive", "IsActive")),
|
|
154
161
|
};
|
|
155
162
|
});
|
|
156
|
-
const sfAccounts = await query(`SELECT ${selectFields("accounts")} FROM Account${whereClause}
|
|
163
|
+
const sfAccounts = await query(`SELECT ${selectFields("accounts")} FROM Account${whereClause}`, progressFor("account"));
|
|
157
164
|
const accounts = sfAccounts.map((account) => {
|
|
158
165
|
const id = String(readMapped(account, "accounts", "id", "Id"));
|
|
159
166
|
return {
|
|
@@ -170,7 +177,7 @@ export function createSalesforceConnector(options) {
|
|
|
170
177
|
raw: account,
|
|
171
178
|
};
|
|
172
179
|
});
|
|
173
|
-
const sfContacts = await query(`SELECT ${selectFields("contacts")} FROM Contact${whereClause}
|
|
180
|
+
const sfContacts = await query(`SELECT ${selectFields("contacts")} FROM Contact${whereClause}`, progressFor("contact"));
|
|
174
181
|
const contacts = sfContacts.map((contact) => {
|
|
175
182
|
const id = String(readMapped(contact, "contacts", "id", "Id"));
|
|
176
183
|
return {
|
|
@@ -188,7 +195,7 @@ export function createSalesforceConnector(options) {
|
|
|
188
195
|
raw: contact,
|
|
189
196
|
};
|
|
190
197
|
});
|
|
191
|
-
const sfOpportunities = await query(`SELECT ${selectFields("deals")} FROM Opportunity${whereClause}
|
|
198
|
+
const sfOpportunities = await query(`SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`, progressFor("deal"));
|
|
192
199
|
const deals = sfOpportunities.map((opportunity) => {
|
|
193
200
|
const id = String(readMapped(opportunity, "deals", "id", "Id"));
|
|
194
201
|
const probability = numberOrUndefined(readMapped(opportunity, "deals", "probability", "Probability"));
|
|
@@ -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;
|