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,143 @@
1
+ /**
2
+ * Pure, node-free health scoring. Split from health.ts (which owns the
3
+ * fs-backed profile timeline) so V8-runtime consumers — the hosted app's
4
+ * Convex queries — can import the scoring math without pulling node built-ins,
5
+ * the same pattern as audit.ts/format.ts.
6
+ *
7
+ * The score is DETERMINISTIC (no LLM): same plan + same snapshot → same score,
8
+ * so it is stable in CI and comparable run-over-run.
9
+ */
10
+ // Severity weights: a critical finding costs ~3× a warning, ~10× an info.
11
+ const SEVERITY_WEIGHT = { info: 1, warning: 3, critical: 10 };
12
+ /**
13
+ * Deterministically score a saved audit. score = 100 / (1 + weighted findings
14
+ * per record): 0 findings → 100; one weighted finding per record → 50; the
15
+ * curve is bounded (0, 100], monotonic, and needs no clamping or magic
16
+ * constants. A messy 50-record CRM scores worse than the same finding count
17
+ * across 5,000 records, which is the point.
18
+ */
19
+ export function computeHealth(plan, snapshot, at) {
20
+ const byRule = {};
21
+ const severityCounts = { info: 0, warning: 0, critical: 0 };
22
+ const typeTally = {
23
+ account: { findings: 0, weightedFindings: 0 },
24
+ contact: { findings: 0, weightedFindings: 0 },
25
+ deal: { findings: 0, weightedFindings: 0 },
26
+ };
27
+ let weightedFindings = 0;
28
+ for (const finding of plan.findings) {
29
+ byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
30
+ severityCounts[finding.severity] += 1;
31
+ weightedFindings += SEVERITY_WEIGHT[finding.severity];
32
+ const t = finding.objectType;
33
+ if (t === "account" || t === "contact" || t === "deal") {
34
+ typeTally[t].findings += 1;
35
+ typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
36
+ }
37
+ }
38
+ const records = {
39
+ accounts: snapshot.accounts.length,
40
+ contacts: snapshot.contacts.length,
41
+ deals: snapshot.deals.length,
42
+ total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
43
+ };
44
+ const scoreFor = (weighted, recordCount) => Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
45
+ const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
46
+ const byObjectType = ["account", "contact", "deal"].reduce((acc, t) => {
47
+ acc[t] = {
48
+ records: recordsByType[t],
49
+ findings: typeTally[t].findings,
50
+ weightedFindings: typeTally[t].weightedFindings,
51
+ score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
52
+ };
53
+ return acc;
54
+ }, {});
55
+ const score = scoreFor(weightedFindings, records.total);
56
+ return {
57
+ at,
58
+ planId: plan.id,
59
+ score,
60
+ findings: plan.findings.length,
61
+ weightedFindings,
62
+ records,
63
+ byRule,
64
+ severityCounts,
65
+ byObjectType,
66
+ };
67
+ }
68
+ /** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
69
+ export function summarizeHealth(entries, profile) {
70
+ if (entries.length === 0)
71
+ return null;
72
+ // Oldest → newest, so `current` is the last entry.
73
+ const sorted = [...entries].sort((a, b) => a.at.localeCompare(b.at));
74
+ const current = sorted[sorted.length - 1];
75
+ const previous = sorted.length > 1 ? sorted[sorted.length - 2] : null;
76
+ const ruleIds = new Set([
77
+ ...Object.keys(current.byRule),
78
+ ...(previous ? Object.keys(previous.byRule) : []),
79
+ ]);
80
+ const ruleDeltas = [...ruleIds]
81
+ .map((ruleId) => {
82
+ const cur = current.byRule[ruleId] ?? 0;
83
+ const prev = previous?.byRule[ruleId] ?? 0;
84
+ return { ruleId, current: cur, previous: prev, delta: cur - prev };
85
+ })
86
+ .sort((a, b) => b.current - a.current || a.ruleId.localeCompare(b.ruleId));
87
+ return {
88
+ profile,
89
+ auditCount: sorted.length,
90
+ first: sorted[0].at,
91
+ latest: current.at,
92
+ current,
93
+ previous,
94
+ scoreDelta: previous ? current.score - previous.score : null,
95
+ ruleDeltas,
96
+ history: sorted.map((entry) => ({ at: entry.at, score: entry.score, findings: entry.findings })),
97
+ };
98
+ }
99
+ // Sign-based and uniform: ▲ = the number went up, ▼ = down. The reader supplies
100
+ // the meaning (score up = good; findings up = bad) — mixing arrow direction with
101
+ // good/bad reads as contradictory ("▲ -3").
102
+ function arrow(delta) {
103
+ if (delta === 0)
104
+ return "·";
105
+ return delta > 0 ? "▲" : "▼";
106
+ }
107
+ /** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
108
+ export function healthToMarkdown(rollup) {
109
+ const { current, scoreDelta } = rollup;
110
+ const lines = [`# GTM health — profile \`${rollup.profile}\``, ""];
111
+ const deltaText = scoreDelta === null
112
+ ? "(first audit — no prior reading)"
113
+ : `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
114
+ lines.push(`Score: **${current.score}/100** ${deltaText}`, `Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`, `Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`, `Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`, "");
115
+ lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
116
+ for (const t of ["account", "contact", "deal"]) {
117
+ const b = current.byObjectType[t];
118
+ lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
119
+ }
120
+ lines.push("");
121
+ if (rollup.history.length > 1) {
122
+ lines.push("## Trend", "");
123
+ for (const point of rollup.history) {
124
+ const marker = point.at === rollup.latest ? " ← latest" : "";
125
+ lines.push(`- ${shortDate(point.at)} score ${point.score} (${point.findings} findings)${marker}`);
126
+ }
127
+ lines.push("");
128
+ }
129
+ if (rollup.ruleDeltas.length > 0) {
130
+ lines.push("## By rule (Δ since previous audit)", "", "| Rule | Findings | Δ |", "| --- | --- | --- |");
131
+ for (const rule of rollup.ruleDeltas) {
132
+ const sign = rule.delta > 0 ? `+${rule.delta}` : `${rule.delta}`;
133
+ const cell = rule.delta === 0 ? "·" : `${arrow(rule.delta)} ${sign}`;
134
+ lines.push(`| ${rule.ruleId} | ${rule.current} | ${cell} |`);
135
+ }
136
+ lines.push("");
137
+ }
138
+ lines.push("> Score is deterministic: 100 / (1 + severity-weighted findings per record). Lower findings or more clean records ⇒ higher score.");
139
+ return `${lines.join("\n")}\n`;
140
+ }
141
+ function shortDate(iso) {
142
+ return iso.slice(0, 10);
143
+ }
package/dist/icp.d.ts CHANGED
@@ -86,18 +86,22 @@ export declare function icpToTheirStackFilters(icp: Icp): {
86
86
  /**
87
87
  * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
88
88
  * matches real LinkedIn title strings (case-sensitive), so keywords are Title
89
- * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
90
- * tables above.
89
+ * Cased. Industry is mapped to Crustdata's controlled vocab via the table above.
91
90
  *
92
- * LIVE FINDINGS (2026-06-26): `current_job_titles` is confirmed working (a
93
- * titles-only RevOps search returns results); `current_title` is rejected (422).
94
- * The full ICP filter returned 0 — the prime suspect is the industry vocab
95
- * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
96
- * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
97
- * exhausted mid-investigation) re-run `enrich acquire --source pipe0` once
98
- * credits refill; if it still returns 0, the next suspects are the
99
- * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
100
- * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
91
+ * LIVE FINDINGS (2026-06-26, re-confirmed 2026-07-02 with fresh credits):
92
+ * `current_job_titles` works; `current_title` is rejected (422). The culprit
93
+ * that zeroed the full ICP filter is `current_seniority_levels`: ANY non-empty
94
+ * `include` returns 0 results through pipe0's people:profiles:crustdata@1
95
+ * including Crustdata's own documented values ("CXO", "Director",
96
+ * "Vice President"), lowercase, and SNAKE_CASE variants while the identical
97
+ * search without it returns results. (An array instead of the
98
+ * {include, exclude} object is a 422, so the shape was right; the filter is
99
+ * broken upstream.) So job levels are NOT sent to the provider: persona
100
+ * seniority is enforced by fit scoring (scoreProspectAgainstIcp weights
101
+ * jobLevels), which was always the precision backstop. `locations` and
102
+ * `current_employers_linkedin_industries` (both-taxonomy hedge) are live-
103
+ * verified working in combination with titles. Note fit-scoring backs up
104
+ * PERSONA precision but NOT industry, so the industry filter is load-bearing.
101
105
  */
