fullstackgtm 0.13.0 → 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/dist/llm.js ADDED
@@ -0,0 +1,241 @@
1
+ import { getCredential } from "./credentials.js";
2
+ export const DEFAULT_MODELS = {
3
+ anthropic: "claude-haiku-4-5",
4
+ openai: "gpt-4o-mini",
5
+ };
6
+ const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
7
+ const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
8
+ // Bound cost and context: long calls keep the head and tail.
9
+ const MAX_TRANSCRIPT_CHARS = 28_000;
10
+ export function detectProviderFromKey(apiKey) {
11
+ return apiKey.startsWith("sk-ant-") ? "anthropic" : "openai";
12
+ }
13
+ /** Env first (ANTHROPIC_API_KEY, then OPENAI_API_KEY), then the credential store. */
14
+ export function resolveLlmCredential(env = process.env) {
15
+ if (env.ANTHROPIC_API_KEY)
16
+ return { provider: "anthropic", apiKey: env.ANTHROPIC_API_KEY, source: "env" };
17
+ if (env.OPENAI_API_KEY)
18
+ return { provider: "openai", apiKey: env.OPENAI_API_KEY, source: "env" };
19
+ for (const provider of ["anthropic", "openai"]) {
20
+ const stored = getCredential(provider);
21
+ if (stored?.accessToken)
22
+ return { provider, apiKey: stored.accessToken, source: "stored" };
23
+ }
24
+ return null;
25
+ }
26
+ const INSIGHT_TYPES = [
27
+ "pain_point",
28
+ "objection",
29
+ "competitor_mention",
30
+ "next_step",
31
+ "feature_request",
32
+ "pricing",
33
+ "decision_criteria",
34
+ "risk",
35
+ "coaching_moment",
36
+ ];
37
+ const EXTRACT_SCHEMA = {
38
+ type: "object",
39
+ required: ["insights"],
40
+ properties: {
41
+ insights: {
42
+ type: "array",
43
+ items: {
44
+ type: "object",
45
+ required: ["type", "text", "evidence", "importance", "confidence"],
46
+ properties: {
47
+ type: { type: "string", enum: INSIGHT_TYPES },
48
+ text: { type: "string", description: "The insight, concise and specific (one sentence)." },
49
+ evidence: { type: "string", description: "VERBATIM quote from the transcript that grounds this insight. Never paraphrase." },
50
+ speaker: { type: "string", description: "Who said the evidence, exactly as named in the transcript." },
51
+ importance: { type: "integer", minimum: 1, maximum: 5 },
52
+ confidence: { type: "number", minimum: 0, maximum: 1 },
53
+ owner: { type: "string", description: "next_step only: who committed to the action." },
54
+ deadline: { type: "string", description: "next_step only: when, as stated (e.g. 'Thursday 2 PM')." },
55
+ commitment: { type: "string", enum: ["firm", "tentative", "exploratory"], description: "next_step only." },
56
+ },
57
+ },
58
+ },
59
+ },
60
+ };
61
+ const EXTRACT_INSTRUCTIONS = `Extract GTM insights from this sales call transcript.
62
+ Rules:
63
+ - evidence MUST be a verbatim quote from the transcript. If you cannot quote it, do not emit the insight.
64
+ - text is your concise restatement; one sentence, specific (names, numbers, dates).
65
+ - next_step insights are concrete commitments: include owner, deadline (as stated), and commitment level.
66
+ - importance: 5 = affects the deal outcome directly, 1 = color.
67
+ - Emit nothing for small talk. Quality over quantity.`;
68
+ export async function extractInsightsLlm(transcript, options) {
69
+ const model = options.model ?? DEFAULT_MODELS[options.provider];
70
+ const text = truncateTranscript(transcript);
71
+ const prompt = `${EXTRACT_INSTRUCTIONS}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
72
+ const result = (await forcedToolCall(prompt, "extract_call_insights", EXTRACT_SCHEMA, model, options));
73
+ const insights = (result.insights ?? [])
74
+ .filter((insight) => INSIGHT_TYPES.includes(insight.type))
75
+ .map((insight) => ({
76
+ ...insight,
77
+ title: insight.type.replace(/_/g, " "),
78
+ importance: clamp(Math.round(insight.importance ?? 3), 1, 5),
79
+ confidence: clamp(insight.confidence ?? 0.7, 0, 1),
80
+ }))
81
+ .sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
82
+ return { insights, model };
83
+ }
84
+ export const DEFAULT_RUBRIC = {
85
+ scale: 5,
86
+ dimensions: [
87
+ { name: "Depth of Discovery", weight: 1.2, rubric: "Did the rep uncover concrete pain, current process, and cost of inaction with specifics — 5 — or stay at surface level — 1?" },
88
+ { name: "Next Steps & Commitment", weight: 1.2, rubric: "Did the call end with a specific, time-bound, mutually agreed next step (5) or vague intentions (1)?" },
89
+ { name: "Stakeholder Engagement", weight: 1.0, rubric: "Were decision makers and influencers identified and engaged (5) or is the rep single-threaded with an unknown buying group (1)?" },
90
+ { name: "Value Articulation", weight: 1.0, rubric: "Was value tied to the prospect's own stated problems and numbers (5) or generic feature talk (1)?" },
91
+ { name: "Objection Handling", weight: 1.0, rubric: "Were concerns surfaced, acknowledged, and resolved with evidence (5), or dismissed/avoided (1)?" },
92
+ ],
93
+ };
94
+ const SCORE_SCHEMA = (scale, dimensions) => ({
95
+ type: "object",
96
+ required: ["dimensions", "highlights", "missed_items"],
97
+ properties: {
98
+ dimensions: {
99
+ type: "array",
100
+ items: {
101
+ type: "object",
102
+ required: ["name", "score", "evidence", "coaching_note"],
103
+ properties: {
104
+ name: { type: "string", enum: dimensions.map((d) => d.name) },
105
+ score: { type: "integer", minimum: 1, maximum: scale },
106
+ evidence: { type: "array", items: { type: "string" }, description: "Verbatim quotes supporting the score." },
107
+ coaching_note: { type: "string", description: "One actionable sentence, max 25 words." },
108
+ },
109
+ },
110
+ },
111
+ highlights: { type: "array", items: { type: "string" } },
112
+ missed_items: { type: "array", items: { type: "string" } },
113
+ },
114
+ });
115
+ export async function scoreCallLlm(transcript, rubric, options) {
116
+ const model = options.model ?? DEFAULT_MODELS[options.provider];
117
+ const text = truncateTranscript(transcript);
118
+ const rubricText = rubric.dimensions
119
+ .map((d) => `- ${d.name} (weight ${d.weight}): ${d.rubric}`)
120
+ .join("\n");
121
+ const prompt = `Score this sales call against the rubric. Score every dimension 1-${rubric.scale}. Ground every score in verbatim quotes; if the transcript gives no signal for a dimension, score it low and say why in the coaching note.\n\nRubric:\n${rubricText}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
122
+ const result = (await forcedToolCall(prompt, "score_call", SCORE_SCHEMA(rubric.scale, rubric.dimensions), model, options));
123
+ const byName = new Map((result.dimensions ?? []).map((d) => [d.name, d]));
124
+ const dimensions = rubric.dimensions.map((dim) => {
125
+ const scored = byName.get(dim.name);
126
+ return {
127
+ name: dim.name,
128
+ score: clamp(Math.round(scored?.score ?? 1), 1, rubric.scale),
129
+ maxScore: rubric.scale,
130
+ weight: dim.weight,
131
+ evidence: scored?.evidence ?? [],
132
+ coachingNote: scored?.coaching_note ?? "No signal for this dimension in the transcript.",
133
+ };
134
+ });
135
+ const totalWeight = dimensions.reduce((sum, d) => sum + d.weight, 0);
136
+ const overallScore = Math.round((dimensions.reduce((sum, d) => sum + d.score * d.weight, 0) / totalWeight) * 100) / 100;
137
+ return {
138
+ dimensions,
139
+ overallScore,
140
+ scale: rubric.scale,
141
+ highlights: result.highlights ?? [],
142
+ missedItems: result.missed_items ?? [],
143
+ model,
144
+ };
145
+ }
146
+ export function parseRubric(json) {
147
+ const parsed = JSON.parse(json);
148
+ if (!Array.isArray(parsed.dimensions) || parsed.dimensions.length === 0) {
149
+ throw new Error("Rubric needs a dimensions array: { scale, dimensions: [{ name, weight, rubric }] }");
150
+ }
151
+ return {
152
+ scale: parsed.scale ?? 5,
153
+ dimensions: parsed.dimensions.map((d) => ({
154
+ name: String(d.name),
155
+ weight: typeof d.weight === "number" ? d.weight : 1,
156
+ rubric: String(d.rubric ?? ""),
157
+ })),
158
+ };
159
+ }
160
+ // ── Provider plumbing (raw fetch, forced tool calls) ───────────────────────
161
+ async function forcedToolCall(prompt, toolName, schema, model, options) {
162
+ const fetchImpl = options.fetchImpl ?? fetch;
163
+ if (options.provider === "anthropic") {
164
+ const response = await llmFetch(fetchImpl, ANTHROPIC_URL, {
165
+ method: "POST",
166
+ headers: {
167
+ "x-api-key": options.apiKey,
168
+ "anthropic-version": "2023-06-01",
169
+ "Content-Type": "application/json",
170
+ },
171
+ body: JSON.stringify({
172
+ model,
173
+ max_tokens: 4096,
174
+ tools: [{ name: toolName, description: `Return the ${toolName} result.`, input_schema: schema }],
175
+ tool_choice: { type: "tool", name: toolName },
176
+ messages: [{ role: "user", content: prompt }],
177
+ }),
178
+ });
179
+ const block = response.content?.find((item) => item.type === "tool_use");
180
+ if (!block?.input)
181
+ throw new Error("Anthropic returned no tool call — try again or a different --model.");
182
+ return block.input;
183
+ }
184
+ const response = await llmFetch(fetchImpl, OPENAI_URL, {
185
+ method: "POST",
186
+ headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
187
+ body: JSON.stringify({
188
+ model,
189
+ messages: [{ role: "user", content: prompt }],
190
+ tools: [{ type: "function", function: { name: toolName, parameters: schema } }],
191
+ tool_choice: { type: "function", function: { name: toolName } },
192
+ }),
193
+ });
194
+ const call = response
195
+ .choices?.[0]?.message?.tool_calls?.[0];
196
+ if (!call?.function?.arguments)
197
+ throw new Error("OpenAI returned no tool call — try again or a different --model.");
198
+ return JSON.parse(call.function.arguments);
199
+ }
200
+ async function llmFetch(fetchImpl, url, init) {
201
+ let response;
202
+ try {
203
+ response = await fetchImpl(url, init);
204
+ }
205
+ catch (error) {
206
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
207
+ throw new Error(`Cannot reach ${new URL(url).hostname}${cause}. Check network access.`);
208
+ }
209
+ if (!response.ok) {
210
+ // Status line only — provider error bodies can reflect request content.
211
+ throw new Error(`LLM API error ${response.status} ${response.statusText} from ${new URL(url).hostname}. Check the API key (\`fullstackgtm login anthropic|openai\`) and model name.`);
212
+ }
213
+ return response.json();
214
+ }
215
+ function truncateTranscript(transcript) {
216
+ if (transcript.length <= MAX_TRANSCRIPT_CHARS)
217
+ return transcript;
218
+ const half = MAX_TRANSCRIPT_CHARS / 2;
219
+ return `${transcript.slice(0, half)}\n[... middle of transcript truncated ...]\n${transcript.slice(-half)}`;
220
+ }
221
+ function clamp(value, min, max) {
222
+ return Math.min(max, Math.max(min, value));
223
+ }
224
+ /** Cheap key validation against the provider's model-list endpoint. Status line only. */
225
+ export async function validateLlmKey(provider, apiKey, fetchImpl = fetch) {
226
+ const url = provider === "anthropic" ? "https://api.anthropic.com/v1/models" : "https://api.openai.com/v1/models";
227
+ const headers = provider === "anthropic"
228
+ ? { "x-api-key": apiKey, "anthropic-version": "2023-06-01" }
229
+ : { Authorization: `Bearer ${apiKey}` };
230
+ let response;
231
+ try {
232
+ response = await fetchImpl(url, { headers });
233
+ }
234
+ catch (error) {
235
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
236
+ return { ok: false, detail: `Cannot reach ${new URL(url).hostname}${cause}.` };
237
+ }
238
+ return response.ok
239
+ ? { ok: true, detail: `Key accepted by the ${provider} API.` }
240
+ : { ok: false, detail: `HTTP ${response.status} ${response.statusText}`.trim() };
241
+ }
package/dist/mcp.js CHANGED
@@ -45,7 +45,8 @@ import { generateDemoSnapshot } from "./demo.js";
45
45
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
46
46
  import { builtinAuditRules } from "./rules.js";
