fullstackgtm 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.14.0] — 2026-06-11
9
+
10
+ LLM-powered call intelligence, bring-your-own-key. The dry-run replications
11
+ of two real client pipelines proved the deterministic baseline is an
12
+ analytics floor, not what call workflows actually run on — so the no-LLM
13
+ guardrail is consciously retired for the `call` commands (and only there).
14
+
15
+ ### Added
16
+
17
+ - **LLM extraction is the default for `call parse`** — constrained tool
18
+ calls against Anthropic (default claude-haiku-4-5) or OpenAI (default
19
+ gpt-4o-mini) via raw fetch, no SDK dependency. Mandatory verbatim-quote
20
+ evidence; next steps carry owner/deadline/commitment; every insight is
21
+ provenance-marked (`extractor: "llm:<provider>:<model>"` vs
22
+ `"deterministic"`). `--deterministic` keeps the free keyword baseline.
23
+ - **First-touch key onboarding**: the first LLM command without a key (TTY)
24
+ explains, captures the key via muted prompt/stdin, auto-detects the
25
+ provider from the prefix, validates it against the provider's API, and
26
+ stores it 0600 in the credential store. Non-interactive contexts get an
27
+ actionable error, never a prompt. Also: `fullstackgtm login anthropic|openai`
28
+ and `logout`, and a `doctor` row showing LLM key status.
29
+ - **`fullstackgtm call score`**: rubric-driven coaching scorecards —
30
+ built-in five-dimension rubric or `--rubric rubric.json`
31
+ ({ scale, dimensions: [{ name, weight, rubric }] }); per-dimension
32
+ verbatim evidence + coaching notes; the weighted overall is computed
33
+ deterministically client-side. Markdown table or `--json`.
34
+ - **MCP `fullstackgtm_call_parse` gains `extractor: auto|llm|deterministic`**
35
+ (auto uses a key when the server has one, else the baseline) and `--model`.
36
+
37
+ ### Security
38
+
39
+ - LLM keys follow the same discipline as CRM secrets: stdin/prompt only,
40
+ never argv; 0600 store; provider error bodies never echoed (status line
41
+ only); keys go directly to api.anthropic.com / api.openai.com.
42
+
8
43
  ## [0.13.1] — 2026-06-11
9
44
 
10
45
  ### Fixed
@@ -49,6 +49,12 @@ In an agent sandbox, prefer rung 1 or 2. Never echo tokens into argv —
49
49
  environments — login flows then print verification URLs instead of opening
50
50
  the OS browser.
51
51
 
52
+ LLM calls (`call parse`, `call score`): set `ANTHROPIC_API_KEY` or
53
+ `OPENAI_API_KEY` in the environment, or have the human run
54
+ `echo "$KEY" | fullstackgtm login anthropic` once. Without a key, use
55
+ `call parse --deterministic` (free keyword baseline, no prompt). In
56
+ non-interactive contexts the CLI never prompts — it fails with this guidance.
57
+
52
58
  Provider prerequisites (what the human must create, and which scopes) are in
53
59
  the README's **"Connect your CRM"** section: HubSpot needs a private app with
54
60
  four `crm.objects.*.read` scopes (plus write scopes only for `apply`);
package/README.md CHANGED
@@ -49,22 +49,30 @@ Nothing is ever written without an explicit `--approve`. Operations whose value
49
49
 
50
50
  ## Call workflows: calls become governed evidence
51
51
 
