fullstackgtm 0.45.0 → 0.47.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 (95) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +27 -18
  4. package/dist/backfill.d.ts +86 -0
  5. package/dist/backfill.js +251 -0
  6. package/dist/bin.js +4 -1
  7. package/dist/cli/auth.d.ts +2 -0
  8. package/dist/cli/auth.js +119 -12
  9. package/dist/cli/backfill.d.ts +1 -0
  10. package/dist/cli/backfill.js +125 -0
  11. package/dist/cli/backfillRuns.d.ts +1 -0
  12. package/dist/cli/backfillRuns.js +187 -0
  13. package/dist/cli/call.js +23 -9
  14. package/dist/cli/enrich.js +28 -10
  15. package/dist/cli/fix.js +9 -7
  16. package/dist/cli/help.js +60 -23
  17. package/dist/cli/plans.js +13 -11
  18. package/dist/cli/shared.d.ts +4 -3
  19. package/dist/cli/shared.js +31 -20
  20. package/dist/cli/ui.d.ts +21 -0
  21. package/dist/cli/ui.js +53 -1
  22. package/dist/cli.js +37 -41
  23. package/dist/connector.d.ts +8 -0
  24. package/dist/connector.js +104 -21
  25. package/dist/connectors/hubspot.d.ts +8 -1
  26. package/dist/connectors/hubspot.js +406 -13
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +8 -1
  30. package/dist/connectors/salesforce.js +163 -2
  31. package/dist/connectors/stripe.d.ts +37 -0
  32. package/dist/connectors/stripe.js +103 -31
  33. package/dist/enrich.d.ts +7 -0
  34. package/dist/enrich.js +7 -0
  35. package/dist/health.d.ts +11 -69
  36. package/dist/health.js +4 -134
  37. package/dist/healthScore.d.ts +71 -0
  38. package/dist/healthScore.js +143 -0
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/llm.d.ts +29 -0
  42. package/dist/llm.js +206 -0
  43. package/dist/market.d.ts +39 -1
  44. package/dist/market.js +116 -44
  45. package/dist/marketClassify.d.ts +9 -1
  46. package/dist/marketClassify.js +10 -1
  47. package/dist/marketTaxonomy.d.ts +6 -1
  48. package/dist/marketTaxonomy.js +4 -2
  49. package/dist/mcp-bin.js +29 -0
  50. package/dist/mcp.js +117 -4
  51. package/dist/progress.d.ts +96 -0
  52. package/dist/progress.js +142 -0
  53. package/dist/runReport.d.ts +24 -0
  54. package/dist/runReport.js +139 -4
  55. package/dist/types.d.ts +33 -1
  56. package/docs/api.md +4 -2
  57. package/docs/architecture.md +2 -0
  58. package/docs/linkedin-connector-spec.md +1 -1
  59. package/llms.txt +3 -3
  60. package/package.json +10 -3
  61. package/skills/fullstackgtm/SKILL.md +1 -0
  62. package/src/backfill.ts +340 -0
  63. package/src/bin.ts +5 -1
  64. package/src/cli/auth.ts +135 -15
  65. package/src/cli/backfill.ts +156 -0
  66. package/src/cli/backfillRuns.ts +198 -0
  67. package/src/cli/call.ts +26 -10
  68. package/src/cli/enrich.ts +33 -10
  69. package/src/cli/fix.ts +11 -10
  70. package/src/cli/help.ts +61 -23
  71. package/src/cli/plans.ts +15 -14
  72. package/src/cli/shared.ts +44 -27
  73. package/src/cli/ui.ts +72 -1
  74. package/src/cli.ts +38 -41
  75. package/src/connector.ts +110 -16
  76. package/src/connectors/hubspot.ts +423 -14
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +168 -3
  80. package/src/connectors/stripe.ts +154 -34
  81. package/src/enrich.ts +13 -0
  82. package/src/health.ts +14 -213
  83. package/src/healthScore.ts +223 -0
  84. package/src/index.ts +28 -1
  85. package/src/llm.ts +239 -0
  86. package/src/market.ts +157 -44
  87. package/src/marketClassify.ts +18 -1
  88. package/src/marketTaxonomy.ts +10 -2
  89. package/src/mcp-bin.ts +32 -0
  90. package/src/mcp.ts +140 -6
  91. package/src/progress.ts +197 -0
  92. package/src/runReport.ts +159 -4
  93. package/src/types.ts +35 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. package/docs/roadmap-to-1.0.md +0 -211