47
47
  import { sampleSnapshot } from "./sampleData.js";
48
- import { parseCall } from "./calls.js";
48
+ import { normalizeTranscript, parseCall } from "./calls.js";
49
+ import { extractInsightsLlm, resolveLlmCredential } from "./llm.js";
49
50
  import { suggestValues } from "./suggest.js";
50
51
  function content(value) {
51
52
  return {
@@ -63,7 +64,10 @@ async function connectorFor(provider) {
63
64
  if (!token) {
64
65
  throw new Error("No HubSpot credentials. Run `fullstackgtm login hubspot` or set HUBSPOT_ACCESS_TOKEN in the MCP server environment.");
65
66
  }
66
- return createHubspotConnector({ getAccessToken: () => token });
67
+ return createHubspotConnector({
68
+ getAccessToken: () => token,
69
+ apiBaseUrl: process.env.HUBSPOT_API_BASE_URL,
70
+ });
67
71
  }
68
72
  if (provider === "salesforce") {
69
73
  const connection = process.env.SALESFORCE_ACCESS_TOKEN && process.env.SALESFORCE_INSTANCE_URL
@@ -159,21 +163,38 @@ export async function startMcpServer() {
159
163
  });
160
164
  server.registerTool("fullstackgtm_call_parse", {
161
165
  title: "Parse Call Transcript",
162
- description: "Deterministically parse a call transcript (Speaker:/[Speaker]: lines or Granola " +
163
- "utterance JSON) into canonical segments, keyword-derived insights (next steps, " +
164
- "objections, pricing, risks, competitor mentions...), and GtmEvidence records. " +
165
- "Read-only and LLM-free; pair with fullstackgtm_audit/apply for governed writes.",
166
+ description: "Parse a call transcript (Speaker:/[Speaker]: lines or Granola utterance JSON) into " +
167
+ "canonical segments, insights, and GtmEvidence records. extractor: 'auto' (default) " +
168
+ "uses LLM extraction when an Anthropic/OpenAI key is configured in the server " +
169
+ "environment or credential store, else the free deterministic keyword baseline; " +
170
+ "'llm' and 'deterministic' force either. Read-only; every insight is provenance-marked.",
166
171
  inputSchema: {
167
172
  transcript: z.string().optional(),
168
173
  transcriptPath: z.string().optional(),
169
174
  title: z.string().optional(),
170
175
  source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
176
+ extractor: z.enum(["auto", "llm", "deterministic"]).optional(),
177
+ model: z.string().optional(),
171
178
  },
172
- }, async ({ transcript, transcriptPath, title, source }) => {
179
+ }, async ({ transcript, transcriptPath, title, source, extractor, model }) => {
173
180
  const raw = transcript ??
174
181
  (transcriptPath ? readFileSync(resolve(process.cwd(), transcriptPath), "utf8") : null);
175
182
  if (!raw)
176
183
  throw new Error("Provide transcript (text) or transcriptPath (file).");
184
+ const mode = extractor ?? "auto";
185
+ const credential = mode === "deterministic" ? null : resolveLlmCredential();
186
+ if (mode === "llm" && !credential) {
187
+ throw new Error("extractor 'llm' needs an API key: set ANTHROPIC_API_KEY or OPENAI_API_KEY in the MCP server environment, or store one with `fullstackgtm login anthropic|openai`.");
188
+ }
189
+ if (credential) {
190
+ const normalized = normalizeTranscript(raw);
191
+ const { insights, model: used } = await extractInsightsLlm(normalized, {
192
+ ...credential,
193
+ model,
194
+ title,
195
+ });
196
+ return content(parseCall(raw, { title, sourceSystem: source, insights, extractor: `llm:${credential.provider}:${used}` }));
197
+ }
177
198
  return content(parseCall(raw, { title, sourceSystem: source }));
178
199
  });
179
200
  server.registerTool("fullstackgtm_rules", {
package/llms.txt CHANGED
@@ -20,8 +20,10 @@ at/above `--fail-on`.
20
20
 
21
21
  ## Key invariants (calls)
22
22
 
23
- `fullstackgtm call parse` (and MCP fullstackgtm_call_parse) is deterministic
24
- and LLM-free; `call link` suggests the deal with confidence + reason;
23
+ `fullstackgtm call parse` defaults to LLM extraction (BYO Anthropic/OpenAI
24
+ key; env or stored via `login anthropic|openai`); `--deterministic` is the
25
+ free keyword baseline; `call score --rubric` produces evidence-quoted
26
+ coaching scorecards; `call link` suggests the deal with confidence + reason;
25
27
  `call plan` proposes governed next-step writes through the standard
26
28
  approve/apply lifecycle.
27
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM",
package/src/calls.ts CHANGED
@@ -270,6 +270,8 @@ export type ParsedCall = {
270
270
  id: string;
271
271
  title?: string;
272
272
  sourceSystem: GtmEvidenceSourceSystem;
273
+ /** What produced the insights: "deterministic" or "llm:<provider>:<model>". */
274
+ extractor: string;
273
275
  segments: ParsedTranscriptSegment[];
274
276
  insights: ExtractedCallInsight[];
275
277
  evidence: GtmEvidence[];
@@ -299,7 +301,17 @@ export function normalizeTranscript(raw: string): string {
299
301
  // not JSON — fall through and treat as plain text
300
302
  }
301
303
  }
302
- return raw;
304
+ // Granola's formatted text export writes "[Speaker] text" with no colon;
305
+ // rewrite those lines to the canonical "[Speaker]: text" form.
306
+ return raw
307
+ .split(/\r?\n/)
308
+ .map((line) => {
309
+ const match = /^(\[[^\]]{1,60}\])\s+(\S.*)$/.exec(line);
310
+ return match && !line.slice(match[1].length).trimStart().startsWith(":")
311
+ ? `${match[1]}: ${match[2]}`
312
+ : line;
313
+ })
314
+ .join("\n");
303
315
  }
304
316
 
305
317
  /**
@@ -309,11 +321,18 @@ export function normalizeTranscript(raw: string): string {
309
321
  */
310
322
  export function parseCall(
311
323
  raw: string,
312
- options: { title?: string; sourceSystem?: GtmEvidenceSourceSystem; capturedAt?: string } = {},
324
+ options: {
325
+ title?: string;
326
+ sourceSystem?: GtmEvidenceSourceSystem;
327
+ capturedAt?: string;
328
+ /** Pre-extracted insights (e.g. LLM); skips the deterministic extractor. */
329
+ insights?: ExtractedCallInsight[];
330
+ extractor?: string;
331
+ } = {},
313
332
  ): ParsedCall {
314
333
  const normalized = normalizeTranscript(raw);
315
334
  const segments = parseTranscript(normalized);
316
- const insights = extractCallInsights(normalized, segments);
335
+ const insights = options.insights ?? extractCallInsights(normalized, segments);
317
336
  const sourceSystem = options.sourceSystem ?? "manual";
318
337
  const id = `call_${callHash(normalized)}`;
319
338
  const evidence: GtmEvidence[] = insights.map((insight, index) => ({
@@ -325,6 +344,7 @@ export function parseCall(
325
344
  text: insight.evidence,
326
345
  capturedAt: options.capturedAt,
327
346
  metadata: {
347
+ extractor: options.extractor ?? "deterministic",
328
348
  insightType: insight.type,
329
349
  speaker: insight.speaker,
330
350
  confidence: insight.confidence,
@@ -336,6 +356,7 @@ export function parseCall(
336
356
  id,
337
357
  title: options.title,
338
358
  sourceSystem,
359
+ extractor: options.extractor ?? "deterministic",
339
360
  segments,
340
361
  insights,
341
362
  evidence,
package/src/cli.ts CHANGED
@@ -38,7 +38,18 @@ import { createFilePlanStore } from "./planStore.ts";
38
38
  import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
39
39
  import { builtinAuditRules } from "./rules.ts";
40
40
  import { sampleSnapshot } from "./sampleData.ts";
41
- import { parseCall, suggestCallDeal, type ExtractedCallInsight, type ParsedCall } from "./calls.ts";
41
+ import { normalizeTranscript, parseCall, suggestCallDeal, type ExtractedCallInsight, type ParsedCall } from "./calls.ts";
42
+ import {
43
+ DEFAULT_RUBRIC,
44
+ detectProviderFromKey,
45
+ extractInsightsLlm,
46
+ parseRubric,
47
+ resolveLlmCredential,
48
+ scoreCallLlm,
49
+ validateLlmKey,
50
+ type CallScorecard,
51
+ type LlmProvider,
52
+ } from "./llm.ts";
42
53
  import { suggestValues, type ValueSuggestion } from "./suggest.ts";
43
54
  import type { FieldMappings } from "./mappings.ts";
44
55
  import type {
@@ -59,7 +70,7 @@ Usage:
59
70
  fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>]
60
71
  fullstackgtm login salesforce --instance-url <url> [--no-validate]
61
72
  fullstackgtm login stripe [--no-validate]
62
- fullstackgtm logout <hubspot|salesforce|stripe|broker>
73
+ fullstackgtm login anthropic | openai store an LLM API key for call parse/score\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|broker>
63
74
 
64
75
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
65
76
  the process list and shell history. Pipe them on stdin or enter them at the
@@ -70,11 +81,15 @@ Usage:
70
81
  fullstackgtm report [source options] [audit options] [report options]
71
82
  fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
72
83
  fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
73
- fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--json|--ndjson] [--out <path>]
84
+ fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
85
+ fullstackgtm call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
74
86
  fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
75
87
  fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
76
- calls become evidence: parse dialects (Speaker:/[Me]/Granola JSON),
77
- link to the right deal, and propose governed next-step writes
88
+ calls become evidence: LLM extraction by default (bring your own
89
+ Anthropic or OpenAI key captured once on first use, or
90
+ ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
91
+ --deterministic uses the free keyword baseline. Then link the call
92
+ to its deal and propose governed next-step writes.
78
93
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
79
94
  derive values for requires_human_* placeholders
80
95
  from snapshot evidence, with confidence + reasons
@@ -218,6 +233,8 @@ async function connectorFor(provider: string, args: string[]): Promise<GtmConnec
218
233
  return createHubspotConnector({
219
234
  getAccessToken: () => connection.accessToken,
220
235
  fieldMappings: (connection.fieldMappings as FieldMappings | undefined) ?? undefined,
236
+ // Point at a mock/proxy HubSpot (tests, evals, request-recording).
237
+ apiBaseUrl: process.env.HUBSPOT_API_BASE_URL,
221
238
  });
222
239
  }
223
240
  if (provider === "salesforce") {
@@ -474,7 +491,7 @@ function parseValueOverrides(args: string[]) {
474
491
  async function callCommand(args: string[]) {
475
492
  const [subcommand, ...rest] = args;
476
493
 
477
- const loadParsedCall = (): ParsedCall => {
494
+ const loadParsedCall = async (): Promise<ParsedCall> => {
478
495
  const callPath = option(rest, "--call");
479
496
  if (callPath) {
480
497
  return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8")) as ParsedCall;
@@ -483,15 +500,31 @@ async function callCommand(args: string[]) {
483
500
  if (!transcriptPath) throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
484
501
  const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
485
502
  const source = option(rest, "--source") as ParsedCall["sourceSystem"] | undefined;
486
- return parseCall(raw, {
503
+ const base = {
487
504
  title: option(rest, "--title") ?? undefined,
488
505
  sourceSystem: source,
489
506
  capturedAt: new Date().toISOString(),
507
+ };
508
+ if (rest.includes("--deterministic")) {
509
+ return parseCall(raw, base);
510
+ }
511
+ // LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
512
+ const credential = await requireLlmCredential();
513
+ const normalized = normalizeTranscript(raw);
514
+ const { insights, model } = await extractInsightsLlm(normalized, {
515
+ ...credential,
516
+ model: option(rest, "--model") ?? undefined,
517
+ title: base.title,
518
+ });
519
+ return parseCall(raw, {
520
+ ...base,
521
+ insights,
522
+ extractor: `llm:${credential.provider}:${model}`,
490
523
  });
491
524
  };
492
525
 
493
526
  if (subcommand === "parse") {
494
- const parsed = loadParsedCall();
527
+ const parsed = await loadParsedCall();
495
528
  const outPath = option(rest, "--out");
496
529
  if (outPath) writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
497
530
  if (rest.includes("--ndjson")) {
@@ -549,7 +582,7 @@ async function callCommand(args: string[]) {
549
582
  if (subcommand === "plan") {
550
583
  const dealId = option(rest, "--deal");
551
584
  if (!dealId) throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
552
- const parsed = loadParsedCall();
585
+ const parsed = await loadParsedCall();
553
586
  const snapshot = await readSnapshot(rest);
554
587
  const deal = snapshot.deals.find((row) => row.id === dealId);
555
588
  if (!deal) throw new Error(`Deal ${dealId} is not in the snapshot — check the id or the snapshot source.`);
@@ -575,7 +608,93 @@ async function callCommand(args: string[]) {
575
608
  return;
576
609
  }
577
610
 
578
- throw new Error(`call supports: parse, link, plan (got ${subcommand ?? "nothing"})`);
611
+ if (subcommand === "score") {
612
+ const credential = await requireLlmCredential();
613
+ const rubricPath = option(rest, "--rubric");
614
+ const rubric = rubricPath
615
+ ? parseRubric(readFileSync(resolve(process.cwd(), rubricPath), "utf8"))
616
+ : DEFAULT_RUBRIC;
617
+ const transcriptPath = option(rest, "--transcript");
618
+ let transcriptText: string;
619
+ let title = option(rest, "--title") ?? undefined;
620
+ if (transcriptPath) {
621
+ transcriptText = normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
622
+ } else {
623
+ const callPath = option(rest, "--call");
624
+ if (!callPath) throw new Error("call score requires --transcript <file> or --call <parsed.json>");
625
+ const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8")) as ParsedCall;
626
+ transcriptText = parsed.segments
627
+ .map((segment) => (segment.speaker ? `${segment.speaker}: ${segment.text}` : segment.text))
628
+ .join("\n");
629
+ title = title ?? parsed.title;
630
+ }
631
+ const scorecard = await scoreCallLlm(transcriptText, rubric, {
632
+ ...credential,
633
+ model: option(rest, "--model") ?? undefined,
634
+ title,
635
+ });
636
+ const outPath = option(rest, "--out");
637
+ if (outPath) writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(scorecard, null, 2)}\n`);
638
+ if (rest.includes("--json")) {
639
+ console.log(JSON.stringify(scorecard, null, 2));
640
+ return;
641
+ }
642
+ console.log(renderScorecard(scorecard, title));
643
+ return;
644
+ }
645
+
646
+ throw new Error(`call supports: parse, link, plan, score (got ${subcommand ?? "nothing"})`);
647
+ }
648
+
649
+ /**
650
+ * First-touch key onboarding: env vars win, then the credential store; on a
651
+ * TTY a missing key is captured once (validated, stored 0600 like provider
652
+ * logins). Non-interactive contexts get an actionable error instead.
653
+ */
654
+ async function requireLlmCredential(): Promise<{ provider: LlmProvider; apiKey: string }> {
655
+ const resolved = resolveLlmCredential();
656
+ if (resolved) return resolved;
657
+ if (!process.stdin.isTTY) {
658
+ throw new Error(
659
+ "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.",
660
+ );
661
+ }
662
+ console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
663
+ console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
664
+ console.error("(Alternatives: set ANTHROPIC_API_KEY / OPENAI_API_KEY, or pass --deterministic for the free keyword baseline.)\n");
665
+ const apiKey = await readSecret("API key (sk-ant-... or sk-...): ");
666
+ const provider = detectProviderFromKey(apiKey);
667
+ const validation = await validateLlmKey(provider, apiKey);
668
+ if (!validation.ok) throw new Error(`${provider} rejected the key: ${validation.detail}`);
669
+ const now = new Date().toISOString();
670
+ storeCredential(provider, { kind: "api_key", accessToken: apiKey, createdAt: now, updatedAt: now });
671
+ console.error(`Stored ${provider} key (${validation.detail}). Future runs use it automatically; remove with \`fullstackgtm logout ${provider}\`.\n`);
672
+ return { provider, apiKey };
673
+ }
674
+
675
+ function renderScorecard(scorecard: CallScorecard, title?: string): string {
676
+ const lines = [
677
+ `# Coaching Scorecard${title ? ` — ${title}` : ""}`,
678
+ "",
679
+ `**Overall: ${scorecard.overallScore}/${scorecard.scale}** (model: ${scorecard.model})`,
680
+ "",
681
+ "| Dimension | Score | | Coaching note |",
682
+ "| --- | --- | --- | --- |",
683
+ ];
684
+ for (const dim of scorecard.dimensions) {
685
+ const filled = Math.round((dim.score / dim.maxScore) * 5);
686
+ const bar = "█".repeat(filled) + "░".repeat(5 - filled);
687
+ lines.push(`| ${dim.name} | ${dim.score}/${dim.maxScore} | ${bar} | ${dim.coachingNote} |`);
688
+ }
689
+ if (scorecard.highlights.length) {
690
+ lines.push("", "**Highlights**");
691
+ for (const h of scorecard.highlights) lines.push(`- ${h}`);
692
+ }
693
+ if (scorecard.missedItems.length) {
694
+ lines.push("", "**Missed**");
695
+ for (const m of scorecard.missedItems) lines.push(`- ${m}`);
696
+ }
697
+ return lines.join("\n");
579
698
  }
580
699
 
581
700
  function buildCallPlan(
@@ -1205,9 +1324,22 @@ async function login(args: string[]) {
1205
1324
  console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
1206
1325
  return;
1207
1326
  }
1327
+ if (provider === "anthropic" || provider === "openai") {
1328
+ const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
1329
+ if (!key) throw new Error(`No ${provider} key provided.`);
1330
+ if (!args.includes("--no-validate")) {
1331
+ const validation = await validateLlmKey(provider, key);
1332
+ if (!validation.ok) throw new Error(`${provider} rejected the key: ${validation.detail}`);
1333
+ console.log(validation.detail);
1334
+ }
1335
+ const stamp = new Date().toISOString();
1336
+ storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
1337
+ console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
1338
+ return;
1339
+ }
1208
1340
  if (provider !== "hubspot") {
1209
1341
  throw new Error(
1210
- "login supports: hubspot, salesforce, stripe, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com",
1342
+ "login supports: hubspot, salesforce, stripe, anthropic, openai, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com",
1211
1343
  );
1212
1344
  }
1213
1345
  const now = new Date().toISOString();
@@ -1300,6 +1432,7 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
1300
1432
  : providerStatus("stripe", broker),
1301
1433
  };
1302
1434
 
1435
+ const llm = resolveLlmCredential(env);
1303
1436
  const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
1304
1437
  try {
1305
1438
  import.meta.resolve(name);
@@ -1326,6 +1459,9 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
1326
1459
  config: { path: configPath, exists: existsSync(configPath) },
1327
1460
  providers,
1328
1461
  broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
1462
+ llm: llm
1463
+ ? { configured: true, provider: llm.provider, source: llm.source }
1464
+ : { configured: false, detail: "call parse/score will prompt once, or set ANTHROPIC_API_KEY / OPENAI_API_KEY" },
1329
1465
  mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
1330
1466
  nextSteps,
1331
1467
  };
@@ -1372,6 +1508,7 @@ function doctorCommand(args: string[]) {
1372
1508
  ` ${provider.padEnd(11)} ${status.source === "none" ? `not connected (${status.detail})` : `${status.source}: ${status.detail}`}`,
1373
1509
  ),
1374
1510
  ` ${"broker".padEnd(11)} ${report.broker.paired ? `paired with ${report.broker.baseUrl}` : "not paired (fullstackgtm login --via <hosted url>)"}`,
1511
+ ` ${"llm".padEnd(11)} ${report.llm.configured ? `${report.llm.provider} key (${report.llm.source}) — call parse/score ready` : `not configured (${report.llm.detail})`}`,
1375
1512
  "",
1376
1513
  report.mcp.peersInstalled
1377
1514
  ? "MCP: peers installed — `fullstackgtm-mcp` is ready"
@@ -74,7 +74,7 @@ export function listProfiles(): string[] {
74
74
  }
75
75
 
76
76
  export type StoredCredential = {
77
- kind: "private_app" | "oauth" | "broker";
77
+ kind: "private_app" | "oauth" | "broker" | "api_key";
78
78
  accessToken: string;
79
79
  refreshToken?: string;
80
80
  /** Epoch ms when the access token expires (oauth only). */