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/src/connector.ts
CHANGED
|
@@ -105,6 +105,22 @@ export type ApplyPatchPlanOptions = {
|
|
|
105
105
|
* edited mid-apply is conflicted out instead of overwritten. Default 25.
|
|
106
106
|
*/
|
|
107
107
|
recheckEvery?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Per-operation progress as the run executes (presentation only — a
|
|
110
|
+
* throwing callback never affects the run). `completed` counts every
|
|
111
|
+
* resolved operation including skips and conflicts; `total` is the plan's
|
|
112
|
+
* full operation count.
|
|
113
|
+
*/
|
|
114
|
+
onOperation?: (progress: ApplyProgress) => void;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type ApplyProgress = {
|
|
118
|
+
completed: number;
|
|
119
|
+
total: number;
|
|
120
|
+
applied: number;
|
|
121
|
+
failed: number;
|
|
122
|
+
conflicts: number;
|
|
123
|
+
skipped: number;
|
|
108
124
|
};
|
|
109
125
|
|
|
110
126
|
const FIELD_WRITE_OPERATIONS = new Set(["set_field", "clear_field", "link_record"]);
|
|
@@ -313,7 +329,36 @@ export async function applyPatchPlan(
|
|
|
313
329
|
}
|
|
314
330
|
}
|
|
315
331
|
|
|
332
|
+
// Progress ticker state: every loop iteration below pushes at most one
|
|
333
|
+
// result; `lastNotified` guards the rare early-throw path so an iteration
|
|
334
|
+
// that pushed nothing reports nothing.
|
|
335
|
+
const progressCounts = { applied: 0, failed: 0, conflicts: 0, skipped: 0 };
|
|
336
|
+
const resultsBefore = results.length;
|
|
337
|
+
let lastNotified = results.length;
|
|
338
|
+
const notifyProgress = () => {
|
|
339
|
+
if (!options.onOperation || results.length === lastNotified) return;
|
|
340
|
+
lastNotified = results.length;
|
|
341
|
+
const last = results[results.length - 1];
|
|
342
|
+
if (last.status === "applied") progressCounts.applied += 1;
|
|
343
|
+
else if (last.status === "failed") progressCounts.failed += 1;
|
|
344
|
+
else if (last.status === "conflict") progressCounts.conflicts += 1;
|
|
345
|
+
else progressCounts.skipped += 1;
|
|
346
|
+
try {
|
|
347
|
+
options.onOperation({
|
|
348
|
+
completed: results.length - resultsBefore,
|
|
349
|
+
total: plan.operations.length,
|
|
350
|
+
...progressCounts,
|
|
351
|
+
});
|
|
352
|
+
} catch {
|
|
353
|
+
// progress is presentation-only
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
316
357
|
for (const operation of plan.operations) {
|
|
358
|
+
// Report the previous iteration's result (guarded: no-op if it pushed
|
|
359
|
+
// nothing). One-iteration lag keeps this a two-line hook instead of a
|
|
360
|
+
// notify after all ten push sites; the loop-exit call below flushes the last.
|
|
361
|
+
notifyProgress();
|
|
317
362
|
const batchedResult = batched.get(operation.id);
|
|
318
363
|
if (batchedResult) {
|
|
319
364
|
results.push(batchedResult);
|
|
@@ -403,6 +448,7 @@ export async function applyPatchPlan(
|
|
|
403
448
|
});
|
|
404
449
|
}
|
|
405
450
|
}
|
|
451
|
+
notifyProgress();
|
|
406
452
|
|
|
407
453
|
return {
|
|
408
454
|
planId: plan.id,
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
GtmObjectType,
|
|
19
19
|
PatchOperation,
|
|
20
20
|
PatchOperationResult,
|
|
21
|
+
SnapshotProgress,
|
|
21
22
|
} from "../types.ts";
|
|
22
23
|
|
|
23
24
|
const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
|
|
@@ -30,6 +31,8 @@ export type HubspotConnectorOptions = {
|
|
|
30
31
|
apiBaseUrl?: string;
|
|
31
32
|
/** Injectable fetch for testing. */
|
|
32
33
|
fetchImpl?: typeof fetch;
|
|
34
|
+
/** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
|
|
35
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
|
|
@@ -123,7 +126,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
123
126
|
return map;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
async function list(path: string): Promise<any[]> {
|
|
129
|
+
async function list(path: string, onPage?: (fetched: number) => void): Promise<any[]> {
|
|
127
130
|
const results: any[] = [];
|
|
128
131
|
let after: string | undefined;
|
|
129
132
|
const seen = new Set<string>();
|
|
@@ -137,6 +140,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
137
140
|
const separator = path.includes("?") ? "&" : "?";
|
|
138
141
|
const data = await request(`${path}${after ? `${separator}after=${encodeURIComponent(after)}` : ""}`);
|
|
139
142
|
results.push(...(data.results ?? []));
|
|
143
|
+
try {
|
|
144
|
+
onPage?.(results.length);
|
|
145
|
+
} catch {
|
|
146
|
+
// progress is presentation-only; never let it fail a pull
|
|
147
|
+
}
|
|
140
148
|
after = data.paging?.next?.after;
|
|
141
149
|
} while (after);
|
|
142
150
|
return results;
|
|
@@ -149,7 +157,9 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
149
157
|
withAssociations: boolean,
|
|
150
158
|
) => Promise<any[]>,
|
|
151
159
|
): Promise<CanonicalGtmSnapshot> {
|
|
152
|
-
const owners = await list("/crm/v3/owners?limit=100")
|
|
160
|
+
const owners = await list("/crm/v3/owners?limit=100", (fetched) =>
|
|
161
|
+
options.onProgress?.({ objectType: "user", fetched }),
|
|
162
|
+
);
|
|
153
163
|
const users: CanonicalUser[] = owners
|
|
154
164
|
.filter((owner) => owner.id)
|
|
155
165
|
.map((owner) => ({
|
|
@@ -310,9 +320,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
310
320
|
}
|
|
311
321
|
|
|
312
322
|
async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
|
|
323
|
+
const canonicalType = { companies: "account", contacts: "contact", deals: "deal" } as const;
|
|
313
324
|
return assembleSnapshot((objectType, properties, withAssociations) =>
|
|
314
325
|
list(
|
|
315
326
|
`/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`,
|
|
327
|
+
(fetched) => options.onProgress?.({ objectType: canonicalType[objectType], fetched }),
|
|
316
328
|
),
|
|
317
329
|
);
|
|
318
330
|
}
|
|
@@ -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(/\/$/, "");
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
GtmObjectType,
|
|
18
18
|
PatchOperation,
|
|
19
19
|
PatchOperationResult,
|
|
20
|
+
SnapshotProgress,
|
|
20
21
|
} from "../types.ts";
|
|
21
22
|
|
|
22
23
|
const DEFAULT_API_VERSION = "v59.0";
|
|
@@ -35,6 +36,8 @@ export type SalesforceConnectorOptions = {
|
|
|
35
36
|
apiVersion?: string;
|
|
36
37
|
/** Injectable fetch for testing. */
|
|
37
38
|
fetchImpl?: typeof fetch;
|
|
39
|
+
/** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
|
|
40
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
const SOBJECT_TYPES: Partial<Record<GtmObjectType, string>> = {
|
|
@@ -161,7 +164,7 @@ export function createSalesforceConnector(
|
|
|
161
164
|
return text ? JSON.parse(text) : null;
|
|
162
165
|
}
|
|
163
166
|
|
|
164
|
-
async function query(soql: string): Promise<any[]> {
|
|
167
|
+
async function query(soql: string, onPage?: (fetched: number) => void): Promise<any[]> {
|
|
165
168
|
const records: any[] = [];
|
|
166
169
|
let next: string | undefined = `/services/data/${apiVersion}/query?q=${encodeURIComponent(soql)}`;
|
|
167
170
|
const seen = new Set<string>();
|
|
@@ -171,6 +174,11 @@ export function createSalesforceConnector(
|
|
|
171
174
|
seen.add(next);
|
|
172
175
|
const data = await request(next);
|
|
173
176
|
records.push(...(data?.records ?? []));
|
|
177
|
+
try {
|
|
178
|
+
onPage?.(records.length);
|
|
179
|
+
} catch {
|
|
180
|
+
// progress is presentation-only; never let it fail a pull
|
|
181
|
+
}
|
|
174
182
|
next = data?.nextRecordsUrl ?? undefined;
|
|
175
183
|
}
|
|
176
184
|
return records;
|
|
@@ -194,7 +202,12 @@ export function createSalesforceConnector(
|
|
|
194
202
|
}
|
|
195
203
|
|
|
196
204
|
async function assembleSnapshot(whereClause: string): Promise<CanonicalGtmSnapshot> {
|
|
197
|
-
const
|
|
205
|
+
const progressFor = (objectType: SnapshotProgress["objectType"]) => (fetched: number) =>
|
|
206
|
+
options.onProgress?.({ objectType, fetched });
|
|
207
|
+
const sfUsers = await query(
|
|
208
|
+
`SELECT ${selectFields("owners")} FROM User${whereClause}`,
|
|
209
|
+
progressFor("user"),
|
|
210
|
+
);
|
|
198
211
|
const users: CanonicalUser[] = sfUsers.map((user) => {
|
|
199
212
|
const id = String(readMapped(user, "owners", "id", "Id"));
|
|
200
213
|
const email = stringOrUndefined(readMapped(user, "owners", "email", "Email"));
|
|
@@ -212,6 +225,7 @@ export function createSalesforceConnector(
|
|
|
212
225
|
|
|
213
226
|
const sfAccounts = await query(
|
|
214
227
|
`SELECT ${selectFields("accounts")} FROM Account${whereClause}`,
|
|
228
|
+
progressFor("account"),
|
|
215
229
|
);
|
|
216
230
|
const accounts: CanonicalAccount[] = sfAccounts.map((account) => {
|
|
217
231
|
const id = String(readMapped(account, "accounts", "id", "Id"));
|
|
@@ -236,6 +250,7 @@ export function createSalesforceConnector(
|
|
|
236
250
|
|
|
237
251
|
const sfContacts = await query(
|
|
238
252
|
`SELECT ${selectFields("contacts")} FROM Contact${whereClause}`,
|
|
253
|
+
progressFor("contact"),
|
|
239
254
|
);
|
|
240
255
|
const contacts: CanonicalContact[] = sfContacts.map((contact) => {
|
|
241
256
|
const id = String(readMapped(contact, "contacts", "id", "Id"));
|
|
@@ -257,6 +272,7 @@ export function createSalesforceConnector(
|
|
|
257
272
|
|
|
258
273
|
const sfOpportunities = await query(
|
|
259
274
|
`SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`,
|
|
275
|
+
progressFor("deal"),
|
|
260
276
|
);
|
|
261
277
|
const deals: CanonicalDeal[] = sfOpportunities.map((opportunity) => {
|
|
262
278
|
const id = String(readMapped(opportunity, "deals", "id", "Id"));
|