102
106
  export declare function icpToCrustdataFilters(icp: Icp): Record<string, unknown>;
103
107
  export type IcpFit = {
package/dist/icp.js CHANGED
@@ -125,26 +125,10 @@ export function icpToTheirStackFilters(icp) {
125
125
  f.max_employee_count = range.max;
126
126
  return f;
127
127
  }
128
- /**
129
- * Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
130
- * Crustdata expects these exact capitalized strings (verified: "CXO",
131
- * "Vice President", "Director" appear in Crustdata's docs/examples).
132
- */
133
- const CRUSTDATA_SENIORITY = {
134
- cxo: "CXO",
135
- "c-suite": "CXO",
136
- c_suite: "CXO",
137
- founder: "Owner",
138
- owner: "Owner",
139
- partner: "Partner",
140
- vp: "Vice President",
141
- "vice president": "Vice President",
142
- head: "Director",
143
- director: "Director",
144
- manager: "Manager",
145
- senior: "Senior",
146
- entry: "Entry",
147
- };
128
+ // NOTE: a CRUSTDATA_SENIORITY vocab table lived here until 2026-07-02 — the
129
+ // `current_seniority_levels` filter is broken upstream (see the live findings
130
+ // on icpToCrustdataFilters below), so job levels are no longer sent to the
131
+ // provider at all. Resurrect the table from git history if pipe0 fixes it.
148
132
  /**
149
133
  * ICP industry keyword → LinkedIn industry names Crustdata filters on.
150
134
  *
@@ -177,18 +161,22 @@ const CRUSTDATA_INDUSTRY = {
177
161
  /**
178
162
  * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
179
163
  * matches real LinkedIn title strings (case-sensitive), so keywords are Title
180
- * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
181
- * tables above.
164
+ * Cased. Industry is mapped to Crustdata's controlled vocab via the table above.
182
165
  *
183
- * LIVE FINDINGS (2026-06-26): `current_job_titles` is confirmed working (a
184
- * titles-only RevOps search returns results); `current_title` is rejected (422).
185
- * The full ICP filter returned 0 — the prime suspect is the industry vocab
186
- * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
187
- * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
188
- * exhausted mid-investigation) re-run `enrich acquire --source pipe0` once
189
- * credits refill; if it still returns 0, the next suspects are the
190
- * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
191
- * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
166
+ * LIVE FINDINGS (2026-06-26, re-confirmed 2026-07-02 with fresh credits):
167
+ * `current_job_titles` works; `current_title` is rejected (422). The culprit
168
+ * that zeroed the full ICP filter is `current_seniority_levels`: ANY non-empty
169
+ * `include` returns 0 results through pipe0's people:profiles:crustdata@1
170
+ * including Crustdata's own documented values ("CXO", "Director",
171
+ * "Vice President"), lowercase, and SNAKE_CASE variants while the identical
172
+ * search without it returns results. (An array instead of the
173
+ * {include, exclude} object is a 422, so the shape was right; the filter is
174
+ * broken upstream.) So job levels are NOT sent to the provider: persona
175
+ * seniority is enforced by fit scoring (scoreProspectAgainstIcp weights
176
+ * jobLevels), which was always the precision backstop. `locations` and
177
+ * `current_employers_linkedin_industries` (both-taxonomy hedge) are live-
178
+ * verified working in combination with titles. Note fit-scoring backs up
179
+ * PERSONA precision but NOT industry, so the industry filter is load-bearing.
192
180
  */
193
181
  export function icpToCrustdataFilters(icp) {
194
182
  const f = {};
@@ -197,11 +185,6 @@ export function icpToCrustdataFilters(icp) {
197
185
  if (icp.firmographics.geos?.length) {
198
186
  f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
199
187
  }
200
- if (icp.persona.jobLevels?.length) {
201
- const include = [...new Set(icp.persona.jobLevels.map((l) => CRUSTDATA_SENIORITY[l.toLowerCase()]).filter(Boolean))];
202
- if (include.length)
203
- f.current_seniority_levels = { include, exclude: [] };
204
- }
205
188
  if (icp.firmographics.industries?.length) {
206
189
  const inds = [
207
190
  ...new Set(icp.firmographics.industries.flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)])),
package/dist/index.d.ts CHANGED
@@ -5,11 +5,13 @@ export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
5
5
  export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
6
6
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, type FullstackgtmConfig, type LoadedConfig, } from "./config.ts";
7
7
  export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
8
+ export { APPLY_STAGES, BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter, CRM_SYNC_STAGES, nullProgressEmitter, SNAPSHOT_PULL_STAGES, STRIPE_SNAPSHOT_STAGES, type ProgressEmitter, type ProgressEvent, type ProgressListener, type ProgressSnapshot, } from "./progress.ts";
8
9
  export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
9
10
  export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refreshHubspotToken, runHubspotLoopbackLogin, validateHubspotToken, type HubspotTokenSet, type LoopbackLoginOptions, } from "./connectors/hubspotAuth.ts";