package/dist/health.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { activeProfile, credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
4
+ // Pure scoring lives in healthScore.ts (node-free, importable from V8 runtimes
5
+ // like the hosted app's Convex queries); this module owns the fs-backed
6
+ // profile timeline. Re-exported so existing imports keep working.
7
+ export { computeHealth, summarizeHealth, healthToMarkdown, } from "./healthScore.js";
4
8
  /**
5
9
  * The engagement workspace: a per-client health timeline that accrues from the
6
10
  * verb people already run (`audit --save`). State lives in the profile dir
@@ -11,140 +15,6 @@ import { activeProfile, credentialsDir, ensureSecureHomeDir, writeSecureFile } f
11
15
  * The score is DETERMINISTIC (no LLM): same plan + same snapshot → same score,
12
16
  * so it is stable in CI and comparable run-over-run.
13
17
  */
14
- // Severity weights: a critical finding costs ~3× a warning, ~10× an info.
15
- const SEVERITY_WEIGHT = { info: 1, warning: 3, critical: 10 };
16
- /**
17
- * Deterministically score a saved audit. score = 100 / (1 + weighted findings
18
- * per record): 0 findings → 100; one weighted finding per record → 50; the
19
- * curve is bounded (0, 100], monotonic, and needs no clamping or magic
20
- * constants. A messy 50-record CRM scores worse than the same finding count
21
- * across 5,000 records, which is the point.
22
- */
23
- export function computeHealth(plan, snapshot, at) {
24
- const byRule = {};
25
- const severityCounts = { info: 0, warning: 0, critical: 0 };
26
- const typeTally = {
27
- account: { findings: 0, weightedFindings: 0 },
28
- contact: { findings: 0, weightedFindings: 0 },
29
- deal: { findings: 0, weightedFindings: 0 },
30
- };
31
- let weightedFindings = 0;
32
- for (const finding of plan.findings) {
33
- byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
34
- severityCounts[finding.severity] += 1;
35
- weightedFindings += SEVERITY_WEIGHT[finding.severity];
36
- const t = finding.objectType;
37
- if (t === "account" || t === "contact" || t === "deal") {
38
- typeTally[t].findings += 1;
39
- typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
40
- }
41
- }
42
- const records = {
43
- accounts: snapshot.accounts.length,
44
- contacts: snapshot.contacts.length,
45
- deals: snapshot.deals.length,
46
- total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
47
- };
48
- const scoreFor = (weighted, recordCount) => Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
49
- const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
50
- const byObjectType = ["account", "contact", "deal"].reduce((acc, t) => {
51
- acc[t] = {
52
- records: recordsByType[t],
53
- findings: typeTally[t].findings,
54
- weightedFindings: typeTally[t].weightedFindings,
55
- score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
56
- };
57
- return acc;
58
- }, {});
59
- const score = scoreFor(weightedFindings, records.total);
60
- return {
61
- at,
62
- planId: plan.id,
63
- score,
64
- findings: plan.findings.length,
65
- weightedFindings,
66
- records,
67
- byRule,
68
- severityCounts,
69
- byObjectType,
70
- };
71
- }
72
- /** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
73
- export function summarizeHealth(entries, profile) {
74
- if (entries.length === 0)
75
- return null;
76
- // Oldest → newest, so `current` is the last entry.
77
- const sorted = [...entries].sort((a, b) => a.at.localeCompare(b.at));
78
- const current = sorted[sorted.length - 1];
79
- const previous = sorted.length > 1 ? sorted[sorted.length - 2] : null;
80
- const ruleIds = new Set([
81
- ...Object.keys(current.byRule),
82
- ...(previous ? Object.keys(previous.byRule) : []),
83
- ]);
84
- const ruleDeltas = [...ruleIds]
85
- .map((ruleId) => {
86
- const cur = current.byRule[ruleId] ?? 0;
87
- const prev = previous?.byRule[ruleId] ?? 0;
88
- return { ruleId, current: cur, previous: prev, delta: cur - prev };
89
- })
90
- .sort((a, b) => b.current - a.current || a.ruleId.localeCompare(b.ruleId));
91
- return {
92
- profile,
93
- auditCount: sorted.length,
94
- first: sorted[0].at,
95
- latest: current.at,
96
- current,
97
- previous,
98
- scoreDelta: previous ? current.score - previous.score : null,
99
- ruleDeltas,
100
- history: sorted.map((entry) => ({ at: entry.at, score: entry.score, findings: entry.findings })),
101
- };
102
- }
103
- // Sign-based and uniform: ▲ = the number went up, ▼ = down. The reader supplies
104
- // the meaning (score up = good; findings up = bad) — mixing arrow direction with
105
- // good/bad reads as contradictory ("▲ -3").
106
- function arrow(delta) {
107
- if (delta === 0)
108
- return "·";
109
- return delta > 0 ? "▲" : "▼";
110
- }
111
- /** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
112
- export function healthToMarkdown(rollup) {
113
- const { current, scoreDelta } = rollup;
114
- const lines = [`# GTM health — profile \`${rollup.profile}\``, ""];
115
- const deltaText = scoreDelta === null
116
- ? "(first audit — no prior reading)"
117
- : `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
118
- 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`, "");
119
- lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
120
- for (const t of ["account", "contact", "deal"]) {
121
- const b = current.byObjectType[t];
122
- lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
123
- }
124
- lines.push("");
125
- if (rollup.history.length > 1) {
126
- lines.push("## Trend", "");
127
- for (const point of rollup.history) {
128
- const marker = point.at === rollup.latest ? " ← latest" : "";
129
- lines.push(`- ${shortDate(point.at)} score ${point.score} (${point.findings} findings)${marker}`);
130
- }
131
- lines.push("");
132
- }
133
- if (rollup.ruleDeltas.length > 0) {
134
- lines.push("## By rule (Δ since previous audit)", "", "| Rule | Findings | Δ |", "| --- | --- | --- |");
135
- for (const rule of rollup.ruleDeltas) {
136
- const sign = rule.delta > 0 ? `+${rule.delta}` : `${rule.delta}`;
137
- const cell = rule.delta === 0 ? "·" : `${arrow(rule.delta)} ${sign}`;
138
- lines.push(`| ${rule.ruleId} | ${rule.current} | ${cell} |`);
139
- }
140
- lines.push("");
141
- }
142
- lines.push("> Score is deterministic: 100 / (1 + severity-weighted findings per record). Lower findings or more clean records ⇒ higher score.");
143
- return `${lines.join("\n")}\n`;
144
- }
145
- function shortDate(iso) {
146
- return iso.slice(0, 10);
147
- }
148
18
  // ── Profile-scoped storage ──────────────────────────────────────────────────
149
19
  /** `$FSGTM_HOME[/profiles/<name>]/health.jsonl` — append-only, one entry per audit-save. */
150
20
  export function healthFilePath() {
@@ -0,0 +1,71 @@
1
+ import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
2
+ export type HealthEntry = {
3
+ /** ISO timestamp of the audit that produced this entry. */
4
+ at: string;
5
+ /** The saved plan this entry summarizes (correlates to plans/ and snapshots/). */
6
+ planId: string;
7
+ /** Deterministic 0–100 hygiene score (higher is healthier). */
8
+ score: number;
9
+ /** Total finding count across all rules. */
10
+ findings: number;
11
+ /** Severity-weighted finding total (drives the score). */
12
+ weightedFindings: number;
13
+ /** Record counts at audit time — the denominator that normalizes the score. */
14
+ records: {
15
+ accounts: number;
16
+ contacts: number;
17
+ deals: number;
18
+ total: number;
19
+ };
20
+ /** Finding count per rule id. */
21
+ byRule: Record<string, number>;
22
+ /** Finding count per severity. */
23
+ severityCounts: Record<AuditFindingSeverity, number>;
24
+ /**
25
+ * Per-object-type breakdown — so "is my contact data clean but my pipeline
26
+ * messy?" is answerable without re-auditing. Each type carries its own
27
+ * record-normalized score (same curve as the overall score, scoped to that
28
+ * type's records + findings).
29
+ */
30
+ byObjectType: Record<"account" | "contact" | "deal", {
31
+ records: number;
32
+ findings: number;
33
+ weightedFindings: number;
34
+ score: number;
35
+ }>;
36
+ };
37
+ export type HealthRuleDelta = {
38
+ ruleId: string;
39
+ current: number;
40
+ previous: number;
41
+ /** current − previous: positive = more findings (worse), negative = fewer (better). */
42
+ delta: number;
43
+ };
44
+ export type HealthRollup = {
45
+ profile: string;
46
+ auditCount: number;
47
+ first: string;
48
+ latest: string;
49
+ current: HealthEntry;
50
+ previous: HealthEntry | null;
51
+ /** current.score − previous.score: positive = improving. null on the first audit. */
52
+ scoreDelta: number | null;
53
+ ruleDeltas: HealthRuleDelta[];
54
+ history: Array<{
55
+ at: string;
56
+ score: number;
57
+ findings: number;
58
+ }>;
59
+ };
60
+ /**
61
+ * Deterministically score a saved audit. score = 100 / (1 + weighted findings
62
+ * per record): 0 findings → 100; one weighted finding per record → 50; the
63
+ * curve is bounded (0, 100], monotonic, and needs no clamping or magic
64
+ * constants. A messy 50-record CRM scores worse than the same finding count
65
+ * across 5,000 records, which is the point.
66
+ */
67
+ export declare function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry;
68
+ /** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
69
+ export declare function summarizeHealth(entries: HealthEntry[], profile: string): HealthRollup | null;
70
+ /** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
71
+ export declare function healthToMarkdown(rollup: HealthRollup): string;
@@ -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/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/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
+ }