fullstackgtm 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -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 +140 -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 +146 -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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.14.0] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
LLM-powered call intelligence, bring-your-own-key. The dry-run replications
|
|
11
|
+
of two real client pipelines proved the deterministic baseline is an
|
|
12
|
+
analytics floor, not what call workflows actually run on — so the no-LLM
|
|
13
|
+
guardrail is consciously retired for the `call` commands (and only there).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **LLM extraction is the default for `call parse`** — constrained tool
|
|
18
|
+
calls against Anthropic (default claude-haiku-4-5) or OpenAI (default
|
|
19
|
+
gpt-4o-mini) via raw fetch, no SDK dependency. Mandatory verbatim-quote
|
|
20
|
+
evidence; next steps carry owner/deadline/commitment; every insight is
|
|
21
|
+
provenance-marked (`extractor: "llm:<provider>:<model>"` vs
|
|
22
|
+
`"deterministic"`). `--deterministic` keeps the free keyword baseline.
|
|
23
|
+
- **First-touch key onboarding**: the first LLM command without a key (TTY)
|
|
24
|
+
explains, captures the key via muted prompt/stdin, auto-detects the
|
|
25
|
+
provider from the prefix, validates it against the provider's API, and
|
|
26
|
+
stores it 0600 in the credential store. Non-interactive contexts get an
|
|
27
|
+
actionable error, never a prompt. Also: `fullstackgtm login anthropic|openai`
|
|
28
|
+
and `logout`, and a `doctor` row showing LLM key status.
|
|
29
|
+
- **`fullstackgtm call score`**: rubric-driven coaching scorecards —
|
|
30
|
+
built-in five-dimension rubric or `--rubric rubric.json`
|
|
31
|
+
({ scale, dimensions: [{ name, weight, rubric }] }); per-dimension
|
|
32
|
+
verbatim evidence + coaching notes; the weighted overall is computed
|
|
33
|
+
deterministically client-side. Markdown table or `--json`.
|
|
34
|
+
- **MCP `fullstackgtm_call_parse` gains `extractor: auto|llm|deterministic`**
|
|
35
|
+
(auto uses a key when the server has one, else the baseline) and `--model`.
|
|
36
|
+
|
|
37
|
+
### Security
|
|
38
|
+
|
|
39
|
+
- LLM keys follow the same discipline as CRM secrets: stdin/prompt only,
|
|
40
|
+
never argv; 0600 store; provider error bodies never echoed (status line
|
|
41
|
+
only); keys go directly to api.anthropic.com / api.openai.com.
|
|
42
|
+
|
|
8
43
|
## [0.13.1] — 2026-06-11
|
|
9
44
|
|
|
10
45
|
### Fixed
|
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
|
@@ -246,7 +246,7 @@ export function normalizeTranscript(raw) {
|
|
|
246
246
|
export function parseCall(raw, options = {}) {
|
|
247
247
|
const normalized = normalizeTranscript(raw);
|
|
248
248
|
const segments = parseTranscript(normalized);
|
|
249
|
-
const insights = extractCallInsights(normalized, segments);
|
|
249
|
+
const insights = options.insights ?? extractCallInsights(normalized, segments);
|
|
250
250
|
const sourceSystem = options.sourceSystem ?? "manual";
|
|
251
251
|
const id = `call_${callHash(normalized)}`;
|
|
252
252
|
const evidence = insights.map((insight, index) => ({
|
|
@@ -258,6 +258,7 @@ export function parseCall(raw, options = {}) {
|
|
|
258
258
|
text: insight.evidence,
|
|
259
259
|
capturedAt: options.capturedAt,
|
|
260
260
|
metadata: {
|
|
261
|
+
extractor: options.extractor ?? "deterministic",
|
|
261
262
|
insightType: insight.type,
|
|
262
263
|
speaker: insight.speaker,
|
|
263
264
|
confidence: insight.confidence,
|
|
@@ -269,6 +270,7 @@ export function parseCall(raw, options = {}) {
|
|
|
269
270
|
id,
|
|
270
271
|
title: options.title,
|
|
271
272
|
sourceSystem,
|
|
273
|
+
extractor: options.extractor ?? "deterministic",
|
|
272
274
|
segments,
|
|
273
275
|
insights,
|
|
274
276
|
evidence,
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LlmProvider } from "./llm.ts";
|
|
1
2
|
type ProviderDoctorStatus = {
|
|
2
3
|
source: "env" | "stored" | "broker" | "none";
|
|
3
4
|
detail: string;
|
|
@@ -29,6 +30,17 @@ export declare function doctorReport(env?: Record<string, string | undefined>):
|
|
|
29
30
|
paired: boolean;
|
|
30
31
|
baseUrl?: undefined;
|
|
31
32
|
};
|
|
33
|
+
llm: {
|
|
34
|
+
configured: boolean;
|
|
35
|
+
provider: LlmProvider;
|
|
36
|
+
source: "env" | "stored";
|
|
37
|
+
detail?: undefined;
|
|
38
|
+
} | {
|
|
39
|
+
configured: boolean;
|
|
40
|
+
detail: string;
|
|
41
|
+
provider?: undefined;
|
|
42
|
+
source?: undefined;
|
|
43
|
+
};
|
|
32
44
|
mcp: {
|
|
33
45
|
peersInstalled: boolean;
|
|
34
46
|
missing: string[];
|
package/dist/cli.js
CHANGED
|
@@ -17,7 +17,8 @@ import { createFilePlanStore } from "./planStore.js";
|
|
|
17
17
|
import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
18
18
|
import { builtinAuditRules } from "./rules.js";
|
|
19
19
|
import { sampleSnapshot } from "./sampleData.js";
|
|
20
|
-
import { parseCall, suggestCallDeal } from "./calls.js";
|
|
20
|
+
import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
|
|
21
|
+
import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
21
22
|
import { suggestValues } from "./suggest.js";
|
|
22
23
|
function usage() {
|
|
23
24
|
return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
|
|
@@ -30,7 +31,7 @@ Usage:
|
|
|
30
31
|
fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>]
|
|
31
32
|
fullstackgtm login salesforce --instance-url <url> [--no-validate]
|
|
32
33
|
fullstackgtm login stripe [--no-validate]
|
|
33
|
-
fullstackgtm logout <hubspot|salesforce|stripe|broker>
|
|
34
|
+
fullstackgtm login anthropic | openai store an LLM API key for call parse/score\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|broker>
|
|
34
35
|
|
|
35
36
|
Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
|
|
36
37
|
the process list and shell history. Pipe them on stdin or enter them at the
|
|
@@ -41,11 +42,15 @@ Usage:
|
|
|
41
42
|
fullstackgtm report [source options] [audit options] [report options]
|
|
42
43
|
fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
|
|
43
44
|
fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
|
|
44
|
-
fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--json|--ndjson] [--out <path>]
|
|
45
|
+
fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
|
|
46
|
+
fullstackgtm call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
|
|
45
47
|
fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
46
48
|
fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
|
|
47
|
-
calls become evidence:
|
|
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
|
|
@@ -413,7 +418,7 @@ function parseValueOverrides(args) {
|
|
|
413
418
|
}
|
|
414
419
|
async function callCommand(args) {
|
|
415
420
|
const [subcommand, ...rest] = args;
|
|
416
|
-
const loadParsedCall = () => {
|
|
421
|
+
const loadParsedCall = async () => {
|
|
417
422
|
const callPath = option(rest, "--call");
|
|
418
423
|
if (callPath) {
|
|
419
424
|
return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
|
|
@@ -423,14 +428,30 @@ async function callCommand(args) {
|
|
|
423
428
|
throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
|
|
424
429
|
const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
|
|
425
430
|
const source = option(rest, "--source");
|
|
426
|
-
|
|
431
|
+
const base = {
|
|
427
432
|
title: option(rest, "--title") ?? undefined,
|
|
428
433
|
sourceSystem: source,
|
|
429
434
|
capturedAt: new Date().toISOString(),
|
|
435
|
+
};
|
|
436
|
+
if (rest.includes("--deterministic")) {
|
|
437
|
+
return parseCall(raw, base);
|
|
438
|
+
}
|
|
439
|
+
// LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
|
|
440
|
+
const credential = await requireLlmCredential();
|
|
441
|
+
const normalized = normalizeTranscript(raw);
|
|
442
|
+
const { insights, model } = await extractInsightsLlm(normalized, {
|
|
443
|
+
...credential,
|
|
444
|
+
model: option(rest, "--model") ?? undefined,
|
|
445
|
+
title: base.title,
|
|
446
|
+
});
|
|
447
|
+
return parseCall(raw, {
|
|
448
|
+
...base,
|
|
449
|
+
insights,
|
|
450
|
+
extractor: `llm:${credential.provider}:${model}`,
|
|
430
451
|
});
|
|
431
452
|
};
|
|
432
453
|
if (subcommand === "parse") {
|
|
433
|
-
const parsed = loadParsedCall();
|
|
454
|
+
const parsed = await loadParsedCall();
|
|
434
455
|
const outPath = option(rest, "--out");
|
|
435
456
|
if (outPath)
|
|
436
457
|
writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
@@ -490,7 +511,7 @@ async function callCommand(args) {
|
|
|
490
511
|
const dealId = option(rest, "--deal");
|
|
491
512
|
if (!dealId)
|
|
492
513
|
throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
|
|
493
|
-
const parsed = loadParsedCall();
|
|
514
|
+
const parsed = await loadParsedCall();
|
|
494
515
|
const snapshot = await readSnapshot(rest);
|
|
495
516
|
const deal = snapshot.deals.find((row) => row.id === dealId);
|
|
496
517
|
if (!deal)
|
|
@@ -512,7 +533,95 @@ async function callCommand(args) {
|
|
|
512
533
|
console.log(rest.includes("--json") ? JSON.stringify(plan, null, 2) : patchPlanToMarkdown(plan));
|
|
513
534
|
return;
|
|
514
535
|
}
|
|
515
|
-
|
|
536
|
+
if (subcommand === "score") {
|
|
537
|
+
const credential = await requireLlmCredential();
|
|
538
|
+
const rubricPath = option(rest, "--rubric");
|
|
539
|
+
const rubric = rubricPath
|
|
540
|
+
? parseRubric(readFileSync(resolve(process.cwd(), rubricPath), "utf8"))
|
|
541
|
+
: DEFAULT_RUBRIC;
|
|
542
|
+
const transcriptPath = option(rest, "--transcript");
|
|
543
|
+
let transcriptText;
|
|
544
|
+
let title = option(rest, "--title") ?? undefined;
|
|
545
|
+
if (transcriptPath) {
|
|
546
|
+
transcriptText = normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
const callPath = option(rest, "--call");
|
|
550
|
+
if (!callPath)
|
|
551
|
+
throw new Error("call score requires --transcript <file> or --call <parsed.json>");
|
|
552
|
+
const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
|
|
553
|
+
transcriptText = parsed.segments
|
|
554
|
+
.map((segment) => (segment.speaker ? `${segment.speaker}: ${segment.text}` : segment.text))
|
|
555
|
+
.join("\n");
|
|
556
|
+
title = title ?? parsed.title;
|
|
557
|
+
}
|
|
558
|
+
const scorecard = await scoreCallLlm(transcriptText, rubric, {
|
|
559
|
+
...credential,
|
|
560
|
+
model: option(rest, "--model") ?? undefined,
|
|
561
|
+
title,
|
|
562
|
+
});
|
|
563
|
+
const outPath = option(rest, "--out");
|
|
564
|
+
if (outPath)
|
|
565
|
+
writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(scorecard, null, 2)}\n`);
|
|
566
|
+
if (rest.includes("--json")) {
|
|
567
|
+
console.log(JSON.stringify(scorecard, null, 2));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
console.log(renderScorecard(scorecard, title));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
throw new Error(`call supports: parse, link, plan, score (got ${subcommand ?? "nothing"})`);
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* First-touch key onboarding: env vars win, then the credential store; on a
|
|
577
|
+
* TTY a missing key is captured once (validated, stored 0600 like provider
|
|
578
|
+
* logins). Non-interactive contexts get an actionable error instead.
|
|
579
|
+
*/
|
|
580
|
+
async function requireLlmCredential() {
|
|
581
|
+
const resolved = resolveLlmCredential();
|
|
582
|
+
if (resolved)
|
|
583
|
+
return resolved;
|
|
584
|
+
if (!process.stdin.isTTY) {
|
|
585
|
+
throw new Error("LLM extraction needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, run `echo \"$KEY\" | fullstackgtm login anthropic` (or `login openai`) once, or pass --deterministic for the free keyword baseline.");
|
|
586
|
+
}
|
|
587
|
+
console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
|
|
588
|
+
console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
|
|
589
|
+
console.error("(Alternatives: set ANTHROPIC_API_KEY / OPENAI_API_KEY, or pass --deterministic for the free keyword baseline.)\n");
|
|
590
|
+
const apiKey = await readSecret("API key (sk-ant-... or sk-...): ");
|
|
591
|
+
const provider = detectProviderFromKey(apiKey);
|
|
592
|
+
const validation = await validateLlmKey(provider, apiKey);
|
|
593
|
+
if (!validation.ok)
|
|
594
|
+
throw new Error(`${provider} rejected the key: ${validation.detail}`);
|
|
595
|
+
const now = new Date().toISOString();
|
|
596
|
+
storeCredential(provider, { kind: "api_key", accessToken: apiKey, createdAt: now, updatedAt: now });
|
|
597
|
+
console.error(`Stored ${provider} key (${validation.detail}). Future runs use it automatically; remove with \`fullstackgtm logout ${provider}\`.\n`);
|
|
598
|
+
return { provider, apiKey };
|
|
599
|
+
}
|
|
600
|
+
function renderScorecard(scorecard, title) {
|
|
601
|
+
const lines = [
|
|
602
|
+
`# Coaching Scorecard${title ? ` — ${title}` : ""}`,
|
|
603
|
+
"",
|
|
604
|
+
`**Overall: ${scorecard.overallScore}/${scorecard.scale}** (model: ${scorecard.model})`,
|
|
605
|
+
"",
|
|
606
|
+
"| Dimension | Score | | Coaching note |",
|
|
607
|
+
"| --- | --- | --- | --- |",
|
|
608
|
+
];
|
|
609
|
+
for (const dim of scorecard.dimensions) {
|
|
610
|
+
const filled = Math.round((dim.score / dim.maxScore) * 5);
|
|
611
|
+
const bar = "█".repeat(filled) + "░".repeat(5 - filled);
|
|
612
|
+
lines.push(`| ${dim.name} | ${dim.score}/${dim.maxScore} | ${bar} | ${dim.coachingNote} |`);
|
|
613
|
+
}
|
|
614
|
+
if (scorecard.highlights.length) {
|
|
615
|
+
lines.push("", "**Highlights**");
|
|
616
|
+
for (const h of scorecard.highlights)
|
|
617
|
+
lines.push(`- ${h}`);
|
|
618
|
+
}
|
|
619
|
+
if (scorecard.missedItems.length) {
|
|
620
|
+
lines.push("", "**Missed**");
|
|
621
|
+
for (const m of scorecard.missedItems)
|
|
622
|
+
lines.push(`- ${m}`);
|
|
623
|
+
}
|
|
624
|
+
return lines.join("\n");
|
|
516
625
|
}
|
|
517
626
|
function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
|
|
518
627
|
const findings = [];
|
|
@@ -1067,8 +1176,23 @@ async function login(args) {
|
|
|
1067
1176
|
console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
|
|
1068
1177
|
return;
|
|
1069
1178
|
}
|
|
1179
|
+
if (provider === "anthropic" || provider === "openai") {
|
|
1180
|
+
const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
|
|
1181
|
+
if (!key)
|
|
1182
|
+
throw new Error(`No ${provider} key provided.`);
|
|
1183
|
+
if (!args.includes("--no-validate")) {
|
|
1184
|
+
const validation = await validateLlmKey(provider, key);
|
|
1185
|
+
if (!validation.ok)
|
|
1186
|
+
throw new Error(`${provider} rejected the key: ${validation.detail}`);
|
|
1187
|
+
console.log(validation.detail);
|
|
1188
|
+
}
|
|
1189
|
+
const stamp = new Date().toISOString();
|
|
1190
|
+
storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
|
|
1191
|
+
console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1070
1194
|
if (provider !== "hubspot") {
|
|
1071
|
-
throw new Error("login supports: hubspot, salesforce, stripe, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
|
|
1195
|
+
throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
|
|
1072
1196
|
}
|
|
1073
1197
|
const now = new Date().toISOString();
|
|
1074
1198
|
if (args.includes("--oauth")) {
|
|
@@ -1150,6 +1274,7 @@ export function doctorReport(env = process.env) {
|
|
|
1150
1274
|
? { source: "env", detail: "STRIPE_SECRET_KEY" }
|
|
1151
1275
|
: providerStatus("stripe", broker),
|
|
1152
1276
|
};
|
|
1277
|
+
const llm = resolveLlmCredential(env);
|
|
1153
1278
|
const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
|
|
1154
1279
|
try {
|
|
1155
1280
|
import.meta.resolve(name);
|
|
@@ -1174,6 +1299,9 @@ export function doctorReport(env = process.env) {
|
|
|
1174
1299
|
config: { path: configPath, exists: existsSync(configPath) },
|
|
1175
1300
|
providers,
|
|
1176
1301
|
broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
|
|
1302
|
+
llm: llm
|
|
1303
|
+
? { configured: true, provider: llm.provider, source: llm.source }
|
|
1304
|
+
: { configured: false, detail: "call parse/score will prompt once, or set ANTHROPIC_API_KEY / OPENAI_API_KEY" },
|
|
1177
1305
|
mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
|
|
1178
1306
|
nextSteps,
|
|
1179
1307
|
};
|
|
@@ -1215,6 +1343,7 @@ function doctorCommand(args) {
|
|
|
1215
1343
|
"Providers:",
|
|
1216
1344
|
...Object.entries(report.providers).map(([provider, status]) => ` ${provider.padEnd(11)} ${status.source === "none" ? `not connected (${status.detail})` : `${status.source}: ${status.detail}`}`),
|
|
1217
1345
|
` ${"broker".padEnd(11)} ${report.broker.paired ? `paired with ${report.broker.baseUrl}` : "not paired (fullstackgtm login --via <hosted url>)"}`,
|
|
1346
|
+
` ${"llm".padEnd(11)} ${report.llm.configured ? `${report.llm.provider} key (${report.llm.source}) — call parse/score ready` : `not configured (${report.llm.detail})`}`,
|
|
1218
1347
|
"",
|
|
1219
1348
|
report.mcp.peersInstalled
|
|
1220
1349
|
? "MCP: peers installed — `fullstackgtm-mcp` is ready"
|
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
|
+
}>;
|