diff --git a/.env.example b/.env.example index a6dd066..4994dbe 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -VERSION=0.1.17 +VERSION=0.1.18 # Nest run in docker, change host to database container name DB_HOST=localhost diff --git a/apps/webapp/app/components/graph/space-search.tsx b/apps/webapp/app/components/graph/space-search.tsx index e7785c1..031fc35 100644 --- a/apps/webapp/app/components/graph/space-search.tsx +++ b/apps/webapp/app/components/graph/space-search.tsx @@ -30,41 +30,6 @@ export function SpaceSearch({ } }, [debouncedSearchQuery, searchQuery, onSearchChange]); - // Count statement nodes that match the search - const matchingStatements = useMemo(() => { - if (!debouncedSearchQuery.trim()) return 0; - - const query = debouncedSearchQuery.toLowerCase(); - let count = 0; - - const isStatementNode = (node: any) => { - return ( - node.attributes?.fact || - (node.labels && node.labels.includes("Statement")) - ); - }; - - triplets.forEach((triplet) => { - // Check if source node is a statement and matches - if ( - isStatementNode(triplet.sourceNode) && - triplet.sourceNode.attributes?.fact?.toLowerCase().includes(query) - ) { - count++; - } - - // Check if target node is a statement and matches - if ( - isStatementNode(triplet.targetNode) && - triplet.targetNode.attributes?.fact?.toLowerCase().includes(query) - ) { - count++; - } - }); - - return count; - }, [triplets, debouncedSearchQuery]); - // Helper to determine if a node is a statement const isStatementNode = useCallback((node: any) => { // Check if node has a fact attribute (indicates it's a statement) @@ -74,6 +39,34 @@ export function SpaceSearch({ ); }, []); + // Count statement nodes that match the search + const matchingStatements = useMemo(() => { + if (!debouncedSearchQuery.trim()) return 0; + + const query = debouncedSearchQuery.toLowerCase(); + const statements: Record = {}; + + triplets.forEach((triplet) => { + // Check if source node is a statement and matches + if ( + isStatementNode(triplet.sourceNode) && + triplet.sourceNode.attributes?.fact?.toLowerCase().includes(query) + ) { + statements[triplet.sourceNode.uuid] = 1; + } + + // Check if target node is a statement and matches + if ( + isStatementNode(triplet.targetNode) && + triplet.targetNode.attributes?.fact?.toLowerCase().includes(query) + ) { + statements[triplet.targetNode.uuid] = 1; + } + }); + + return Object.keys(statements).length; + }, [triplets, debouncedSearchQuery]); + const handleInputChange = (event: React.ChangeEvent) => { setInputValue(event.target.value); }; diff --git a/apps/webapp/app/components/integrations/mcp-auth-section.tsx b/apps/webapp/app/components/integrations/mcp-auth-section.tsx index 541df20..6250864 100644 --- a/apps/webapp/app/components/integrations/mcp-auth-section.tsx +++ b/apps/webapp/app/components/integrations/mcp-auth-section.tsx @@ -1,8 +1,7 @@ import React, { useCallback, useState } from "react"; import { useFetcher } from "@remix-run/react"; import { Button } from "~/components/ui/button"; -import { Check, Copy } from "lucide-react"; -import { Input } from "../ui/input"; +import { Check } from "lucide-react"; interface MCPAuthSectionProps { integration: { @@ -19,49 +18,6 @@ interface MCPAuthSectionProps { hasMCPAuth: boolean; } -interface MCPUrlBoxProps { - mcpUrl: string; -} - -function MCPUrlBox({ mcpUrl }: MCPUrlBoxProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(mcpUrl).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }, [mcpUrl]); - - return ( -
- e.target.select()} - /> - -
- ); -} - export function MCPAuthSection({ integration, activeAccount, @@ -74,8 +30,6 @@ export function MCPAuthSection({ const isMCPConnected = !!activeAccount?.integrationConfiguration?.mcp; const isConnected = !!activeAccount; - const mcpUrl = `https://core.heysol.ai/api/v1/mcp/${integration.slug}`; - const handleMCPConnect = useCallback(() => { setIsMCPConnecting(true); mcpFetcher.submit( @@ -135,8 +89,8 @@ export function MCPAuthSection({

MCP Authentication

- {hasMCPAuth ? ( - isMCPConnected ? ( + {hasMCPAuth && + (isMCPConnected ? (

@@ -145,7 +99,6 @@ export function MCPAuthSection({

MCP (Model Context Protocol) authentication is active

-
- ) - ) : ( - // hasMCPAuth is false, but integration is connected: show just the MCPUrlBox -
-
-

- Integration Connected -

-

- You can use the MCP endpoint for this integration: -

- -
-
- )} + ))}
); } diff --git a/apps/webapp/app/components/spaces/space-fact-card.tsx b/apps/webapp/app/components/spaces/space-fact-card.tsx index 82b10fc..2ec47f4 100644 --- a/apps/webapp/app/components/spaces/space-fact-card.tsx +++ b/apps/webapp/app/components/spaces/space-fact-card.tsx @@ -39,7 +39,7 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
{displayText}
-
+
{!!recallCount && Recalled: {recallCount} times} diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index a3b36de..4392768 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -1,12 +1,4 @@ -import { - Prisma, - PrismaClient, - type PrismaClientOrTransaction, - type PrismaReplicaClient, - type PrismaTransactionClient, - type PrismaTransactionOptions, - $transaction as transac, -} from "@core/database"; +import { Prisma, PrismaClient } from "@core/database"; import invariant from "tiny-invariant"; import { z } from "zod"; import { env } from "./env.server"; @@ -16,72 +8,11 @@ import { singleton } from "./utils/singleton"; import { type Span } from "@opentelemetry/api"; -export type { - PrismaTransactionClient, - PrismaClientOrTransaction, - PrismaTransactionOptions, - PrismaReplicaClient, -}; - -export async function $transaction( - prisma: PrismaClientOrTransaction, - name: string, - fn: (prisma: PrismaTransactionClient, span?: Span) => Promise, - options?: PrismaTransactionOptions, -): Promise; -export async function $transaction( - prisma: PrismaClientOrTransaction, - fn: (prisma: PrismaTransactionClient) => Promise, - options?: PrismaTransactionOptions, -): Promise; -export async function $transaction( - prisma: PrismaClientOrTransaction, - fnOrName: ((prisma: PrismaTransactionClient) => Promise) | string, - fnOrOptions?: - | ((prisma: PrismaTransactionClient) => Promise) - | PrismaTransactionOptions, - options?: PrismaTransactionOptions, -): Promise { - if (typeof fnOrName === "string") { - const fn = fnOrOptions as (prisma: PrismaTransactionClient) => Promise; - - return await transac( - prisma, - (client) => fn(client), - (error) => { - logger.error("prisma.$transaction error", { - code: error.code, - meta: error.meta, - stack: error.stack, - message: error.message, - name: error.name, - }); - }, - options, - ); - } else { - return transac( - prisma, - fnOrName, - (error) => { - logger.error("prisma.$transaction error", { - code: error.code, - meta: error.meta, - stack: error.stack, - message: error.message, - name: error.name, - }); - }, - typeof fnOrOptions === "function" ? undefined : fnOrOptions, - ); - } -} - export { Prisma }; export const prisma = singleton("prisma", getClient); -export const $replica: PrismaReplicaClient = singleton( +export const $replica = singleton( "replica", () => getReplicaClient() ?? prisma, ); diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index 19e50a9..2847e6e 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -15,6 +15,8 @@ import { RemixServer } from "@remix-run/react"; import { isbot } from "isbot"; import { renderToPipeableStream } from "react-dom/server"; import { initializeStartupServices } from "./utils/startup"; +import { handleMCPRequest, handleSessionRequest } from "~/services/mcp.server"; +import { authenticateHybridRequest } from "~/services/routeBuilders/apiBuilder.server"; const ABORT_DELAY = 5_000; @@ -149,3 +151,5 @@ function handleBrowserRequest( setTimeout(abort, ABORT_DELAY); }); } + +export { handleMCPRequest, handleSessionRequest, authenticateHybridRequest }; diff --git a/apps/webapp/app/routes/api.v1.extension-search.tsx b/apps/webapp/app/routes/api.v1.extension-search.tsx index 4af6833..eff9bfd 100644 --- a/apps/webapp/app/routes/api.v1.extension-search.tsx +++ b/apps/webapp/app/routes/api.v1.extension-search.tsx @@ -1,33 +1,12 @@ import { z } from "zod"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { json } from "@remix-run/node"; -import { searchMemoryAgent } from "~/agents/searchMemoryAgent.server"; +import { extensionSearch } from "~/trigger/extension/search"; export const ExtensionSearchBodyRequest = z.object({ input: z.string().min(1, "Input text is required"), }); -/** - * Generate context summary from user input using SearchMemoryAgent - */ -async function generateContextSummary( - userInput: string, - userId: string, -): Promise { - try { - const summary = await searchMemoryAgent.generateContextSummary({ - userInput, - userId, - }); - - return summary; - } catch (error) { - console.error("Error generating context with agent:", error); - // Fallback: use simple context description - return `Context related to: ${userInput}. Looking for relevant background information, previous discussions, and related concepts that would help provide a comprehensive answer.`; - } -} - const { action, loader } = createActionApiRoute( { body: ExtensionSearchBodyRequest, @@ -39,18 +18,12 @@ const { action, loader } = createActionApiRoute( corsStrategy: "all", }, async ({ body, authentication }) => { - // Generate context summary using SearchMemoryAgent - const contextSummary = await generateContextSummary( - body.input, - authentication.userId, - ); + const trigger = await extensionSearch.trigger({ + userInput: body.input, + userId: authentication.userId, + }); - // Return results with agent-generated context summary - const finalResults = { - context_summary: contextSummary, // Agent's context summary - }; - - return json(finalResults); + return json(trigger); }, ); diff --git a/apps/webapp/app/routes/api.v1.extension-summary.tsx b/apps/webapp/app/routes/api.v1.extension-summary.tsx index 166a9fb..624ba98 100644 --- a/apps/webapp/app/routes/api.v1.extension-summary.tsx +++ b/apps/webapp/app/routes/api.v1.extension-summary.tsx @@ -1,9 +1,7 @@ import { z } from "zod"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; -import { makeModelCall } from "~/lib/model.server"; import { json } from "@remix-run/node"; -import type { CoreMessage } from "ai"; -import * as cheerio from "cheerio"; +import { extensionSummary } from "~/trigger/extension/summary"; export const ExtensionSummaryBodyRequest = z.object({ html: z.string().min(1, "HTML content is required"), @@ -11,163 +9,6 @@ export const ExtensionSummaryBodyRequest = z.object({ title: z.string().optional(), }); -export type PageType = "text" | "video"; - -interface ContentExtractionResult { - pageType: PageType; - title: string; - content: string; - metadata: { - url: string; - wordCount: number; - }; - supported: boolean; -} - -/** - * Detect if page contains video content - */ -function isVideoPage(url: string, $: cheerio.CheerioAPI): boolean { - const hostname = new URL(url).hostname.toLowerCase(); - - // Known video platforms - if ( - hostname.includes("youtube.com") || - hostname.includes("youtu.be") || - hostname.includes("vimeo.com") || - hostname.includes("twitch.tv") || - hostname.includes("tiktok.com") - ) { - return true; - } - - // Generic video content detection - const videoElements = $("video").length; - const videoPlayers = $( - '.video-player, [class*="video-player"], [data-testid*="video"]', - ).length; - - // If there are multiple video indicators, likely a video-focused page - return videoElements > 0 || videoPlayers > 2; -} - -/** - * Extract all text content from any webpage - */ -function extractTextContent( - $: cheerio.CheerioAPI, - url: string, -): ContentExtractionResult { - // Extract title from multiple possible locations - const title = - $("title").text() || - $('meta[property="og:title"]').attr("content") || - $('meta[name="title"]').attr("content") || - $("h1").first().text() || - "Untitled Page"; - - // Check if this is primarily a video page - const isVideo = isVideoPage(url, $); - const pageType: PageType = isVideo ? "video" : "text"; - - let content = ""; - - if (isVideo) { - // For video pages, try to get description/transcript text - content = - $("#description, .video-description, .description").text() || - $('meta[name="description"]').attr("content") || - $('[class*="transcript"], [class*="caption"]').text() || - "Video content detected - text summarization not available"; - } else { - // Simple universal text extraction - // Remove non-content elements - $("script, style, noscript, nav, header, footer").remove(); - - // Get all text content - const allText = $("body").text(); - - // Split into sentences and filter for meaningful content - const sentences = allText - .split(/[.!?]+/) - .map((s) => s.trim()) - .filter((s) => s.length > 20) // Keep sentences with substance - .filter( - (s) => - !/^(click|menu|button|nav|home|search|login|signup|subscribe)$/i.test( - s.toLowerCase(), - ), - ) // Remove UI text - .filter((s) => s.split(" ").length > 3); // Keep sentences with multiple words - - content = sentences.join(". ").slice(0, 10000); - } - - // Clean up whitespace and normalize text - content = content.replace(/\s+/g, " ").trim(); - - const wordCount = content - .split(/\s+/) - .filter((word) => word.length > 0).length; - const supported = !isVideo && content.length > 50; - - return { - pageType, - title: title.trim(), - content: content.slice(0, 10000), // Limit content size for processing - metadata: { - url, - wordCount, - }, - supported, - }; -} - -/** - * Generate summary using LLM - */ -async function generateSummary( - title: string, - content: string, -): Promise { - const messages: CoreMessage[] = [ - { - role: "system", - content: `You are a helpful assistant that creates concise summaries of web content in HTML format. - -Create a clear, informative summary that captures the key points and main ideas from the provided content. The summary should: -- Focus on the most important information and key takeaways -- Be concise but comprehensive -- Maintain the original context and meaning -- Be useful for someone who wants to quickly understand the content -- Format the summary in HTML, using appropriate tags like

,

,

    ,
  • to structure the information - -Extract the essential information while preserving important details, facts, or insights.`, - }, - { - role: "user", - content: `Title: ${title} -Content: ${content} - -Please provide a concise summary of this content in HTML format.`, - }, - ]; - - try { - const response = await makeModelCall( - false, - messages, - () => {}, // onFinish callback - { temperature: 0.3 }, - ); - - return response as string; - } catch (error) { - console.error("Error generating summary:", error); - return "

    Unable to generate summary at this time.

    "; - } -} - const { action, loader } = createActionApiRoute( { body: ExtensionSummaryBodyRequest, @@ -178,64 +19,9 @@ const { action, loader } = createActionApiRoute( corsStrategy: "all", }, async ({ body }) => { - try { - const $ = cheerio.load(body.html); + const response = await extensionSummary.trigger(body); - // Extract content from any webpage - const extraction = extractTextContent($, body.url); - - // Override title if provided - if (body.title) { - extraction.title = body.title; - } - - let summary = ""; - - if (extraction.supported && extraction.content.length > 0) { - // Generate summary for text content - summary = await generateSummary(extraction.title, extraction.content); - } else { - // Handle unsupported content types - if (extraction.pageType === "video") { - summary = - "Video content detected. Text summarization not available for video-focused pages."; - } else { - summary = - "Unable to extract sufficient text content for summarization."; - } - } - - const response = { - success: true, - pageType: extraction.pageType, - title: extraction.title, - summary, - content: extraction.content.slice(0, 1000), // Return first 1000 chars of content - supported: extraction.supported, - metadata: extraction.metadata, - }; - - return json(response); - } catch (error) { - console.error("Error processing extension summary request:", error); - - return json( - { - success: false, - error: "Failed to process page content", - pageType: "text" as PageType, - title: body.title || "Error", - summary: "Unable to process this page content.", - content: "", - supported: false, - metadata: { - url: body.url, - wordCount: 0, - }, - }, - { status: 500 }, - ); - } + return json(response); }, ); diff --git a/apps/webapp/app/routes/ingest.tsx b/apps/webapp/app/routes/ingest.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 6166c02..856c8ea 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -1,6 +1,6 @@ import { findUserByToken } from "~/models/personal-token.server"; import { oauth2Service } from "~/services/oauth2.server"; - +import { type Request as ERequest } from "express"; // See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20 export type Prettify = { [K in keyof T]: T[K]; @@ -111,8 +111,13 @@ export function isSecretApiKey(key: string) { return key.startsWith("rc_"); } -export function getApiKeyFromRequest(request: Request) { - return getApiKeyFromHeader(request.headers.get("Authorization")); +export function getApiKeyFromRequest(request: Request | ERequest) { + const authorizationHeader = + request instanceof Request + ? request.headers.get("Authorization") + : request.headers["authorization"]; + + return getApiKeyFromHeader(authorizationHeader); } export function getApiKeyFromHeader(authorization?: string | null) { diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts index 7cdd211..be69e97 100644 --- a/apps/webapp/app/services/impersonation.server.ts +++ b/apps/webapp/app/services/impersonation.server.ts @@ -1,5 +1,6 @@ import { createCookieSessionStorage, type Session } from "@remix-run/node"; import { env } from "~/env.server"; +import { type Request as ERequest } from "express"; export const impersonationSessionStorage = createCookieSessionStorage({ cookie: { @@ -13,8 +14,13 @@ export const impersonationSessionStorage = createCookieSessionStorage({ }, }); -export function getImpersonationSession(request: Request) { - return impersonationSessionStorage.getSession(request.headers.get("Cookie")); +export function getImpersonationSession(request: Request | ERequest) { + const cookieHeader = + request instanceof Request + ? request.headers.get("Cookie") + : request.headers["cookie"]; + + return impersonationSessionStorage.getSession(cookieHeader); } export function commitImpersonationSession(session: Session) { diff --git a/apps/webapp/app/routes/api.v1.mcp.tsx b/apps/webapp/app/services/mcp.server.ts similarity index 57% rename from apps/webapp/app/routes/api.v1.mcp.tsx rename to apps/webapp/app/services/mcp.server.ts index 65170ca..925d181 100644 --- a/apps/webapp/app/routes/api.v1.mcp.tsx +++ b/apps/webapp/app/services/mcp.server.ts @@ -1,4 +1,3 @@ -import { json } from "@remix-run/node"; import { randomUUID } from "node:crypto"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -8,87 +7,18 @@ import { CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -import { - createHybridActionApiRoute, - createLoaderApiRoute, -} from "~/services/routeBuilders/apiBuilder.server"; -import { handleTransport } from "~/utils/mcp"; import { MCPSessionManager } from "~/utils/mcp/session-manager"; import { TransportManager } from "~/utils/mcp/transport-manager"; import { IntegrationLoader } from "~/utils/mcp/integration-loader"; import { callMemoryTool, memoryTools } from "~/utils/mcp/memory"; import { logger } from "~/services/logger.service"; +import { type Response, type Request } from "express"; -// Request schemas -const MCPRequestSchema = z.object({}).passthrough(); const QueryParams = z.object({ source: z.string().optional(), integrations: z.string().optional(), // comma-separated slugs }); -// Common function to create and setup transport -async function createTransport( - sessionId: string, - source: string, - integrations: string[], - userId: string, - workspaceId: string, -): Promise { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => sessionId, - onsessioninitialized: async (sessionId) => { - // Clean up old sessions (24+ hours) during new session initialization - try { - const [dbCleanupCount, memoryCleanupCount] = await Promise.all([ - MCPSessionManager.cleanupOldSessions(), - TransportManager.cleanupOldSessions(), - ]); - if (dbCleanupCount > 0 || memoryCleanupCount > 0) { - logger.log(`Cleaned up ${dbCleanupCount} DB sessions and ${memoryCleanupCount} memory sessions`); - } - } catch (error) { - logger.error(`Error during session cleanup: ${error}`); - } - - // Store session in database - await MCPSessionManager.upsertSession(sessionId, source, integrations); - - // Store main transport - TransportManager.setMainTransport(sessionId, transport); - }, - }); - - // Setup cleanup on close - transport.onclose = async () => { - await MCPSessionManager.deleteSession(sessionId); - await TransportManager.cleanupSession(sessionId); - }; - - // Load integration transports - try { - const result = await IntegrationLoader.loadIntegrationTransports( - sessionId, - userId, - workspaceId, - integrations.length > 0 ? integrations : undefined, - ); - logger.log( - `Loaded ${result.loaded} integration transports for session ${sessionId}`, - ); - if (result.failed.length > 0) { - logger.warn(`Failed to load some integrations: ${result.failed}`); - } - } catch (error) { - logger.error(`Error loading integration transports: ${error}`); - } - - // Create and connect MCP server - const server = await createMcpServer(userId, sessionId); - await server.connect(transport); - - return transport; -} - // Create MCP server with memory tools + dynamic integration tools async function createMcpServer(userId: string, sessionId: string) { const server = new Server( @@ -152,49 +82,82 @@ async function createMcpServer(userId: string, sessionId: string) { throw new Error(`Unknown tool: ${name}`); }); - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - // Handle memory tools - if (name.startsWith("memory_")) { - return await callMemoryTool(name, args, userId); - } - - // Handle integration tools (prefixed with integration slug) - if (name.includes("_") && !name.startsWith("memory_")) { - try { - return await IntegrationLoader.callIntegrationTool( - sessionId, - name, - args, - ); - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error calling integration tool: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }; - } - } - - throw new Error(`Unknown tool: ${name}`); - }); - return server; } -// Handle MCP requests -const handleMCPRequest = async ( +// Common function to create and setup transport +async function createTransport( + sessionId: string, + source: string, + integrations: string[], + userId: string, + workspaceId: string, +): Promise { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => sessionId, + onsessioninitialized: async (sessionId) => { + // Clean up old sessions (24+ hours) during new session initialization + try { + const [dbCleanupCount, memoryCleanupCount] = await Promise.all([ + MCPSessionManager.cleanupOldSessions(), + TransportManager.cleanupOldSessions(), + ]); + if (dbCleanupCount > 0 || memoryCleanupCount > 0) { + logger.log( + `Cleaned up ${dbCleanupCount} DB sessions and ${memoryCleanupCount} memory sessions`, + ); + } + } catch (error) { + logger.error(`Error during session cleanup: ${error}`); + } + + // Store session in database + await MCPSessionManager.upsertSession(sessionId, source, integrations); + + // Store main transport + TransportManager.setMainTransport(sessionId, transport); + }, + }); + + // Setup cleanup on close + transport.onclose = async () => { + await MCPSessionManager.deleteSession(sessionId); + await TransportManager.cleanupSession(sessionId); + }; + + // Load integration transports + try { + const result = await IntegrationLoader.loadIntegrationTransports( + sessionId, + userId, + workspaceId, + integrations.length > 0 ? integrations : undefined, + ); + logger.log( + `Loaded ${result.loaded} integration transports for session ${sessionId}`, + ); + if (result.failed.length > 0) { + logger.warn(`Failed to load some integrations: ${result.failed}`); + } + } catch (error) { + logger.error(`Error loading integration transports: ${error}`); + } + + // Create and connect MCP server + const server = await createMcpServer(userId, sessionId); + await server.connect(transport); + + return transport; +} + +export const handleMCPRequest = async ( request: Request, + res: Response, body: any, authentication: any, queryParams: z.infer, ) => { - const sessionId = request.headers.get("mcp-session-id") as string | undefined; + const sessionId = request.headers["mcp-session-id"] as string | undefined; const source = queryParams.source || "api"; const integrations = queryParams.integrations ? queryParams.integrations.split(",").map((s) => s.trim()) @@ -240,134 +203,34 @@ const handleMCPRequest = async ( ); } else { // Invalid request - return json( - { - jsonrpc: "2.0", - error: { - code: -32000, - message: - "Bad Request: No valid session ID provided or session inactive", - }, - id: body?.id || null, - }, - { status: 400 }, - ); + throw new Error("No session id"); } // Handle the request through existing transport utility - const response = await handleTransport(transport!, request, body); - return response; + return await transport.handleRequest(request, res, body); } catch (error) { console.error("MCP SSE request error:", error); - return json( - { - jsonrpc: "2.0", - error: { - code: -32000, - message: - error instanceof Error ? error.message : "Internal server error", - }, - id: body?.id || null, - }, - { status: 500 }, - ); + throw new Error("MCP SSE request error"); } }; -// Handle DELETE requests for session cleanup -const handleDelete = async (request: Request) => { - const sessionId = request.headers.get("mcp-session-id") as string | undefined; - - if (!sessionId) { - return new Response("Missing session ID", { status: 400 }); - } - - try { - // Mark session as deleted in database - await MCPSessionManager.deleteSession(sessionId); - - // Clean up all transports - await TransportManager.cleanupSession(sessionId); - - return new Response(null, { status: 204 }); - } catch (error) { - console.error("Error deleting session:", error); - return new Response("Internal server error", { status: 500 }); - } -}; - -const { action } = createHybridActionApiRoute( - { - body: MCPRequestSchema, - searchParams: QueryParams, - allowJWT: true, - authorization: { - action: "mcp", - }, - corsStrategy: "all", - }, - async ({ body, authentication, request, searchParams }) => { - const method = request.method; - - if (method === "POST") { - return await handleMCPRequest( - request, - body, - authentication, - searchParams, - ); - } else if (method === "DELETE") { - return await handleDelete(request); - } else { - return json( - { - jsonrpc: "2.0", - error: { - code: -32601, - message: "Method not allowed", - }, - id: null, - }, - { status: 405 }, - ); - } - }, -); - -const loader = createLoaderApiRoute( - { - allowJWT: true, - corsStrategy: "all", - findResource: async () => 1, - }, - async ({ request }) => { - // Handle SSE requests (for server-to-client notifications) - const sessionId = request.headers.get("mcp-session-id"); - if (!sessionId) { - return new Response("Missing session ID for SSE", { status: 400 }); - } +export const handleSessionRequest = async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (sessionId && (await MCPSessionManager.isSessionActive(sessionId))) { const sessionData = TransportManager.getSessionInfo(sessionId); - if (!sessionData.exists) { - // Check if session exists in database and recreate transport - const sessionDetails = await MCPSessionManager.getSession(sessionId); - if (!sessionDetails) { - return new Response("Session not found", { status: 404 }); - } - // Session exists in DB but not in memory - need authentication to recreate - return new Response("Session not found", { status: 404 }); + if (sessionData.exists) { + const transport = + sessionData.mainTransport as StreamableHTTPServerTransport; + + await transport.handleRequest(req, res); + } else { + res.status(400).send("Invalid or missing session ID"); + return; } - - // Return SSE stream (this would be handled by the transport's handleRequest method) - // For now, just return session info - return json({ - sessionId, - active: await MCPSessionManager.isSessionActive(sessionId), - integrationCount: sessionData.integrationCount, - createdAt: sessionData.createdAt, - }); - }, -); - -export { action, loader }; + } else { + res.status(400).send("Invalid or missing session ID"); + return; + } +}; diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index cef70bb..87fc25d 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -643,7 +643,7 @@ export type HybridAuthenticationResult = userId: string; }; -async function authenticateHybridRequest( +export async function authenticateHybridRequest( request: Request, options: { allowJWT?: boolean } = {}, ): Promise { diff --git a/apps/webapp/app/services/search.server.ts b/apps/webapp/app/services/search.server.ts index 286f09c..51bcd65 100644 --- a/apps/webapp/app/services/search.server.ts +++ b/apps/webapp/app/services/search.server.ts @@ -2,9 +2,7 @@ import type { EpisodicNode, StatementNode } from "@core/types"; import { logger } from "./logger.service"; import { applyCrossEncoderReranking, - applyMultiFactorReranking, applyMultiFactorMMRReranking, - applyWeightedRRF, } from "./search/rerank"; import { getEpisodesByStatements, diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index 3ca8396..288dccd 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -3,13 +3,21 @@ import { getUserById, getUserLeftCredits } from "~/models/user.server"; import { sessionStorage } from "./sessionStorage.server"; import { getImpersonationId } from "./impersonation.server"; import { getWorkspaceByUser } from "~/models/workspace.server"; +import { type Request as ERequest } from "express"; -export async function getUserId(request: Request): Promise { - const impersonatedUserId = await getImpersonationId(request); +export async function getUserId( + request: Request | ERequest, +): Promise { + const impersonatedUserId = await getImpersonationId(request as Request); if (impersonatedUserId) return impersonatedUserId; - let session = await sessionStorage.getSession(request.headers.get("cookie")); + const cookieHeader = + request instanceof Request + ? request.headers.get("Cookie") + : request.headers["cookie"]; + + let session = await sessionStorage.getSession(cookieHeader); let user = session.get("user"); return user?.userId; diff --git a/apps/webapp/app/agents/searchMemoryAgent.server.ts b/apps/webapp/app/trigger/extension/search.ts similarity index 67% rename from apps/webapp/app/agents/searchMemoryAgent.server.ts rename to apps/webapp/app/trigger/extension/search.ts index 0cc90ec..e9c56c1 100644 --- a/apps/webapp/app/agents/searchMemoryAgent.server.ts +++ b/apps/webapp/app/trigger/extension/search.ts @@ -1,11 +1,16 @@ +import { metadata, task } from "@trigger.dev/sdk"; +import { streamText, type CoreMessage, tool } from "ai"; import { z } from "zod"; -import { openai } from "@ai-sdk/openai"; -import { type CoreMessage, generateText, tool } from "ai"; -import { logger } from "~/services/logger.service"; -import { SearchService } from "~/services/search.server"; -// Input schema for the agent -export const SearchMemoryAgentInput = z.object({ +import { openai } from "@ai-sdk/openai"; +import { logger } from "~/services/logger.service"; +import { + deletePersonalAccessToken, + getOrCreatePersonalAccessToken, +} from "../utils/utils"; +import axios from "axios"; + +export const ExtensionSearchBodyRequest = z.object({ userInput: z.string().min(1, "User input is required"), userId: z.string().min(1, "User ID is required"), context: z @@ -14,20 +19,18 @@ export const SearchMemoryAgentInput = z.object({ .describe("Additional context about the user's current work"), }); -/** - * Search Memory Agent - Designed to find relevant context from user's memory - * - * This agent searches the user's memory using a searchMemory tool, retrieves relevant - * facts and episodes, then summarizes them into a concise, relevant context summary. - */ -export class SearchMemoryAgent { - private model = openai("gpt-4o"); - private searchService = new SearchService(); +// Export a singleton instance +export const extensionSearch = task({ + id: "extensionSearch", + maxDuration: 3000, + run: async (body: z.infer) => { + const { userInput, userId, context } = + ExtensionSearchBodyRequest.parse(body); - async generateContextSummary( - input: z.infer, - ): Promise { - const { userInput, userId, context } = SearchMemoryAgentInput.parse(input); + const pat = await getOrCreatePersonalAccessToken({ + name: "extensionSearch", + userId: userId as string, + }); // Define the searchMemory tool that actually calls the search service const searchMemoryTool = tool({ @@ -38,7 +41,16 @@ export class SearchMemoryAgent { }), execute: async ({ query }) => { try { - const searchResult = await this.searchService.search(query, userId); + const response = await axios.post( + `${process.env.API_BASE_URL}/api/v1/search`, + { query }, + { + headers: { + Authorization: `Bearer ${pat.token}`, + }, + }, + ); + const searchResult = response.data; return { facts: searchResult.facts || [], @@ -79,8 +91,8 @@ If no relevant information is found, provide a brief statement indicating that.` ]; try { - const result = await generateText({ - model: this.model, + const result = streamText({ + model: openai(process.env.MODEL as string), messages, tools: { searchMemory: searchMemoryTool, @@ -90,14 +102,21 @@ If no relevant information is found, provide a brief statement indicating that.` maxTokens: 600, }); - return result.text.trim(); + const stream = await metadata.stream("messages", result.textStream); + + let finalText: string = ""; + for await (const chunk of stream) { + finalText = finalText + chunk; + } + + await deletePersonalAccessToken(pat.id); + + return finalText; } catch (error) { logger.error(`SearchMemoryAgent error: ${error}`); + await deletePersonalAccessToken(pat.id); return `Context related to: ${userInput}. Looking for relevant background information, previous discussions, and related concepts that would help provide a comprehensive answer.`; } - } -} - -// Export a singleton instance -export const searchMemoryAgent = new SearchMemoryAgent(); + }, +}); diff --git a/apps/webapp/app/trigger/extension/summary.ts b/apps/webapp/app/trigger/extension/summary.ts new file mode 100644 index 0000000..072910c --- /dev/null +++ b/apps/webapp/app/trigger/extension/summary.ts @@ -0,0 +1,234 @@ +import { metadata, task } from "@trigger.dev/sdk"; +import { type CoreMessage } from "ai"; +import * as cheerio from "cheerio"; +import { z } from "zod"; +import { makeModelCall } from "~/lib/model.server"; + +export type PageType = "text" | "video"; + +export const ExtensionSummaryBodyRequest = z.object({ + html: z.string().min(1, "HTML content is required"), + url: z.string().url("Valid URL is required"), + title: z.string().optional(), +}); + +interface ContentExtractionResult { + pageType: PageType; + title: string; + content: string; + metadata: { + url: string; + wordCount: number; + }; + supported: boolean; +} + +/** + * Detect if page contains video content + */ +function isVideoPage(url: string, $: cheerio.CheerioAPI): boolean { + const hostname = new URL(url).hostname.toLowerCase(); + + // Known video platforms + if ( + hostname.includes("youtube.com") || + hostname.includes("youtu.be") || + hostname.includes("vimeo.com") || + hostname.includes("twitch.tv") || + hostname.includes("tiktok.com") + ) { + return true; + } + + // Generic video content detection + const videoElements = $("video").length; + const videoPlayers = $( + '.video-player, [class*="video-player"], [data-testid*="video"]', + ).length; + + // If there are multiple video indicators, likely a video-focused page + return videoElements > 0 || videoPlayers > 2; +} + +/** + * Extract all text content from any webpage + */ +function extractTextContent( + $: cheerio.CheerioAPI, + url: string, +): ContentExtractionResult { + // Extract title from multiple possible locations + const title = + $("title").text() || + $('meta[property="og:title"]').attr("content") || + $('meta[name="title"]').attr("content") || + $("h1").first().text() || + "Untitled Page"; + + // Check if this is primarily a video page + const isVideo = isVideoPage(url, $); + const pageType: PageType = isVideo ? "video" : "text"; + + let content = ""; + + if (isVideo) { + // For video pages, try to get description/transcript text + content = + $("#description, .video-description, .description").text() || + $('meta[name="description"]').attr("content") || + $('[class*="transcript"], [class*="caption"]').text() || + "Video content detected - text summarization not available"; + } else { + // Simple universal text extraction + // Remove non-content elements + $("script, style, noscript, nav, header, footer").remove(); + + // Get all text content + const allText = $("body").text(); + + // Split into sentences and filter for meaningful content + const sentences = allText + .split(/[.!?]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 20) // Keep sentences with substance + .filter( + (s) => + !/^(click|menu|button|nav|home|search|login|signup|subscribe)$/i.test( + s.toLowerCase(), + ), + ) // Remove UI text + .filter((s) => s.split(" ").length > 3); // Keep sentences with multiple words + + content = sentences.join(". ").slice(0, 10000); + } + + // Clean up whitespace and normalize text + content = content.replace(/\s+/g, " ").trim(); + + const wordCount = content + .split(/\s+/) + .filter((word) => word.length > 0).length; + const supported = !isVideo && content.length > 50; + + return { + pageType, + title: title.trim(), + content: content.slice(0, 10000), // Limit content size for processing + metadata: { + url, + wordCount, + }, + supported, + }; +} + +/** + * Generate summary using LLM + */ +async function generateSummary(title: string, content: string) { + const messages: CoreMessage[] = [ + { + role: "system", + content: `You are a helpful assistant that creates concise summaries of web content in HTML format. + +Create a clear, informative summary that captures the key points and main ideas from the provided content. The summary should: +- Focus on the most important information and key takeaways +- Be concise but comprehensive +- Maintain the original context and meaning +- Be useful for someone who wants to quickly understand the content +- Format the summary in clean HTML using appropriate tags like

    ,

    ,

    ,

      ,
    • to structure the information + +IMPORTANT: Return ONLY the HTML content without any markdown code blocks or formatting. Do not wrap the response in \`\`\`html or any other markdown syntax. Return the raw HTML directly. + +Extract the essential information while preserving important details, facts, or insights.`, + }, + { + role: "user", + content: `Title: ${title} +Content: ${content} + +Please provide a concise summary of this content in HTML format.`, + }, + ]; + + return await makeModelCall( + true, + messages, + () => {}, // onFinish callback + { temperature: 0.3 }, + ); +} + +export const extensionSummary = task({ + id: "extensionSummary", + maxDuration: 3000, + run: async (body: z.infer) => { + try { + const $ = cheerio.load(body.html); + + // Extract content from any webpage + const extraction = extractTextContent($, body.url); + + // Override title if provided + if (body.title) { + extraction.title = body.title; + } + + let summary = ""; + + if (extraction.supported && extraction.content.length > 0) { + // Generate summary for text content + const response = (await generateSummary( + extraction.title, + extraction.content, + )) as any; + + const stream = await metadata.stream("messages", response.textStream); + + let finalText: string = ""; + for await (const chunk of stream) { + finalText = finalText + chunk; + } + + summary = finalText; + } else { + // Handle unsupported content types + if (extraction.pageType === "video") { + summary = + "Video content detected. Text summarization not available for video-focused pages."; + } else { + summary = + "Unable to extract sufficient text content for summarization."; + } + } + + const response = { + success: true, + pageType: extraction.pageType, + title: extraction.title, + summary, + content: extraction.content.slice(0, 1000), // Return first 1000 chars of content + supported: extraction.supported, + metadata: extraction.metadata, + }; + + return response; + } catch (error) { + console.error("Error processing extension summary request:", error); + + return { + success: false, + error: "Failed to process page content", + pageType: "text" as PageType, + title: body.title || "Error", + summary: "Unable to process this page content.", + content: "", + supported: false, + metadata: { + url: body.url, + wordCount: 0, + }, + }; + } + }, +}); diff --git a/apps/webapp/app/utils/mcp/integration-loader.ts b/apps/webapp/app/utils/mcp/integration-loader.ts index 4ea3994..ac7e996 100644 --- a/apps/webapp/app/utils/mcp/integration-loader.ts +++ b/apps/webapp/app/utils/mcp/integration-loader.ts @@ -1,5 +1,7 @@ import { prisma } from "~/db.server"; import { TransportManager } from "./transport-manager"; +import { configureStdioMCPEnvironment } from "~/trigger/utils/mcp"; +import { getDefaultEnvironment } from "@core/mcp-proxy"; export interface IntegrationAccountWithDefinition { id: string; @@ -132,11 +134,23 @@ export class IntegrationLoader { loaded++; } else { - // Skip non-HTTP transports for now - failed.push({ - slug: account.integrationDefinition.slug, - error: `Unsupported transport type: ${mcpConfig.type}`, - }); + const { env, args } = configureStdioMCPEnvironment(spec, account); + const slug = account.integrationDefinition.slug; + + // Extract headers from the incoming request and convert to environment variables + const extractedEnv = { ...getDefaultEnvironment(), ...env }; + + // Use the saved local file instead of command + const executablePath = `./integrations/${slug}/main`; + + await TransportManager.addStdioIntegrationTransport( + sessionId, + account.id, + account.integrationDefinition.slug, + executablePath, + args, + extractedEnv, + ); } } catch (error) { failed.push({ diff --git a/apps/webapp/app/utils/mcp/transport-manager.ts b/apps/webapp/app/utils/mcp/transport-manager.ts index 25d1532..eacf2df 100644 --- a/apps/webapp/app/utils/mcp/transport-manager.ts +++ b/apps/webapp/app/utils/mcp/transport-manager.ts @@ -1,13 +1,14 @@ import { type StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; export interface IntegrationTransport { client: McpClient; - transport: StreamableHTTPClientTransport; + transport: StreamableHTTPClientTransport | StdioClientTransport; integrationAccountId: string; slug: string; - url: string; + url?: string; } export interface SessionTransports { @@ -102,6 +103,47 @@ export class TransportManager { return integrationTransport; } + static async addStdioIntegrationTransport( + sessionId: string, + integrationAccountId: string, + slug: string, + command: string, + args: string[], + env?: any, + ): Promise { + const session = this.getOrCreateSession(sessionId); + + const transport = new StdioClientTransport({ + command, + args: args || [], + env, + }); + + // Create MCP client + const client = new McpClient({ + name: `core-client-${slug}`, + version: "1.0.0", + }); + + // Connect client to transport + await client.connect(transport); + + const integrationTransport: IntegrationTransport = { + client, + transport, + integrationAccountId, + slug, + url: command, + }; + + session.integrationTransports.set( + integrationAccountId, + integrationTransport, + ); + + return integrationTransport; + } + /** * Get integration transport by account ID */ diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 34223f5..36d4be1 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -4,11 +4,11 @@ "sideEffects": false, "type": "module", "scripts": { - "build": "remix vite:build", - "dev": "node ./server.mjs", + "build": "remix vite:build && tsc server.ts --outDir ./ --module ESNext --moduleResolution bundler --target ES2022 --allowSyntheticDefaultImports --skipLibCheck", + "dev": "tsx watch server.ts", "lint": "eslint --fix --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "lint:fix": "eslint 'app/**/*.{ts,tsx,js,jsx}' --rule 'turbo/no-undeclared-env-vars:error' -f table", - "start": "remix-serve ./build/server/index.js", + "start": "node server.js", "typecheck": "tsc", "trigger:dev": "pnpm dlx trigger.dev@4.0.0-v4-beta.22 dev", "trigger:deploy": "pnpm dlx trigger.dev@4.0.0-v4-beta.22 deploy" @@ -175,7 +175,8 @@ "tailwindcss": "4.1.7", "typescript": "5.8.3", "vite": "^6.0.0", - "vite-tsconfig-paths": "^4.2.1" + "vite-tsconfig-paths": "^4.2.1", + "tsx": "4.20.4" }, "engines": { "node": ">=20.0.0" diff --git a/apps/webapp/server.mjs b/apps/webapp/server.ts similarity index 54% rename from apps/webapp/server.mjs rename to apps/webapp/server.ts index 5f4cdce..a22a20c 100644 --- a/apps/webapp/server.mjs +++ b/apps/webapp/server.ts @@ -3,7 +3,13 @@ import compression from "compression"; import express from "express"; import morgan from "morgan"; -let viteDevServer; +// import { +// handleMCPRequest, +// handleSessionRequest, +// } from "~/services/mcp.server"; +// import { authenticateHybridRequest } from "~/services/routeBuilders/apiBuilder.server"; + +let viteDevServer: any; let remixHandler; async function init() { @@ -14,10 +20,13 @@ async function init() { }); } - const build = viteDevServer + const build: any = viteDevServer ? () => viteDevServer.ssrLoadModule("virtual:remix/server-build") : await import("./build/server/index.js"); + const { authenticateHybridRequest, handleMCPRequest, handleSessionRequest } = + build.entry.module; + remixHandler = createRequestHandler({ build }); const app = express(); @@ -44,6 +53,68 @@ async function init() { app.use(morgan("tiny")); + app.get("/api/v1/mcp", async (req, res) => { + const authenticationResult = await authenticateHybridRequest(req as any, { + allowJWT: true, + }); + + if (!authenticationResult) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + await handleSessionRequest(req, res); + }); + + app.post("/api/v1/mcp", async (req, res) => { + const authenticationResult = await authenticateHybridRequest(req as any, { + allowJWT: true, + }); + + if (!authenticationResult) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + + req.on("end", async () => { + try { + const parsedBody = JSON.parse(body); + const queryParams = req.query; // Get query parameters from the request + await handleMCPRequest( + req, + res, + parsedBody, + authenticationResult, + queryParams, + ); + } catch (error) { + res.status(400).json({ error: "Invalid JSON" }); + } + }); + }); + + app.delete("/api/v1/mcp", async (req, res) => { + const authenticationResult = await authenticateHybridRequest(req as any, { + allowJWT: true, + }); + + if (!authenticationResult) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + await handleSessionRequest(req, res); + }); + + app.options("/api/v1/mcp", (_, res) => { + res.json({}); + }); + app.get("/.well-known/oauth-authorization-server", (req, res) => { res.json({ issuer: process.env.APP_ORIGIN, diff --git a/apps/webapp/tsconfig.json b/apps/webapp/tsconfig.json index 630a90c..9c897d6 100644 --- a/apps/webapp/tsconfig.json +++ b/apps/webapp/tsconfig.json @@ -8,7 +8,7 @@ "tailwind.config.js", "tailwind.config.js", "trigger.config.ts", - "server.mjs" + "server.ts" ], "compilerOptions": { "types": ["@remix-run/node", "vite/client"], diff --git a/docker/Dockerfile b/docker/Dockerfile index ec0ca39..8833971 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -64,7 +64,7 @@ ENV NODE_ENV production COPY --from=base /usr/bin/dumb-init /usr/bin/dumb-init COPY --from=pruner --chown=node:node /core/out/full/ . COPY --from=production-deps --chown=node:node /core . -COPY --from=builder --chown=node:node /core/apps/webapp/server.mjs ./apps/webapp/server.mjs +COPY --from=builder --chown=node:node /core/apps/webapp/server.js ./apps/webapp/server.js COPY --from=builder --chown=node:node /core/apps/webapp/build ./apps/webapp/build COPY --from=builder --chown=node:node /core/apps/webapp/public ./apps/webapp/public COPY --from=builder --chown=node:node /core/scripts ./scripts diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index f80b40c..969d2cf 100755 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -15,4 +15,4 @@ cp packages/database/prisma/schema.prisma apps/webapp/prisma/ cd /core/apps/webapp # exec dumb-init pnpm run start:local -NODE_PATH='/core/node_modules/.pnpm/node_modules' exec dumb-init node --max-old-space-size=8192 ./server.mjs +NODE_PATH='/core/node_modules/.pnpm/node_modules' exec dumb-init node --max-old-space-size=8192 ./server.js diff --git a/hosting/docker/.env b/hosting/docker/.env index 764d81d..4209078 100644 --- a/hosting/docker/.env +++ b/hosting/docker/.env @@ -1,4 +1,4 @@ -VERSION=0.1.17 +VERSION=0.1.18 # Nest run in docker, change host to database container name DB_HOST=postgres diff --git a/package.json b/package.json index 0ec6d1b..10e7868 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "core", "private": true, - "version": "0.1.17", + "version": "0.1.18", "workspaces": [ "apps/*", "packages/*" diff --git a/packages/mcp-proxy/src/core/mcp-remote-client.ts b/packages/mcp-proxy/src/core/mcp-remote-client.ts index 4d17d2b..cf35328 100644 --- a/packages/mcp-proxy/src/core/mcp-remote-client.ts +++ b/packages/mcp-proxy/src/core/mcp-remote-client.ts @@ -1,26 +1,10 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { - MCPRemoteClientConfig, - AuthenticationResult, - ProxyConnectionConfig, - CredentialLoadCallback, - MCPProxyFunction, - StoredCredentials, - TransportStrategy, -} from "../types/remote-client.js"; +import { MCPRemoteClientConfig, AuthenticationResult } from "../types/remote-client.js"; import { MCPAuthProxyError } from "../utils/errors.js"; import { NodeOAuthClientProvider } from "../lib/node-oauth-client-provider.js"; import { globalAuthStorage } from "../lib/in-memory-auth-storage.js"; import { getServerUrlHash } from "../lib/utils.js"; -import { RemixMCPTransport } from "../utils/mcp-transport.js"; -import { createMCPTransportBridge } from "../utils/mcp-transport-bridge.js"; -import { - createAuthProviderFromConfig, - createAuthProviderForProxy, -} from "../utils/auth-provider-factory.js"; +import { createAuthProviderFromConfig } from "../utils/auth-provider-factory.js"; /** * Creates an MCP authentication client that handles OAuth flow @@ -32,424 +16,10 @@ export function createMCPAuthClient(config: MCPRemoteClientConfig): MCPAuthentic return new MCPAuthenticationClient(config); } -/** - * Creates an MCP proxy that forwards requests to the remote MCP server - * Consolidates all proxy functionality into a single function - * @param config Configuration for the proxy connection - * @param onCredentialLoad Callback to load credentials from your database - * @returns Proxy function that can be used in your Remix API routes - */ -export function createMCPProxy( - config: ProxyConnectionConfig & { - /** Enable debug logging */ - debug?: boolean; - }, - onCredentialLoad: CredentialLoadCallback -): MCPProxyFunction { - return async (request: Request, userApiKey: string): Promise => { - return new Promise(async (resolve) => { - let bridge: any = null; - - try { - // Load credentials for this user and server - const credentials = await onCredentialLoad(userApiKey, config.serverUrl); - - if (!credentials) { - return resolve( - new Response( - JSON.stringify({ - error: "No credentials found for this service", - }), - { - status: 401, - headers: { "Content-Type": "application/json" }, - } - ) - ); - } - - // Check if tokens are expired - if (credentials.expiresAt && credentials.expiresAt < new Date()) { - return resolve( - new Response( - JSON.stringify({ - error: "Credentials expired - please re-authenticate", - }), - { - status: 401, - headers: { "Content-Type": "application/json" }, - } - ) - ); - } - - // Extract session ID and last event ID from incoming request - const clientSessionId = request.headers.get("Mcp-Session-Id"); - const lastEventId = request.headers.get("Last-Event-Id"); - - // Create remote transport (connects to the MCP server) FIRST - const serverTransport = await createRemoteTransport( - credentials.serverUrl, - credentials, - config.redirectUrl, - config.transportStrategy || "sse-first", - { sessionId: clientSessionId, lastEventId } // Pass both session and event IDs - ); - - // Start server transport and wait for connection - await serverTransport.start(); - - // Create Remix transport (converts HTTP to MCP messages) - const clientTransport = new RemixMCPTransport(request, resolve); - - // Bridge the transports - const bridgeOptions: any = { - debug: config.debug || false, - onError: (error: Error, source: string) => { - console.error(`[MCP Bridge] ${source} error:`, error); - }, - }; - - if (config.debug) { - bridgeOptions.onMessage = (direction: string, message: any) => { - console.log(`[MCP Bridge] ${direction}:`, message.method || message.id); - }; - } - - bridge = createMCPTransportBridge( - clientTransport as any, - serverTransport as any, - bridgeOptions - ); - - // Start only the client transport (server is already started) - await clientTransport.start(); - } catch (error) { - console.error("MCP Transport Proxy Error:", error); - - if (bridge) { - bridge.close().catch(console.error); - } - - const errorMessage = error instanceof Error ? error.message : String(error); - resolve( - new Response( - JSON.stringify({ - error: `Transport proxy error: ${errorMessage}`, - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - } - ) - ); - } - }); - }; - - // Helper function to create remote transport - async function createRemoteTransport( - serverUrl: string, - credentials: StoredCredentials, - redirectUrl: string, - transportStrategy: TransportStrategy = "sse-first", - clientHeaders?: { sessionId?: string | null; lastEventId?: string | null } - ): Promise { - // Create auth provider with stored credentials using common factory - const authProvider = await createAuthProviderForProxy(serverUrl, credentials, redirectUrl); - - const url = new URL(serverUrl); - const headers: Record = { - Authorization: `Bearer ${credentials.tokens.access_token}`, - "Content-Type": "application/json", - ...config.headers, - }; - - // Add session and event headers if provided - if (clientHeaders?.sessionId) { - headers["Mcp-Session-Id"] = clientHeaders.sessionId; - } - if (clientHeaders?.lastEventId) { - headers["Last-Event-Id"] = clientHeaders.lastEventId; - } - - // Create transport based on strategy (don't start yet) - let transport: SSEClientTransport | StreamableHTTPClientTransport | StdioClientTransport; - - switch (transportStrategy) { - case "stdio": - // For stdio transport, serverUrl should contain the command to execute - // This is mainly for completeness - prefer using createMCPStdioProxy directly - throw new Error( - "Stdio transport not supported in createRemoteTransport. Use createMCPStdioProxy instead." - ); - - case "sse-only": - transport = new SSEClientTransport(url, { - authProvider, - requestInit: { headers }, - }); - break; - - case "http-only": - transport = new StreamableHTTPClientTransport(url, { - requestInit: { headers }, - }); - break; - - case "sse-first": - // Try SSE first, fallback to HTTP on error - try { - transport = new SSEClientTransport(url, { - authProvider, - requestInit: { headers }, - }); - } catch (error) { - console.warn("SSE transport failed, falling back to HTTP:", error); - transport = new StreamableHTTPClientTransport(url, { - requestInit: { headers }, - }); - } - break; - - case "http-first": - // Try HTTP first, fallback to SSE on error - try { - transport = new StreamableHTTPClientTransport(url, { - requestInit: { headers }, - }); - } catch (error) { - console.warn("HTTP transport failed, falling back to SSE:", error); - transport = new SSEClientTransport(url, { - authProvider, - requestInit: { headers }, - }); - } - break; - - default: - throw new Error(`Unknown transport strategy: ${transportStrategy}`); - } - - return transport; - } -} - -/** - * Creates an MCP proxy that forwards requests to a stdio process. - * Maintains a mapping of sessionId -> StdioClientTransport for reuse. - * If sessionId is provided, it is returned in the response header as mcp_session_id. - * @param request The incoming HTTP request - * @param command The command to execute for the stdio process - * @param args Arguments for the command - * @param options Optional configuration for the proxy - * @param sessionId Optional session id for transport reuse - * @returns Promise that resolves to the HTTP response - */ -// Track both the transport and its last used timestamp -type StdioTransportEntry = { - transport: StdioClientTransport; - lastUsed: number; // ms since epoch -}; - -const stdioTransports: Map = new Map(); - -/** - * Cleans up any stdio transports that have not been used in the last 5 minutes. - * Closes and removes them from the map. - */ -function cleanupOldStdioTransports() { - const now = Date.now(); - const FIVE_MINUTES = 5 * 60 * 1000; - for (const [sessionId, entry] of stdioTransports.entries()) { - if (now - entry.lastUsed > FIVE_MINUTES) { - try { - entry.transport.close?.(); - } catch (err) { - // ignore - } - stdioTransports.delete(sessionId); - } - } -} - -export function createMCPStdioProxy( - request: Request, - command: string, - args?: string[], - options?: { - /** Enable debug logging */ - debug?: boolean; - /** Environment variables to pass to the process */ - env?: Record; - /** Custom header-to-environment variable mapping */ - headerMapping?: Record; - /** Optional session id for transport reuse */ - sessionId?: string; - } -): Promise { - return new Promise(async (resolve) => { - let bridge: any = null; - let serverTransport: StdioClientTransport | undefined; - let sessionId: string | undefined = - options?.sessionId || request.headers.get("Mcp-Session-Id") || undefined; - - // Clean up old transports before handling new connection - cleanupOldStdioTransports(); - - try { - // Extract headers from the incoming request and convert to environment variables - const env = createEnvironmentFromRequest( - request, - options?.env || {}, - options?.headerMapping || {} - ); - - // If sessionId is provided, try to reuse the transport - let entry: StdioTransportEntry | undefined; - if (sessionId) { - entry = stdioTransports.get(sessionId); - if (entry) { - serverTransport = entry.transport; - entry.lastUsed = Date.now(); - } - } - - // If no transport exists for this sessionId, create a new one and store it - if (!serverTransport) { - serverTransport = new StdioClientTransport({ - command, - args: args || [], - env, - }); - await serverTransport.start(); - if (sessionId) { - stdioTransports.set(sessionId, { - transport: serverTransport, - lastUsed: Date.now(), - }); - } - } - - // Create Remix transport (converts HTTP to MCP messages) - // We need to wrap resolve to inject the sessionId header if present - const resolveWithSessionId = (response: Response) => { - if (sessionId) { - // Clone the response and add the mcp_session_id header - const headers = new Headers(response.headers); - headers.set("mcp-session-id", sessionId); - resolve( - new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }) - ); - } else { - resolve(response); - } - }; - - const clientTransport = new RemixMCPTransport(request, resolveWithSessionId); - - // Bridge the transports - const bridgeOptions: any = { - debug: options?.debug || false, - onError: (error: Error, source: string) => { - console.error(`[MCP Stdio Bridge] ${source} error:`, error); - }, - }; - - if (options?.debug) { - bridgeOptions.onMessage = (direction: string, message: any) => { - console.log(`[MCP Stdio Bridge] ${direction}:`, message.method || message.id); - }; - } - - bridge = createMCPTransportBridge( - clientTransport as any, - serverTransport as any, - bridgeOptions - ); - - // Start only the client transport (server is already started) - await clientTransport.start(); - } catch (error) { - console.error("MCP Stdio Proxy Error:", error); - - if (bridge) { - bridge.close().catch(console.error); - } - - const errorMessage = error instanceof Error ? error.message : String(error); - // Always include mcp_session_id header if sessionId is present - const headers: Record = { "Content-Type": "application/json" }; - if (sessionId) { - headers["mcp-session-id"] = sessionId; - } - resolve( - new Response( - JSON.stringify({ - error: `Stdio proxy error: ${errorMessage}`, - }), - { - status: 500, - headers, - } - ) - ); - } - }); -} - -/** - * Creates environment variables from request headers - */ -function createEnvironmentFromRequest( - request: Request, - baseEnv: Record, - headerMapping: Record -): Record { - // Start with base environment (inherit safe environment variables) - const env: Record = { - ...getDefaultEnvironment(), - ...baseEnv, - }; - - // Add standard MCP headers as environment variables - const sessionId = request.headers.get("Mcp-Session-Id"); - const lastEventId = request.headers.get("Last-Event-Id"); - const contentType = request.headers.get("Content-Type"); - const userAgent = request.headers.get("User-Agent"); - - if (sessionId) { - env["MCP_SESSION_ID"] = sessionId; - } - if (lastEventId) { - env["MCP_LAST_EVENT_ID"] = lastEventId; - } - if (contentType) { - env["MCP_CONTENT_TYPE"] = contentType; - } - if (userAgent) { - env["MCP_USER_AGENT"] = userAgent; - } - - // Apply custom header-to-environment variable mapping - for (const [headerName, envVarName] of Object.entries(headerMapping)) { - const headerValue = request.headers.get(headerName); - if (headerValue) { - env[envVarName] = headerValue; - } - } - - return env; -} - /** * Returns a default environment object including only environment variables deemed safe to inherit. */ -function getDefaultEnvironment(): Record { +export function getDefaultEnvironment(): Record { const DEFAULT_INHERITED_ENV_VARS = process.platform === "win32" ? [ diff --git a/packages/mcp-proxy/src/index.ts b/packages/mcp-proxy/src/index.ts index 4a4e149..515fb73 100644 --- a/packages/mcp-proxy/src/index.ts +++ b/packages/mcp-proxy/src/index.ts @@ -4,8 +4,7 @@ export * from "./types/index.js"; // MCP Remote Client exports (new simplified interface) export { createMCPAuthClient, - createMCPProxy, - createMCPStdioProxy, + getDefaultEnvironment, MCPAuthenticationClient, } from "./core/mcp-remote-client.js"; diff --git a/packages/types/src/graph/graph.entity.ts b/packages/types/src/graph/graph.entity.ts index 369d7cd..1f72a79 100644 --- a/packages/types/src/graph/graph.entity.ts +++ b/packages/types/src/graph/graph.entity.ts @@ -54,7 +54,7 @@ export interface StatementNode { userId: string; space?: string; // Legacy field - deprecated in favor of spaceIds spaceIds?: string[]; // Array of space UUIDs this statement belongs to - recallCount?: number; + recallCount?: { low: number; high: number }; provenanceCount?: number; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03c945b..af5fa72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,7 +646,7 @@ importers: devDependencies: '@remix-run/dev': specifier: 2.16.7 - version: 2.16.7(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/serve@2.16.7(typescript@5.8.3))(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(typescript@5.8.3)(vite@6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0))(yaml@2.8.0) + version: 2.16.7(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/serve@2.16.7(typescript@5.8.3))(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0))(yaml@2.8.0) '@remix-run/eslint-config': specifier: 2.16.7 version: 2.16.7(eslint@8.57.1)(react@18.3.1)(typescript@5.8.3) @@ -661,7 +661,7 @@ importers: version: 0.5.16(tailwindcss@4.1.7) '@tailwindcss/vite': specifier: ^4.1.7 - version: 4.1.9(vite@6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0)) + version: 4.1.9(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0)) '@trigger.dev/build': specifier: 4.0.0-v4-beta.22 version: 4.0.0-v4-beta.22(typescript@5.8.3) @@ -752,15 +752,18 @@ importers: tailwindcss: specifier: 4.1.7 version: 4.1.7 + tsx: + specifier: 4.20.4 + version: 4.20.4 typescript: specifier: 5.8.3 version: 5.8.3 vite: specifier: ^6.0.0 - version: 6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0) + version: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0) vite-tsconfig-paths: specifier: ^4.2.1 - version: 4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0)) + version: 4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0)) packages/database: dependencies: @@ -829,7 +832,7 @@ importers: version: 20.19.7 tsup: specifier: ^8.0.1 - version: 8.5.0(@swc/core@1.3.101)(jiti@2.4.2)(postcss@8.5.5)(tsx@4.17.0)(typescript@5.8.3)(yaml@2.8.0) + version: 8.5.0(@swc/core@1.3.101(@swc/helpers@0.5.17))(jiti@2.4.2)(postcss@8.5.5)(tsx@4.20.4)(typescript@5.8.3)(yaml@2.8.0) typescript: specifier: ^5.0.0 version: 5.8.3 @@ -866,7 +869,7 @@ importers: version: 6.0.1 tsup: specifier: ^8.0.1 - version: 8.5.0(@swc/core@1.3.101)(jiti@2.4.2)(postcss@8.5.5)(tsx@4.17.0)(typescript@5.8.3)(yaml@2.8.0) + version: 8.5.0(@swc/core@1.3.101(@swc/helpers@0.5.17))(jiti@2.4.2)(postcss@8.5.5)(tsx@4.20.4)(typescript@5.8.3)(yaml@2.8.0) typescript: specifier: ^5.3.0 version: 5.8.3 @@ -10880,6 +10883,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.20.4: + resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==} + engines: {node: '>=18.0.0'} + hasBin: true + tty-table@4.2.3: resolution: {integrity: sha512-Fs15mu0vGzCrj8fmJNP7Ynxt5J7praPXqFN0leZeZBXJwkMxv9cb2D454k1ltrtUSJbZ4yH4e0CynsHLxmUfFA==} engines: {node: '>=8.0.0'} @@ -12911,7 +12919,7 @@ snapshots: dependencies: '@floating-ui/dom': 1.7.1 react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) '@floating-ui/react-dom@2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -13664,7 +13672,7 @@ snapshots: dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.47)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -13718,7 +13726,7 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.2.0) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -13746,7 +13754,7 @@ snapshots: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.47)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-slot': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -13879,7 +13887,7 @@ snapshots: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.47)(react@18.2.0) '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -13944,7 +13952,7 @@ snapshots: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.47)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14036,7 +14044,7 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.2.0) aria-hidden: 1.2.6 react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) react-remove-scroll: 2.5.7(@types/react@18.2.47)(react@18.2.0) optionalDependencies: '@types/react': 18.2.47 @@ -14078,7 +14086,7 @@ snapshots: '@radix-ui/react-use-size': 1.1.0(@types/react@18.2.47)(react@18.2.0) '@radix-ui/rect': 1.1.0 react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14113,7 +14121,7 @@ snapshots: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.47)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14141,7 +14149,7 @@ snapshots: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.2.0) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14167,7 +14175,7 @@ snapshots: dependencies: '@radix-ui/react-slot': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14193,7 +14201,7 @@ snapshots: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.47)(react@18.2.0) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14378,7 +14386,7 @@ snapshots: '@radix-ui/react-toggle': 1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.47)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14389,7 +14397,7 @@ snapshots: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.47)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14409,7 +14417,7 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.2.0) '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.47)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14561,7 +14569,7 @@ snapshots: dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.47)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) optionalDependencies: '@types/react': 18.2.47 '@types/react-dom': 18.2.18 @@ -14682,7 +14690,7 @@ snapshots: html-to-text: 9.0.5 js-beautify: 1.15.4 react: 18.3.1 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) react-promise-suspense: 0.3.4 '@react-email/row@0.0.7(react@18.3.1)': @@ -14716,7 +14724,7 @@ snapshots: transitivePeerDependencies: - encoding - '@remix-run/dev@2.16.7(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/serve@2.16.7(typescript@5.8.3))(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(typescript@5.8.3)(vite@6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0))(yaml@2.8.0)': + '@remix-run/dev@2.16.7(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/serve@2.16.7(typescript@5.8.3))(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0))(yaml@2.8.0)': dependencies: '@babel/core': 7.27.4 '@babel/generator': 7.27.5 @@ -14733,7 +14741,7 @@ snapshots: '@remix-run/router': 1.23.0 '@remix-run/server-runtime': 2.16.7(typescript@5.8.3) '@types/mdx': 2.0.13 - '@vanilla-extract/integration': 6.5.0(@types/node@22.16.0)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0) + '@vanilla-extract/integration': 6.5.0(@types/node@20.19.7)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0) arg: 5.0.2 cacache: 17.1.4 chalk: 4.1.2 @@ -14773,12 +14781,12 @@ snapshots: tar-fs: 2.1.3 tsconfig-paths: 4.2.0 valibot: 0.41.0(typescript@5.8.3) - vite-node: 3.2.3(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0) + vite-node: 3.2.3(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0) ws: 7.5.10 optionalDependencies: '@remix-run/serve': 2.16.7(typescript@5.8.3) typescript: 5.8.3 - vite: 6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0) + vite: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -15517,12 +15525,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.7 - '@tailwindcss/vite@4.1.9(vite@6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0))': + '@tailwindcss/vite@4.1.9(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0))': dependencies: '@tailwindcss/node': 4.1.9 '@tailwindcss/oxide': 4.1.9 tailwindcss: 4.1.9 - vite: 6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0) + vite: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0) '@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -16531,7 +16539,7 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - '@vanilla-extract/integration@6.5.0(@types/node@22.16.0)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)': + '@vanilla-extract/integration@6.5.0(@types/node@20.19.7)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)': dependencies: '@babel/core': 7.27.4 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.4) @@ -16544,8 +16552,8 @@ snapshots: lodash: 4.17.21 mlly: 1.7.4 outdent: 0.8.0 - vite: 5.4.19(@types/node@22.16.0)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0) - vite-node: 1.6.1(@types/node@22.16.0)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0) + vite: 5.4.19(@types/node@20.19.7)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0) + vite-node: 1.6.1(@types/node@20.19.7)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -18904,7 +18912,7 @@ snapshots: optionalDependencies: '@emotion/is-prop-valid': 0.8.8 react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) framework-utils@1.1.0: {} @@ -20754,7 +20762,7 @@ snapshots: graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) styled-jsx: 5.1.1(@babel/core@7.24.5)(react@18.2.0) optionalDependencies: '@next/swc-darwin-arm64': 14.1.4 @@ -21378,13 +21386,13 @@ snapshots: optionalDependencies: postcss: 8.5.5 - postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.5)(tsx@4.17.0)(yaml@2.8.0): + postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.5)(tsx@4.20.4)(yaml@2.8.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.4.2 postcss: 8.5.5 - tsx: 4.17.0 + tsx: 4.20.4 yaml: 2.8.0 postcss-loader@8.1.1(postcss@8.5.5)(typescript@5.8.3)(webpack@5.99.9(esbuild@0.25.5)): @@ -21808,6 +21816,12 @@ snapshots: react: 18.2.0 scheduler: 0.23.2 + react-dom@18.2.0(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -21847,7 +21861,7 @@ snapshots: postcss: 8.4.38 prism-react-renderer: 2.1.0(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) socket.io: 4.7.3 socket.io-client: 4.7.3 sonner: 1.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -22650,7 +22664,7 @@ snapshots: sonner@1.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.2.0(react@18.3.1) source-map-js@1.0.2: {} @@ -23177,7 +23191,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.0(@swc/core@1.3.101)(jiti@2.4.2)(postcss@8.5.5)(tsx@4.17.0)(typescript@5.8.3)(yaml@2.8.0): + tsup@8.5.0(@swc/core@1.3.101(@swc/helpers@0.5.17))(jiti@2.4.2)(postcss@8.5.5)(tsx@4.20.4)(typescript@5.8.3)(yaml@2.8.0): dependencies: bundle-require: 5.1.0(esbuild@0.25.5) cac: 6.7.14 @@ -23188,7 +23202,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.5)(tsx@4.17.0)(yaml@2.8.0) + postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.5)(tsx@4.20.4)(yaml@2.8.0) resolve-from: 5.0.0 rollup: 4.43.0 source-map: 0.8.0-beta.0 @@ -23218,6 +23232,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.20.4: + dependencies: + esbuild: 0.25.5 + get-tsconfig: 4.10.1 + optionalDependencies: + fsevents: 2.3.3 + tty-table@4.2.3: dependencies: chalk: 4.1.2 @@ -23580,13 +23601,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-node@1.6.1(@types/node@22.16.0)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0): + vite-node@1.6.1(@types/node@20.19.7)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@10.0.0) pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.19(@types/node@22.16.0)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0) + vite: 5.4.19(@types/node@20.19.7)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0) transitivePeerDependencies: - '@types/node' - less @@ -23598,13 +23619,13 @@ snapshots: - supports-color - terser - vite-node@3.2.3(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0): + vite-node@3.2.3(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@10.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0) + vite: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' - jiti @@ -23619,31 +23640,31 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0)): + vite-tsconfig-paths@4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0)): dependencies: debug: 4.4.1(supports-color@10.0.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.8.3) optionalDependencies: - vite: 6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0) + vite: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0) transitivePeerDependencies: - supports-color - typescript - vite@5.4.19(@types/node@22.16.0)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0): + vite@5.4.19(@types/node@20.19.7)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0): dependencies: esbuild: 0.21.5 postcss: 8.5.5 rollup: 4.43.0 optionalDependencies: - '@types/node': 22.16.0 + '@types/node': 20.19.7 fsevents: 2.3.3 less: 4.4.0 lightningcss: 1.30.1 sass: 1.89.2 terser: 5.42.0 - vite@6.3.5(@types/node@22.16.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0): + vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.20.4)(yaml@2.8.0): dependencies: esbuild: 0.25.5 fdir: 6.4.6(picomatch@4.0.2) @@ -23652,14 +23673,14 @@ snapshots: rollup: 4.43.0 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 22.16.0 + '@types/node': 20.19.7 fsevents: 2.3.3 jiti: 2.4.2 less: 4.4.0 lightningcss: 1.30.1 sass: 1.89.2 terser: 5.42.0 - tsx: 4.17.0 + tsx: 4.20.4 yaml: 2.8.0 w3c-keyname@2.2.8: {}