fullstackgtm 0.45.0 → 0.47.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 +115 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +27 -18
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.js +119 -12
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.js +23 -9
- package/dist/cli/enrich.js +28 -10
- package/dist/cli/fix.js +9 -7
- package/dist/cli/help.js +60 -23
- package/dist/cli/plans.js +13 -11
- package/dist/cli/shared.d.ts +4 -3
- package/dist/cli/shared.js +31 -20
- package/dist/cli/ui.d.ts +21 -0
- package/dist/cli/ui.js +53 -1
- package/dist/cli.js +37 -41
- package/dist/connector.d.ts +8 -0
- package/dist/connector.js +104 -21
- package/dist/connectors/hubspot.d.ts +8 -1
- package/dist/connectors/hubspot.js +406 -13
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +8 -1
- package/dist/connectors/salesforce.js +163 -2
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +9 -1
- package/dist/marketClassify.js +10 -1
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +29 -0
- package/dist/mcp.js +117 -4
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/types.d.ts +33 -1
- package/docs/api.md +4 -2
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/llms.txt +3 -3
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +1 -0
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/auth.ts +135 -15
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +26 -10
- package/src/cli/enrich.ts +33 -10
- package/src/cli/fix.ts +11 -10
- package/src/cli/help.ts +61 -23
- package/src/cli/plans.ts +15 -14
- package/src/cli/shared.ts +44 -27
- package/src/cli/ui.ts +72 -1
- package/src/cli.ts +38 -41
- package/src/connector.ts +110 -16
- package/src/connectors/hubspot.ts +423 -14
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +168 -3
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/index.ts +28 -1
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +18 -1
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +140 -6
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/types.ts +35 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/src/llm.ts
CHANGED
|
@@ -511,3 +511,242 @@ export async function validateLlmKey(
|
|
|
511
511
|
? { ok: true, detail: `Key accepted by the ${provider} API.` }
|
|
512
512
|
: { ok: false, detail: `HTTP ${response.status} ${response.statusText}`.trim() };
|
|
513
513
|
}
|
|
514
|
+
|
|
515
|
+
// ── Chunked extraction (the TamTone "langextract" method) ───────────────────
|
|
516
|
+
//
|
|
517
|
+
// The quality property this encodes, validated on real call corpora in
|
|
518
|
+
// TamTone: transcripts are never sent whole. They are chopped into small
|
|
519
|
+
// (~1,500-char) speaker-turn-respecting chunks and each chunk gets its own
|
|
520
|
+
// focused extraction call — small chunks yielded ~95% more grounded signals
|
|
521
|
+
// than 4,000-char chunks in TamTone's testing, and whole-transcript passes
|
|
522
|
+
// (this module's original extractInsightsLlm, kept for tiny inputs) are the
|
|
523
|
+
// worst case: truncation drops the tail and one context window extracts
|
|
524
|
+
// shallowly. Per-chunk results pass the SAME verbatim-evidence and
|
|
525
|
+
// next-step-grounding gates as the single-shot path (checked against the
|
|
526
|
+
// chunk they came from), then a TamTone-style quality score
|
|
527
|
+
// (0.25·length + 0.40·specificity + 0.35·confidence, reject < 0.15) filters
|
|
528
|
+
// filler before per-call dedupe and ranking.
|
|
529
|
+
|
|
530
|
+
const CHUNK_MAX_CHARS = 1500;
|
|
531
|
+
const CHUNK_CONCURRENCY = 4;
|
|
532
|
+
const MAX_CHUNKS_PER_CALL = 80; // cost bound ≈ 120K chars of transcript
|
|
533
|
+
const MAX_INSIGHTS_PER_CALL = 40;
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Chop a "Speaker: text" transcript into ≤maxChars chunks on speaker-turn
|
|
537
|
+
* (line) boundaries; a single oversize turn splits on sentence boundaries.
|
|
538
|
+
* No overlap (per-chunk extraction + downstream dedupe make it unnecessary).
|
|
539
|
+
*/
|
|
540
|
+
export function chunkTranscript(transcript: string, maxChars = CHUNK_MAX_CHARS): string[] {
|
|
541
|
+
const lines = transcript.split("\n").filter((line) => line.trim().length > 0);
|
|
542
|
+
const pieces: string[] = [];
|
|
543
|
+
for (const line of lines) {
|
|
544
|
+
if (line.length <= maxChars) {
|
|
545
|
+
pieces.push(line);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
// Oversize turn: split on sentence boundaries within the budget.
|
|
549
|
+
let rest = line;
|
|
550
|
+
while (rest.length > maxChars) {
|
|
551
|
+
const window = rest.slice(0, maxChars);
|
|
552
|
+
const cut = Math.max(
|
|
553
|
+
window.lastIndexOf(". "),
|
|
554
|
+
window.lastIndexOf("? "),
|
|
555
|
+
window.lastIndexOf("! "),
|
|
556
|
+
);
|
|
557
|
+
const at = cut >= maxChars * 0.4 ? cut + 1 : maxChars;
|
|
558
|
+
pieces.push(rest.slice(0, at).trim());
|
|
559
|
+
rest = rest.slice(at).trim();
|
|
560
|
+
}
|
|
561
|
+
if (rest) pieces.push(rest);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const chunks: string[] = [];
|
|
565
|
+
let current = "";
|
|
566
|
+
for (const piece of pieces) {
|
|
567
|
+
if (current && current.length + piece.length + 1 > maxChars) {
|
|
568
|
+
chunks.push(current);
|
|
569
|
+
current = piece;
|
|
570
|
+
} else {
|
|
571
|
+
current = current ? `${current}\n${piece}` : piece;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (current) chunks.push(current);
|
|
575
|
+
return chunks;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Few-shot examples are load-bearing (TamTone's extractor refuses to run
|
|
579
|
+
// without an example set): they anchor the "verbatim span, not paraphrase"
|
|
580
|
+
// contract and calibrate what NOT to extract far better than instructions.
|
|
581
|
+
const CHUNK_FEW_SHOT = `Examples:
|
|
582
|
+
|
|
583
|
+
Excerpt: "Prospect: honestly the big blocker is that our reps spend every Friday cleaning the pipeline by hand. Rep: how long does that take? Prospect: most of the day, easily five or six hours."
|
|
584
|
+
→ [{"type":"pain_point","text":"Reps lose ~5-6 hours every Friday manually cleaning the pipeline.","evidence":"our reps spend every Friday cleaning the pipeline by hand","speaker":"Prospect","importance":4,"confidence":0.9}]
|
|
585
|
+
|
|
586
|
+
Excerpt: "Prospect: we're also looking at Clari for this. Their forecasting is strong but the rollout quote scared us. Rep: what was the number? Prospect: about eighty thousand a year."
|
|
587
|
+
→ [{"type":"competitor_mention","text":"Evaluating Clari; strong forecasting but ~$80K/yr rollout quote is a concern.","evidence":"we're also looking at Clari for this","speaker":"Prospect","importance":4,"confidence":0.9},{"type":"pricing","text":"Clari quoted about $80K per year.","evidence":"about eighty thousand a year","speaker":"Prospect","importance":3,"confidence":0.85}]
|
|
588
|
+
|
|
589
|
+
Excerpt: "Rep: I'll send the security questionnaire answers by Thursday. Prospect: perfect, and I'll get you time with our CFO next week."
|
|
590
|
+
→ [{"type":"next_step","text":"Rep to send security questionnaire answers by Thursday.","evidence":"I'll send the security questionnaire answers by Thursday","speaker":"Rep","importance":4,"confidence":0.95,"owner":"Rep","deadline":"Thursday","commitment":"firm"},{"type":"next_step","text":"Prospect to schedule time with their CFO next week.","evidence":"I'll get you time with our CFO next week","speaker":"Prospect","importance":4,"confidence":0.9,"owner":"Prospect","deadline":"next week","commitment":"firm"}]
|
|
591
|
+
|
|
592
|
+
Excerpt: "Rep: how was the long weekend? Prospect: great, we took the kids camping. Rep: nice! okay, let me share my screen."
|
|
593
|
+
→ []
|
|
594
|
+
|
|
595
|
+
Do NOT extract: greetings, scheduling chatter, filler ("yeah", "makes sense"), screen-share logistics, or anything you cannot quote verbatim from THIS excerpt.`;
|
|
596
|
+
|
|
597
|
+
function chunkInstructions(index: number, total: number, title?: string): string {
|
|
598
|
+
return `${EXTRACT_INSTRUCTIONS}
|
|
599
|
+
|
|
600
|
+
You are seeing excerpt ${index + 1} of ${total} from a longer call${title ? ` ("${title}")` : ""}.
|
|
601
|
+
Extract ONLY what this excerpt itself grounds — the other excerpts are handled separately.
|
|
602
|
+
evidence MUST be a verbatim span of THIS excerpt.
|
|
603
|
+
|
|
604
|
+
${CHUNK_FEW_SHOT}`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// TamTone quality gate (analysis/quality_scorer.py):
|
|
608
|
+
// 0.25·length + 0.40·specificity + 0.35·confidence, hard caps for
|
|
609
|
+
// stopword-only/too-short, reject below 0.15. Specificity rewards the
|
|
610
|
+
// concrete: numbers, money, dates/durations, named things.
|
|
611
|
+
const STOPWORDS = new Set(
|
|
612
|
+
"a an and are as at be but by for from had has have i if in into is it its me my not of on or our so that the their them they this to was we were what when which who will with you your yeah okay right sure like just really kind sort".split(" "),
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
export function scoreInsightQuality(insight: { text: string; evidence: string; confidence: number }): number {
|
|
616
|
+
const text = `${insight.text} ${insight.evidence}`.trim();
|
|
617
|
+
const words = text.toLowerCase().split(/[^a-z0-9$%']+/).filter(Boolean);
|
|
618
|
+
if (text.length < 5) return 0.1;
|
|
619
|
+
const nonStop = words.filter((word) => !STOPWORDS.has(word));
|
|
620
|
+
if (nonStop.length === 0) return 0.1;
|
|
621
|
+
|
|
622
|
+
const lengthScore = Math.min(1, words.length / 20);
|
|
623
|
+
let specificity = 0;
|
|
624
|
+
if (/\d/.test(text)) specificity += 0.3; // numbers/amounts/dates
|
|
625
|
+
if (/[$€£]|\b(dollars?|thousand|million|percent|%)\b/i.test(text)) specificity += 0.2;
|
|
626
|
+
if (/\b(q[1-4]|january|february|march|april|may|june|july|august|september|october|november|december|week|month|quarter|year|monday|tuesday|wednesday|thursday|friday)\b/i.test(text)) specificity += 0.2;
|
|
627
|
+
if (/\b[A-Z][a-z]{2,}/.test(insight.text.slice(1))) specificity += 0.15; // named things
|
|
628
|
+
specificity += Math.min(0.3, nonStop.length / 40);
|
|
629
|
+
specificity = Math.min(1, specificity);
|
|
630
|
+
|
|
631
|
+
return 0.25 * lengthScore + 0.4 * specificity + 0.35 * Math.min(1, Math.max(0, insight.confidence));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const QUALITY_REJECT_BELOW = 0.15;
|
|
635
|
+
|
|
636
|
+
export type ChunkedExtractResult = {
|
|
637
|
+
insights: LlmExtractedInsight[];
|
|
638
|
+
model: string;
|
|
639
|
+
chunks: number;
|
|
640
|
+
chunksUsed: number;
|
|
641
|
+
chunksFailed: number;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The chunked pipeline: chunk → per-chunk forced-tool extraction (bounded
|
|
646
|
+
* concurrency) → per-chunk verbatim + grounding gates → quality gate →
|
|
647
|
+
* per-call dedupe → rank. A failed chunk is skipped (best-effort); only
|
|
648
|
+
* all-chunks-failed throws.
|
|
649
|
+
*/
|
|
650
|
+
export async function extractInsightsChunked(
|
|
651
|
+
transcript: string,
|
|
652
|
+
options: LlmCallOptions & { title?: string; maxChunks?: number; concurrency?: number },
|
|
653
|
+
): Promise<ChunkedExtractResult> {
|
|
654
|
+
const model = options.model ?? DEFAULT_MODELS[options.provider];
|
|
655
|
+
const allChunks = chunkTranscript(transcript);
|
|
656
|
+
const chunks = allChunks.slice(0, options.maxChunks ?? MAX_CHUNKS_PER_CALL);
|
|
657
|
+
if (chunks.length <= 1) {
|
|
658
|
+
// Tiny transcript: the single-shot path is identical work with one call.
|
|
659
|
+
const single = await extractInsightsLlm(transcript, options);
|
|
660
|
+
return { ...single, chunks: allChunks.length, chunksUsed: chunks.length, chunksFailed: 0 };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const concurrency = Math.max(1, options.concurrency ?? CHUNK_CONCURRENCY);
|
|
664
|
+
const perChunk: LlmExtractedInsight[][] = new Array(chunks.length);
|
|
665
|
+
let failed = 0;
|
|
666
|
+
let cursor = 0;
|
|
667
|
+
async function worker() {
|
|
668
|
+
while (cursor < chunks.length) {
|
|
669
|
+
const index = cursor;
|
|
670
|
+
cursor += 1;
|
|
671
|
+
const chunk = chunks[index];
|
|
672
|
+
try {
|
|
673
|
+
const result = (await forcedToolCall(
|
|
674
|
+
`${chunkInstructions(index, chunks.length, options.title)}\n\nExcerpt:\n${chunk}`,
|
|
675
|
+
"extract_call_insights",
|
|
676
|
+
EXTRACT_SCHEMA,
|
|
677
|
+
model,
|
|
678
|
+
options,
|
|
679
|
+
)) as { insights?: LlmExtractedInsight[] };
|
|
680
|
+
const normalizedChunk = normalizeSpan(chunk);
|
|
681
|
+
perChunk[index] = (result.insights ?? [])
|
|
682
|
+
.filter((insight) => INSIGHT_TYPES.includes(insight.type))
|
|
683
|
+
// The SAME gates as the single-shot path, applied per chunk: the
|
|
684
|
+
// evidence must be a verbatim span of the chunk it came from, and a
|
|
685
|
+
// next_step's written action must be grounded in that evidence.
|
|
686
|
+
.filter((insight) => {
|
|
687
|
+
const quote = normalizeSpan(insight.evidence ?? "");
|
|
688
|
+
return quote.length >= 12 && normalizedChunk.includes(quote);
|
|
689
|
+
})
|
|
690
|
+
.filter(
|
|
691
|
+
(insight) =>
|
|
692
|
+
insight.type !== "next_step" ||
|
|
693
|
+
actionGroundedInEvidence(insight.text, insight.evidence ?? ""),
|
|
694
|
+
)
|
|
695
|
+
.map((insight) => ({
|
|
696
|
+
...insight,
|
|
697
|
+
title: insight.type.replace(/_/g, " "),
|
|
698
|
+
importance: clamp(Math.round(insight.importance ?? 3), 1, 5),
|
|
699
|
+
confidence: clamp(insight.confidence ?? 0.7, 0, 1),
|
|
700
|
+
}))
|
|
701
|
+
.filter((insight) => scoreInsightQuality(insight) >= QUALITY_REJECT_BELOW);
|
|
702
|
+
} catch {
|
|
703
|
+
failed += 1;
|
|
704
|
+
perChunk[index] = [];
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
|
|
709
|
+
|
|
710
|
+
if (failed === chunks.length) {
|
|
711
|
+
throw new Error(`LLM extraction failed for all ${chunks.length} chunks.`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Per-call dedupe: exact on (type, normalized evidence), then near-dup on
|
|
715
|
+
// (type, normalized text) containment — chunks don't overlap, so dupes are
|
|
716
|
+
// the model restating the same fact from adjacent context.
|
|
717
|
+
const seen = new Map<string, LlmExtractedInsight>();
|
|
718
|
+
const richness = (insight: LlmExtractedInsight) =>
|
|
719
|
+
insight.confidence +
|
|
720
|
+
(insight.owner ? 0.05 : 0) +
|
|
721
|
+
(insight.deadline ? 0.05 : 0) +
|
|
722
|
+
(insight.speaker ? 0.02 : 0);
|
|
723
|
+
for (const insights of perChunk) {
|
|
724
|
+
for (const insight of insights ?? []) {
|
|
725
|
+
const key = `${insight.type}:${normalizeSpan(insight.evidence ?? "")}`;
|
|
726
|
+
const kept = seen.get(key);
|
|
727
|
+
// Same grounded fact stated twice: keep the richer statement (owner/
|
|
728
|
+
// deadline/speaker attached, higher confidence), not the first seen.
|
|
729
|
+
if (!kept || richness(insight) > richness(kept)) seen.set(key, insight);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const merged: LlmExtractedInsight[] = [];
|
|
733
|
+
for (const insight of seen.values()) {
|
|
734
|
+
const text = normalizeSpan(insight.text);
|
|
735
|
+
const dupe = merged.find(
|
|
736
|
+
(kept) =>
|
|
737
|
+
kept.type === insight.type &&
|
|
738
|
+
(normalizeSpan(kept.text).includes(text) || text.includes(normalizeSpan(kept.text))),
|
|
739
|
+
);
|
|
740
|
+
if (!dupe) merged.push(insight);
|
|
741
|
+
else if (insight.importance > dupe.importance) merged[merged.indexOf(dupe)] = insight;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
merged.sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
|
|
745
|
+
return {
|
|
746
|
+
insights: merged.slice(0, MAX_INSIGHTS_PER_CALL),
|
|
747
|
+
model,
|
|
748
|
+
chunks: allChunks.length,
|
|
749
|
+
chunksUsed: chunks.length,
|
|
750
|
+
chunksFailed: failed,
|
|
751
|
+
};
|
|
752
|
+
}
|
package/src/market.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
|
|
4
4
|
import { isIP } from "node:net";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { credentialsDir } from "./credentials.ts";
|
|
7
|
+
import { MARKET_CAPTURE_STAGES, nullProgressEmitter, type ProgressEmitter } from "./progress.ts";
|
|
7
8
|
import type { GtmEvidence } from "./types.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -445,12 +446,24 @@ const defaultFetchPage: FetchPage = async (url) => {
|
|
|
445
446
|
};
|
|
446
447
|
|
|
447
448
|
export type CaptureOptions = {
|
|
448
|
-
/** Directory for captures; defaults to <marketHome>/captures. */
|
|
449
|
+
/** Directory for captures; defaults to <marketHome>/captures. Ignored when `store` is given. */
|
|
449
450
|
dir?: string;
|
|
450
451
|
runLabel?: string;
|
|
451
452
|
/** Injectable for tests; defaults to global fetch. */
|
|
452
453
|
fetchPage?: FetchPage;
|
|
453
454
|
now?: () => Date;
|
|
455
|
+
/**
|
|
456
|
+
* Storage seam (see MarketStore below). Defaults to the profile-home file
|
|
457
|
+
* store; the hosted app passes a Convex-backed store. Same engine, same
|
|
458
|
+
* evidence chain, different persistence.
|
|
459
|
+
*/
|
|
460
|
+
store?: MarketStore;
|
|
461
|
+
/**
|
|
462
|
+
* Progress emission over MARKET_CAPTURE_STAGES ("sources" → "capture" →
|
|
463
|
+
* "persist" here; "classify" belongs to classifyMarket). Presentation-only:
|
|
464
|
+
* a throwing listener never fails the capture.
|
|
465
|
+
*/
|
|
466
|
+
progress?: ProgressEmitter;
|
|
454
467
|
};
|
|
455
468
|
|
|
456
469
|
export type CaptureResult = {
|
|
@@ -459,57 +472,55 @@ export type CaptureResult = {
|
|
|
459
472
|
};
|
|
460
473
|
|
|
461
474
|
export async function captureMarket(config: MarketConfig, options: CaptureOptions = {}): Promise<CaptureResult> {
|
|
462
|
-
const
|
|
475
|
+
const store = options.store ?? createFileMarketStore(config.category, { capturesDir: options.dir });
|
|
476
|
+
const progress = options.progress ?? nullProgressEmitter();
|
|
463
477
|
const runLabel = options.runLabel ?? "run-1";
|
|
464
478
|
const fetchPage = options.fetchPage ?? defaultFetchPage;
|
|
465
479
|
const fetchedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
466
|
-
mkdirSync(dir, { recursive: true });
|
|
467
480
|
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
:
|
|
481
|
+
progress.stage(MARKET_CAPTURE_STAGES[0], 0, MARKET_CAPTURE_STAGES.length);
|
|
482
|
+
const targets: Array<{ vendorId: string; kind: CaptureEntry["kind"]; url: string }> = [];
|
|
483
|
+
for (const vendor of config.vendors) {
|
|
484
|
+
targets.push({ vendorId: vendor.id, kind: "home", url: vendor.urls.home });
|
|
485
|
+
if (vendor.urls.pricing) targets.push({ vendorId: vendor.id, kind: "pricing", url: vendor.urls.pricing });
|
|
486
|
+
for (const url of vendor.urls.product) targets.push({ vendorId: vendor.id, kind: "product", url });
|
|
487
|
+
}
|
|
488
|
+
progress.note(`${config.vendors.length} vendor(s), ${targets.length} page(s)`);
|
|
472
489
|
|
|
490
|
+
progress.stage(MARKET_CAPTURE_STAGES[1], 1, MARKET_CAPTURE_STAGES.length);
|
|
473
491
|
const entries: CaptureEntry[] = [];
|
|
474
|
-
for (const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
let text = "";
|
|
484
|
-
try {
|
|
485
|
-
const page = await fetchPage(target.url);
|
|
486
|
-
status = page.status;
|
|
487
|
-
if (page.status === 200) text = extractReadableText(page.body);
|
|
488
|
-
} catch {
|
|
489
|
-
status = null;
|
|
490
|
-
}
|
|
491
|
-
let captureHash: string | null = null;
|
|
492
|
-
if (text) {
|
|
493
|
-
captureHash = createHash("sha256").update(text).digest("hex");
|
|
494
|
-
// Content-addressed: an unchanged page dedupes to the same file.
|
|
495
|
-
writeFileSync(join(dir, `${captureHash}.txt`), text);
|
|
496
|
-
}
|
|
497
|
-
const entry: CaptureEntry = {
|
|
498
|
-
runLabel,
|
|
499
|
-
vendorId: vendor.id,
|
|
500
|
-
kind: target.kind,
|
|
501
|
-
url: target.url,
|
|
502
|
-
fetchedAt,
|
|
503
|
-
httpStatus: status,
|
|
504
|
-
captureHash,
|
|
505
|
-
textChars: text.length,
|
|
506
|
-
};
|
|
507
|
-
manifest.push(entry);
|
|
508
|
-
entries.push(entry);
|
|
492
|
+
for (const target of targets) {
|
|
493
|
+
let status: number | null = null;
|
|
494
|
+
let text = "";
|
|
495
|
+
try {
|
|
496
|
+
const page = await fetchPage(target.url);
|
|
497
|
+
status = page.status;
|
|
498
|
+
if (page.status === 200) text = extractReadableText(page.body);
|
|
499
|
+
} catch {
|
|
500
|
+
status = null;
|
|
509
501
|
}
|
|
502
|
+
let captureHash: string | null = null;
|
|
503
|
+
if (text) {
|
|
504
|
+
captureHash = createHash("sha256").update(text).digest("hex");
|
|
505
|
+
// Content-addressed: an unchanged page dedupes to the same key.
|
|
506
|
+
await store.saveCaptureText(captureHash, text);
|
|
507
|
+
}
|
|
508
|
+
entries.push({
|
|
509
|
+
runLabel,
|
|
510
|
+
vendorId: target.vendorId,
|
|
511
|
+
kind: target.kind,
|
|
512
|
+
url: target.url,
|
|
513
|
+
fetchedAt,
|
|
514
|
+
httpStatus: status,
|
|
515
|
+
captureHash,
|
|
516
|
+
textChars: text.length,
|
|
517
|
+
});
|
|
518
|
+
progress.items(entries.length, targets.length);
|
|
510
519
|
}
|
|
511
|
-
|
|
512
|
-
|
|
520
|
+
progress.stage(MARKET_CAPTURE_STAGES[3], 3, MARKET_CAPTURE_STAGES.length);
|
|
521
|
+
await store.appendCaptureEntries(entries);
|
|
522
|
+
progress.flush();
|
|
523
|
+
return { entries, manifestPath: store.captureLocation() };
|
|
513
524
|
}
|
|
514
525
|
|
|
515
526
|
// ---------------------------------------------------------------------------
|
|
@@ -651,6 +662,108 @@ export function loadCaptureTexts(
|
|
|
651
662
|
return { entries, textByHash };
|
|
652
663
|
}
|
|
653
664
|
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// MarketStore — the storage seam for the whole market workspace (captures +
|
|
667
|
+
// observation runs), mirroring the PlanStore precedent: the profile-home file
|
|
668
|
+
// layout and the hosted app's Convex tables are two implementations of the
|
|
669
|
+
// same contract. Engines never touch storage directly — captureMarket and
|
|
670
|
+
// classifyMarket take a store — and the default file store preserves the
|
|
671
|
+
// on-disk layout exactly, so the CLI's behavior is unchanged.
|
|
672
|
+
//
|
|
673
|
+
// loadConfig is deliberately NOT part of the store: the config is a reviewed
|
|
674
|
+
// input (a versioned file, or a hosted map document) that callers load and
|
|
675
|
+
// hand to the engines. The store owns only what the engines write.
|
|
676
|
+
|
|
677
|
+
export interface MarketStore {
|
|
678
|
+
/** Operator-facing location of the capture manifest (file path or logical label). */
|
|
679
|
+
captureLocation(): string;
|
|
680
|
+
/** Persist one page's extracted text under its content hash (idempotent). */
|
|
681
|
+
saveCaptureText(captureHash: string, text: string): Promise<void>;
|
|
682
|
+
/** Append one capture pass's manifest entries (append-only; never rewrites history). */
|
|
683
|
+
appendCaptureEntries(entries: CaptureEntry[]): Promise<void>;
|
|
684
|
+
/** Full manifest + resolvable texts — the evidence-verification input. */
|
|
685
|
+
loadCaptureTexts(): Promise<{ entries: CaptureEntry[]; textByHash: Map<string, string> }>;
|
|
686
|
+
/** Append-only observation runs (the same ObservationStore contract as before). */
|
|
687
|
+
observations: ObservationStore;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The CLI's store: captures + observations under the profile market home. */
|
|
691
|
+
export function createFileMarketStore(
|
|
692
|
+
category: string,
|
|
693
|
+
options: { capturesDir?: string; observationsDir?: string } = {},
|
|
694
|
+
): MarketStore {
|
|
695
|
+
const dir = options.capturesDir ?? join(marketHome(category), "captures");
|
|
696
|
+
const manifestPath = join(dir, "manifest.json");
|
|
697
|
+
return {
|
|
698
|
+
captureLocation: () => manifestPath,
|
|
699
|
+
async saveCaptureText(captureHash, text) {
|
|
700
|
+
mkdirSync(dir, { recursive: true });
|
|
701
|
+
writeFileSync(join(dir, `${captureHash}.txt`), text);
|
|
702
|
+
},
|
|
703
|
+
async appendCaptureEntries(entries) {
|
|
704
|
+
mkdirSync(dir, { recursive: true });
|
|
705
|
+
const manifest: CaptureEntry[] = existsSync(manifestPath)
|
|
706
|
+
? (JSON.parse(readFileSync(manifestPath, "utf8")) as CaptureEntry[])
|
|
707
|
+
: [];
|
|
708
|
+
manifest.push(...entries);
|
|
709
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
710
|
+
},
|
|
711
|
+
async loadCaptureTexts() {
|
|
712
|
+
return loadCaptureTexts(category, dir);
|
|
713
|
+
},
|
|
714
|
+
observations: createFileObservationStore(category, options.observationsDir),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* In-memory store: seam tests, and throwaway grounding captures (e.g. the
|
|
720
|
+
* taxonomy proposer's bootstrap pass) that should never persist anywhere.
|
|
721
|
+
*/
|
|
722
|
+
export function createMemoryMarketStore(category: string): MarketStore {
|
|
723
|
+
const entries: CaptureEntry[] = [];
|
|
724
|
+
const textByHash = new Map<string, string>();
|
|
725
|
+
const sets = new Map<string, ObservationSet>();
|
|
726
|
+
const sorted = () => [...sets.values()].sort((a, b) => a.runAt.localeCompare(b.runAt));
|
|
727
|
+
return {
|
|
728
|
+
captureLocation: () => `memory://${category}/captures`,
|
|
729
|
+
async saveCaptureText(captureHash, text) {
|
|
730
|
+
textByHash.set(captureHash, text);
|
|
731
|
+
},
|
|
732
|
+
async appendCaptureEntries(newEntries) {
|
|
733
|
+
entries.push(...newEntries);
|
|
734
|
+
},
|
|
735
|
+
async loadCaptureTexts() {
|
|
736
|
+
return { entries: [...entries], textByHash: new Map(textByHash) };
|
|
737
|
+
},
|
|
738
|
+
observations: {
|
|
739
|
+
async append(set) {
|
|
740
|
+
if (set.category !== category) {
|
|
741
|
+
throw new Error(`Observation set category "${set.category}" does not match store "${category}"`);
|
|
742
|
+
}
|
|
743
|
+
if (sets.has(set.runLabel)) {
|
|
744
|
+
throw new Error(`Run "${set.runLabel}" already exists — observations are append-only; use a new run label`);
|
|
745
|
+
}
|
|
746
|
+
sets.set(set.runLabel, set);
|
|
747
|
+
return set;
|
|
748
|
+
},
|
|
749
|
+
async get(runLabel) {
|
|
750
|
+
return sets.get(runLabel) ?? null;
|
|
751
|
+
},
|
|
752
|
+
async list() {
|
|
753
|
+
return sorted().map((set) => ({
|
|
754
|
+
runLabel: set.runLabel,
|
|
755
|
+
runAt: set.runAt,
|
|
756
|
+
observations: set.observations.length,
|
|
757
|
+
}));
|
|
758
|
+
},
|
|
759
|
+
async latest() {
|
|
760
|
+
const all = sorted();
|
|
761
|
+
return all.length ? all[all.length - 1] : null;
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
654
767
|
/**
|
|
655
768
|
* Whitespace-only normalization for span matching, plus one extraction
|
|
656
769
|
* artifact: the HTML-to-text step can emit a line break before punctuation
|
package/src/marketClassify.ts
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
type MarketClaim,
|
|
8
8
|
type MarketConfig,
|
|
9
9
|
type MarketObservation,
|
|
10
|
+
type MarketStore,
|
|
10
11
|
type ObservationSet,
|
|
11
12
|
type SpanVerificationFailure,
|
|
12
13
|
} from "./market.ts";
|
|
14
|
+
import { MARKET_CAPTURE_STAGES, nullProgressEmitter, type ProgressEmitter } from "./progress.ts";
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* LLM intensity classification for the market map — the same
|
|
@@ -112,9 +114,16 @@ export type ClassifyMarketOptions = {
|
|
|
112
114
|
vendors?: string[];
|
|
113
115
|
/** Captures directory override (tests); defaults to the profile market home. */
|
|
114
116
|
capturesDir?: string;
|
|
117
|
+
/** Storage seam: when given, captures are read through it instead of the file layout. */
|
|
118
|
+
store?: MarketStore;
|
|
115
119
|
now?: () => Date;
|
|
116
120
|
/** Per-vendor progress (presentation only — a throwing callback never fails the run). */
|
|
117
121
|
onVendor?: (done: number, total: number, vendorId: string) => void;
|
|
122
|
+
/**
|
|
123
|
+
* Progress emission over MARKET_CAPTURE_STAGES (the "classify" stage, items
|
|
124
|
+
* per vendor). Presentation-only: a throwing listener never fails the run.
|
|
125
|
+
*/
|
|
126
|
+
progress?: ProgressEmitter;
|
|
118
127
|
};
|
|
119
128
|
|
|
120
129
|
export type ClassifyMarketResult = {
|
|
@@ -129,7 +138,10 @@ export async function classifyMarket(
|
|
|
129
138
|
options: ClassifyMarketOptions,
|
|
130
139
|
): Promise<ClassifyMarketResult> {
|
|
131
140
|
const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
|
|
132
|
-
const
|
|
141
|
+
const progress = options.progress ?? nullProgressEmitter();
|
|
142
|
+
const { entries, textByHash } = options.store
|
|
143
|
+
? await options.store.loadCaptureTexts()
|
|
144
|
+
: loadCaptureTexts(config.category, options.capturesDir);
|
|
133
145
|
if (entries.length === 0) {
|
|
134
146
|
throw new Error(`No captures for ${config.category} — run \`market capture\` first`);
|
|
135
147
|
}
|
|
@@ -145,12 +157,14 @@ export async function classifyMarket(
|
|
|
145
157
|
const observations: MarketObservation[] = [];
|
|
146
158
|
const retriedVendorIds: string[] = [];
|
|
147
159
|
|
|
160
|
+
progress.stage(MARKET_CAPTURE_STAGES[2], 2, MARKET_CAPTURE_STAGES.length);
|
|
148
161
|
for (const [vendorIndex, vendorId] of vendorIds.entries()) {
|
|
149
162
|
try {
|
|
150
163
|
options.onVendor?.(vendorIndex, vendorIds.length, vendorId);
|
|
151
164
|
} catch {
|
|
152
165
|
// progress is presentation-only
|
|
153
166
|
}
|
|
167
|
+
progress.note(vendorId);
|
|
154
168
|
const vendor = config.vendors.find((candidate) => candidate.id === vendorId);
|
|
155
169
|
if (!vendor) throw new Error(`Unknown vendor "${vendorId}"`);
|
|
156
170
|
const vendorEntries = runEntries.filter((entry) => entry.vendorId === vendorId);
|
|
@@ -174,6 +188,7 @@ export async function classifyMarket(
|
|
|
174
188
|
evidence: [],
|
|
175
189
|
});
|
|
176
190
|
}
|
|
191
|
+
progress.items(vendorIndex + 1, vendorIds.length);
|
|
177
192
|
continue;
|
|
178
193
|
}
|
|
179
194
|
|
|
@@ -229,7 +244,9 @@ export async function classifyMarket(
|
|
|
229
244
|
);
|
|
230
245
|
}
|
|
231
246
|
for (const reading of outcome.readings) observations.push(toObservation(reading, vendorId));
|
|
247
|
+
progress.items(vendorIndex + 1, vendorIds.length);
|
|
232
248
|
}
|
|
249
|
+
progress.flush();
|
|
233
250
|
|
|
234
251
|
return {
|
|
235
252
|
set: {
|
package/src/marketTaxonomy.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
loadCaptureTexts,
|
|
10
10
|
type MarketClaim,
|
|
11
11
|
type MarketConfig,
|
|
12
|
+
type MarketStore,
|
|
12
13
|
type MarketVendor,
|
|
13
14
|
} from "./market.ts";
|
|
14
15
|
|
|
@@ -44,6 +45,11 @@ export type SuggestTaxonomyOptions = {
|
|
|
44
45
|
/** Test injectables. */
|
|
45
46
|
fetchPage?: FetchPage;
|
|
46
47
|
capturesDir?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Storage seam for the bootstrap captures; defaults to the file layout.
|
|
50
|
+
* Pass createMemoryMarketStore for throwaway grounding (hosted flow).
|
|
51
|
+
*/
|
|
52
|
+
store?: MarketStore;
|
|
47
53
|
now?: () => Date;
|
|
48
54
|
};
|
|
49
55
|
|
|
@@ -227,9 +233,11 @@ export async function suggestMarketConfig(options: SuggestTaxonomyOptions): Prom
|
|
|
227
233
|
// fetched (the SSRF guard in captureMarket applies to these user-supplied URLs).
|
|
228
234
|
await captureMarket(
|
|
229
235
|
{ category, vendors, claims: [] },
|
|
230
|
-
{ dir: options.capturesDir, runLabel: "bootstrap", fetchPage: options.fetchPage, now: options.now },
|
|
236
|
+
{ dir: options.capturesDir, store: options.store, runLabel: "bootstrap", fetchPage: options.fetchPage, now: options.now },
|
|
231
237
|
);
|
|
232
|
-
const capture =
|
|
238
|
+
const capture = options.store
|
|
239
|
+
? await options.store.loadCaptureTexts()
|
|
240
|
+
: loadCaptureTexts(category, options.capturesDir);
|
|
233
241
|
const { dossier, unreadable } = buildDossier(vendors, capture, perVendorChars);
|
|
234
242
|
if (!dossier.trim()) {
|
|
235
243
|
throw new Error(
|
package/src/mcp-bin.ts
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
const args = process.argv.slice(2);
|
|
3
|
+
|
|
4
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
5
|
+
console.log(`Usage: fullstackgtm-mcp [--help] [--version]
|
|
6
|
+
|
|
7
|
+
Starts the fullstackgtm MCP stdio server.
|
|
8
|
+
|
|
9
|
+
The MCP server requires optional peer dependencies:
|
|
10
|
+
@modelcontextprotocol/sdk
|
|
11
|
+
zod
|
|
12
|
+
|
|
13
|
+
Zero-install with peers:
|
|
14
|
+
npx -y -p fullstackgtm -p @modelcontextprotocol/sdk -p zod fullstackgtm-mcp`);
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
19
|
+
console.log(await readPackageVersion());
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
2
23
|
export {};
|
|
3
24
|
|
|
25
|
+
async function readPackageVersion(): Promise<string> {
|
|
26
|
+
try {
|
|
27
|
+
const { readFileSync } = await import("node:fs");
|
|
28
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
29
|
+
const parsed = JSON.parse(raw) as { version?: string };
|
|
30
|
+
return parsed.version ?? "unknown";
|
|
31
|
+
} catch {
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
4
36
|
// The MCP server needs the optional peer dependencies. Import dynamically so
|
|
5
37
|
// a missing peer produces install guidance instead of a module-load stack
|
|
6
38
|
// trace — `npx fullstackgtm-mcp` alone never installs optional peers.
|