fullstackgtm 0.13.1 → 0.14.1
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 +55 -0
- package/INSTALL_FOR_AGENTS.md +6 -0
- package/README.md +15 -7
- package/dist/calls.d.ts +5 -0
- package/dist/calls.js +3 -1
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +163 -11
- package/dist/credentials.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/llm.d.ts +71 -0
- package/dist/llm.js +241 -0
- package/dist/mcp.js +24 -6
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/src/calls.ts +13 -2
- package/src/cli.ts +171 -11
- package/src/credentials.ts +1 -1
- package/src/index.ts +16 -0
- package/src/llm.ts +334 -0
- package/src/mcp.ts +28 -6
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 {
|
|
@@ -162,21 +163,38 @@ export async function startMcpServer() {
|
|
|
162
163
|
});
|
|
163
164
|
server.registerTool("fullstackgtm_call_parse", {
|
|
164
165
|
title: "Parse Call Transcript",
|
|
165
|
-
description: "
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
"
|
|
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.",
|
|
169
171
|
inputSchema: {
|
|
170
172
|
transcript: z.string().optional(),
|
|
171
173
|
transcriptPath: z.string().optional(),
|
|
172
174
|
title: z.string().optional(),
|
|
173
175
|
source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
|
|
176
|
+
extractor: z.enum(["auto", "llm", "deterministic"]).optional(),
|
|
177
|
+
model: z.string().optional(),
|
|
174
178
|
},
|
|
175
|
-
}, async ({ transcript, transcriptPath, title, source }) => {
|
|
179
|
+
}, async ({ transcript, transcriptPath, title, source, extractor, model }) => {
|
|
176
180
|
const raw = transcript ??
|
|
177
181
|
(transcriptPath ? readFileSync(resolve(process.cwd(), transcriptPath), "utf8") : null);
|
|
178
182
|
if (!raw)
|
|
179
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
|
+
}
|
|
180
198
|
return content(parseCall(raw, { title, sourceSystem: source }));
|
|
181
199
|
});
|
|
182
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`
|
|
24
|
-
|
|
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.
|
|
3
|
+
"version": "0.14.1",
|
|
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[];
|
|
@@ -319,11 +321,18 @@ export function normalizeTranscript(raw: string): string {
|
|
|
319
321
|
*/
|
|
320
322
|
export function parseCall(
|
|
321
323
|
raw: string,
|
|
322
|
-
options: {
|
|
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
|
+
} = {},
|
|
323
332
|
): ParsedCall {
|
|
324
333
|
const normalized = normalizeTranscript(raw);
|
|
325
334
|
const segments = parseTranscript(normalized);
|
|
326
|
-
const insights = extractCallInsights(normalized, segments);
|
|
335
|
+
const insights = options.insights ?? extractCallInsights(normalized, segments);
|
|
327
336
|
const sourceSystem = options.sourceSystem ?? "manual";
|
|
328
337
|
const id = `call_${callHash(normalized)}`;
|
|
329
338
|
const evidence: GtmEvidence[] = insights.map((insight, index) => ({
|
|
@@ -335,6 +344,7 @@ export function parseCall(
|
|
|
335
344
|
text: insight.evidence,
|
|
336
345
|
capturedAt: options.capturedAt,
|
|
337
346
|
metadata: {
|
|
347
|
+
extractor: options.extractor ?? "deterministic",
|
|
338
348
|
insightType: insight.type,
|
|
339
349
|
speaker: insight.speaker,
|
|
340
350
|
confidence: insight.confidence,
|
|
@@ -346,6 +356,7 @@ export function parseCall(
|
|
|
346
356
|
id,
|
|
347
357
|
title: options.title,
|
|
348
358
|
sourceSystem,
|
|
359
|
+
extractor: options.extractor ?? "deterministic",
|
|
349
360
|
segments,
|
|
350
361
|
insights,
|
|
351
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:
|
|
77
|
-
|
|
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
|
|
@@ -475,8 +490,19 @@ function parseValueOverrides(args: string[]) {
|
|
|
475
490
|
|
|
476
491
|
async function callCommand(args: string[]) {
|
|
477
492
|
const [subcommand, ...rest] = args;
|
|
493
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
494
|
+
console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
|
|
495
|
+
call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
|
|
496
|
+
call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
497
|
+
call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
|
|
498
|
+
|
|
499
|
+
parse/score default to LLM extraction (Anthropic or OpenAI key via env,
|
|
500
|
+
\`login anthropic|openai\`, or a one-time prompt). parse --deterministic is
|
|
501
|
+
the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
478
504
|
|
|
479
|
-
const loadParsedCall = (): ParsedCall => {
|
|
505
|
+
const loadParsedCall = async (): Promise<ParsedCall> => {
|
|
480
506
|
const callPath = option(rest, "--call");
|
|
481
507
|
if (callPath) {
|
|
482
508
|
return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8")) as ParsedCall;
|
|
@@ -485,15 +511,31 @@ async function callCommand(args: string[]) {
|
|
|
485
511
|
if (!transcriptPath) throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
|
|
486
512
|
const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
|
|
487
513
|
const source = option(rest, "--source") as ParsedCall["sourceSystem"] | undefined;
|
|
488
|
-
|
|
514
|
+
const base = {
|
|
489
515
|
title: option(rest, "--title") ?? undefined,
|
|
490
516
|
sourceSystem: source,
|
|
491
517
|
capturedAt: new Date().toISOString(),
|
|
518
|
+
};
|
|
519
|
+
if (rest.includes("--deterministic")) {
|
|
520
|
+
return parseCall(raw, base);
|
|
521
|
+
}
|
|
522
|
+
// LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
|
|
523
|
+
const credential = await requireLlmCredential();
|
|
524
|
+
const normalized = normalizeTranscript(raw);
|
|
525
|
+
const { insights, model } = await extractInsightsLlm(normalized, {
|
|
526
|
+
...credential,
|
|
527
|
+
model: option(rest, "--model") ?? undefined,
|
|
528
|
+
title: base.title,
|
|
529
|
+
});
|
|
530
|
+
return parseCall(raw, {
|
|
531
|
+
...base,
|
|
532
|
+
insights,
|
|
533
|
+
extractor: `llm:${credential.provider}:${model}`,
|
|
492
534
|
});
|
|
493
535
|
};
|
|
494
536
|
|
|
495
537
|
if (subcommand === "parse") {
|
|
496
|
-
const parsed = loadParsedCall();
|
|
538
|
+
const parsed = await loadParsedCall();
|
|
497
539
|
const outPath = option(rest, "--out");
|
|
498
540
|
if (outPath) writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
499
541
|
if (rest.includes("--ndjson")) {
|
|
@@ -504,6 +546,7 @@ async function callCommand(args: string[]) {
|
|
|
504
546
|
call_id: parsed.id,
|
|
505
547
|
call_title: parsed.title ?? null,
|
|
506
548
|
source_system: parsed.sourceSystem,
|
|
549
|
+
extractor: parsed.extractor,
|
|
507
550
|
type: insight.type,
|
|
508
551
|
title: insight.title,
|
|
509
552
|
text: insight.text,
|
|
@@ -551,7 +594,7 @@ async function callCommand(args: string[]) {
|
|
|
551
594
|
if (subcommand === "plan") {
|
|
552
595
|
const dealId = option(rest, "--deal");
|
|
553
596
|
if (!dealId) throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
|
|
554
|
-
const parsed = loadParsedCall();
|
|
597
|
+
const parsed = await loadParsedCall();
|
|
555
598
|
const snapshot = await readSnapshot(rest);
|
|
556
599
|
const deal = snapshot.deals.find((row) => row.id === dealId);
|
|
557
600
|
if (!deal) throw new Error(`Deal ${dealId} is not in the snapshot — check the id or the snapshot source.`);
|
|
@@ -577,7 +620,105 @@ async function callCommand(args: string[]) {
|
|
|
577
620
|
return;
|
|
578
621
|
}
|
|
579
622
|
|
|
580
|
-
|
|
623
|
+
if (subcommand === "score") {
|
|
624
|
+
// Rubric problems surface before any credential or API work.
|
|
625
|
+
const rubricPath = option(rest, "--rubric");
|
|
626
|
+
let rubric = DEFAULT_RUBRIC;
|
|
627
|
+
if (rubricPath) {
|
|
628
|
+
const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
|
|
629
|
+
try {
|
|
630
|
+
rubric = parseRubric(rubricRaw);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`${rubricPath} is not a valid rubric: ${error instanceof Error ? error.message : String(error)} Expected JSON like { "scale": 5, "dimensions": [{ "name": "...", "weight": 1, "rubric": "..." }] }.`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const credential = await requireLlmCredential("score");
|
|
638
|
+
const transcriptPath = option(rest, "--transcript");
|
|
639
|
+
let transcriptText: string;
|
|
640
|
+
let title = option(rest, "--title") ?? undefined;
|
|
641
|
+
if (transcriptPath) {
|
|
642
|
+
transcriptText = normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
|
|
643
|
+
} else {
|
|
644
|
+
const callPath = option(rest, "--call");
|
|
645
|
+
if (!callPath) throw new Error("call score requires --transcript <file> or --call <parsed.json>");
|
|
646
|
+
const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8")) as ParsedCall;
|
|
647
|
+
transcriptText = parsed.segments
|
|
648
|
+
.map((segment) => (segment.speaker ? `${segment.speaker}: ${segment.text}` : segment.text))
|
|
649
|
+
.join("\n");
|
|
650
|
+
title = title ?? parsed.title;
|
|
651
|
+
}
|
|
652
|
+
const scorecard = await scoreCallLlm(transcriptText, rubric, {
|
|
653
|
+
...credential,
|
|
654
|
+
model: option(rest, "--model") ?? undefined,
|
|
655
|
+
title,
|
|
656
|
+
});
|
|
657
|
+
const outPath = option(rest, "--out");
|
|
658
|
+
if (outPath) writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(scorecard, null, 2)}\n`);
|
|
659
|
+
if (rest.includes("--json")) {
|
|
660
|
+
console.log(JSON.stringify(scorecard, null, 2));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
console.log(renderScorecard(scorecard, title));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
throw new Error(`call supports: parse, link, plan, score (got ${subcommand ?? "nothing"})`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* First-touch key onboarding: env vars win, then the credential store; on a
|
|
672
|
+
* TTY a missing key is captured once (validated, stored 0600 like provider
|
|
673
|
+
* logins). Non-interactive contexts get an actionable error instead.
|
|
674
|
+
*/
|
|
675
|
+
async function requireLlmCredential(command: "parse" | "score" = "parse"): Promise<{ provider: LlmProvider; apiKey: string }> {
|
|
676
|
+
const resolved = resolveLlmCredential();
|
|
677
|
+
if (resolved) return resolved;
|
|
678
|
+
// Scoring is inherently LLM work — there is no keyword fallback to suggest.
|
|
679
|
+
const fallbackHint =
|
|
680
|
+
command === "parse" ? ", or pass --deterministic for the free keyword baseline" : " (call score has no non-LLM mode)";
|
|
681
|
+
if (!process.stdin.isTTY) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
`LLM ${command === "score" ? "scoring" : "extraction"} needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or run \`echo "$KEY" | fullstackgtm login anthropic\` (or \`login openai\`) once${fallbackHint}.`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
|
|
687
|
+
console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
|
|
688
|
+
console.error("(Alternatives: set ANTHROPIC_API_KEY / OPENAI_API_KEY, or pass --deterministic for the free keyword baseline.)\n");
|
|
689
|
+
const apiKey = await readSecret("API key (sk-ant-... or sk-...): ");
|
|
690
|
+
const provider = detectProviderFromKey(apiKey);
|
|
691
|
+
const validation = await validateLlmKey(provider, apiKey);
|
|
692
|
+
if (!validation.ok) throw new Error(`${provider} rejected the key: ${validation.detail}`);
|
|
693
|
+
const now = new Date().toISOString();
|
|
694
|
+
storeCredential(provider, { kind: "api_key", accessToken: apiKey, createdAt: now, updatedAt: now });
|
|
695
|
+
console.error(`Stored ${provider} key (${validation.detail}). Future runs use it automatically; remove with \`fullstackgtm logout ${provider}\`.\n`);
|
|
696
|
+
return { provider, apiKey };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function renderScorecard(scorecard: CallScorecard, title?: string): string {
|
|
700
|
+
const lines = [
|
|
701
|
+
`# Coaching Scorecard${title ? ` — ${title}` : ""}`,
|
|
702
|
+
"",
|
|
703
|
+
`**Overall: ${scorecard.overallScore}/${scorecard.scale}** (model: ${scorecard.model})`,
|
|
704
|
+
"",
|
|
705
|
+
"| Dimension | Score | | Coaching note |",
|
|
706
|
+
"| --- | --- | --- | --- |",
|
|
707
|
+
];
|
|
708
|
+
for (const dim of scorecard.dimensions) {
|
|
709
|
+
const filled = Math.round((dim.score / dim.maxScore) * 5);
|
|
710
|
+
const bar = "█".repeat(filled) + "░".repeat(5 - filled);
|
|
711
|
+
lines.push(`| ${dim.name} | ${dim.score}/${dim.maxScore} | ${bar} | ${dim.coachingNote} |`);
|
|
712
|
+
}
|
|
713
|
+
if (scorecard.highlights.length) {
|
|
714
|
+
lines.push("", "**Highlights**");
|
|
715
|
+
for (const h of scorecard.highlights) lines.push(`- ${h}`);
|
|
716
|
+
}
|
|
717
|
+
if (scorecard.missedItems.length) {
|
|
718
|
+
lines.push("", "**Missed**");
|
|
719
|
+
for (const m of scorecard.missedItems) lines.push(`- ${m}`);
|
|
720
|
+
}
|
|
721
|
+
return lines.join("\n");
|
|
581
722
|
}
|
|
582
723
|
|
|
583
724
|
function buildCallPlan(
|
|
@@ -1207,9 +1348,23 @@ async function login(args: string[]) {
|
|
|
1207
1348
|
console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
|
|
1208
1349
|
return;
|
|
1209
1350
|
}
|
|
1351
|
+
if (provider === "anthropic" || provider === "openai") {
|
|
1352
|
+
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
1353
|
+
const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
|
|
1354
|
+
if (!key) throw new Error(`No ${provider} key provided.`);
|
|
1355
|
+
if (!args.includes("--no-validate")) {
|
|
1356
|
+
const validation = await validateLlmKey(provider, key);
|
|
1357
|
+
if (!validation.ok) throw new Error(`${provider} rejected the key: ${validation.detail}`);
|
|
1358
|
+
console.log(validation.detail);
|
|
1359
|
+
}
|
|
1360
|
+
const stamp = new Date().toISOString();
|
|
1361
|
+
storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
|
|
1362
|
+
console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1210
1365
|
if (provider !== "hubspot") {
|
|
1211
1366
|
throw new Error(
|
|
1212
|
-
"login supports: hubspot, salesforce, stripe, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com",
|
|
1367
|
+
"login supports: hubspot, salesforce, stripe, anthropic, openai, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com",
|
|
1213
1368
|
);
|
|
1214
1369
|
}
|
|
1215
1370
|
const now = new Date().toISOString();
|
|
@@ -1302,6 +1457,7 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
|
|
|
1302
1457
|
: providerStatus("stripe", broker),
|
|
1303
1458
|
};
|
|
1304
1459
|
|
|
1460
|
+
const llm = resolveLlmCredential(env);
|
|
1305
1461
|
const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
|
|
1306
1462
|
try {
|
|
1307
1463
|
import.meta.resolve(name);
|
|
@@ -1328,6 +1484,9 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
|
|
|
1328
1484
|
config: { path: configPath, exists: existsSync(configPath) },
|
|
1329
1485
|
providers,
|
|
1330
1486
|
broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
|
|
1487
|
+
llm: llm
|
|
1488
|
+
? { configured: true, provider: llm.provider, source: llm.source }
|
|
1489
|
+
: { configured: false, detail: "call parse/score will prompt once, or set ANTHROPIC_API_KEY / OPENAI_API_KEY" },
|
|
1331
1490
|
mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
|
|
1332
1491
|
nextSteps,
|
|
1333
1492
|
};
|
|
@@ -1374,6 +1533,7 @@ function doctorCommand(args: string[]) {
|
|
|
1374
1533
|
` ${provider.padEnd(11)} ${status.source === "none" ? `not connected (${status.detail})` : `${status.source}: ${status.detail}`}`,
|
|
1375
1534
|
),
|
|
1376
1535
|
` ${"broker".padEnd(11)} ${report.broker.paired ? `paired with ${report.broker.baseUrl}` : "not paired (fullstackgtm login --via <hosted url>)"}`,
|
|
1536
|
+
` ${"llm".padEnd(11)} ${report.llm.configured ? `${report.llm.provider} key (${report.llm.source}) — call parse/score ready` : `not configured (${report.llm.detail})`}`,
|
|
1377
1537
|
"",
|
|
1378
1538
|
report.mcp.peersInstalled
|
|
1379
1539
|
? "MCP: peers installed — `fullstackgtm-mcp` is ready"
|
package/src/credentials.ts
CHANGED
|
@@ -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). */
|