@fragments-sdk/cli 0.7.15 → 0.7.16

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.
Files changed (32) hide show
  1. package/dist/bin.js +2 -2
  2. package/dist/init-DIZ6UNBL.js +806 -0
  3. package/dist/init-DIZ6UNBL.js.map +1 -0
  4. package/dist/{viewer-7I4WGVU3.js → viewer-QKIAPTPG.js} +67 -4
  5. package/dist/viewer-QKIAPTPG.js.map +1 -0
  6. package/package.json +3 -2
  7. package/src/commands/init-framework.ts +414 -0
  8. package/src/commands/init.ts +41 -1
  9. package/src/viewer/components/App.tsx +5 -0
  10. package/src/viewer/components/HealthDashboard.tsx +1 -1
  11. package/src/viewer/components/PropsTable.tsx +2 -2
  12. package/src/viewer/components/RuntimeToolsRegistrar.tsx +17 -0
  13. package/src/viewer/components/ViewerStateSync.tsx +52 -0
  14. package/src/viewer/components/WebMCPDevTools.tsx +509 -0
  15. package/src/viewer/components/WebMCPIntegration.tsx +47 -0
  16. package/src/viewer/components/WebMCPStatusIndicator.tsx +60 -0
  17. package/src/viewer/entry.tsx +6 -3
  18. package/src/viewer/hooks/useA11yService.ts +1 -135
  19. package/src/viewer/hooks/useCompiledFragments.ts +42 -0
  20. package/src/viewer/server.ts +58 -3
  21. package/src/viewer/vite-plugin.ts +18 -0
  22. package/src/viewer/webmcp/__tests__/analytics.test.ts +108 -0
  23. package/src/viewer/webmcp/analytics.ts +165 -0
  24. package/src/viewer/webmcp/index.ts +3 -0
  25. package/src/viewer/webmcp/posthog-bridge.ts +39 -0
  26. package/src/viewer/webmcp/runtime-tools.ts +152 -0
  27. package/src/viewer/webmcp/scan-utils.ts +135 -0
  28. package/src/viewer/webmcp/use-tool-analytics.ts +69 -0
  29. package/src/viewer/webmcp/viewer-state.ts +45 -0
  30. package/dist/init-V42FFMUJ.js +0 -498
  31. package/dist/init-V42FFMUJ.js.map +0 -1
  32. package/dist/viewer-7I4WGVU3.js.map +0 -1
@@ -8,10 +8,8 @@
8
8
  */
9
9
 
10
10
  import { useCallback, useEffect, useRef, useState } from 'react';
11
- import type { Result } from 'axe-core';
12
11
  import type {
13
12
  A11yServiceConfig,
14
- SerializedViolation,
15
13
  ComponentScanState,
16
14
  ScanStatus,
17
15
  } from '../types/a11y.js';
@@ -22,6 +20,7 @@ import {
22
20
  isComponentStale,
23
21
  getA11ySummary,
24
22
  } from './useA11yCache.js';
23
+ import { runAxeScan, type ScanResult } from '../webmcp/scan-utils.js';
25
24
 
26
25
  // Default configuration