52
- Calls are where pipeline truth lives. `call parse` normalizes any transcript dialect — `Speaker: text` lines (Fathom, Gong exports), `[Speaker]:` labels, or raw Granola utterance JSON — into canonical segments, deterministic keyword-derived insights (next steps, objections, pricing, risks, competitor mentions…), and `GtmEvidence` records, all LLM-free and byte-stable per transcript. `call link` answers "which deal was this call about" from attendee domains (account domain or contact emails → open deals, most recent activity first, with confidence + reason). `call plan` turns next-step insights into the same governed plan lifecycle as everything else.
52
+ Calls are where pipeline truth lives. `call parse` normalizes any transcript dialect — `Speaker: text` lines (Fathom, Gong exports), `[Speaker]:` labels, or raw Granola utterance JSON — into canonical segments, insights, and `GtmEvidence` records.
53
+
54
+ **Extraction is LLM-powered by default, with your own key.** The first time you run `call parse` or `call score`, the CLI asks for an Anthropic or OpenAI API key (auto-detected from the prefix), validates it against the provider, and stores it in the 0600 credential store — same treatment as CRM logins. Or skip the prompt: set `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`, or `echo "$KEY" | fullstackgtm login anthropic` (or `openai`). The key talks directly to your provider — raw fetch, no SDK, no middleman. `--model` overrides the defaults (claude-haiku-4-5 / gpt-4o-mini). LLM insights carry verbatim-quote evidence and, for next steps, owner/deadline/commitment. Pass `--deterministic` for the free, instant, byte-stable keyword baseline (no key needed — right for CI and warehouse bulk loads). Every insight is provenance-marked (`extractor: "llm:anthropic:…"` vs `"deterministic"`).
55
+
56
+ **`call score`** rates the call against a coaching rubric — five built-in dimensions (discovery, next steps, stakeholders, value, objections) or your own via `--rubric rubric.json` (`{ scale, dimensions: [{ name, weight, rubric }] }`); the weighted overall is computed deterministically client-side, every dimension score is evidence-quoted, and the rubric file is where your client-specific coaching framework lives.
57
+
58
+ `call link` answers "which deal was this call about" from attendee domains (account domain or contact emails → open deals, most recent activity first, with confidence + reason). `call plan` turns next-step insights into the same governed plan lifecycle as everything else.
53
59
 
54
60
  ```bash
55
- # Coaching/score pipeline (Slack + CRM): parse → link → govern the writeback
56
- fullstackgtm call parse --transcript call.txt --title "Acme disco" --out parsed.json
57
- fullstackgtm call link --attendees jane@acme.com --provider hubspot # deal id + reason
61
+ # Coaching pipeline (Slack + CRM): parse → score → link → govern the writeback
62
+ fullstackgtm call parse --transcript call.txt --title "Acme disco" --out parsed.json # LLM extraction
63
+ fullstackgtm call score --call parsed.json --rubric team-rubric.json # evidence-quoted scorecard
64
+ fullstackgtm call link --attendees jane@acme.com --provider hubspot # → deal id + reason
58
65
  fullstackgtm call plan --call parsed.json --deal 123 --provider hubspot --save
59
66
  # review → plans approve → apply: deal.next_step + follow-up tasks, compare-and-set protected
60
- # (LLM scoring/extraction stays in your own pipeline; pipe parsed.json into it and into Slack)
67
+ # (pipe the parse/score JSON into Slack/Notion however you like)
61
68
 
62
69
  # Analytics pipeline (warehouse): one flat NDJSON row per insight
63
- for t in transcripts/*; do fullstackgtm call parse --transcript "$t" --ndjson; done > insights.ndjson
70
+ for t in transcripts/*; do fullstackgtm call parse --transcript "$t" --ndjson --deterministic; done > insights.ndjson
71
+ # free keyword baseline for bulk loads; drop --deterministic for LLM-quality rows
64
72
  # COPY into your warehouse (stable call/evidence ids make reloads idempotent)
65
73
  ```
66
74
 
67
- The deliberate boundary: parsing, linking, and governed writeback are deterministic CLI primitives; LLM scoring, rubric extraction, and Slack/Notion/warehouse sinks are *your* pipeline, composed around the JSON.
75
+ The boundary that remains: Slack/Notion/warehouse sinks are *your* pipeline, composed around the JSON — and your rubrics and keys stay yours.
68
76
 
69
77
  ## From findings to fixes: the suggest chain
70
78
 
