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.
Files changed (149) hide show
  1. package/CHANGELOG.md +293 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +45 -24
  4. package/dist/audit.d.ts +3 -1
  5. package/dist/audit.js +29 -2
  6. package/dist/backfill.d.ts +86 -0
  7. package/dist/backfill.js +251 -0
  8. package/dist/bin.js +4 -1
  9. package/dist/cli/audit.d.ts +15 -0
  10. package/dist/cli/audit.js +392 -0
  11. package/dist/cli/auth.d.ts +82 -0
  12. package/dist/cli/auth.js +607 -0
  13. package/dist/cli/backfill.d.ts +1 -0
  14. package/dist/cli/backfill.js +125 -0
  15. package/dist/cli/backfillRuns.d.ts +1 -0
  16. package/dist/cli/backfillRuns.js +187 -0
  17. package/dist/cli/call.d.ts +1 -0
  18. package/dist/cli/call.js +387 -0
  19. package/dist/cli/capabilities.d.ts +30 -0
  20. package/dist/cli/capabilities.js +197 -0
  21. package/dist/cli/draft.d.ts +7 -0
  22. package/dist/cli/draft.js +87 -0
  23. package/dist/cli/enrich.d.ts +8 -0
  24. package/dist/cli/enrich.js +806 -0
  25. package/dist/cli/fix.d.ts +33 -0
  26. package/dist/cli/fix.js +346 -0
  27. package/dist/cli/help.d.ts +25 -0
  28. package/dist/cli/help.js +646 -0
  29. package/dist/cli/icp.d.ts +7 -0
  30. package/dist/cli/icp.js +229 -0
  31. package/dist/cli/init.d.ts +7 -0
  32. package/dist/cli/init.js +58 -0
  33. package/dist/cli/market.d.ts +1 -0
  34. package/dist/cli/market.js +391 -0
  35. package/dist/cli/plans.d.ts +5 -0
  36. package/dist/cli/plans.js +456 -0
  37. package/dist/cli/schedule.d.ts +8 -0
  38. package/dist/cli/schedule.js +479 -0
  39. package/dist/cli/shared.d.ts +71 -0
  40. package/dist/cli/shared.js +342 -0
  41. package/dist/cli/signals.d.ts +7 -0
  42. package/dist/cli/signals.js +412 -0
  43. package/dist/cli/suggest.d.ts +49 -0
  44. package/dist/cli/suggest.js +135 -0
  45. package/dist/cli/tam.d.ts +9 -0
  46. package/dist/cli/tam.js +387 -0
  47. package/dist/cli/ui.d.ts +142 -0
  48. package/dist/cli/ui.js +427 -0
  49. package/dist/cli.d.ts +1 -57
  50. package/dist/cli.js +123 -5157
  51. package/dist/connector.d.ts +23 -0
  52. package/dist/connector.js +47 -0
  53. package/dist/connectors/hubspot.d.ts +10 -1
  54. package/dist/connectors/hubspot.js +244 -10
  55. package/dist/connectors/hubspotAuth.js +5 -1
  56. package/dist/connectors/linkedin.js +14 -14
  57. package/dist/connectors/salesforce.d.ts +10 -1
  58. package/dist/connectors/salesforce.js +39 -6
  59. package/dist/connectors/signalSources.js +7 -59
  60. package/dist/connectors/stripe.d.ts +37 -0
  61. package/dist/connectors/stripe.js +103 -31
  62. package/dist/enrich.d.ts +7 -0
  63. package/dist/enrich.js +7 -0
  64. package/dist/health.d.ts +11 -69
  65. package/dist/health.js +4 -134
  66. package/dist/healthScore.d.ts +71 -0
  67. package/dist/healthScore.js +143 -0
  68. package/dist/icp.d.ts +15 -11
  69. package/dist/icp.js +19 -36
  70. package/dist/index.d.ts +3 -1
  71. package/dist/index.js +3 -1
  72. package/dist/judge.d.ts +2 -0
  73. package/dist/judge.js +6 -0
  74. package/dist/llm.d.ts +29 -0
  75. package/dist/llm.js +206 -0
  76. package/dist/market.d.ts +39 -1
  77. package/dist/market.js +116 -44
  78. package/dist/marketClassify.d.ts +11 -1
  79. package/dist/marketClassify.js +17 -2
  80. package/dist/marketTaxonomy.d.ts +6 -1
  81. package/dist/marketTaxonomy.js +4 -2
  82. package/dist/mcp-bin.js +31 -2
  83. package/dist/mcp.js +372 -166
  84. package/dist/progress.d.ts +96 -0
  85. package/dist/progress.js +142 -0
  86. package/dist/runReport.d.ts +24 -0
  87. package/dist/runReport.js +139 -4
  88. package/dist/schedule.d.ts +80 -2
  89. package/dist/schedule.js +254 -1
  90. package/dist/spoolFiles.d.ts +44 -0
  91. package/dist/spoolFiles.js +114 -0
  92. package/dist/types.d.ts +29 -1
  93. package/docs/api.md +75 -8
  94. package/docs/architecture.md +2 -0
  95. package/docs/linkedin-connector-spec.md +1 -1
  96. package/docs/signal-spool-format.md +13 -0
  97. package/llms.txt +18 -9
  98. package/package.json +10 -3
  99. package/skills/fullstackgtm/SKILL.md +2 -1
  100. package/src/audit.ts +27 -1
  101. package/src/backfill.ts +340 -0
  102. package/src/bin.ts +5 -1
  103. package/src/cli/audit.ts +447 -0
  104. package/src/cli/auth.ts +669 -0
  105. package/src/cli/backfill.ts +156 -0
  106. package/src/cli/backfillRuns.ts +198 -0
  107. package/src/cli/call.ts +414 -0
  108. package/src/cli/capabilities.ts +215 -0
  109. package/src/cli/draft.ts +101 -0
  110. package/src/cli/enrich.ts +908 -0
  111. package/src/cli/fix.ts +373 -0
  112. package/src/cli/help.ts +702 -0
  113. package/src/cli/icp.ts +265 -0
  114. package/src/cli/init.ts +65 -0
  115. package/src/cli/market.ts +423 -0
  116. package/src/cli/plans.ts +524 -0
  117. package/src/cli/schedule.ts +526 -0
  118. package/src/cli/shared.ts +392 -0
  119. package/src/cli/signals.ts +434 -0
  120. package/src/cli/suggest.ts +151 -0
  121. package/src/cli/tam.ts +435 -0
  122. package/src/cli/ui.ts +497 -0
  123. package/src/cli.ts +122 -5855
  124. package/src/connector.ts +66 -0
  125. package/src/connectors/hubspot.ts +273 -9
  126. package/src/connectors/hubspotAuth.ts +5 -1
  127. package/src/connectors/linkedin.ts +14 -14
  128. package/src/connectors/salesforce.ts +51 -3
  129. package/src/connectors/signalSources.ts +7 -56
  130. package/src/connectors/stripe.ts +154 -34
  131. package/src/enrich.ts +13 -0
  132. package/src/health.ts +14 -213
  133. package/src/healthScore.ts +223 -0
  134. package/src/icp.ts +19 -35
  135. package/src/index.ts +28 -1
  136. package/src/judge.ts +7 -0
  137. package/src/llm.ts +239 -0
  138. package/src/market.ts +157 -44
  139. package/src/marketClassify.ts +26 -2
  140. package/src/marketTaxonomy.ts +10 -2
  141. package/src/mcp-bin.ts +34 -2
  142. package/src/mcp.ts +270 -63
  143. package/src/progress.ts +197 -0
  144. package/src/runReport.ts +159 -4
  145. package/src/schedule.ts +310 -3
  146. package/src/spoolFiles.ts +116 -0
  147. package/src/types.ts +32 -2
  148. package/docs/dx-punch-list.md +0 -87
  149. 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
- * Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
163
- * Crustdata expects these exact capitalized strings (verified: "CXO",
164
- * "Vice President", "Director" appear in Crustdata's docs/examples).
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. Seniority + industry are mapped to Crustdata's controlled vocab via the
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): `current_job_titles` is confirmed working (a
219
- * titles-only RevOps search returns results); `current_title` is rejected (422).
220
- * The full ICP filter returned 0 — the prime suspect is the industry vocab
221
- * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
222
- * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
223
- * exhausted mid-investigation) re-run `enrich acquire --source pipe0` once
224
- * credits refill; if it still returns 0, the next suspects are the
225
- * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
226
- * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
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 { createStripeConnector, type StripeConnectorOptions } from "./connectors/stripe.ts";
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
+ }