10
11
  export { createSalesforceConnector, type SalesforceConnection, type SalesforceConnectorOptions, } from "./connectors/salesforce.ts";
11
12
  export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceToken, type SalesforceDeviceAuthorization, type SalesforceTokenSet, } from "./connectors/salesforceAuth.ts";
12
- export { createStripeConnector, type StripeConnectorOptions } from "./connectors/stripe.ts";
13
+ export { createStripeConnector, fetchStripePaidInvoices, type StripeConnectorOptions, type StripePaidInvoice, } from "./connectors/stripe.ts";
14
+ export { buildStripeBackfillPlan, DEFAULT_BACKFILL_MATCH_PROPERTY, type StripeBackfillCounts, type StripeBackfillOptions, type StripeBackfillResult, type StripeBackfillUnmatched, } from "./backfill.ts";
13
15
  export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
14
16
  export { generateDemoSnapshot, type DemoSnapshotOptions } from "./demo.ts";
15
17
  export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, type BuildEnrichPlanOptions, type EnrichAmbiguity, type EnrichConfig, type EnrichCounts, type EnrichFieldConfig, type EnrichMatchConfig, type EnrichMode, type EnrichObjectType, type EnrichPlanResult, type EnrichRun, type EnrichRunStore, type EnrichSourceConfig, type EnrichSourceRecord, type EnrichStamp, type EnrichWorkItem, type MatchOutcome, } from "./enrich.ts";
