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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,61 @@ 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.1] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
Fixes from the 0.14.0 journey verification (4 agents, 21 checks).
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`call score` no longer suggests a flag it rejects**: the keyless error
|
|
15
|
+
said "pass --deterministic" but score has no non-LLM mode — its error now
|
|
16
|
+
says exactly that. Rubric files are also validated *before* the
|
|
17
|
+
credential check (keyless users see rubric errors), and a non-JSON rubric
|
|
18
|
+
names the file and the expected shape instead of a raw parse error.
|
|
19
|
+
- **NDJSON rows carry `extractor`** — LLM and deterministic rows were
|
|
20
|
+
per-row indistinguishable in warehouse loads, contradicting the
|
|
21
|
+
provenance claim. (Docs wording was right at the parse-result and
|
|
22
|
+
evidence level; now it is true per-row too.)
|
|
23
|
+
- **`call … --help` prints help** instead of being shadowed by argument
|
|
24
|
+
and credential checks.
|
|
25
|
+
- `login anthropic|openai` gets the same explicit argv-secret rejection
|
|
26
|
+
(`--token`/`--key`/`--api-key`) as the CRM providers.
|
|
27
|
+
|
|
28
|
+
## [0.14.0] — 2026-06-11
|
|
29
|
+
|
|
30
|
+
LLM-powered call intelligence, bring-your-own-key. The dry-run replications
|
|
31
|
+
of two real client pipelines proved the deterministic baseline is an
|
|
32
|
+
analytics floor, not what call workflows actually run on — so the no-LLM
|
|
33
|
+
guardrail is consciously retired for the `call` commands (and only there).
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **LLM extraction is the default for `call parse`** — constrained tool
|
|
38
|
+
calls against Anthropic (default claude-haiku-4-5) or OpenAI (default
|
|
39
|
+
gpt-4o-mini) via raw fetch, no SDK dependency. Mandatory verbatim-quote
|
|
40
|
+
evidence; next steps carry owner/deadline/commitment; every insight is
|
|
41
|
+
provenance-marked (`extractor: "llm:<provider>:<model>"` vs
|
|
42
|
+
`"deterministic"`). `--deterministic` keeps the free keyword baseline.
|
|
43
|
+
- **First-touch key onboarding**: the first LLM command without a key (TTY)
|
|
44
|
+
explains, captures the key via muted prompt/stdin, auto-detects the
|
|
45
|
+
provider from the prefix, validates it against the provider's API, and
|
|
46
|
+
stores it 0600 in the credential store. Non-interactive contexts get an
|
|
47
|
+
actionable error, never a prompt. Also: `fullstackgtm login anthropic|openai`
|
|
48
|
+
and `logout`, and a `doctor` row showing LLM key status.
|
|
49
|
+
- **`fullstackgtm call score`**: rubric-driven coaching scorecards —
|
|
50
|
+
built-in five-dimension rubric or `--rubric rubric.json`
|
|
51
|
+
({ scale, dimensions: [{ name, weight, rubric }] }); per-dimension
|
|
52
|
+
verbatim evidence + coaching notes; the weighted overall is computed
|
|
53
|
+
deterministically client-side. Markdown table or `--json`.
|
|
54
|
+
- **MCP `fullstackgtm_call_parse` gains `extractor: auto|llm|deterministic`**
|
|
55
|
+
(auto uses a key when the server has one, else the baseline) and `--model`.
|
|
56
|
+
|
|
57
|
+
### Security
|
|
58
|
+
|
|
59
|
+
- LLM keys follow the same discipline as CRM secrets: stdin/prompt only,
|
|
60
|
+
never argv; 0600 store; provider error bodies never echoed (status line
|
|
61
|
+
only); keys go directly to api.anthropic.com / api.openai.com.
|
|
62
|
+
|
|
8
63
|
## [0.13.1] — 2026-06-11
|
|
9
64
|
|
|
10
65
|
### 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,18 @@ function parseValueOverrides(args) {
|
|
|
413
418
|
}
|
|
414
419
|
async function callCommand(args) {
|
|
415
420
|
const [subcommand, ...rest] = args;
|
|
416
|
-
|
|
421
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
422
|
+
console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
|
|
423
|
+
call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
|
|
424
|
+
call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
425
|
+
call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
|
|
426
|
+
|
|
427
|
+
parse/score default to LLM extraction (Anthropic or OpenAI key via env,
|
|
428
|
+
\`login anthropic|openai\`, or a one-time prompt). parse --deterministic is
|
|
429
|
+
the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const loadParsedCall = async () => {
|
|
417
433
|
const callPath = option(rest, "--call");
|
|
418
434
|
if (callPath) {
|
|
419
435
|
return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
|
|
@@ -423,14 +439,30 @@ async function callCommand(args) {
|
|
|
423
439
|
throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
|
|
424
440
|
const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
|
|
425
441
|
const source = option(rest, "--source");
|
|
426
|
-
|
|
442
|
+
const base = {
|
|
427
443
|
title: option(rest, "--title") ?? undefined,
|
|
428
444
|
sourceSystem: source,
|
|
429
445
|
capturedAt: new Date().toISOString(),
|
|
446
|
+
};
|
|
447
|
+
if (rest.includes("--deterministic")) {
|
|
448
|
+
return parseCall(raw, base);
|
|
449
|
+
}
|
|
450
|
+
// LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
|
|
451
|
+
const credential = await requireLlmCredential();
|
|
452
|
+
const normalized = normalizeTranscript(raw);
|
|
453
|
+
const { insights, model } = await extractInsightsLlm(normalized, {
|
|
454
|
+
...credential,
|
|
455
|
+
model: option(rest, "--model") ?? undefined,
|
|
456
|
+
title: base.title,
|
|
457
|
+
});
|
|
458
|
+
return parseCall(raw, {
|
|
459
|
+
...base,
|
|
460
|
+
insights,
|
|
461
|
+
extractor: `llm:${credential.provider}:${model}`,
|
|
430
462
|
});
|
|
431
463
|
};
|
|
432
464
|
if (subcommand === "parse") {
|
|
433
|
-
const parsed = loadParsedCall();
|
|
465
|
+
const parsed = await loadParsedCall();
|
|
434
466
|
const outPath = option(rest, "--out");
|
|
435
467
|
if (outPath)
|
|
436
468
|
writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
@@ -441,6 +473,7 @@ async function callCommand(args) {
|
|
|
441
473
|
call_id: parsed.id,
|
|
442
474
|
call_title: parsed.title ?? null,
|
|
443
475
|
source_system: parsed.sourceSystem,
|
|
476
|
+
extractor: parsed.extractor,
|
|
444
477
|
type: insight.type,
|
|
445
478
|
title: insight.title,
|
|
446
479
|
text: insight.text,
|
|
@@ -490,7 +523,7 @@ async function callCommand(args) {
|
|
|
490
523
|
const dealId = option(rest, "--deal");
|
|
491
524
|
if (!dealId)
|
|
492
525
|
throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
|
|
493
|
-
const parsed = loadParsedCall();
|
|
526
|
+
const parsed = await loadParsedCall();
|
|
494
527
|
const snapshot = await readSnapshot(rest);
|
|
495
528
|
const deal = snapshot.deals.find((row) => row.id === dealId);
|
|
496
529
|
if (!deal)
|
|
@@ -512,7 +545,105 @@ async function callCommand(args) {
|
|
|
512
545
|
console.log(rest.includes("--json") ? JSON.stringify(plan, null, 2) : patchPlanToMarkdown(plan));
|
|
513
546
|
return;
|
|
514
547
|
}
|
|
515
|
-
|
|
548
|
+
if (subcommand === "score") {
|
|
549
|
+
// Rubric problems surface before any credential or API work.
|
|
550
|
+
const rubricPath = option(rest, "--rubric");
|
|
551
|
+
let rubric = DEFAULT_RUBRIC;
|
|
552
|
+
if (rubricPath) {
|
|
553
|
+
const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
|
|
554
|
+
try {
|
|
555
|
+
rubric = parseRubric(rubricRaw);
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
throw new Error(`${rubricPath} is not a valid rubric: ${error instanceof Error ? error.message : String(error)} Expected JSON like { "scale": 5, "dimensions": [{ "name": "...", "weight": 1, "rubric": "..." }] }.`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const credential = await requireLlmCredential("score");
|
|
562
|
+
const transcriptPath = option(rest, "--transcript");
|
|
563
|
+
let transcriptText;
|
|
564
|
+
let title = option(rest, "--title") ?? undefined;
|
|
565
|
+
if (transcriptPath) {
|
|
566
|
+
transcriptText = normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
const callPath = option(rest, "--call");
|
|
570
|
+
if (!callPath)
|
|
571
|
+
throw new Error("call score requires --transcript <file> or --call <parsed.json>");
|
|
572
|
+
const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
|
|
573
|
+
transcriptText = parsed.segments
|
|
574
|
+
.map((segment) => (segment.speaker ? `${segment.speaker}: ${segment.text}` : segment.text))
|
|
575
|
+
.join("\n");
|
|
576
|
+
title = title ?? parsed.title;
|
|
577
|
+
}
|
|
578
|
+
const scorecard = await scoreCallLlm(transcriptText, rubric, {
|
|
579
|
+
...credential,
|
|
580
|
+
model: option(rest, "--model") ?? undefined,
|
|
581
|
+
title,
|
|
582
|
+
});
|
|
583
|
+
const outPath = option(rest, "--out");
|
|
584
|
+
if (outPath)
|
|
585
|
+
writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(scorecard, null, 2)}\n`);
|
|
586
|
+
if (rest.includes("--json")) {
|
|
587
|
+
console.log(JSON.stringify(scorecard, null, 2));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
console.log(renderScorecard(scorecard, title));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
throw new Error(`call supports: parse, link, plan, score (got ${subcommand ?? "nothing"})`);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* First-touch key onboarding: env vars win, then the credential store; on a
|
|
597
|
+
* TTY a missing key is captured once (validated, stored 0600 like provider
|
|
598
|
+
* logins). Non-interactive contexts get an actionable error instead.
|
|
599
|
+
*/
|
|
600
|
+
async function requireLlmCredential(command = "parse") {
|
|
601
|
+
const resolved = resolveLlmCredential();
|
|
602
|
+
if (resolved)
|
|
603
|
+
return resolved;
|
|
604
|
+
// Scoring is inherently LLM work — there is no keyword fallback to suggest.
|
|
605
|
+
const fallbackHint = command === "parse" ? ", or pass --deterministic for the free keyword baseline" : " (call score has no non-LLM mode)";
|
|
606
|
+
if (!process.stdin.isTTY) {
|
|
607
|
+
throw new Error(`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}.`);
|
|
608
|
+
}
|
|
609
|
+
console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
|
|
610
|
+
console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
|
|
611
|
+
console.error("(Alternatives: set ANTHROPIC_API_KEY / OPENAI_API_KEY, or pass --deterministic for the free keyword baseline.)\n");
|
|
612
|
+
const apiKey = await readSecret("API key (sk-ant-... or sk-...): ");
|
|
613
|
+
const provider = detectProviderFromKey(apiKey);
|
|
614
|
+
const validation = await validateLlmKey(provider, apiKey);
|
|
615
|
+
if (!validation.ok)
|
|
616
|
+
throw new Error(`${provider} rejected the key: ${validation.detail}`);
|
|
617
|
+
const now = new Date().toISOString();
|
|
618
|
+
storeCredential(provider, { kind: "api_key", accessToken: apiKey, createdAt: now, updatedAt: now });
|
|
619
|
+
console.error(`Stored ${provider} key (${validation.detail}). Future runs use it automatically; remove with \`fullstackgtm logout ${provider}\`.\n`);
|
|
620
|
+
return { provider, apiKey };
|
|
621
|
+
}
|
|
622
|
+
function renderScorecard(scorecard, title) {
|
|
623
|
+
const lines = [
|
|
624
|
+
`# Coaching Scorecard${title ? ` — ${title}` : ""}`,
|
|
625
|
+
"",
|
|
626
|
+
`**Overall: ${scorecard.overallScore}/${scorecard.scale}** (model: ${scorecard.model})`,
|
|
627
|
+
"",
|
|
628
|
+
"| Dimension | Score | | Coaching note |",
|
|
629
|
+
"| --- | --- | --- | --- |",
|
|
630
|
+
];
|
|
631
|
+
for (const dim of scorecard.dimensions) {
|
|
632
|
+
const filled = Math.round((dim.score / dim.maxScore) * 5);
|
|
633
|
+
const bar = "█".repeat(filled) + "░".repeat(5 - filled);
|
|
634
|
+
lines.push(`| ${dim.name} | ${dim.score}/${dim.maxScore} | ${bar} | ${dim.coachingNote} |`);
|
|
635
|
+
}
|
|
636
|
+
if (scorecard.highlights.length) {
|
|
637
|
+
lines.push("", "**Highlights**");
|
|
638
|
+
for (const h of scorecard.highlights)
|
|
639
|
+
lines.push(`- ${h}`);
|
|
640
|
+
}
|
|
641
|
+
if (scorecard.missedItems.length) {
|
|
642
|
+
lines.push("", "**Missed**");
|
|
643
|
+
for (const m of scorecard.missedItems)
|
|
644
|
+
lines.push(`- ${m}`);
|
|
645
|
+
}
|
|
646
|
+
return lines.join("\n");
|
|
516
647
|
}
|
|
517
648
|
function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
|
|
518
649
|
const findings = [];
|
|
@@ -1067,8 +1198,24 @@ async function login(args) {
|
|
|
1067
1198
|
console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
|
|
1068
1199
|
return;
|
|
1069
1200
|
}
|
|
1201
|
+
if (provider === "anthropic" || provider === "openai") {
|
|
1202
|
+
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
1203
|
+
const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
|
|
1204
|
+
if (!key)
|
|
1205
|
+
throw new Error(`No ${provider} key provided.`);
|
|
1206
|
+
if (!args.includes("--no-validate")) {
|
|
1207
|
+
const validation = await validateLlmKey(provider, key);
|
|
1208
|
+
if (!validation.ok)
|
|
1209
|
+
throw new Error(`${provider} rejected the key: ${validation.detail}`);
|
|
1210
|
+
console.log(validation.detail);
|
|
1211
|
+
}
|
|
1212
|
+
const stamp = new Date().toISOString();
|
|
1213
|
+
storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
|
|
1214
|
+
console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1070
1217
|
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");
|
|
1218
|
+
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
1219
|
}
|
|
1073
1220
|
const now = new Date().toISOString();
|
|
1074
1221
|
if (args.includes("--oauth")) {
|
|
@@ -1150,6 +1297,7 @@ export function doctorReport(env = process.env) {
|
|
|
1150
1297
|
? { source: "env", detail: "STRIPE_SECRET_KEY" }
|
|
1151
1298
|
: providerStatus("stripe", broker),
|
|
1152
1299
|
};
|
|
1300
|
+
const llm = resolveLlmCredential(env);
|
|
1153
1301
|
const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
|
|
1154
1302
|
try {
|
|
1155
1303
|
import.meta.resolve(name);
|
|
@@ -1174,6 +1322,9 @@ export function doctorReport(env = process.env) {
|
|
|
1174
1322
|
config: { path: configPath, exists: existsSync(configPath) },
|
|
1175
1323
|
providers,
|
|
1176
1324
|
broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
|
|
1325
|
+
llm: llm
|
|
1326
|
+
? { configured: true, provider: llm.provider, source: llm.source }
|
|
1327
|
+
: { configured: false, detail: "call parse/score will prompt once, or set ANTHROPIC_API_KEY / OPENAI_API_KEY" },
|
|
1177
1328
|
mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
|
|
1178
1329
|
nextSteps,
|
|
1179
1330
|
};
|
|
@@ -1215,6 +1366,7 @@ function doctorCommand(args) {
|
|
|
1215
1366
|
"Providers:",
|
|
1216
1367
|
...Object.entries(report.providers).map(([provider, status]) => ` ${provider.padEnd(11)} ${status.source === "none" ? `not connected (${status.detail})` : `${status.source}: ${status.detail}`}`),
|
|
1217
1368
|
` ${"broker".padEnd(11)} ${report.broker.paired ? `paired with ${report.broker.baseUrl}` : "not paired (fullstackgtm login --via <hosted url>)"}`,
|
|
1369
|
+
` ${"llm".padEnd(11)} ${report.llm.configured ? `${report.llm.provider} key (${report.llm.source}) — call parse/score ready` : `not configured (${report.llm.detail})`}`,
|
|
1218
1370
|
"",
|
|
1219
1371
|
report.mcp.peersInstalled
|
|
1220
1372
|
? "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
|
+
}>;
|