fullstackgtm 0.33.0 → 0.34.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 CHANGED
@@ -5,6 +5,17 @@ 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.34.0] — 2026-06-18
9
+
10
+ ### Added
11
+
12
+ - **`discoverCompetitors(category, { llm, anchorUrl?, exclude? })`** — propose the
13
+ real vendor set for a category via the LLM (BYOK through `forcedToolCall`), so a
14
+ cold-start map needs only a category. Returns canonical homepages + a
15
+ category-specific `productUrl` per vendor; excludes the anchor and supplied hosts,
16
+ de-dupes by registrable domain, and instructs the model to skip acquired/defunct
17
+ brands. Pairs with `findCategoryPage` / `detectDrift` / `fetchLogoDataUri` (0.33).
18
+
8
19
  ## [0.33.0] — 2026-06-18
9
20
 
10
21
  ### Added
package/dist/index.d.ts CHANGED
@@ -34,7 +34,7 @@ export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type Classi
34
34
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
35
35
  export { computeScaleIndex, dimensionForMetric, scaleReportToText, type ScaleDimension, type ScaleReport, type SignalEstimate, type VendorScale, } from "./marketScale.ts";
36
36
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
37
- export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, type FetchText, type FetchBytes, type ResolveUrl, } from "./marketSourcing.ts";
37
+ export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, type FetchText, type FetchBytes, type ResolveUrl, type DiscoveredVendor, type DiscoverCompetitorsOptions, } from "./marketSourcing.ts";
38
38
  export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, type CronExpression, type CrontabIo, type ScheduleEntry, type ScheduleProvider, type ScheduleRunRecord, type ScheduleRunStore, type ScheduleRunTrigger, type ScheduleStore, } from "./schedule.ts";
39
39
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
40
40
  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
@@ -34,6 +34,6 @@ export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
34
34
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
35
35
  export { computeScaleIndex, dimensionForMetric, scaleReportToText, } from "./marketScale.js";
36
36
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
37
- export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, } from "./marketSourcing.js";
37
+ export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, } from "./marketSourcing.js";
38
38
  export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
39
39
  export { suggestValues } from "./suggest.js";
@@ -1,3 +1,4 @@
1
+ import { type LlmCallOptions } from "./llm.ts";
1
2
  /** Fetch a text resource (sitemap, robots.txt); null on any failure. */
2
3
  export type FetchText = (url: string) => Promise<string | null>;
3
4
  /** Fetch raw bytes (a logo image); null on any failure. */
@@ -64,3 +65,27 @@ export declare function findCategoryPage(homepageHtml: string, homepageUrl: stri
64
65
  * Bounded to small raster/SVG (≤50KB).
65
66
  */
66
67
  export declare function fetchLogoDataUri(homepageUrl: string, html?: string, fetchBytes?: FetchBytes): Promise<string | null>;
68
+ /** A vendor proposed by `discoverCompetitors`. */
69
+ export type DiscoveredVendor = {
70
+ name: string;
71
+ /** Canonical homepage. */
72
+ url: string;
73
+ /** The page most specific to the category — the product page for multi-product
74
+ * companies, else the homepage. Use as the capture seed. */
75
+ productUrl: string;
76
+ };
77
+ export type DiscoverCompetitorsOptions = {
78
+ llm: LlmCallOptions;
79
+ /** The user's own company: competitors are listed for it and it's excluded. */
80
+ anchorUrl?: string;
81
+ /** Vendor hosts already in the set, to exclude from results. */
82
+ exclude?: string[];
83
+ };
84
+ /**
85
+ * Propose the real vendor set for a category via the LLM — so a cold-start map
86
+ * needs only a category, not a hand-built vendor list. Returns canonical homepages
87
+ * plus a category-specific `productUrl` per vendor; excludes the anchor + any
88
+ * supplied hosts, de-dupes by registrable domain, and instructs the model to skip
89
+ * acquired/defunct brands. BYOK via the package's `forcedToolCall`.
90
+ */
91
+ export declare function discoverCompetitors(category: string, options: DiscoverCompetitorsOptions): Promise<DiscoveredVendor[]>;
@@ -17,6 +17,7 @@
17
17
  * headless browser, so the default fetch is sufficient even in the hosted layer.
18
18
  */
19
19
  import { assertPublicUrl } from "./market.js";
20
+ import { DEFAULT_MODELS, forcedToolCall } from "./llm.js";
20
21
  const USER_AGENT = "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)";
21
22
  const FETCH_TIMEOUT_MS = 15_000;
22
23
  const MAX_REDIRECTS = 5;
@@ -403,3 +404,84 @@ export async function fetchLogoDataUri(homepageUrl, html, fetchBytes = defaultFe
403
404
  }
404
405
  return null;
405
406
  }