package/dist/index.js CHANGED
@@ -5,11 +5,13 @@ export { buildDedupePlan, dedupeKey } from "./dedupe.js";
5
5
  export { buildReassignPlans } from "./reassign.js";
6
6
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, } from "./config.js";
7
7
  export { applyPatchPlan } from "./connector.js";
8
+ export { APPLY_STAGES, BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter, CRM_SYNC_STAGES, nullProgressEmitter, SNAPSHOT_PULL_STAGES, STRIPE_SNAPSHOT_STAGES, } from "./progress.js";
8
9
  export { createHubspotConnector } from "./connectors/hubspot.js";
9
10
  export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refreshHubspotToken, runHubspotLoopbackLogin, validateHubspotToken, } from "./connectors/hubspotAuth.js";
10
11
  export { createSalesforceConnector, } from "./connectors/salesforce.js";
11
12
  export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
12
- export { createStripeConnector } from "./connectors/stripe.js";
13
+ export { createStripeConnector, fetchStripePaidInvoices, } from "./connectors/stripe.js";
14
+ export { buildStripeBackfillPlan, DEFAULT_BACKFILL_MATCH_PROPERTY, } from "./backfill.js";
13
15
  export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, } from "./credentials.js";
14
16
  export { generateDemoSnapshot } from "./demo.js";
15
17
  export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
package/dist/judge.d.ts CHANGED
@@ -230,6 +230,8 @@ export declare function judgeSignals(opts: {
230
230
  promptTemplate?: string;
231
231
  llm?: LlmCallOptions;
232
232
  now?: Date;
233
+ /** Per-account progress (presentation only — a throwing callback never fails the run). */
234
+ onAccount?: (done: number, total: number, domain: string) => void;
233
235
  }): Promise<JudgeDecision[]>;