package/dist/calls.d.ts CHANGED
@@ -36,6 +36,8 @@ export type ParsedCall = {
36
36
  id: string;
37
37
  title?: string;
38
38
  sourceSystem: GtmEvidenceSourceSystem;
39
+ /** What produced the insights: "deterministic" or "llm:<provider>:<model>". */
40
+ extractor: string;
39
41
  segments: ParsedTranscriptSegment[];
40
42
  insights: ExtractedCallInsight[];
41
43
  evidence: GtmEvidence[];
@@ -52,6 +54,9 @@ export declare function parseCall(raw: string, options?: {
52
54
  title?: string;
53
55
  sourceSystem?: GtmEvidenceSourceSystem;
54
56
  capturedAt?: string;
57
+ /** Pre-extracted insights (e.g. LLM); skips the deterministic extractor. */
58
+ insights?: ExtractedCallInsight[];
59
+ extractor?: string;
55
60
  }): ParsedCall;
56
61
  export type CallDealSuggestion = {
57
62
  dealId: string | null;
package/dist/calls.js CHANGED
@@ -246,7 +246,7 @@ export function normalizeTranscript(raw) {
246
246
  export function parseCall(raw, options = {}) {
247
247
  const normalized = normalizeTranscript(raw);
248
248
  const segments = parseTranscript(normalized);
249
- const insights = extractCallInsights(normalized, segments);
249
+ const insights = options.insights ?? extractCallInsights(normalized, segments);
250
250
  const sourceSystem = options.sourceSystem ?? "manual";
251
251
  const id = `call_${callHash(normalized)}`;
252
252
  const evidence = insights.map((insight, index) => ({
@@ -258,6 +258,7 @@ export function parseCall(raw, options = {}) {
258
258
  text: insight.evidence,
259
259
  capturedAt: options.capturedAt,
260
260
  metadata: {
261
+ extractor: options.extractor ?? "deterministic",
261
262
  insightType: insight.type,
262
263
  speaker: insight.speaker,
263
264
  confidence: insight.confidence,
@@ -269,6 +270,7 @@ export function parseCall(raw, options = {}) {
269
270
  id,
270
271
  title: options.title,
271
272
  sourceSystem,
273
+ extractor: options.extractor ?? "deterministic",
272
274
  segments,
273
275
  insights,
274
276
  evidence,
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type LlmProvider } from "./llm.ts";
1
2
  type ProviderDoctorStatus = {
2
3
  source: "env" | "stored" | "broker" | "none";
3
4
  detail: string;
@@ -29,6 +30,17 @@ export declare function doctorReport(env?: Record<string, string | undefined>):
29
30
  paired: boolean;
30
31
  baseUrl?: undefined;
31
32
  };
33
+ llm: {
34
+ configured: boolean;
35
+ provider: LlmProvider;
36
+ source: "env" | "stored";
37
+ detail?: undefined;
38
+ } | {
39
+ configured: boolean;
40
+ detail: string;
41
+ provider?: undefined;
42
+ source?: undefined;
43
+ };
32
44
  mcp: {
33
45
  peersInstalled: boolean;
34
46
  missing: string[];
package/dist/cli.js CHANGED
@@ -17,7 +17,8 @@ import { createFilePlanStore } from "./planStore.js";
17
17
  import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
18
18
  import { builtinAuditRules } from "./rules.js";
19
19
  import { sampleSnapshot } from "./sampleData.js";
20
- import { parseCall, suggestCallDeal } from "./calls.js";
20
+ import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
21
+ import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
21
22
  import { suggestValues } from "./suggest.js";
22
23
  function usage() {
23
24
  return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
@@ -30,7 +31,7 @@ Usage:
30
31
  fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>]
31
32
  fullstackgtm login salesforce --instance-url <url> [--no-validate]
32
33
  fullstackgtm login stripe [--no-validate]
33
- fullstackgtm logout <hubspot|salesforce|stripe|broker>
34
+ fullstackgtm login anthropic | openai store an LLM API key for call parse/score\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|broker>
34
35
 
35
36
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
36
37
  the process list and shell history. Pipe them on stdin or enter them at the
@@ -41,11 +42,15 @@ Usage:
41
42
  fullstackgtm report [source options] [audit options] [report options]
42
43
  fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
43
44
  fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
44
- fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--json|--ndjson] [--out <path>]
45
+ fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
46
+ fullstackgtm call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
45
47
  fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
46
48
  fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
47
- calls become evidence: parse dialects (Speaker:/[Me]/Granola JSON),
48
- link to the right deal, and propose governed next-step writes
49
+ calls become evidence: LLM extraction by default (bring your own
50
+ Anthropic or OpenAI key captured once on first use, or
51
+ ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
52
+ --deterministic uses the free keyword baseline. Then link the call
53
+ to its deal and propose governed next-step writes.
49
54
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
50
55
  derive values for requires_human_* placeholders
51
56
  from snapshot evidence, with confidence + reasons
@@ -413,7 +418,7 @@ function parseValueOverrides(args) {
413
418
  }
414
419
  async function callCommand(args) {
415
420
  const [subcommand, ...rest] = args;
416
- const loadParsedCall = () => {
421
+ const loadParsedCall = async () => {
417
422
  const callPath = option(rest, "--call");
418
423
  if (callPath) {
419
424
  return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
@@ -423,14 +428,30 @@ async function callCommand(args) {
423
428
  throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
424
429
  const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
425
430
  const source = option(rest, "--source");
426
- return parseCall(raw, {
431
+ const base = {
427
432
  title: option(rest, "--title") ?? undefined,
428
433
  sourceSystem: source,
429
434
  capturedAt: new Date().toISOString(),
435
+ };
436
+ if (rest.includes("--deterministic")) {
437
+ return parseCall(raw, base);
438
+ }
439
+ // LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
440
+ const credential = await requireLlmCredential();
441
+ const normalized = normalizeTranscript(raw);
442
+ const { insights, model } = await extractInsightsLlm(normalized, {
443
+ ...credential,
444
+ model: option(rest, "--model") ?? undefined,
445
+ title: base.title,
446
+ });
447
+ return parseCall(raw, {
448
+ ...base,
449
+ insights,
450
+ extractor: `llm:${credential.provider}:${model}`,
430
451
  });
431
452
  };
432
453
  if (subcommand === "parse") {
433
- const parsed = loadParsedCall();
454
+ const parsed = await loadParsedCall();
434
455
  const outPath = option(rest, "--out");
435
456
  if (outPath)
436
457
  writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
@@ -490,7 +511,7 @@ async function callCommand(args) {
490
511
  const dealId = option(rest, "--deal");
491
512
  if (!dealId)
492
513
  throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
493
- const parsed = loadParsedCall();
514
+ const parsed = await loadParsedCall();
494
515
  const snapshot = await readSnapshot(rest);
495
516
  const deal = snapshot.deals.find((row) => row.id === dealId);
496
517
  if (!deal)
@@ -512,7 +533,95 @@ async function callCommand(args) {
512
533
  console.log(rest.includes("--json") ? JSON.stringify(plan, null, 2) : patchPlanToMarkdown(plan));
513
534
  return;
514
535
  }
515
- throw new Error(`call supports: parse, link, plan (got ${subcommand ?? "nothing"})`);
536
+ if (subcommand === "score") {
537
+ const credential = await requireLlmCredential();
538
+ const rubricPath = option(rest, "--rubric");
539
+ const rubric = rubricPath
540
+ ? parseRubric(readFileSync(resolve(process.cwd(), rubricPath), "utf8"))
541
+ : DEFAULT_RUBRIC;
542
+ const transcriptPath = option(rest, "--transcript");
543
+ let transcriptText;
544
+ let title = option(rest, "--title") ?? undefined;
545
+ if (transcriptPath) {
546
+ transcriptText = normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
547
+ }
548
+ else {
549
+ const callPath = option(rest, "--call");
550
+ if (!callPath)
551
+ throw new Error("call score requires --transcript <file> or --call <parsed.json>");
552
+ const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
553
+ transcriptText = parsed.segments
554
+ .map((segment) => (segment.speaker ? `${segment.speaker}: ${segment.text}` : segment.text))
555
+ .join("\n");
556
+ title = title ?? parsed.title;
557
+ }
558
+ const scorecard = await scoreCallLlm(transcriptText, rubric, {
559
+ ...credential,
560
+ model: option(rest, "--model") ?? undefined,
561
+ title,
562
+ });
563
+ const outPath = option(rest, "--out");
564
+ if (outPath)
565
+ writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(scorecard, null, 2)}\n`);
566
+ if (rest.includes("--json")) {
567
+ console.log(JSON.stringify(scorecard, null, 2));
568
+ return;
569
+ }
570
+ console.log(renderScorecard(scorecard, title));
571
+ return;
572
+ }
573
+ throw new Error(`call supports: parse, link, plan, score (got ${subcommand ?? "nothing"})`);
574
+ }
575
+ /**
576
+ * First-touch key onboarding: env vars win, then the credential store; on a
577
+ * TTY a missing key is captured once (validated, stored 0600 like provider
578
+ * logins). Non-interactive contexts get an actionable error instead.
579
+ */
580
+ async function requireLlmCredential() {
581
+ const resolved = resolveLlmCredential();
582
+ if (resolved)
583
+ return resolved;
584
+ if (!process.stdin.isTTY) {
585
+ throw new Error("LLM extraction needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, run `echo \"$KEY\" | fullstackgtm login anthropic` (or `login openai`) once, or pass --deterministic for the free keyword baseline.");
586
+ }
587
+ console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
588
+ console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
589
+ console.error("(Alternatives: set ANTHROPIC_API_KEY / OPENAI_API_KEY, or pass --deterministic for the free keyword baseline.)\n");
590
+ const apiKey = await readSecret("API key (sk-ant-... or sk-...): ");
591
+ const provider = detectProviderFromKey(apiKey);
592
+ const validation = await validateLlmKey(provider, apiKey);
593
+ if (!validation.ok)
594
+ throw new Error(`${provider} rejected the key: ${validation.detail}`);
595
+ const now = new Date().toISOString();
596
+ storeCredential(provider, { kind: "api_key", accessToken: apiKey, createdAt: now, updatedAt: now });
597
+ console.error(`Stored ${provider} key (${validation.detail}). Future runs use it automatically; remove with \`fullstackgtm logout ${provider}\`.\n`);
598
+ return { provider, apiKey };
599
+ }
600
+ function renderScorecard(scorecard, title) {
601
+ const lines = [
602
+ `# Coaching Scorecard${title ? ` — ${title}` : ""}`,
603
+ "",
604
+ `**Overall: ${scorecard.overallScore}/${scorecard.scale}** (model: ${scorecard.model})`,
605
+ "",
606
+ "| Dimension | Score | | Coaching note |",
607
+ "| --- | --- | --- | --- |",
608
+ ];
609
+ for (const dim of scorecard.dimensions) {
610
+ const filled = Math.round((dim.score / dim.maxScore) * 5);
611
+ const bar = "█".repeat(filled) + "░".repeat(5 - filled);
612
+ lines.push(`| ${dim.name} | ${dim.score}/${dim.maxScore} | ${bar} | ${dim.coachingNote} |`);
613
+ }
614
+ if (scorecard.highlights.length) {
615
+ lines.push("", "**Highlights**");
616
+ for (const h of scorecard.highlights)
617
+ lines.push(`- ${h}`);
618
+ }
619
+ if (scorecard.missedItems.length) {
620
+ lines.push("", "**Missed**");
621
+ for (const m of scorecard.missedItems)
622
+ lines.push(`- ${m}`);
623
+ }
624
+ return lines.join("\n");
516
625
  }
517
626
  function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
518
627
  const findings = [];
@@ -1067,8 +1176,23 @@ async function login(args) {
1067
1176
  console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
1068
1177
  return;
1069
1178
  }
1179
+ if (provider === "anthropic" || provider === "openai") {
1180
+ const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
1181
+ if (!key)
1182
+ throw new Error(`No ${provider} key provided.`);
1183
+ if (!args.includes("--no-validate")) {
1184
+ const validation = await validateLlmKey(provider, key);
1185
+ if (!validation.ok)
1186
+ throw new Error(`${provider} rejected the key: ${validation.detail}`);
1187
+ console.log(validation.detail);
1188
+ }
1189
+ const stamp = new Date().toISOString();
1190
+ storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
1191
+ console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
1192
+ return;
1193
+ }
1070
1194
  if (provider !== "hubspot") {
1071
- throw new Error("login supports: hubspot, salesforce, stripe, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
1195
+ throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
1072
1196
  }
1073
1197
  const now = new Date().toISOString();
1074
1198
  if (args.includes("--oauth")) {
@@ -1150,6 +1274,7 @@ export function doctorReport(env = process.env) {
1150
1274
  ? { source: "env", detail: "STRIPE_SECRET_KEY" }
1151
1275
  : providerStatus("stripe", broker),
1152
1276
  };
1277
+ const llm = resolveLlmCredential(env);
1153
1278
  const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
1154
1279
  try {
1155
1280
  import.meta.resolve(name);
@@ -1174,6 +1299,9 @@ export function doctorReport(env = process.env) {
1174
1299
  config: { path: configPath, exists: existsSync(configPath) },
1175
1300
  providers,
1176
1301
  broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
1302
+ llm: llm
1303
+ ? { configured: true, provider: llm.provider, source: llm.source }
1304
+ : { configured: false, detail: "call parse/score will prompt once, or set ANTHROPIC_API_KEY / OPENAI_API_KEY" },
1177
1305
  mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
1178
1306
  nextSteps,
1179
1307
  };
@@ -1215,6 +1343,7 @@ function doctorCommand(args) {
1215
1343
  "Providers:",
1216
1344
  ...Object.entries(report.providers).map(([provider, status]) => ` ${provider.padEnd(11)} ${status.source === "none" ? `not connected (${status.detail})` : `${status.source}: ${status.detail}`}`),
1217
1345
  ` ${"broker".padEnd(11)} ${report.broker.paired ? `paired with ${report.broker.baseUrl}` : "not paired (fullstackgtm login --via <hosted url>)"}`,
1346
+ ` ${"llm".padEnd(11)} ${report.llm.configured ? `${report.llm.provider} key (${report.llm.source}) — call parse/score ready` : `not configured (${report.llm.detail})`}`,
1218
1347
  "",
1219
1348
  report.mcp.peersInstalled
1220
1349
  ? "MCP: peers installed — `fullstackgtm-mcp` is ready"
@@ -23,7 +23,7 @@ export declare function baseHomeDir(): string;
23
23
  */
24
24
  export declare function listProfiles(): string[];
25
25
  export type StoredCredential = {
26
- kind: "private_app" | "oauth" | "broker";
26
+ kind: "private_app" | "oauth" | "broker" | "api_key";
27
27
  accessToken: string;
28
28
  refreshToken?: string;
29
29
  /** Epoch ms when the access token expires (oauth only). */
package/dist/index.d.ts CHANGED
@@ -17,5 +17,6 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
17
17
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
18
18
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
19
19
  export { sampleSnapshot } from "./sampleData.ts";
20
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
20
21
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
21
22
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -17,4 +17,5 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
17
17
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
18
18
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
19
19
  export { sampleSnapshot } from "./sampleData.js";
20
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
20
21
  export { suggestValues } from "./suggest.js";
package/dist/llm.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { ExtractedCallInsight } from "./calls.ts";
2
+ /**
3
+ * LLM-powered call extraction and scoring. Bring-your-own-key, two providers
4
+ * (Anthropic, OpenAI), raw fetch — no SDK dependency, mirroring how the CRM
5
+ * connectors talk to their APIs. Constrained tool calls keep the output in
6
+ * the same canonical insight shape as the deterministic engine, with
7
+ * mandatory verbatim-quote evidence; every insight is provenance-marked with
8
+ * the extractor that produced it.
9
+ */
10
+ export type LlmProvider = "anthropic" | "openai";
11
+ export type LlmCredential = {
12
+ provider: LlmProvider;
13
+ apiKey: string;
14
+ source: "env" | "stored";
15
+ };
16
+ export declare const DEFAULT_MODELS: Record<LlmProvider, string>;
17
+ export declare function detectProviderFromKey(apiKey: string): LlmProvider;
18
+ /** Env first (ANTHROPIC_API_KEY, then OPENAI_API_KEY), then the credential store. */
19
+ export declare function resolveLlmCredential(env?: Record<string, string | undefined>): LlmCredential | null;
20
+ export type LlmCallOptions = {
21
+ provider: LlmProvider;
22
+ apiKey: string;
23
+ model?: string;
24
+ fetchImpl?: typeof fetch;
25
+ };
26
+ export type LlmExtractedInsight = ExtractedCallInsight & {
27
+ owner?: string;
28
+ deadline?: string;
29
+ commitment?: "firm" | "tentative" | "exploratory";
30
+ };
31
+ export declare function extractInsightsLlm(transcript: string, options: LlmCallOptions & {
32
+ title?: string;
33
+ }): Promise<{
34
+ insights: LlmExtractedInsight[];
35
+ model: string;
36
+ }>;
37
+ export type Rubric = {
38
+ scale: number;
39
+ dimensions: Array<{
40
+ name: string;
41
+ weight: number;
42
+ rubric: string;
43
+ }>;
44
+ };
45
+ export declare const DEFAULT_RUBRIC: Rubric;
46
+ export type ScoredDimension = {
47
+ name: string;
48
+ score: number;
49
+ maxScore: number;
50
+ weight: number;
51
+ evidence: string[];
52
+ coachingNote: string;
53
+ };
54
+ export type CallScorecard = {
55
+ dimensions: ScoredDimension[];
56
+ /** Weighted average, computed deterministically client-side. */
57
+ overallScore: number;
58
+ scale: number;
59
+ highlights: string[];
60
+ missedItems: string[];
61
+ model: string;
62
+ };
63
+ export declare function scoreCallLlm(transcript: string, rubric: Rubric, options: LlmCallOptions & {
64
+ title?: string;
65
+ }): Promise<CallScorecard>;
66
+ export declare function parseRubric(json: string): Rubric;
67
+ /** Cheap key validation against the provider's model-list endpoint. Status line only. */
68
+ export declare function validateLlmKey(provider: LlmProvider, apiKey: string, fetchImpl?: typeof fetch): Promise<{
69
+ ok: boolean;
70
+ detail: string;
71
+ }>;