fullstackgtm 0.44.0 → 0.46.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 +293 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +45 -24
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +82 -0
- package/dist/cli/auth.js +607 -0
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +387 -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 +806 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +346 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +646 -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 +456 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +71 -0
- package/dist/cli/shared.js +342 -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 +142 -0
- package/dist/cli/ui.js +427 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +123 -5157
- package/dist/connector.d.ts +23 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/hubspot.d.ts +10 -1
- package/dist/connectors/hubspot.js +244 -10
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +10 -1
- package/dist/connectors/salesforce.js +39 -6
- package/dist/connectors/signalSources.js +7 -59
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +11 -1
- package/dist/marketClassify.js +17 -2
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +31 -2
- package/dist/mcp.js +372 -166
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +29 -1
- package/docs/api.md +75 -8
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +18 -9
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/audit.ts +27 -1
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +669 -0
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +414 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +908 -0
- package/src/cli/fix.ts +373 -0
- package/src/cli/help.ts +702 -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 +524 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +392 -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 +497 -0
- package/src/cli.ts +122 -5855
- package/src/connector.ts +66 -0
- package/src/connectors/hubspot.ts +273 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +51 -3
- package/src/connectors/signalSources.ts +7 -56
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/icp.ts +19 -35
- package/src/index.ts +28 -1
- package/src/judge.ts +7 -0
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +26 -2
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +34 -2
- package/src/mcp.ts +270 -63
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +32 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure, node-free health scoring. Split from health.ts (which owns the
|
|
5
|
+
* fs-backed profile timeline) so V8-runtime consumers — the hosted app's
|
|
6
|
+
* Convex queries — can import the scoring math without pulling node built-ins,
|
|
7
|
+
* the same pattern as audit.ts/format.ts.
|
|
8
|
+
*
|
|
9
|
+
* The score is DETERMINISTIC (no LLM): same plan + same snapshot → same score,
|
|
10
|
+
* so it is stable in CI and comparable run-over-run.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Severity weights: a critical finding costs ~3× a warning, ~10× an info.
|
|
14
|
+
const SEVERITY_WEIGHT: Record<AuditFindingSeverity, number> = { info: 1, warning: 3, critical: 10 };
|
|
15
|
+
|
|
16
|
+
export type HealthEntry = {
|
|
17
|
+
/** ISO timestamp of the audit that produced this entry. */
|
|
18
|
+
at: string;
|
|
19
|
+
/** The saved plan this entry summarizes (correlates to plans/ and snapshots/). */
|
|
20
|
+
planId: string;
|
|
21
|
+
/** Deterministic 0–100 hygiene score (higher is healthier). */
|
|
22
|
+
score: number;
|
|
23
|
+
/** Total finding count across all rules. */
|
|
24
|
+
findings: number;
|
|
25
|
+
/** Severity-weighted finding total (drives the score). */
|
|
26
|
+
weightedFindings: number;
|
|
27
|
+
/** Record counts at audit time — the denominator that normalizes the score. */
|
|
28
|
+
records: { accounts: number; contacts: number; deals: number; total: number };
|
|
29
|
+
/** Finding count per rule id. */
|
|
30
|
+
byRule: Record<string, number>;
|
|
31
|
+
/** Finding count per severity. */
|
|
32
|
+
severityCounts: Record<AuditFindingSeverity, number>;
|
|
33
|
+
/**
|
|
34
|
+
* Per-object-type breakdown — so "is my contact data clean but my pipeline
|
|
35
|
+
* messy?" is answerable without re-auditing. Each type carries its own
|
|
36
|
+
* record-normalized score (same curve as the overall score, scoped to that
|
|
37
|
+
* type's records + findings).
|
|
38
|
+
*/
|
|
39
|
+
byObjectType: Record<"account" | "contact" | "deal", {
|
|
40
|
+
records: number;
|
|
41
|
+
findings: number;
|
|
42
|
+
weightedFindings: number;
|
|
43
|
+
score: number;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type HealthRuleDelta = {
|
|
48
|
+
ruleId: string;
|
|
49
|
+
current: number;
|
|
50
|
+
previous: number;
|
|
51
|
+
/** current − previous: positive = more findings (worse), negative = fewer (better). */
|
|
52
|
+
delta: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type HealthRollup = {
|
|
56
|
+
profile: string;
|
|
57
|
+
auditCount: number;
|
|
58
|
+
first: string;
|
|
59
|
+
latest: string;
|
|
60
|
+
current: HealthEntry;
|
|
61
|
+
previous: HealthEntry | null;
|
|
62
|
+
/** current.score − previous.score: positive = improving. null on the first audit. */
|
|
63
|
+
scoreDelta: number | null;
|
|
64
|
+
ruleDeltas: HealthRuleDelta[];
|
|
65
|
+
history: Array<{ at: string; score: number; findings: number }>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Deterministically score a saved audit. score = 100 / (1 + weighted findings
|
|
70
|
+
* per record): 0 findings → 100; one weighted finding per record → 50; the
|
|
71
|
+
* curve is bounded (0, 100], monotonic, and needs no clamping or magic
|
|
72
|
+
* constants. A messy 50-record CRM scores worse than the same finding count
|
|
73
|
+
* across 5,000 records, which is the point.
|
|
74
|
+
*/
|
|
75
|
+
export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry {
|
|
76
|
+
const byRule: Record<string, number> = {};
|
|
77
|
+
const severityCounts: Record<AuditFindingSeverity, number> = { info: 0, warning: 0, critical: 0 };
|
|
78
|
+
const typeTally: Record<"account" | "contact" | "deal", { findings: number; weightedFindings: number }> = {
|
|
79
|
+
account: { findings: 0, weightedFindings: 0 },
|
|
80
|
+
contact: { findings: 0, weightedFindings: 0 },
|
|
81
|
+
deal: { findings: 0, weightedFindings: 0 },
|
|
82
|
+
};
|
|
83
|
+
let weightedFindings = 0;
|
|
84
|
+
for (const finding of plan.findings) {
|
|
85
|
+
byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
|
|
86
|
+
severityCounts[finding.severity] += 1;
|
|
87
|
+
weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
88
|
+
const t = finding.objectType;
|
|
89
|
+
if (t === "account" || t === "contact" || t === "deal") {
|
|
90
|
+
typeTally[t].findings += 1;
|
|
91
|
+
typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const records = {
|
|
96
|
+
accounts: snapshot.accounts.length,
|
|
97
|
+
contacts: snapshot.contacts.length,
|
|
98
|
+
deals: snapshot.deals.length,
|
|
99
|
+
total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const scoreFor = (weighted: number, recordCount: number) =>
|
|
103
|
+
Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
|
|
104
|
+
const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
|
|
105
|
+
const byObjectType = (["account", "contact", "deal"] as const).reduce(
|
|
106
|
+
(acc, t) => {
|
|
107
|
+
acc[t] = {
|
|
108
|
+
records: recordsByType[t],
|
|
109
|
+
findings: typeTally[t].findings,
|
|
110
|
+
weightedFindings: typeTally[t].weightedFindings,
|
|
111
|
+
score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
|
|
112
|
+
};
|
|
113
|
+
return acc;
|
|
114
|
+
},
|
|
115
|
+
{} as HealthEntry["byObjectType"],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const score = scoreFor(weightedFindings, records.total);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
at,
|
|
122
|
+
planId: plan.id,
|
|
123
|
+
score,
|
|
124
|
+
findings: plan.findings.length,
|
|
125
|
+
weightedFindings,
|
|
126
|
+
records,
|
|
127
|
+
byRule,
|
|
128
|
+
severityCounts,
|
|
129
|
+
byObjectType,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
|
|
134
|
+
export function summarizeHealth(entries: HealthEntry[], profile: string): HealthRollup | null {
|
|
135
|
+
if (entries.length === 0) return null;
|
|
136
|
+
// Oldest → newest, so `current` is the last entry.
|
|
137
|
+
const sorted = [...entries].sort((a, b) => a.at.localeCompare(b.at));
|
|
138
|
+
const current = sorted[sorted.length - 1];
|
|
139
|
+
const previous = sorted.length > 1 ? sorted[sorted.length - 2] : null;
|
|
140
|
+
|
|
141
|
+
const ruleIds = new Set<string>([
|
|
142
|
+
...Object.keys(current.byRule),
|
|
143
|
+
...(previous ? Object.keys(previous.byRule) : []),
|
|
144
|
+
]);
|
|
145
|
+
const ruleDeltas: HealthRuleDelta[] = [...ruleIds]
|
|
146
|
+
.map((ruleId) => {
|
|
147
|
+
const cur = current.byRule[ruleId] ?? 0;
|
|
148
|
+
const prev = previous?.byRule[ruleId] ?? 0;
|
|
149
|
+
return { ruleId, current: cur, previous: prev, delta: cur - prev };
|
|
150
|
+
})
|
|
151
|
+
.sort((a, b) => b.current - a.current || a.ruleId.localeCompare(b.ruleId));
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
profile,
|
|
155
|
+
auditCount: sorted.length,
|
|
156
|
+
first: sorted[0].at,
|
|
157
|
+
latest: current.at,
|
|
158
|
+
current,
|
|
159
|
+
previous,
|
|
160
|
+
scoreDelta: previous ? current.score - previous.score : null,
|
|
161
|
+
ruleDeltas,
|
|
162
|
+
history: sorted.map((entry) => ({ at: entry.at, score: entry.score, findings: entry.findings })),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Sign-based and uniform: ▲ = the number went up, ▼ = down. The reader supplies
|
|
167
|
+
// the meaning (score up = good; findings up = bad) — mixing arrow direction with
|
|
168
|
+
// good/bad reads as contradictory ("▲ -3").
|
|
169
|
+
function arrow(delta: number): string {
|
|
170
|
+
if (delta === 0) return "·";
|
|
171
|
+
return delta > 0 ? "▲" : "▼";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
|
|
175
|
+
export function healthToMarkdown(rollup: HealthRollup): string {
|
|
176
|
+
const { current, scoreDelta } = rollup;
|
|
177
|
+
const lines = [`# GTM health — profile \`${rollup.profile}\``, ""];
|
|
178
|
+
|
|
179
|
+
const deltaText =
|
|
180
|
+
scoreDelta === null
|
|
181
|
+
? "(first audit — no prior reading)"
|
|
182
|
+
: `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
|
|
183
|
+
lines.push(
|
|
184
|
+
`Score: **${current.score}/100** ${deltaText}`,
|
|
185
|
+
`Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`,
|
|
186
|
+
`Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`,
|
|
187
|
+
`Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`,
|
|
188
|
+
"",
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
|
|
192
|
+
for (const t of ["account", "contact", "deal"] as const) {
|
|
193
|
+
const b = current.byObjectType[t];
|
|
194
|
+
lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
|
|
195
|
+
}
|
|
196
|
+
lines.push("");
|
|
197
|
+
|
|
198
|
+
if (rollup.history.length > 1) {
|
|
199
|
+
lines.push("## Trend", "");
|
|
200
|
+
for (const point of rollup.history) {
|
|
201
|
+
const marker = point.at === rollup.latest ? " ← latest" : "";
|
|
202
|
+
lines.push(`- ${shortDate(point.at)} score ${point.score} (${point.findings} findings)${marker}`);
|
|
203
|
+
}
|
|
204
|
+
lines.push("");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (rollup.ruleDeltas.length > 0) {
|
|
208
|
+
lines.push("## By rule (Δ since previous audit)", "", "| Rule | Findings | Δ |", "| --- | --- | --- |");
|
|
209
|
+
for (const rule of rollup.ruleDeltas) {
|
|
210
|
+
const sign = rule.delta > 0 ? `+${rule.delta}` : `${rule.delta}`;
|
|
211
|
+
const cell = rule.delta === 0 ? "·" : `${arrow(rule.delta)} ${sign}`;
|
|
212
|
+
lines.push(`| ${rule.ruleId} | ${rule.current} | ${cell} |`);
|
|
213
|
+
}
|
|
214
|
+
lines.push("");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
lines.push("> Score is deterministic: 100 / (1 + severity-weighted findings per record). Lower findings or more clean records ⇒ higher score.");
|
|
218
|
+
return `${lines.join("\n")}\n`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function shortDate(iso: string): string {
|
|
222
|
+
return iso.slice(0, 10);
|
|
223
|
+
}
|
package/src/icp.ts
CHANGED
|
@@ -158,26 +158,10 @@ export function icpToTheirStackFilters(icp: Icp): {
|
|
|
158
158
|
return f;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
*/
|
|
166
|
-
const CRUSTDATA_SENIORITY: Record<string, string> = {
|
|
167
|
-
cxo: "CXO",
|
|
168
|
-
"c-suite": "CXO",
|
|
169
|
-
c_suite: "CXO",
|
|
170
|
-
founder: "Owner",
|
|
171
|
-
owner: "Owner",
|
|
172
|
-
partner: "Partner",
|
|
173
|
-
vp: "Vice President",
|
|
174
|
-
"vice president": "Vice President",
|
|
175
|
-
head: "Director",
|
|
176
|
-
director: "Director",
|
|
177
|
-
manager: "Manager",
|
|
178
|
-
senior: "Senior",
|
|
179
|
-
entry: "Entry",
|
|
180
|
-
};
|
|
161
|
+
// NOTE: a CRUSTDATA_SENIORITY vocab table lived here until 2026-07-02 — the
|
|
162
|
+
// `current_seniority_levels` filter is broken upstream (see the live findings
|
|
163
|
+
// on icpToCrustdataFilters below), so job levels are no longer sent to the
|
|
164
|
+
// provider at all. Resurrect the table from git history if pipe0 fixes it.
|
|
181
165
|
|
|
182
166
|
/**
|
|
183
167
|
* ICP industry keyword → LinkedIn industry names Crustdata filters on.
|
|
@@ -212,18 +196,22 @@ const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
|
|
|
212
196
|
/**
|
|
213
197
|
* pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
|
|
214
198
|
* matches real LinkedIn title strings (case-sensitive), so keywords are Title
|
|
215
|
-
* Cased.
|
|
216
|
-
* tables above.
|
|
199
|
+
* Cased. Industry is mapped to Crustdata's controlled vocab via the table above.
|
|
217
200
|
*
|
|
218
|
-
* LIVE FINDINGS (2026-06-26
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
201
|
+
* LIVE FINDINGS (2026-06-26, re-confirmed 2026-07-02 with fresh credits):
|
|
202
|
+
* `current_job_titles` works; `current_title` is rejected (422). The culprit
|
|
203
|
+
* that zeroed the full ICP filter is `current_seniority_levels`: ANY non-empty
|
|
204
|
+
* `include` returns 0 results through pipe0's people:profiles:crustdata@1 —
|
|
205
|
+
* including Crustdata's own documented values ("CXO", "Director",
|
|
206
|
+
* "Vice President"), lowercase, and SNAKE_CASE variants — while the identical
|
|
207
|
+
* search without it returns results. (An array instead of the
|
|
208
|
+
* {include, exclude} object is a 422, so the shape was right; the filter is
|
|
209
|
+
* broken upstream.) So job levels are NOT sent to the provider: persona
|
|
210
|
+
* seniority is enforced by fit scoring (scoreProspectAgainstIcp weights
|
|
211
|
+
* jobLevels), which was always the precision backstop. `locations` and
|
|
212
|
+
* `current_employers_linkedin_industries` (both-taxonomy hedge) are live-
|
|
213
|
+
* verified working in combination with titles. Note fit-scoring backs up
|
|
214
|
+
* PERSONA precision but NOT industry, so the industry filter is load-bearing.
|
|
227
215
|
*/
|
|
228
216
|
export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
|
|
229
217
|
const f: Record<string, unknown> = {};
|
|
@@ -231,10 +219,6 @@ export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
|
|
|
231
219
|
if (icp.firmographics.geos?.length) {
|
|
232
220
|
f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
|
|
233
221
|
}
|
|
234
|
-
if (icp.persona.jobLevels?.length) {
|
|
235
|
-
const include = [...new Set(icp.persona.jobLevels.map((l) => CRUSTDATA_SENIORITY[l.toLowerCase()]).filter(Boolean))];
|
|
236
|
-
if (include.length) f.current_seniority_levels = { include, exclude: [] };
|
|
237
|
-
}
|
|
238
222
|
if (icp.firmographics.industries?.length) {
|
|
239
223
|
const inds = [
|
|
240
224
|
...new Set(icp.firmographics.industries.flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)])),
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,20 @@ export {
|
|
|
21
21
|
type LoadedConfig,
|
|
22
22
|
} from "./config.ts";
|
|
23
23
|
export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
|
|
24
|
+
export {
|
|
25
|
+
APPLY_STAGES,
|
|
26
|
+
BACKFILL_STRIPE_STAGES,
|
|
27
|
+
composeListeners,
|
|
28
|
+
createProgressEmitter,
|
|
29
|
+
CRM_SYNC_STAGES,
|
|
30
|
+
nullProgressEmitter,
|
|
31
|
+
SNAPSHOT_PULL_STAGES,
|
|
32
|
+
STRIPE_SNAPSHOT_STAGES,
|
|
33
|
+
type ProgressEmitter,
|
|
34
|
+
type ProgressEvent,
|
|
35
|
+
type ProgressListener,
|
|
36
|
+
type ProgressSnapshot,
|
|
37
|
+
} from "./progress.ts";
|
|
24
38
|
export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
|
|
25
39
|
export {
|
|
26
40
|
DEFAULT_LOOPBACK_PORT,
|
|
@@ -45,7 +59,20 @@ export {
|
|
|
45
59
|
type SalesforceDeviceAuthorization,
|
|
46
60
|
type SalesforceTokenSet,
|
|
47
61
|
} from "./connectors/salesforceAuth.ts";
|
|
48
|
-
export {
|
|
62
|
+
export {
|
|
63
|
+
createStripeConnector,
|
|
64
|
+
fetchStripePaidInvoices,
|
|
65
|
+
type StripeConnectorOptions,
|
|
66
|
+
type StripePaidInvoice,
|
|
67
|
+
} from "./connectors/stripe.ts";
|
|
68
|
+
export {
|
|
69
|
+
buildStripeBackfillPlan,
|
|
70
|
+
DEFAULT_BACKFILL_MATCH_PROPERTY,
|
|
71
|
+
type StripeBackfillCounts,
|
|
72
|
+
type StripeBackfillOptions,
|
|
73
|
+
type StripeBackfillResult,
|
|
74
|
+
type StripeBackfillUnmatched,
|
|
75
|
+
} from "./backfill.ts";
|
|
49
76
|
export {
|
|
50
77
|
activeProfile,
|
|
51
78
|
credentialsDir,
|
package/src/judge.ts
CHANGED
|
@@ -612,6 +612,8 @@ export async function judgeSignals(opts: {
|
|
|
612
612
|
promptTemplate?: string;
|
|
613
613
|
llm?: LlmCallOptions;
|
|
614
614
|
now?: Date;
|
|
615
|
+
/** Per-account progress (presentation only — a throwing callback never fails the run). */
|
|
616
|
+
onAccount?: (done: number, total: number, domain: string) => void;
|
|
615
617
|
}): Promise<JudgeDecision[]> {
|
|
616
618
|
const now = opts.now ?? new Date();
|
|
617
619
|
const signalsById = new Map(opts.signals.map((s) => [s.id, s]));
|
|
@@ -629,6 +631,11 @@ export async function judgeSignals(opts: {
|
|
|
629
631
|
|
|
630
632
|
const decisions: JudgeDecision[] = [];
|
|
631
633
|
for (const [domain, signals] of byAccount) {
|
|
634
|
+
try {
|
|
635
|
+
opts.onAccount?.(decisions.length, byAccount.size, domain);
|
|
636
|
+
} catch {
|
|
637
|
+
// progress is presentation-only
|
|
638
|
+
}
|
|
632
639
|
// Memory + fit stay gated on --with-history (scoring semantics unchanged).
|
|
633
640
|
const recentlyTouched =
|
|
634
641
|
opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
|
package/src/llm.ts
CHANGED
|
@@ -511,3 +511,242 @@ export async function validateLlmKey(
|
|
|
511
511
|
? { ok: true, detail: `Key accepted by the ${provider} API.` }
|
|
512
512
|
: { ok: false, detail: `HTTP ${response.status} ${response.statusText}`.trim() };
|
|
513
513
|
}
|
|
514
|
+
|
|
515
|
+
// ── Chunked extraction (the TamTone "langextract" method) ───────────────────
|
|
516
|
+
//
|
|
517
|
+
// The quality property this encodes, validated on real call corpora in
|
|
518
|
+
// TamTone: transcripts are never sent whole. They are chopped into small
|
|
519
|
+
// (~1,500-char) speaker-turn-respecting chunks and each chunk gets its own
|
|
520
|
+
// focused extraction call — small chunks yielded ~95% more grounded signals
|
|
521
|
+
// than 4,000-char chunks in TamTone's testing, and whole-transcript passes
|
|
522
|
+
// (this module's original extractInsightsLlm, kept for tiny inputs) are the
|
|
523
|
+
// worst case: truncation drops the tail and one context window extracts
|
|
524
|
+
// shallowly. Per-chunk results pass the SAME verbatim-evidence and
|
|
525
|
+
// next-step-grounding gates as the single-shot path (checked against the
|
|
526
|
+
// chunk they came from), then a TamTone-style quality score
|
|
527
|
+
// (0.25·length + 0.40·specificity + 0.35·confidence, reject < 0.15) filters
|
|
528
|
+
// filler before per-call dedupe and ranking.
|
|
529
|
+
|
|
530
|
+
const CHUNK_MAX_CHARS = 1500;
|
|
531
|
+
const CHUNK_CONCURRENCY = 4;
|
|
532
|
+
const MAX_CHUNKS_PER_CALL = 80; // cost bound ≈ 120K chars of transcript
|
|
533
|
+
const MAX_INSIGHTS_PER_CALL = 40;
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Chop a "Speaker: text" transcript into ≤maxChars chunks on speaker-turn
|
|
537
|
+
* (line) boundaries; a single oversize turn splits on sentence boundaries.
|
|
538
|
+
* No overlap (per-chunk extraction + downstream dedupe make it unnecessary).
|
|
539
|
+
*/
|
|
540
|
+
export function chunkTranscript(transcript: string, maxChars = CHUNK_MAX_CHARS): string[] {
|
|
541
|
+
const lines = transcript.split("\n").filter((line) => line.trim().length > 0);
|
|
542
|
+
const pieces: string[] = [];
|
|
543
|
+
for (const line of lines) {
|
|
544
|
+
if (line.length <= maxChars) {
|
|
545
|
+
pieces.push(line);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
// Oversize turn: split on sentence boundaries within the budget.
|
|
549
|
+
let rest = line;
|
|
550
|
+
while (rest.length > maxChars) {
|
|
551
|
+
const window = rest.slice(0, maxChars);
|
|
552
|
+
const cut = Math.max(
|
|
553
|
+
window.lastIndexOf(". "),
|
|
554
|
+
window.lastIndexOf("? "),
|
|
555
|
+
window.lastIndexOf("! "),
|
|
556
|
+
);
|
|
557
|
+
const at = cut >= maxChars * 0.4 ? cut + 1 : maxChars;
|
|
558
|
+
pieces.push(rest.slice(0, at).trim());
|
|
559
|
+
rest = rest.slice(at).trim();
|
|
560
|
+
}
|
|
561
|
+
if (rest) pieces.push(rest);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const chunks: string[] = [];
|
|
565
|
+
let current = "";
|
|
566
|
+
for (const piece of pieces) {
|
|
567
|
+
if (current && current.length + piece.length + 1 > maxChars) {
|
|
568
|
+
chunks.push(current);
|
|
569
|
+
current = piece;
|
|
570
|
+
} else {
|
|
571
|
+
current = current ? `${current}\n${piece}` : piece;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (current) chunks.push(current);
|
|
575
|
+
return chunks;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Few-shot examples are load-bearing (TamTone's extractor refuses to run
|
|
579
|
+
// without an example set): they anchor the "verbatim span, not paraphrase"
|
|
580
|
+
// contract and calibrate what NOT to extract far better than instructions.
|
|
581
|
+
const CHUNK_FEW_SHOT = `Examples:
|
|
582
|
+
|
|
583
|
+
Excerpt: "Prospect: honestly the big blocker is that our reps spend every Friday cleaning the pipeline by hand. Rep: how long does that take? Prospect: most of the day, easily five or six hours."
|
|
584
|
+
→ [{"type":"pain_point","text":"Reps lose ~5-6 hours every Friday manually cleaning the pipeline.","evidence":"our reps spend every Friday cleaning the pipeline by hand","speaker":"Prospect","importance":4,"confidence":0.9}]
|
|
585
|
+
|
|
586
|
+
Excerpt: "Prospect: we're also looking at Clari for this. Their forecasting is strong but the rollout quote scared us. Rep: what was the number? Prospect: about eighty thousand a year."
|
|
587
|
+
→ [{"type":"competitor_mention","text":"Evaluating Clari; strong forecasting but ~$80K/yr rollout quote is a concern.","evidence":"we're also looking at Clari for this","speaker":"Prospect","importance":4,"confidence":0.9},{"type":"pricing","text":"Clari quoted about $80K per year.","evidence":"about eighty thousand a year","speaker":"Prospect","importance":3,"confidence":0.85}]
|
|
588
|
+
|
|
589
|
+
Excerpt: "Rep: I'll send the security questionnaire answers by Thursday. Prospect: perfect, and I'll get you time with our CFO next week."
|
|
590
|
+
→ [{"type":"next_step","text":"Rep to send security questionnaire answers by Thursday.","evidence":"I'll send the security questionnaire answers by Thursday","speaker":"Rep","importance":4,"confidence":0.95,"owner":"Rep","deadline":"Thursday","commitment":"firm"},{"type":"next_step","text":"Prospect to schedule time with their CFO next week.","evidence":"I'll get you time with our CFO next week","speaker":"Prospect","importance":4,"confidence":0.9,"owner":"Prospect","deadline":"next week","commitment":"firm"}]
|
|
591
|
+
|
|
592
|
+
Excerpt: "Rep: how was the long weekend? Prospect: great, we took the kids camping. Rep: nice! okay, let me share my screen."
|
|
593
|
+
→ []
|
|
594
|
+
|
|
595
|
+
Do NOT extract: greetings, scheduling chatter, filler ("yeah", "makes sense"), screen-share logistics, or anything you cannot quote verbatim from THIS excerpt.`;
|
|
596
|
+
|
|
597
|
+
function chunkInstructions(index: number, total: number, title?: string): string {
|
|
598
|
+
return `${EXTRACT_INSTRUCTIONS}
|
|
599
|
+
|
|
600
|
+
You are seeing excerpt ${index + 1} of ${total} from a longer call${title ? ` ("${title}")` : ""}.
|
|
601
|
+
Extract ONLY what this excerpt itself grounds — the other excerpts are handled separately.
|
|
602
|
+
evidence MUST be a verbatim span of THIS excerpt.
|
|
603
|
+
|
|
604
|
+
${CHUNK_FEW_SHOT}`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// TamTone quality gate (analysis/quality_scorer.py):
|
|
608
|
+
// 0.25·length + 0.40·specificity + 0.35·confidence, hard caps for
|
|
609
|
+
// stopword-only/too-short, reject below 0.15. Specificity rewards the
|
|
610
|
+
// concrete: numbers, money, dates/durations, named things.
|
|
611
|
+
const STOPWORDS = new Set(
|
|
612
|
+
"a an and are as at be but by for from had has have i if in into is it its me my not of on or our so that the their them they this to was we were what when which who will with you your yeah okay right sure like just really kind sort".split(" "),
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
export function scoreInsightQuality(insight: { text: string; evidence: string; confidence: number }): number {
|
|
616
|
+
const text = `${insight.text} ${insight.evidence}`.trim();
|
|
617
|
+
const words = text.toLowerCase().split(/[^a-z0-9$%']+/).filter(Boolean);
|
|
618
|
+
if (text.length < 5) return 0.1;
|
|
619
|
+
const nonStop = words.filter((word) => !STOPWORDS.has(word));
|
|
620
|
+
if (nonStop.length === 0) return 0.1;
|
|
621
|
+
|
|
622
|
+
const lengthScore = Math.min(1, words.length / 20);
|
|
623
|
+
let specificity = 0;
|
|
624
|
+
if (/\d/.test(text)) specificity += 0.3; // numbers/amounts/dates
|
|
625
|
+
if (/[$€£]|\b(dollars?|thousand|million|percent|%)\b/i.test(text)) specificity += 0.2;
|
|
626
|
+
if (/\b(q[1-4]|january|february|march|april|may|june|july|august|september|october|november|december|week|month|quarter|year|monday|tuesday|wednesday|thursday|friday)\b/i.test(text)) specificity += 0.2;
|
|
627
|
+
if (/\b[A-Z][a-z]{2,}/.test(insight.text.slice(1))) specificity += 0.15; // named things
|
|
628
|
+
specificity += Math.min(0.3, nonStop.length / 40);
|
|
629
|
+
specificity = Math.min(1, specificity);
|
|
630
|
+
|
|
631
|
+
return 0.25 * lengthScore + 0.4 * specificity + 0.35 * Math.min(1, Math.max(0, insight.confidence));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const QUALITY_REJECT_BELOW = 0.15;
|
|
635
|
+
|
|
636
|
+
export type ChunkedExtractResult = {
|
|
637
|
+
insights: LlmExtractedInsight[];
|
|
638
|
+
model: string;
|
|
639
|
+
chunks: number;
|
|
640
|
+
chunksUsed: number;
|
|
641
|
+
chunksFailed: number;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The chunked pipeline: chunk → per-chunk forced-tool extraction (bounded
|
|
646
|
+
* concurrency) → per-chunk verbatim + grounding gates → quality gate →
|
|
647
|
+
* per-call dedupe → rank. A failed chunk is skipped (best-effort); only
|
|
648
|
+
* all-chunks-failed throws.
|
|
649
|
+
*/
|
|
650
|
+
export async function extractInsightsChunked(
|
|
651
|
+
transcript: string,
|
|
652
|
+
options: LlmCallOptions & { title?: string; maxChunks?: number; concurrency?: number },
|
|
653
|
+
): Promise<ChunkedExtractResult> {
|
|
654
|
+
const model = options.model ?? DEFAULT_MODELS[options.provider];
|
|
655
|
+
const allChunks = chunkTranscript(transcript);
|
|
656
|
+
const chunks = allChunks.slice(0, options.maxChunks ?? MAX_CHUNKS_PER_CALL);
|
|
657
|
+
if (chunks.length <= 1) {
|
|
658
|
+
// Tiny transcript: the single-shot path is identical work with one call.
|
|
659
|
+
const single = await extractInsightsLlm(transcript, options);
|
|
660
|
+
return { ...single, chunks: allChunks.length, chunksUsed: chunks.length, chunksFailed: 0 };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const concurrency = Math.max(1, options.concurrency ?? CHUNK_CONCURRENCY);
|
|
664
|
+
const perChunk: LlmExtractedInsight[][] = new Array(chunks.length);
|
|
665
|
+
let failed = 0;
|
|
666
|
+
let cursor = 0;
|
|
667
|
+
async function worker() {
|
|
668
|
+
while (cursor < chunks.length) {
|
|
669
|
+
const index = cursor;
|
|
670
|
+
cursor += 1;
|
|
671
|
+
const chunk = chunks[index];
|
|
672
|
+
try {
|
|
673
|
+
const result = (await forcedToolCall(
|
|
674
|
+
`${chunkInstructions(index, chunks.length, options.title)}\n\nExcerpt:\n${chunk}`,
|
|
675
|
+
"extract_call_insights",
|
|
676
|
+
EXTRACT_SCHEMA,
|
|
677
|
+
model,
|
|
678
|
+
options,
|
|
679
|
+
)) as { insights?: LlmExtractedInsight[] };
|
|
680
|
+
const normalizedChunk = normalizeSpan(chunk);
|
|
681
|
+
perChunk[index] = (result.insights ?? [])
|
|
682
|
+
.filter((insight) => INSIGHT_TYPES.includes(insight.type))
|
|
683
|
+
// The SAME gates as the single-shot path, applied per chunk: the
|
|
684
|
+
// evidence must be a verbatim span of the chunk it came from, and a
|
|
685
|
+
// next_step's written action must be grounded in that evidence.
|
|
686
|
+
.filter((insight) => {
|
|
687
|
+
const quote = normalizeSpan(insight.evidence ?? "");
|
|
688
|
+
return quote.length >= 12 && normalizedChunk.includes(quote);
|
|
689
|
+
})
|
|
690
|
+
.filter(
|
|
691
|
+
(insight) =>
|
|
692
|
+
insight.type !== "next_step" ||
|
|
693
|
+
actionGroundedInEvidence(insight.text, insight.evidence ?? ""),
|
|
694
|
+
)
|
|
695
|
+
.map((insight) => ({
|
|
696
|
+
...insight,
|
|
697
|
+
title: insight.type.replace(/_/g, " "),
|
|
698
|
+
importance: clamp(Math.round(insight.importance ?? 3), 1, 5),
|
|
699
|
+
confidence: clamp(insight.confidence ?? 0.7, 0, 1),
|
|
700
|
+
}))
|
|
701
|
+
.filter((insight) => scoreInsightQuality(insight) >= QUALITY_REJECT_BELOW);
|
|
702
|
+
} catch {
|
|
703
|
+
failed += 1;
|
|
704
|
+
perChunk[index] = [];
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
|
|
709
|
+
|
|
710
|
+
if (failed === chunks.length) {
|
|
711
|
+
throw new Error(`LLM extraction failed for all ${chunks.length} chunks.`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Per-call dedupe: exact on (type, normalized evidence), then near-dup on
|
|
715
|
+
// (type, normalized text) containment — chunks don't overlap, so dupes are
|
|
716
|
+
// the model restating the same fact from adjacent context.
|
|
717
|
+
const seen = new Map<string, LlmExtractedInsight>();
|
|
718
|
+
const richness = (insight: LlmExtractedInsight) =>
|
|
719
|
+
insight.confidence +
|
|
720
|
+
(insight.owner ? 0.05 : 0) +
|
|
721
|
+
(insight.deadline ? 0.05 : 0) +
|
|
722
|
+
(insight.speaker ? 0.02 : 0);
|
|
723
|
+
for (const insights of perChunk) {
|
|
724
|
+
for (const insight of insights ?? []) {
|
|
725
|
+
const key = `${insight.type}:${normalizeSpan(insight.evidence ?? "")}`;
|
|
726
|
+
const kept = seen.get(key);
|
|
727
|
+
// Same grounded fact stated twice: keep the richer statement (owner/
|
|
728
|
+
// deadline/speaker attached, higher confidence), not the first seen.
|
|
729
|
+
if (!kept || richness(insight) > richness(kept)) seen.set(key, insight);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const merged: LlmExtractedInsight[] = [];
|
|
733
|
+
for (const insight of seen.values()) {
|
|
734
|
+
const text = normalizeSpan(insight.text);
|
|
735
|
+
const dupe = merged.find(
|
|
736
|
+
(kept) =>
|
|
737
|
+
kept.type === insight.type &&
|
|
738
|
+
(normalizeSpan(kept.text).includes(text) || text.includes(normalizeSpan(kept.text))),
|
|
739
|
+
);
|
|
740
|
+
if (!dupe) merged.push(insight);
|
|
741
|
+
else if (insight.importance > dupe.importance) merged[merged.indexOf(dupe)] = insight;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
merged.sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
|
|
745
|
+
return {
|
|
746
|
+
insights: merged.slice(0, MAX_INSIGHTS_PER_CALL),
|
|
747
|
+
model,
|
|
748
|
+
chunks: allChunks.length,
|
|
749
|
+
chunksUsed: chunks.length,
|
|
750
|
+
chunksFailed: failed,
|
|
751
|
+
};
|
|
752
|
+
}
|