234
236
  export declare function judgeDir(baseDir?: string): string;
235
237
  export interface JudgeStore {
package/dist/judge.js CHANGED
@@ -438,6 +438,12 @@ export async function judgeSignals(opts) {
438
438
  }
439
439
  const decisions = [];
440
440
  for (const [domain, signals] of byAccount) {
441
+ try {
442
+ opts.onAccount?.(decisions.length, byAccount.size, domain);
443
+ }
444
+ catch {
445
+ // progress is presentation-only
446
+ }
441
447
  // Memory + fit stay gated on --with-history (scoring semantics unchanged).
442
448
  const recentlyTouched = opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
443
449
  const bestContact = opts.withHistory && opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
package/dist/llm.d.ts CHANGED
@@ -134,3 +134,32 @@ export declare function validateLlmKey(provider: LlmProvider, apiKey: string, fe
134
134
  ok: boolean;
135
135
  detail: string;
136
136
  }>;
137
+ /**
138
+ * Chop a "Speaker: text" transcript into ≤maxChars chunks on speaker-turn
139
+ * (line) boundaries; a single oversize turn splits on sentence boundaries.
140
+ * No overlap (per-chunk extraction + downstream dedupe make it unnecessary).
141
+ */
142
+ export declare function chunkTranscript(transcript: string, maxChars?: number): string[];
143
+ export declare function scoreInsightQuality(insight: {
144
+ text: string;
145
+ evidence: string;
146
+ confidence: number;
147
+ }): number;
148
+ export type ChunkedExtractResult = {
149
+ insights: LlmExtractedInsight[];
150
+ model: string;
151
+ chunks: number;
152
+ chunksUsed: number;
153
+ chunksFailed: number;
154
+ };
155
+ /**
156
+ * The chunked pipeline: chunk → per-chunk forced-tool extraction (bounded
157
+ * concurrency) → per-chunk verbatim + grounding gates → quality gate →
158
+ * per-call dedupe → rank. A failed chunk is skipped (best-effort); only
159
+ * all-chunks-failed throws.
160
+ */
161
+ export declare function extractInsightsChunked(transcript: string, options: LlmCallOptions & {
162
+ title?: string;
163
+ maxChunks?: number;
164
+ concurrency?: number;
165
+ }): Promise<ChunkedExtractResult>;
package/dist/llm.js CHANGED
@@ -367,3 +367,209 @@ export async function validateLlmKey(provider, apiKey, fetchImpl = fetch) {
367
367
  ? { ok: true, detail: `Key accepted by the ${provider} API.` }
368
368
  : { ok: false, detail: `HTTP ${response.status} ${response.statusText}`.trim() };
369
369
  }
370
+ // ── Chunked extraction (the TamTone "langextract" method) ───────────────────
371
+ //
372
+ // The quality property this encodes, validated on real call corpora in
373
+ // TamTone: transcripts are never sent whole. They are chopped into small
374
+ // (~1,500-char) speaker-turn-respecting chunks and each chunk gets its own
375
+ // focused extraction call — small chunks yielded ~95% more grounded signals
376
+ // than 4,000-char chunks in TamTone's testing, and whole-transcript passes
377
+ // (this module's original extractInsightsLlm, kept for tiny inputs) are the
378
+ // worst case: truncation drops the tail and one context window extracts
379
+ // shallowly. Per-chunk results pass the SAME verbatim-evidence and
380
+ // next-step-grounding gates as the single-shot path (checked against the
381
+ // chunk they came from), then a TamTone-style quality score
382
+ // (0.25·length + 0.40·specificity + 0.35·confidence, reject < 0.15) filters
383
+ // filler before per-call dedupe and ranking.
384
+ const CHUNK_MAX_CHARS = 1500;
385
+ const CHUNK_CONCURRENCY = 4;
386
+ const MAX_CHUNKS_PER_CALL = 80; // cost bound ≈ 120K chars of transcript
387
+ const MAX_INSIGHTS_PER_CALL = 40;
388
+ /**
389
+ * Chop a "Speaker: text" transcript into ≤maxChars chunks on speaker-turn
390
+ * (line) boundaries; a single oversize turn splits on sentence boundaries.
391
+ * No overlap (per-chunk extraction + downstream dedupe make it unnecessary).
392
+ */
393
+ export function chunkTranscript(transcript, maxChars = CHUNK_MAX_CHARS) {
394
+ const lines = transcript.split("\n").filter((line) => line.trim().length > 0);
395
+ const pieces = [];
396
+ for (const line of lines) {
397
+ if (line.length <= maxChars) {
398
+ pieces.push(line);
399
+ continue;
400
+ }
401
+ // Oversize turn: split on sentence boundaries within the budget.
402
+ let rest = line;
403
+ while (rest.length > maxChars) {
404
+ const window = rest.slice(0, maxChars);
405
+ const cut = Math.max(window.lastIndexOf(". "), window.lastIndexOf("? "), window.lastIndexOf("! "));
406
+ const at = cut >= maxChars * 0.4 ? cut + 1 : maxChars;
407
+ pieces.push(rest.slice(0, at).trim());
408
+ rest = rest.slice(at).trim();
409
+ }
410
+ if (rest)
411
+ pieces.push(rest);
412
+ }
413
+ const chunks = [];
414
+ let current = "";
415
+ for (const piece of pieces) {
416
+ if (current && current.length + piece.length + 1 > maxChars) {
417
+ chunks.push(current);
418
+ current = piece;
419
+ }
420
+ else {
421
+ current = current ? `${current}\n${piece}` : piece;
422
+ }
423
+ }
424
+ if (current)
425
+ chunks.push(current);
426
+ return chunks;
427
+ }
428
+ // Few-shot examples are load-bearing (TamTone's extractor refuses to run
429
+ // without an example set): they anchor the "verbatim span, not paraphrase"
430
+ // contract and calibrate what NOT to extract far better than instructions.
431
+ const CHUNK_FEW_SHOT = `Examples:
432
+
433
+ 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."
434
+ → [{"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}]
435
+
436
+ 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."
437
+ → [{"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}]
438
+
439
+ Excerpt: "Rep: I'll send the security questionnaire answers by Thursday. Prospect: perfect, and I'll get you time with our CFO next week."
440
+ → [{"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"}]
441
+
442
+ Excerpt: "Rep: how was the long weekend? Prospect: great, we took the kids camping. Rep: nice! okay, let me share my screen."
443
+ → []
444
+
445
+ Do NOT extract: greetings, scheduling chatter, filler ("yeah", "makes sense"), screen-share logistics, or anything you cannot quote verbatim from THIS excerpt.`;
446
+ function chunkInstructions(index, total, title) {
447
+ return `${EXTRACT_INSTRUCTIONS}
448
+
449
+ You are seeing excerpt ${index + 1} of ${total} from a longer call${title ? ` ("${title}")` : ""}.
450
+ Extract ONLY what this excerpt itself grounds — the other excerpts are handled separately.
451
+ evidence MUST be a verbatim span of THIS excerpt.
452
+
453
+ ${CHUNK_FEW_SHOT}`;
454
+ }
455
+ // TamTone quality gate (analysis/quality_scorer.py):
456
+ // 0.25·length + 0.40·specificity + 0.35·confidence, hard caps for
457
+ // stopword-only/too-short, reject below 0.15. Specificity rewards the
458
+ // concrete: numbers, money, dates/durations, named things.
459
+ const STOPWORDS = new Set("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(" "));
460
+ export function scoreInsightQuality(insight) {
461
+ const text = `${insight.text} ${insight.evidence}`.trim();
462
+ const words = text.toLowerCase().split(/[^a-z0-9$%']+/).filter(Boolean);
463
+ if (text.length < 5)
464
+ return 0.1;
465
+ const nonStop = words.filter((word) => !STOPWORDS.has(word));
466
+ if (nonStop.length === 0)
467
+ return 0.1;
468
+ const lengthScore = Math.min(1, words.length / 20);
469
+ let specificity = 0;
470
+ if (/\d/.test(text))
471
+ specificity += 0.3; // numbers/amounts/dates
472
+ if (/[$€£]|\b(dollars?|thousand|million|percent|%)\b/i.test(text))
473
+ specificity += 0.2;
474
+ 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))
475
+ specificity += 0.2;
476
+ if (/\b[A-Z][a-z]{2,}/.test(insight.text.slice(1)))
477
+ specificity += 0.15; // named things
478
+ specificity += Math.min(0.3, nonStop.length / 40);
479
+ specificity = Math.min(1, specificity);
480
+ return 0.25 * lengthScore + 0.4 * specificity + 0.35 * Math.min(1, Math.max(0, insight.confidence));
481
+ }
482
+ const QUALITY_REJECT_BELOW = 0.15;
483
+ /**
484
+ * The chunked pipeline: chunk → per-chunk forced-tool extraction (bounded
485
+ * concurrency) → per-chunk verbatim + grounding gates → quality gate →
486
+ * per-call dedupe → rank. A failed chunk is skipped (best-effort); only
487
+ * all-chunks-failed throws.
488
+ */
489
+ export async function extractInsightsChunked(transcript, options) {
490
+ const model = options.model ?? DEFAULT_MODELS[options.provider];
491
+ const allChunks = chunkTranscript(transcript);
492
+ const chunks = allChunks.slice(0, options.maxChunks ?? MAX_CHUNKS_PER_CALL);
493
+ if (chunks.length <= 1) {
494
+ // Tiny transcript: the single-shot path is identical work with one call.
495
+ const single = await extractInsightsLlm(transcript, options);
496
+ return { ...single, chunks: allChunks.length, chunksUsed: chunks.length, chunksFailed: 0 };
497
+ }
498
+ const concurrency = Math.max(1, options.concurrency ?? CHUNK_CONCURRENCY);
499
+ const perChunk = new Array(chunks.length);
500
+ let failed = 0;
501
+ let cursor = 0;
502
+ async function worker() {
503
+ while (cursor < chunks.length) {
504
+ const index = cursor;
505
+ cursor += 1;
506
+ const chunk = chunks[index];
507
+ try {
508
+ const result = (await forcedToolCall(`${chunkInstructions(index, chunks.length, options.title)}\n\nExcerpt:\n${chunk}`, "extract_call_insights", EXTRACT_SCHEMA, model, options));
509
+ const normalizedChunk = normalizeSpan(chunk);
510
+ perChunk[index] = (result.insights ?? [])
511
+ .filter((insight) => INSIGHT_TYPES.includes(insight.type))
512
+ // The SAME gates as the single-shot path, applied per chunk: the
513
+ // evidence must be a verbatim span of the chunk it came from, and a
514
+ // next_step's written action must be grounded in that evidence.
515
+ .filter((insight) => {
516
+ const quote = normalizeSpan(insight.evidence ?? "");
517
+ return quote.length >= 12 && normalizedChunk.includes(quote);
518
+ })
519
+ .filter((insight) => insight.type !== "next_step" ||
520
+ actionGroundedInEvidence(insight.text, insight.evidence ?? ""))
521
+ .map((insight) => ({
522
+ ...insight,
523
+ title: insight.type.replace(/_/g, " "),
524
+ importance: clamp(Math.round(insight.importance ?? 3), 1, 5),
525
+ confidence: clamp(insight.confidence ?? 0.7, 0, 1),
526
+ }))
527
+ .filter((insight) => scoreInsightQuality(insight) >= QUALITY_REJECT_BELOW);
528
+ }
529
+ catch {
530
+ failed += 1;
531
+ perChunk[index] = [];
532
+ }
533
+ }
534
+ }
535
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
536
+ if (failed === chunks.length) {
537
+ throw new Error(`LLM extraction failed for all ${chunks.length} chunks.`);
538
+ }
539
+ // Per-call dedupe: exact on (type, normalized evidence), then near-dup on
540
+ // (type, normalized text) containment — chunks don't overlap, so dupes are
541
+ // the model restating the same fact from adjacent context.
542
+ const seen = new Map();
543
+ const richness = (insight) => insight.confidence +
544
+ (insight.owner ? 0.05 : 0) +
545
+ (insight.deadline ? 0.05 : 0) +
546
+ (insight.speaker ? 0.02 : 0);
547
+ for (const insights of perChunk) {
548
+ for (const insight of insights ?? []) {
549
+ const key = `${insight.type}:${normalizeSpan(insight.evidence ?? "")}`;
550
+ const kept = seen.get(key);
551
+ // Same grounded fact stated twice: keep the richer statement (owner/
552
+ // deadline/speaker attached, higher confidence), not the first seen.
553
+ if (!kept || richness(insight) > richness(kept))
554
+ seen.set(key, insight);
555
+ }
556
+ }
557
+ const merged = [];
558
+ for (const insight of seen.values()) {
559
+ const text = normalizeSpan(insight.text);
560
+ const dupe = merged.find((kept) => kept.type === insight.type &&
561
+ (normalizeSpan(kept.text).includes(text) || text.includes(normalizeSpan(kept.text))));
562
+ if (!dupe)
563
+ merged.push(insight);
564
+ else if (insight.importance > dupe.importance)
565
+ merged[merged.indexOf(dupe)] = insight;
566
+ }
567
+ merged.sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
568
+ return {
569
+ insights: merged.slice(0, MAX_INSIGHTS_PER_CALL),
570
+ model,
571
+ chunks: allChunks.length,
572
+ chunksUsed: chunks.length,
573
+ chunksFailed: failed,
574
+ };
575
+ }
package/dist/market.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ProgressEmitter } from "./progress.ts";
1
2
  import type { GtmEvidence } from "./types.ts";
2
3
  /**
3
4
  * The Market Map: a live model of the competitive category a company sells
@@ -163,12 +164,24 @@ export type FetchPage = (url: string) => Promise<{
163
164
  }>;
164
165
  export declare function assertPublicUrl(rawUrl: string): Promise<URL>;
165
166
  export type CaptureOptions = {
166
- /** Directory for captures; defaults to <marketHome>/captures. */
167
+ /** Directory for captures; defaults to <marketHome>/captures. Ignored when `store` is given. */
167
168
  dir?: string;
168
169
  runLabel?: string;
169
170
  /** Injectable for tests; defaults to global fetch. */
170
171
  fetchPage?: FetchPage;
171
172
  now?: () => Date;
173
+ /**
174
+ * Storage seam (see MarketStore below). Defaults to the profile-home file
175
+ * store; the hosted app passes a Convex-backed store. Same engine, same
176
+ * evidence chain, different persistence.
177
+ */
178
+ store?: MarketStore;
179
+ /**
180
+ * Progress emission over MARKET_CAPTURE_STAGES ("sources" → "capture" →
181
+ * "persist" here; "classify" belongs to classifyMarket). Presentation-only:
182
+ * a throwing listener never fails the capture.
183
+ */
184
+ progress?: ProgressEmitter;
172
185
  };
173
186
  export type CaptureResult = {
174
187
  entries: CaptureEntry[];
@@ -197,6 +210,31 @@ export declare function loadCaptureTexts(category: string, directory?: string):
197
210
  entries: CaptureEntry[];
198
211
  textByHash: Map<string, string>;
199
212
  };
213
+ export interface MarketStore {
214
+ /** Operator-facing location of the capture manifest (file path or logical label). */
215
+ captureLocation(): string;
216
+ /** Persist one page's extracted text under its content hash (idempotent). */
217
+ saveCaptureText(captureHash: string, text: string): Promise<void>;
218
+ /** Append one capture pass's manifest entries (append-only; never rewrites history). */
219
+ appendCaptureEntries(entries: CaptureEntry[]): Promise<void>;
220
+ /** Full manifest + resolvable texts — the evidence-verification input. */
221
+ loadCaptureTexts(): Promise<{
222
+ entries: CaptureEntry[];
223
+ textByHash: Map<string, string>;
224
+ }>;
225
+ /** Append-only observation runs (the same ObservationStore contract as before). */
226
+ observations: ObservationStore;
227
+ }
228
+ /** The CLI's store: captures + observations under the profile market home. */
229
+ export declare function createFileMarketStore(category: string, options?: {
230
+ capturesDir?: string;
231
+ observationsDir?: string;
232
+ }): MarketStore;
233
+ /**
234
+ * In-memory store: seam tests, and throwaway grounding captures (e.g. the
235
+ * taxonomy proposer's bootstrap pass) that should never persist anywhere.
236
+ */
237
+ export declare function createMemoryMarketStore(category: string): MarketStore;
200
238
  /**
201
239
  * Whitespace-only normalization for span matching, plus one extraction
202
240
  * artifact: the HTML-to-text step can emit a line break before punctuation