27
26
  const DEFAULT_CONFIG: A11yServiceConfig = {
@@ -54,139 +53,6 @@ const serviceState: A11yServiceState = {
54
53
  componentStates: new Map(),
55
54
  };
56
55
 
57
- // Cache the axe-core module
58
- let axeModule: typeof import('axe-core') | null = null;
59
-
60
- export interface ScanResult {
61
- violations: SerializedViolation[];
62
- passes: number;
63
- incomplete: number;
64
- counts: {
65
- critical: number;
66
- serious: number;
67
- moderate: number;
68
- minor: number;
69
- };
70
- }
71
-
72
- /**
73
- * Convert axe-core Result to SerializedViolation
74
- */
75
- function serializeViolation(result: Result): SerializedViolation {
76
- return {
77
- id: result.id,
78
- impact: result.impact || null,
79
- description: result.description,
80
- help: result.help,
81
- helpUrl: result.helpUrl,
82
- tags: result.tags,
83
- nodes: result.nodes.map(node => ({
84
- html: node.html,
85
- target: node.target as string[],
86
- failureSummary: node.failureSummary,
87
- any: node.any?.map(check => ({
88
- id: check.id,
89
- data: check.data,
90
- relatedNodes: check.relatedNodes?.map(rn => ({
91
- html: rn.html,
92
- target: rn.target as string[],
93
- })),
94
- impact: check.impact,
95
- message: check.message,
96
- })),
97
- all: node.all?.map(check => ({
98
- id: check.id,
99
- data: check.data,
100
- relatedNodes: check.relatedNodes?.map(rn => ({
101
- html: rn.html,
102
- target: rn.target as string[],
103
- })),
104
- impact: check.impact,
105
- message: check.message,
106
- })),
107
- none: node.none?.map(check => ({
108
- id: check.id,
109
- data: check.data,
110
- relatedNodes: check.relatedNodes?.map(rn => ({
111
- html: rn.html,
112
- target: rn.target as string[],
113
- })),
114
- impact: check.impact,
115
- message: check.message,
116
- })),
117
- })),
118
- };
119
- }
120
-
121
- /**
122
- * Run axe-core scan on a target element
123
- */
124
- async function runAxeScan(targetSelector: string): Promise<ScanResult | null> {
125
- // Load axe-core if not cached
126
- if (!axeModule) {
127
- axeModule = await import('axe-core');
128
- }
129
- // Handle both ESM default export and CommonJS module
130
- const axe = (axeModule as { default?: typeof import('axe-core') }).default || axeModule;
131
-
132
- const target = document.querySelector(targetSelector);
133
- if (!target) {
134
- console.warn(`[A11y] Target element not found: ${targetSelector}`);
135
- return null;
136
- }
137
-
138
- // Configure axe-core
139
- axe.configure({
140
- rules: [
141
- { id: 'color-contrast', enabled: true },
142
- { id: 'image-alt', enabled: true },
143
- { id: 'button-name', enabled: true },
144
- { id: 'link-name', enabled: true },
145
- { id: 'label', enabled: true },
146
- { id: 'aria-valid-attr', enabled: true },
147
- { id: 'aria-valid-attr-value', enabled: true },
148
- ],
149
- });
150
-
151
- // Run the scan
152
- const results = await axe.run(target as HTMLElement, {
153
- resultTypes: ['violations', 'passes', 'incomplete', 'inapplicable'],
154
- });
155
-
156
- // Count violations by severity
157
- const counts = {
158
- critical: 0,
159
- serious: 0,
160
- moderate: 0,
161
- minor: 0,
162
- };
163
-
164
- for (const violation of results.violations) {
165
- switch (violation.impact) {
166
- case 'critical':
167
- counts.critical++;
168
- break;
169
- case 'serious':
170
- counts.serious++;
171
- break;
172
- case 'moderate':
173
- counts.moderate++;
174
- break;
175
- case 'minor':
176
- default:
177
- counts.minor++;
178
- break;
179
- }
180
- }
181
-
182
- return {
183
- violations: results.violations.map(serializeViolation),
184
- passes: results.passes.length,
185
- incomplete: results.incomplete.length,
186
- counts,
187
- };
188
- }
189
-
190
56
  /**
191
57
  * Process the scan queue
192
58
  */
@@ -0,0 +1,42 @@
1
+ import { useState, useEffect } from "react";
2
+ import type { CompiledFragmentsFile } from "@fragments-sdk/context/types";
3
+
4
+ interface UseCompiledFragmentsResult {
5
+ data: CompiledFragmentsFile | null;
6
+ loading: boolean;
7
+ error: string | null;
8
+ }
9
+
10
+ export function useCompiledFragments(): UseCompiledFragmentsResult {
11
+ const [data, setData] = useState<CompiledFragmentsFile | null>(null);
12
+ const [loading, setLoading] = useState(true);
13
+ const [error, setError] = useState<string | null>(null);
14
+
15
+ useEffect(() => {
16
+ let cancelled = false;
17
+
18
+ fetch("/fragments/compiled.json")
19
+ .then((res) => {
20
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
21
+ return res.json();
22
+ })
23
+ .then((json) => {
24
+ if (!cancelled) {
25
+ setData(json as CompiledFragmentsFile);
26
+ setLoading(false);
27
+ }
28
+ })
29
+ .catch((err) => {
30
+ if (!cancelled) {
31
+ setError(err instanceof Error ? err.message : String(err));
32
+ setLoading(false);
33
+ }
34
+ });
35
+
36
+ return () => {
37
+ cancelled = true;
38
+ };
39
+ }, []);
40
+
41
+ return { data, loading, error };
42
+ }
@@ -32,6 +32,8 @@ const packagesRoot = resolve(cliPackageRoot, "..");
32
32
  const localUiLibRoot = resolve(packagesRoot, "../libs/ui/src");
33
33
  const localSharedLibRoot = resolve(packagesRoot, "../libs/shared/src");
34
34
  const vendoredSharedLibRoot = resolve(viewerRoot, "vendor/shared/src");
35
+ const localWebMCPRoot = resolve(packagesRoot, "webmcp/src");
36
+ const localContextRoot = resolve(packagesRoot, "context/src");
35
37
 
36
38
  /**
37
39
  * Resolve the @fragments/ui alias to the correct path.
@@ -57,6 +59,44 @@ function resolveUiLib(nodeModulesDir: string): string {
57
59
  return localUiLibRoot;
58
60
  }
59
61
 
62
+ /**
63
+ * Resolve @fragments-sdk/webmcp to monorepo source or installed package.
64
+ */
65
+ function resolveWebMCPLib(nodeModulesDir: string): string {
66
+ const localIndex = join(localWebMCPRoot, "index.ts");
67
+ if (existsSync(localIndex)) {
68
+ return localWebMCPRoot;
69
+ }
70
+ const installedSrc = join(nodeModulesDir, "@fragments-sdk/webmcp/src/index.ts");
71
+ if (existsSync(installedSrc)) {
72
+ return resolve(dirname(installedSrc));
73
+ }
74
+ const installedDist = join(nodeModulesDir, "@fragments-sdk/webmcp");
75
+ if (existsSync(installedDist)) {
76
+ return installedDist;
77
+ }
78
+ return localWebMCPRoot;
79
+ }
80
+
81
+ /**
82
+ * Resolve @fragments-sdk/context to monorepo source or installed package.
83
+ */
84
+ function resolveContextLib(nodeModulesDir: string): string {
85
+ const localIndex = join(localContextRoot, "index.ts");
86
+ if (existsSync(localIndex)) {
87
+ return localContextRoot;
88
+ }
89
+ const installedSrc = join(nodeModulesDir, "@fragments-sdk/context/src/index.ts");
90
+ if (existsSync(installedSrc)) {
91
+ return resolve(dirname(installedSrc));
92
+ }
93
+ const installedDist = join(nodeModulesDir, "@fragments-sdk/context");
94
+ if (existsSync(installedDist)) {
95
+ return installedDist;
96
+ }
97
+ return localContextRoot;
98
+ }
99
+
60
100
  /**
61
101
  * Resolve the @fragments-sdk/shared alias to either monorepo source
62
102
  * or vendored viewer fallback for npm installs.
@@ -211,6 +251,8 @@ export async function createDevServer(
211
251
  const nodeModulesPath = findNodeModules(projectRoot);
212
252
  const uiLibRoot = resolveUiLib(nodeModulesPath);
213
253
  const sharedLibRoot = resolveSharedLib();
254
+ const webmcpLibRoot = resolveWebMCPLib(nodeModulesPath);
255
+ const contextLibRoot = resolveContextLib(nodeModulesPath);
214
256
  console.log(`📁 Using node_modules: ${nodeModulesPath}`);
215
257
 
216
258
  // Collect installed package roots so Vite can serve files from node_modules
@@ -238,7 +280,7 @@ export async function createDevServer(
238
280
  open: open ? "/fragments/" : false,
239
281
  fs: {
240
282
  // Allow serving files from viewer package, project, shared libs, and node_modules root
241
- allow: [viewerRoot, uiLibRoot, sharedLibRoot, projectRoot, configDir, dirname(nodeModulesPath), ...installedPkgRoots],
283
+ allow: [viewerRoot, uiLibRoot, sharedLibRoot, webmcpLibRoot, contextLibRoot, projectRoot, configDir, dirname(nodeModulesPath), ...installedPkgRoots],
242
284
  },
243
285
  },
244
286
 
@@ -257,8 +299,13 @@ export async function createDevServer(
257
299
  }),
258
300
  ],
259
301
 
260
- // CSS configuration
261
- css: {},
302
+ // CSS configuration — preserve original hyphenated class names in CSS modules
303
+ // Vite 6 defaults to camelCaseOnly, but our components use styles['gap-sm'] etc.
304
+ css: {
305
+ modules: {
306
+ localsConvention: 'camelCase',
307
+ },
308
+ },
262
309
 
263
310
  optimizeDeps: {
264
311
  // Include common dependencies for faster startup
@@ -274,6 +321,14 @@ export async function createDevServer(
274
321
  "@fragments-sdk/ui": uiLibRoot,
275
322
  // Resolve @fragments-sdk/shared to monorepo source or vendored fallback
276
323
  "@fragments-sdk/shared": sharedLibRoot,
324
+ // Resolve @fragments-sdk/webmcp subpaths to monorepo source or installed package
325
+ "@fragments-sdk/webmcp/react": join(webmcpLibRoot, "react/index.ts"),
326
+ "@fragments-sdk/webmcp/fragments": join(webmcpLibRoot, "fragments/index.ts"),
327
+ "@fragments-sdk/webmcp": webmcpLibRoot,
328
+ // Resolve @fragments-sdk/context subpaths to monorepo source or installed package
329
+ "@fragments-sdk/context/types": join(contextLibRoot, "types/index.ts"),
330
+ "@fragments-sdk/context/mcp-tools": join(contextLibRoot, "mcp-tools/index.ts"),
331
+ "@fragments-sdk/context": contextLibRoot,
277
332
  // Resolve @fragments-sdk/cli/core to the CLI's own core source
278
333
  "@fragments-sdk/cli/core": resolve(cliPackageRoot, "src/core/index.ts"),
279
334
  // Ensure ALL react imports resolve to project's node_modules
@@ -928,6 +928,24 @@ export function fragmentsPlugin(options: FragmentsPluginOptions): Plugin[] {
928
928
  return;
929
929
  }
930
930
 
931
+ // Handle /fragments/compiled.json endpoint for WebMCP integration
932
+ if (req.url === "/fragments/compiled.json") {
933
+ try {
934
+ const { join } = await import("node:path");
935
+ const fragmentsJsonPath = join(projectRoot, BRAND.outFile);
936
+ const content = await readFile(fragmentsJsonPath, "utf-8");
937
+ res.writeHead(200, { "Content-Type": "application/json" });
938
+ res.end(content);
939
+ } catch (error) {
940
+ console.warn(`[${BRAND.name}] Failed to serve compiled.json:`, error);
941
+ res.writeHead(404, { "Content-Type": "application/json" });
942
+ res.end(JSON.stringify({
943
+ error: `${BRAND.outFile} not found. Run '${BRAND.cliCommand} build' first.`,
944
+ }));
945
+ }
946
+ return;
947
+ }
948
+
931
949
  // Handle /fragments/context endpoint for AI context generation
932
950
  if (req.url?.startsWith("/fragments/context")) {
933
951
  try {
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ToolAnalyticsCollector } from '../analytics.js';
3
+ import type { ToolCallEvent } from '@fragments-sdk/webmcp';
4
+
5
+ function createEvent(overrides: Partial<ToolCallEvent> = {}): ToolCallEvent {
6
+ return {
7
+ toolName: 'fragments_discover',
8
+ input: {},
9
+ output: {},
10
+ error: null,
11
+ durationMs: 50,
12
+ timestamp: Date.now(),
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe('ToolAnalyticsCollector', () => {
18
+ it('records events and computes summary', () => {
19
+ const collector = new ToolAnalyticsCollector();
20
+ collector.record(createEvent({ toolName: 'fragments_discover', durationMs: 10 }));
21
+ collector.record(createEvent({ toolName: 'fragments_discover', durationMs: 20 }));
22
+ collector.record(createEvent({ toolName: 'fragments_inspect', durationMs: 30 }));
23
+
24
+ const summary = collector.toSummary();
25
+ expect(summary.totalCalls).toBe(3);
26
+ expect(summary.uniqueTools).toBe(2);
27
+ expect(summary.toolStats.fragments_discover.callCount).toBe(2);
28
+ expect(summary.toolStats.fragments_discover.avgDuration).toBe(15);
29
+ expect(summary.toolStats.fragments_inspect.callCount).toBe(1);
30
+ });
31
+
32
+ it('tracks error rate', () => {
33
+ const collector = new ToolAnalyticsCollector();
34
+ collector.record(createEvent({ error: null }));
35
+ collector.record(createEvent({ error: 'fail' }));
36
+
37
+ const summary = collector.toSummary();
38
+ expect(summary.errorRate).toBe(0.5);
39
+ });
40
+
41
+ it('detects common flows', () => {
42
+ const collector = new ToolAnalyticsCollector();
43
+ const base = Date.now();
44
+
45
+ // Create a repeated flow: discover -> inspect -> discover -> inspect
46
+ collector.record(createEvent({ toolName: 'fragments_discover', timestamp: base }));
47
+ collector.record(createEvent({ toolName: 'fragments_inspect', timestamp: base + 100 }));
48
+ collector.record(createEvent({ toolName: 'fragments_discover', timestamp: base + 200 }));
49
+ collector.record(createEvent({ toolName: 'fragments_inspect', timestamp: base + 300 }));
50
+
51
+ const summary = collector.toSummary();
52
+ expect(summary.commonFlows.length).toBeGreaterThan(0);
53
+ expect(summary.commonFlows[0].sequence).toEqual(['fragments_discover', 'fragments_inspect']);
54
+ expect(summary.commonFlows[0].count).toBe(2);
55
+ });
56
+
57
+ it('splits sessions on 30s gap', () => {
58
+ const collector = new ToolAnalyticsCollector();
59
+ const base = Date.now();
60
+
61
+ collector.record(createEvent({ toolName: 'a', timestamp: base }));
62
+ collector.record(createEvent({ toolName: 'b', timestamp: base + 100 }));
63
+ // 31s gap
64
+ collector.record(createEvent({ toolName: 'a', timestamp: base + 31_100 }));
65
+ collector.record(createEvent({ toolName: 'b', timestamp: base + 31_200 }));
66
+
67
+ const summary = collector.toSummary();
68
+ // "a -> b" appears in both sessions
69
+ const abFlow = summary.commonFlows.find(f =>
70
+ f.sequence[0] === 'a' && f.sequence[1] === 'b'
71
+ );
72
+ expect(abFlow).toBeDefined();
73
+ expect(abFlow!.count).toBe(2);
74
+ });
75
+
76
+ it('computes percentiles', () => {
77
+ const collector = new ToolAnalyticsCollector();
78
+ for (let i = 1; i <= 100; i++) {
79
+ collector.record(createEvent({ durationMs: i }));
80
+ }
81
+
82
+ const summary = collector.toSummary();
83
+ const stat = summary.toolStats.fragments_discover;
84
+ expect(stat.p50).toBeGreaterThanOrEqual(49);
85
+ expect(stat.p50).toBeLessThanOrEqual(51);
86
+ expect(stat.p95).toBeGreaterThanOrEqual(94);
87
+ expect(stat.p95).toBeLessThanOrEqual(96);
88
+ });
89
+
90
+ it('produces PostHog properties', () => {
91
+ const collector = new ToolAnalyticsCollector();
92
+ collector.record(createEvent());
93
+
94
+ const props = collector.toPostHogProperties();
95
+ expect(props.webmcp_total_calls).toBe(1);
96
+ expect(props.webmcp_unique_tools).toBe(1);
97
+ expect(typeof props.webmcp_tool_breakdown).toBe('string');
98
+ });
99
+
100
+ it('resets state', () => {
101
+ const collector = new ToolAnalyticsCollector();
102
+ collector.record(createEvent());
103
+ collector.reset();
104
+
105
+ const summary = collector.toSummary();
106
+ expect(summary.totalCalls).toBe(0);
107
+ });
108
+ });
@@ -0,0 +1,165 @@
1
+ import type { ToolCallEvent } from '@fragments-sdk/webmcp';
2
+
3
+ interface ToolStats {
4
+ callCount: number;
5
+ totalDuration: number;
6
+ durations: number[];
7
+ errorCount: number;
8
+ }
9
+
10
+ interface ToolFlow {
11
+ sequence: string[];
12
+ count: number;
13
+ }
14
+
15
+ interface AnalyticsSummary {
16
+ totalCalls: number;
17
+ uniqueTools: number;
18
+ sessionDurationMs: number;
19
+ errorRate: number;
20
+ toolStats: Record<string, {
21
+ callCount: number;
22
+ avgDuration: number;
23
+ p50: number;
24
+ p95: number;
25
+ errorCount: number;
26
+ }>;
27
+ commonFlows: ToolFlow[];
28
+ }
29
+
30
+ const SESSION_GAP_MS = 30_000;
31
+
32
+ export class ToolAnalyticsCollector {
33
+ private stats = new Map<string, ToolStats>();
34
+ private events: ToolCallEvent[] = [];
35
+ private sessionStart: number | null = null;
36
+
37
+ record(event: ToolCallEvent): void {
38
+ this.events.push(event);
39
+
40
+ if (this.sessionStart === null) {
41
+ this.sessionStart = event.timestamp;
42
+ }
43
+
44
+ const existing = this.stats.get(event.toolName);
45
+ if (existing) {
46
+ existing.callCount++;
47
+ existing.totalDuration += event.durationMs;
48
+ existing.durations.push(event.durationMs);
49
+ if (event.error) existing.errorCount++;
50
+ } else {
51
+ this.stats.set(event.toolName, {
52
+ callCount: 1,
53
+ totalDuration: event.durationMs,
54
+ durations: [event.durationMs],
55
+ errorCount: event.error ? 1 : 0,
56
+ });
57
+ }
58
+ }
59
+
60
+ toSummary(): AnalyticsSummary {
61
+ let totalCalls = 0;
62
+ let totalErrors = 0;
63
+ const toolStats: AnalyticsSummary['toolStats'] = {};
64
+
65
+ for (const [name, stat] of this.stats) {
66
+ totalCalls += stat.callCount;
67
+ totalErrors += stat.errorCount;
68
+
69
+ const sorted = [...stat.durations].sort((a, b) => a - b);
70
+ toolStats[name] = {
71
+ callCount: stat.callCount,
72
+ avgDuration: Math.round(stat.totalDuration / stat.callCount),
73
+ p50: percentile(sorted, 50),
74
+ p95: percentile(sorted, 95),
75
+ errorCount: stat.errorCount,
76
+ };
77
+ }
78
+
79
+ const sessionDurationMs = this.sessionStart !== null && this.events.length > 0
80
+ ? this.events[this.events.length - 1].timestamp - this.sessionStart
81
+ : 0;
82
+
83
+ return {
84
+ totalCalls,
85
+ uniqueTools: this.stats.size,
86
+ sessionDurationMs,
87
+ errorRate: totalCalls > 0 ? Math.round((totalErrors / totalCalls) * 100) / 100 : 0,
88
+ toolStats,
89
+ commonFlows: this.detectFlows(),
90
+ };
91
+ }
92
+
93
+ toPostHogProperties(): Record<string, unknown> {
94
+ const summary = this.toSummary();
95
+ return {
96
+ webmcp_total_calls: summary.totalCalls,
97
+ webmcp_unique_tools: summary.uniqueTools,
98
+ webmcp_session_duration_ms: summary.sessionDurationMs,
99
+ webmcp_error_rate: summary.errorRate,
100
+ webmcp_tool_breakdown: JSON.stringify(summary.toolStats),
101
+ webmcp_common_flows: JSON.stringify(summary.commonFlows),
102
+ };
103
+ }
104
+
105
+ private detectFlows(): ToolFlow[] {
106
+ if (this.events.length < 2) return [];
107
+
108
+ // Split into sessions (30s gap)
109
+ const sessions: ToolCallEvent[][] = [];
110
+ let currentSession: ToolCallEvent[] = [this.events[0]];
111
+
112
+ for (let i = 1; i < this.events.length; i++) {
113
+ if (this.events[i].timestamp - this.events[i - 1].timestamp > SESSION_GAP_MS) {
114
+ sessions.push(currentSession);
115
+ currentSession = [];
116
+ }
117
+ currentSession.push(this.events[i]);
118
+ }
119
+ sessions.push(currentSession);
120
+
121
+ // Count 2-grams and 3-grams
122
+ const flowCounts = new Map<string, number>();
123
+
124
+ for (const session of sessions) {
125
+ const names = session.map(e => e.toolName);
126
+
127
+ // 2-grams
128
+ for (let i = 0; i < names.length - 1; i++) {
129
+ const key = `${names[i]} → ${names[i + 1]}`;
130
+ flowCounts.set(key, (flowCounts.get(key) ?? 0) + 1);
131
+ }
132
+
133
+ // 3-grams
134
+ for (let i = 0; i < names.length - 2; i++) {
135
+ const key = `${names[i]} → ${names[i + 1]} → ${names[i + 2]}`;
136
+ flowCounts.set(key, (flowCounts.get(key) ?? 0) + 1);
137
+ }
138
+ }
139
+
140
+ // Return top flows (count >= 2)
141
+ return [...flowCounts.entries()]
142
+ .filter(([, count]) => count >= 2)
143
+ .sort((a, b) => b[1] - a[1])
144
+ .slice(0, 10)
145
+ .map(([key, count]) => ({
146
+ sequence: key.split(' → '),
147
+ count,
148
+ }));
149
+ }
150
+
151
+ reset(): void {
152
+ this.stats.clear();
153
+ this.events = [];
154
+ this.sessionStart = null;
155
+ }
156
+ }
157
+
158
+ function percentile(sorted: number[], p: number): number {
159
+ if (sorted.length === 0) return 0;
160
+ const index = (p / 100) * (sorted.length - 1);
161
+ const lower = Math.floor(index);
162
+ const upper = Math.ceil(index);
163
+ if (lower === upper) return sorted[lower];
164
+ return Math.round(sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower));
165
+ }
@@ -0,0 +1,3 @@
1
+ export { runAxeScan, serializeViolation, type ScanResult } from './scan-utils.js';
2
+ export { createRuntimeWebMCPTools, type RuntimeToolsOptions } from './runtime-tools.js';
3
+ export { getViewerState, setViewerState, type ViewerState } from './viewer-state.js';
@@ -0,0 +1,39 @@
1
+ import type { ToolCallEvent } from '@fragments-sdk/webmcp';
2
+ import type { ToolAnalyticsCollector } from './analytics.js';
3
+
4
+ interface PostHogLike {
5
+ capture(event: string, properties?: Record<string, unknown>): void;
6
+ }
7
+
8
+ interface PostHogBridge {
9
+ onToolCall(event: ToolCallEvent): void;
10
+ onSessionEnd(): void;
11
+ flush(): void;
12
+ }
13
+
14
+ export function createPostHogBridge(
15
+ posthog: PostHogLike,
16
+ collector: ToolAnalyticsCollector
17
+ ): PostHogBridge {
18
+ return {
19
+ onToolCall(event: ToolCallEvent): void {
20
+ posthog.capture('webmcp_tool_call', {
21
+ tool_name: event.toolName,
22
+ duration_ms: event.durationMs,
23
+ success: event.error === null,
24
+ error: event.error,
25
+ });
26
+ },
27
+
28
+ onSessionEnd(): void {
29
+ const props = collector.toPostHogProperties();
30
+ if ((props.webmcp_total_calls as number) > 0) {
31
+ posthog.capture('webmcp_session_summary', props);
32
+ }
33
+ },
34
+
35
+ flush(): void {
36
+ this.onSessionEnd();
37
+ },
38
+ };
39
+ }