407
+ const DISCOVERY_SCHEMA = {
408
+ type: "object",
409
+ required: ["competitors"],
410
+ properties: {
411
+ competitors: {
412
+ type: "array",
413
+ description: "Real vendors competing in this category today, each with its canonical https homepage URL.",
414
+ items: {
415
+ type: "object",
416
+ required: ["name", "url"],
417
+ properties: {
418
+ name: { type: "string" },
419
+ url: { type: "string", description: "Canonical homepage, https://domain, no tracking path." },
420
+ productUrl: {
421
+ type: "string",
422
+ description: "URL of the page on THIS vendor's site most specific to the category. For a multi-product company (e.g. SAP, Oracle) this is the product/solution page for this category, NOT the corporate homepage. For a focused single-product vendor, repeat the homepage.",
423
+ },
424
+ },
425
+ },
426
+ },
427
+ },
428
+ };
429
+ /**
430
+ * Propose the real vendor set for a category via the LLM — so a cold-start map
431
+ * needs only a category, not a hand-built vendor list. Returns canonical homepages
432
+ * plus a category-specific `productUrl` per vendor; excludes the anchor + any
433
+ * supplied hosts, de-dupes by registrable domain, and instructs the model to skip
434
+ * acquired/defunct brands. BYOK via the package's `forcedToolCall`.
435
+ */
436
+ export async function discoverCompetitors(category, options) {
437
+ const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
438
+ const anchorHost = options.anchorUrl ? hostOf(options.anchorUrl) : null;
439
+ const anchorNote = anchorHost
440
+ ? `The user's own company is ${anchorHost} — list its direct competitors and do NOT include ${anchorHost} itself.`
441
+ : "";
442
+ const prompt = `List the most significant vendors competing in the category "${category}" today.
443
+ ${anchorNote}
444
+ Rules:
445
+ - Real companies only, each with its canonical https homepage URL (domain root, no tracking path).
446
+ - 7-9 vendors: a mix of established leaders and notable challengers.
447
+ - EXCLUDE vendors that have been acquired and folded into another brand, or are defunct — i.e. anything whose own site now redirects to a different company. (Name the current independent players instead.)
448
+ - For each, also give productUrl: the page most specific to "${category}". For a big multi-product company, that's its product/solution page for THIS category, not the corporate homepage.
449
+ - No duplicates.`;
450
+ const result = (await forcedToolCall(prompt, "discover_competitors", DISCOVERY_SCHEMA, model, options.llm));
451
+ const excluded = new Set((options.exclude ?? []).map(hostOf).filter((h) => Boolean(h)));
452
+ if (anchorHost)
453
+ excluded.add(anchorHost);
454
+ const seenDomain = new Set();
455
+ const out = [];
456
+ for (const c of result?.competitors ?? []) {
457
+ let u;
458
+ try {
459
+ u = new URL(String(c.url));
460
+ }
461
+ catch {
462
+ continue;
463
+ }
464
+ if (u.protocol !== "http:" && u.protocol !== "https:")
465
+ continue;
466
+ const host = u.hostname.replace(/^www\./, "");
467
+ if (excluded.has(host))
468
+ continue;
469
+ const dom = registrableDomain(host);
470
+ if (seenDomain.has(dom))
471
+ continue;
472
+ seenDomain.add(dom);
473
+ let productUrl = u.toString();
474
+ if (c.productUrl) {
475
+ try {
476
+ const p = new URL(String(c.productUrl));
477
+ if (p.protocol === "http:" || p.protocol === "https:")
478
+ productUrl = p.toString();
479
+ }
480
+ catch {
481
+ /* keep homepage */
482
+ }
483
+ }
484
+ out.push({ name: c.name || host, url: u.toString(), productUrl });
485
+ }
486
+ return out;
487
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
package/src/index.ts CHANGED
@@ -301,9 +301,12 @@ export {
301
301
  findCategoryPageInSitemap,
302
302
  findCategoryPage,
303
303
  fetchLogoDataUri,
304
+ discoverCompetitors,
304
305
  type FetchText,
305
306
  type FetchBytes,
306
307
  type ResolveUrl,
308
+ type DiscoveredVendor,
309
+ type DiscoverCompetitorsOptions,
307
310
  } from "./marketSourcing.ts";
308
311
  export {
309
312
  computeMissedFirings,
@@ -17,6 +17,7 @@
17
17
  * headless browser, so the default fetch is sufficient even in the hosted layer.
18
18
  */
19
19
  import { assertPublicUrl } from "./market.ts";
20
+ import { DEFAULT_MODELS, forcedToolCall, type LlmCallOptions } from "./llm.ts";
20
21
 
21
22
  const USER_AGENT = "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)";
22
23
  const FETCH_TIMEOUT_MS = 15_000;
@@ -403,3 +404,106 @@ export async function fetchLogoDataUri(
403
404
  }
404
405
  return null;
405
406
  }
407
+
408
+ // ── Competitor discovery ─────────────────────────────────────────────────────
409
+
410
+ /** A vendor proposed by `discoverCompetitors`. */
411
+ export type DiscoveredVendor = {
412
+ name: string;
413
+ /** Canonical homepage. */
414
+ url: string;
415
+ /** The page most specific to the category — the product page for multi-product
416
+ * companies, else the homepage. Use as the capture seed. */
417
+ productUrl: string;
418
+ };
419
+
420
+ export type DiscoverCompetitorsOptions = {
421
+ llm: LlmCallOptions;
422
+ /** The user's own company: competitors are listed for it and it's excluded. */
423
+ anchorUrl?: string;
424
+ /** Vendor hosts already in the set, to exclude from results. */
425
+ exclude?: string[];
426
+ };
427
+
428
+ const DISCOVERY_SCHEMA = {
429
+ type: "object",
430
+ required: ["competitors"],
431
+ properties: {
432
+ competitors: {
433
+ type: "array",
434
+ description: "Real vendors competing in this category today, each with its canonical https homepage URL.",
435
+ items: {
436
+ type: "object",
437
+ required: ["name", "url"],
438
+ properties: {
439
+ name: { type: "string" },
440
+ url: { type: "string", description: "Canonical homepage, https://domain, no tracking path." },
441
+ productUrl: {
442
+ type: "string",
443
+ description:
444
+ "URL of the page on THIS vendor's site most specific to the category. For a multi-product company (e.g. SAP, Oracle) this is the product/solution page for this category, NOT the corporate homepage. For a focused single-product vendor, repeat the homepage.",
445
+ },
446
+ },
447
+ },
448
+ },
449
+ },
450
+ };
451
+
452
+ /**
453
+ * Propose the real vendor set for a category via the LLM — so a cold-start map
454
+ * needs only a category, not a hand-built vendor list. Returns canonical homepages
455
+ * plus a category-specific `productUrl` per vendor; excludes the anchor + any
456
+ * supplied hosts, de-dupes by registrable domain, and instructs the model to skip
457
+ * acquired/defunct brands. BYOK via the package's `forcedToolCall`.
458
+ */
459
+ export async function discoverCompetitors(
460
+ category: string,
461
+ options: DiscoverCompetitorsOptions,
462
+ ): Promise<DiscoveredVendor[]> {
463
+ const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
464
+ const anchorHost = options.anchorUrl ? hostOf(options.anchorUrl) : null;
465
+ const anchorNote = anchorHost
466
+ ? `The user's own company is ${anchorHost} — list its direct competitors and do NOT include ${anchorHost} itself.`
467
+ : "";
468
+ const prompt = `List the most significant vendors competing in the category "${category}" today.
469
+ ${anchorNote}
470
+ Rules:
471
+ - Real companies only, each with its canonical https homepage URL (domain root, no tracking path).
472
+ - 7-9 vendors: a mix of established leaders and notable challengers.
473
+ - EXCLUDE vendors that have been acquired and folded into another brand, or are defunct — i.e. anything whose own site now redirects to a different company. (Name the current independent players instead.)
474
+ - For each, also give productUrl: the page most specific to "${category}". For a big multi-product company, that's its product/solution page for THIS category, not the corporate homepage.
475
+ - No duplicates.`;
476
+ const result = (await forcedToolCall(prompt, "discover_competitors", DISCOVERY_SCHEMA, model, options.llm)) as {
477
+ competitors?: Array<{ name?: string; url?: string; productUrl?: string }>;
478
+ };
479
+
480
+ const excluded = new Set((options.exclude ?? []).map(hostOf).filter((h): h is string => Boolean(h)));
481
+ if (anchorHost) excluded.add(anchorHost);
482
+ const seenDomain = new Set<string>();
483
+ const out: DiscoveredVendor[] = [];
484
+ for (const c of result?.competitors ?? []) {
485
+ let u: URL;
486
+ try {
487
+ u = new URL(String(c.url));
488
+ } catch {
489
+ continue;
490
+ }
491
+ if (u.protocol !== "http:" && u.protocol !== "https:") continue;
492
+ const host = u.hostname.replace(/^www\./, "");
493
+ if (excluded.has(host)) continue;
494
+ const dom = registrableDomain(host);
495
+ if (seenDomain.has(dom)) continue;
496
+ seenDomain.add(dom);
497
+ let productUrl = u.toString();
498
+ if (c.productUrl) {
499
+ try {
500
+ const p = new URL(String(c.productUrl));
501
+ if (p.protocol === "http:" || p.protocol === "https:") productUrl = p.toString();
502
+ } catch {
503
+ /* keep homepage */
504
+ }
505
+ }
506
+ out.push({ name: c.name || host, url: u.toString(), productUrl });
507
+ }
508
+ return out;
509
+ }