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/CHANGELOG.md +45 -0
- package/INSTALL_FOR_AGENTS.md +6 -0
- package/README.md +15 -7
- package/dist/calls.d.ts +5 -0
- package/dist/calls.js +14 -2
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +142 -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 +28 -7
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +148 -11
- package/src/credentials.ts +1 -1
- package/src/index.ts +16 -0
- package/src/llm.ts +334 -0
- package/src/mcp.ts +32 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,51 @@ 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
|
+
|
|
43
|
+
## [0.13.1] — 2026-06-11
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **Granola's formatted text export parses correctly**: it writes
|
|
48
|
+
`[Speaker] text` with no colon, which the transcript parser missed
|
|
49
|
+
entirely (found running the published 0.13.0 against a real export).
|
|
50
|
+
`normalizeTranscript` now rewrites those lines to the canonical
|
|
51
|
+
`[Speaker]: text` form.
|
|
52
|
+
|
|
8
53
|
## [0.13.0] — 2026-06-11
|
|
9
54
|
|
|
10
55
|
Calls become evidence: the deterministic skeleton of every call workflow —
|
package/INSTALL_FOR_AGENTS.md
CHANGED
|
@@ -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,
|
|
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
|
|
56
|
-
fullstackgtm call parse --transcript call.txt --title "Acme disco" --out parsed.json
|
|
57
|
-
fullstackgtm call
|
|
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
|
-
# (
|
|
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
|
|
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
|
@@ -226,7 +226,17 @@ export function normalizeTranscript(raw) {
|
|
|
226
226
|
// not JSON — fall through and treat as plain text
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
|
-
|
|
229
|
+
// Granola's formatted text export writes "[Speaker] text" with no colon;
|
|
230
|
+
// rewrite those lines to the canonical "[Speaker]: text" form.
|
|
231
|
+
return raw
|
|
232
|
+
.split(/\r?\n/)
|
|
233
|
+
.map((line) => {
|
|
234
|
+
const match = /^(\[[^\]]{1,60}\])\s+(\S.*)$/.exec(line);
|
|
235
|
+
return match && !line.slice(match[1].length).trimStart().startsWith(":")
|
|
236
|
+
? `${match[1]}: ${match[2]}`
|
|
237
|
+
: line;
|
|
238
|
+
})
|
|
239
|
+
.join("\n");
|
|
230
240
|
}
|
|
231
241
|
/**
|
|
232
242
|
* Parse any supported transcript dialect into a canonical call: segments,
|
|
@@ -236,7 +246,7 @@ export function normalizeTranscript(raw) {
|
|
|
236
246
|
export function parseCall(raw, options = {}) {
|
|
237
247
|
const normalized = normalizeTranscript(raw);
|
|
238
248
|
const segments = parseTranscript(normalized);
|
|
239
|
-
const insights = extractCallInsights(normalized, segments);
|
|
249
|
+
const insights = options.insights ?? extractCallInsights(normalized, segments);
|
|
240
250
|
const sourceSystem = options.sourceSystem ?? "manual";
|
|
241
251
|
const id = `call_${callHash(normalized)}`;
|
|
242
252
|
const evidence = insights.map((insight, index) => ({
|
|
@@ -248,6 +258,7 @@ export function parseCall(raw, options = {}) {
|
|
|
248
258
|
text: insight.evidence,
|
|
249
259
|
capturedAt: options.capturedAt,
|
|
250
260
|
metadata: {
|
|
261
|
+
extractor: options.extractor ?? "deterministic",
|
|
251
262
|
insightType: insight.type,
|
|
252
263
|
speaker: insight.speaker,
|
|
253
264
|
confidence: insight.confidence,
|
|
@@ -259,6 +270,7 @@ export function parseCall(raw, options = {}) {
|
|
|
259
270
|
id,
|
|
260
271
|
title: options.title,
|
|
261
272
|
sourceSystem,
|
|
273
|
+
extractor: options.extractor ?? "deterministic",
|
|
262
274
|
segments,
|
|
263
275
|
insights,
|
|
264
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:
|
|
48
|
-
|
|
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
|
|
@@ -185,6 +190,8 @@ async function connectorFor(provider, args) {
|
|
|
185
190
|
return createHubspotConnector({
|
|
186
191
|
getAccessToken: () => connection.accessToken,
|
|
187
192
|
fieldMappings: connection.fieldMappings ?? undefined,
|
|
193
|
+
// Point at a mock/proxy HubSpot (tests, evals, request-recording).
|
|
194
|
+
apiBaseUrl: process.env.HUBSPOT_API_BASE_URL,
|
|
188
195
|
});
|
|
189
196
|
}
|
|
190
197
|
if (provider === "salesforce") {
|
|
@@ -411,7 +418,7 @@ function parseValueOverrides(args) {
|
|
|
411
418
|
}
|
|
412
419
|
async function callCommand(args) {
|
|
413
420
|
const [subcommand, ...rest] = args;
|
|
414
|
-
const loadParsedCall = () => {
|
|
421
|
+
const loadParsedCall = async () => {
|
|
415
422
|
const callPath = option(rest, "--call");
|
|
416
423
|
if (callPath) {
|
|
417
424
|
return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
|
|
@@ -421,14 +428,30 @@ async function callCommand(args) {
|
|
|
421
428
|
throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
|
|
422
429
|
const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
|
|
423
430
|
const source = option(rest, "--source");
|
|
424
|
-
|
|
431
|
+
const base = {
|
|
425
432
|
title: option(rest, "--title") ?? undefined,
|
|
426
433
|
sourceSystem: source,
|
|
427
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}`,
|
|
428
451
|
});
|
|
429
452
|
};
|
|
430
453
|
if (subcommand === "parse") {
|
|
431
|
-
const parsed = loadParsedCall();
|
|
454
|
+
const parsed = await loadParsedCall();
|
|
432
455
|
const outPath = option(rest, "--out");
|
|
433
456
|
if (outPath)
|
|
434
457
|
writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
@@ -488,7 +511,7 @@ async function callCommand(args) {
|
|
|
488
511
|
const dealId = option(rest, "--deal");
|
|
489
512
|
if (!dealId)
|
|
490
513
|
throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
|
|
491
|
-
const parsed = loadParsedCall();
|
|
514
|
+
const parsed = await loadParsedCall();
|
|
492
515
|
const snapshot = await readSnapshot(rest);
|
|
493
516
|
const deal = snapshot.deals.find((row) => row.id === dealId);
|
|
494
517
|
if (!deal)
|
|
@@ -510,7 +533,95 @@ async function callCommand(args) {
|
|
|
510
533
|
console.log(rest.includes("--json") ? JSON.stringify(plan, null, 2) : patchPlanToMarkdown(plan));
|
|
511
534
|
return;
|
|
512
535
|
}
|
|
513
|
-
|
|
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");
|
|
514
625
|
}
|
|
515
626
|
function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
|
|
516
627
|
const findings = [];
|
|
@@ -1065,8 +1176,23 @@ async function login(args) {
|
|
|
1065
1176
|
console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
|
|
1066
1177
|
return;
|
|
1067
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
|
+
}
|
|
1068
1194
|
if (provider !== "hubspot") {
|
|
1069
|
-
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");
|
|
1070
1196
|
}
|
|
1071
1197
|
const now = new Date().toISOString();
|
|
1072
1198
|
if (args.includes("--oauth")) {
|
|
@@ -1148,6 +1274,7 @@ export function doctorReport(env = process.env) {
|
|
|
1148
1274
|
? { source: "env", detail: "STRIPE_SECRET_KEY" }
|
|
1149
1275
|
: providerStatus("stripe", broker),
|
|
1150
1276
|
};
|
|
1277
|
+
const llm = resolveLlmCredential(env);
|
|
1151
1278
|
const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
|
|
1152
1279
|
try {
|
|
1153
1280
|
import.meta.resolve(name);
|
|
@@ -1172,6 +1299,9 @@ export function doctorReport(env = process.env) {
|
|
|
1172
1299
|
config: { path: configPath, exists: existsSync(configPath) },
|
|
1173
1300
|
providers,
|
|
1174
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" },
|
|
1175
1305
|
mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
|
|
1176
1306
|
nextSteps,
|
|
1177
1307
|
};
|
|
@@ -1213,6 +1343,7 @@ function doctorCommand(args) {
|
|
|
1213
1343
|
"Providers:",
|
|
1214
1344
|
...Object.entries(report.providers).map(([provider, status]) => ` ${provider.padEnd(11)} ${status.source === "none" ? `not connected (${status.detail})` : `${status.source}: ${status.detail}`}`),
|
|
1215
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})`}`,
|
|
1216
1347
|
"",
|
|
1217
1348
|
report.mcp.peersInstalled
|
|
1218
1349
|
? "MCP: peers installed — `fullstackgtm-mcp` is ready"
|
package/dist/credentials.d.ts
CHANGED
|
@@ -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
|
+
}>;
|