Feat: clusters (#37)

* Feat: clustering fact statements

* Feat: cluster drift

* Feat: add recall count and model to search

* Feat: Github integration

* Fix: clustering UI

* Improve graph

* Bump: new version

---------

Co-authored-by: Manoj K <saimanoj58@gmail.com>
This commit is contained in:
Harshith Mullapudi 2025-08-05 15:31:15 +05:30 committed by GitHub
parent 2a6acaf899
commit 4882f227d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 9519 additions and 588 deletions

View File

@ -1,4 +1,4 @@
VERSION=0.1.13
VERSION=0.1.14
# Nest run in docker, change host to database container name
DB_HOST=localhost

View File

@ -62,7 +62,6 @@
"clean": "rimraf dist .tshy .tshy-build .turbo",
"typecheck": "tsc -p tsconfig.src.json --noEmit",
"build": "tshy",
"dev": "tshy --watch",
"test": "vitest",
"test:e2e": "vitest --run -c ./e2e/vitest.config.ts"
},

View File

@ -2,7 +2,7 @@ import { z } from "zod";
const EnvironmentSchema = z.object({
// Version
VERSION: z.string().default("0.1.13"),
VERSION: z.string().default("0.1.14"),
// Database
DB_HOST: z.string().default("localhost"),

View File

@ -0,0 +1,71 @@
import React, { useMemo } from "react";
import CalendarHeatmap from "react-calendar-heatmap";
import { cn } from "~/lib/utils";
interface ContributionGraphProps {
data: Array<{
date: string;
count: number;
status?: string;
}>;
className?: string;
}
export function ContributionGraph({ data, className }: ContributionGraphProps) {
const processedData = useMemo(() => {
const endDate = new Date();
const startDate = new Date();
startDate.setFullYear(endDate.getFullYear() - 1);
return data.map((item) => ({
date: item.date,
count: item.count,
status: item.status,
}));
}, [data]);
const getClassForValue = (value: any) => {
if (!value || value.count === 0) {
return "fill-background dark:fill-background";
}
const count = value.count;
if (count >= 20) return "fill-success";
if (count >= 15) return "fill-success/85";
if (count >= 10) return "fill-success/70";
if (count >= 5) return "fill-success/50";
return "fill-success/30";
};
const getTitleForValue = (value: any) => {
if (!value || value.count === 0) {
return `No activity on ${value?.date || "this date"}`;
}
const count = value.count;
const date = new Date(value.date).toLocaleDateString();
return `${count} ${count === 1 ? "activity" : "activities"} on ${date}`;
};
const endDate = new Date();
const startDate = new Date();
startDate.setFullYear(endDate.getFullYear() - 1);
return (
<div className={cn("flex w-full flex-col justify-center", className)}>
<div className="overflow-x-auto rounded-lg">
<CalendarHeatmap
startDate={startDate}
endDate={endDate}
values={processedData}
classForValue={getClassForValue}
titleForValue={getTitleForValue}
showWeekdayLabels={true}
showMonthLabels={true}
gutterSize={2}
horizontal={true}
/>
</div>
</div>
);
}

View File

@ -1,15 +1,21 @@
import { GraphClusteringProps } from "./graph-clustering";
import { type GraphClusteringVisualizationProps } from "./graph-clustering-visualization";
import { type GraphVisualizationProps } from "./graph-visualization";
import { useState, useEffect } from "react";
export function GraphVisualizationClient(props: GraphVisualizationProps) {
export function GraphVisualizationClient(
props: GraphClusteringVisualizationProps,
) {
const [Component, setComponent] = useState<any>(undefined);
useEffect(() => {
if (typeof window === "undefined") return;
import("./graph-visualization").then(({ GraphVisualization }) => {
setComponent(GraphVisualization);
});
import("./graph-clustering-visualization").then(
({ GraphClusteringVisualization }) => {
setComponent(GraphClusteringVisualization);
},
);
}, []);
if (!Component) {

View File

@ -0,0 +1,271 @@
import { useState, useMemo, forwardRef, useEffect } from "react";
import { useTheme } from "remix-themes";
import { GraphClustering, type GraphClusteringRef } from "./graph-clustering";
import { GraphPopovers } from "./graph-popover";
import type { RawTriplet, NodePopupContent, EdgePopupContent } from "./type";
import { Card, CardContent } from "~/components/ui/card";
import { createLabelColorMap, nodeColorPalette } from "./node-colors";
import { toGraphTriplets } from "./utils";
import { cn } from "~/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
interface ClusterData {
uuid: string;
name: string;
description?: string;
size: number;
cohesionScore?: number;
aspectType?: "thematic" | "social" | "activity";
}
export interface GraphClusteringVisualizationProps {
triplets: RawTriplet[];
clusters: ClusterData[];
width?: number;
height?: number;
zoomOnMount?: boolean;
className?: string;
selectedClusterId?: string | null;
onClusterSelect?: (clusterId: string | null) => void;
}
export const GraphClusteringVisualization = forwardRef<
GraphClusteringRef,
GraphClusteringVisualizationProps
>(
(
{
triplets,
clusters,
width = window.innerWidth * 0.85,
height = window.innerHeight * 0.85,
zoomOnMount = true,
className = "rounded-md h-full overflow-hidden relative",
selectedClusterId,
onClusterSelect,
},
ref,
) => {
const [themeMode] = useTheme();
// Graph state for popovers
const [showNodePopup, setShowNodePopup] = useState<boolean>(false);
const [showEdgePopup, setShowEdgePopup] = useState<boolean>(false);
const [nodePopupContent, setNodePopupContent] =
useState<NodePopupContent | null>(null);
const [edgePopupContent, setEdgePopupContent] =
useState<EdgePopupContent | null>(null);
// Filter triplets based on selected cluster (like Marvel's comic filter)
const filteredTriplets = useMemo(() => {
if (!selectedClusterId) return triplets;
// Filter triplets to show only nodes from the selected cluster
return triplets.filter(
(triplet) =>
triplet.sourceNode.attributes?.clusterId === selectedClusterId ||
triplet.targetNode.attributes?.clusterId === selectedClusterId,
);
}, [triplets, selectedClusterId]);
// Convert filtered triplets to graph triplets
const graphTriplets = useMemo(
() => toGraphTriplets(filteredTriplets),
[filteredTriplets],
);
// Extract all unique labels from triplets
const allLabels = useMemo(() => {
const labels = new Set<string>();
labels.add("Entity"); // Always include Entity as default
graphTriplets.forEach((triplet) => {
if (triplet.source.primaryLabel)
labels.add(triplet.source.primaryLabel);
if (triplet.target.primaryLabel)
labels.add(triplet.target.primaryLabel);
});
return Array.from(labels).sort((a, b) => {
// Always put "Entity" first
if (a === "Entity") return -1;
if (b === "Entity") return 1;
// Sort others alphabetically
return a.localeCompare(b);
});
}, [graphTriplets]);
// Create a shared label color map
const sharedLabelColorMap = useMemo(() => {
return createLabelColorMap(allLabels);
}, [allLabels]);
// Handle node click
const handleNodeClick = (nodeId: string) => {
// Find the triplet that contains this node by searching through graphTriplets
let foundNode = null;
for (const triplet of filteredTriplets) {
if (triplet.sourceNode.uuid === nodeId) {
foundNode = triplet.sourceNode;
break;
} else if (triplet.targetNode.uuid === nodeId) {
foundNode = triplet.targetNode;
break;
}
}
if (!foundNode) {
// Try to find in the converted graph triplets
for (const graphTriplet of graphTriplets) {
if (graphTriplet.source.id === nodeId) {
foundNode = {
uuid: graphTriplet.source.id,
value: graphTriplet.source.value,
primaryLabel: graphTriplet.source.primaryLabel,
attributes: graphTriplet.source,
} as any;
break;
} else if (graphTriplet.target.id === nodeId) {
foundNode = {
uuid: graphTriplet.target.id,
value: graphTriplet.target.value,
primaryLabel: graphTriplet.target.primaryLabel,
attributes: graphTriplet.target,
};
break;
}
}
}
if (!foundNode) return;
// Set popup content and show the popup
setNodePopupContent({
id: nodeId,
node: foundNode,
});
setShowNodePopup(true);
setShowEdgePopup(false);
};
// Handle edge click
const handleEdgeClick = (edgeId: string) => {
// Find the triplet that contains this edge
const triplet = triplets.find((t) => t.edge.uuid === edgeId);
if (!triplet) return;
// Set popup content and show the popup
setEdgePopupContent({
id: edgeId,
source: triplet.sourceNode,
target: triplet.targetNode,
relation: triplet.edge,
});
setShowEdgePopup(true);
setShowNodePopup(false);
};
// Handle cluster click - toggle filter like Marvel
const handleClusterClick = (clusterId: string) => {
if (onClusterSelect) {
const newSelection = selectedClusterId === clusterId ? null : clusterId;
onClusterSelect(newSelection);
}
};
// Handle popover close
const handlePopoverClose = () => {
setShowNodePopup(false);
setShowEdgePopup(false);
};
return (
<div className={cn("flex flex-col gap-4", className)}>
{/* Cluster Filter Dropdown - Marvel style */}
<div>
<Card className="bg-transparent">
<CardContent className="bg-transparent p-1">
<div className="flex items-center gap-2">
<Select
value={selectedClusterId || ""}
onValueChange={(value) =>
value === "all_clusters"
? onClusterSelect?.("")
: onClusterSelect?.(value || null)
}
>
<SelectTrigger
className="bg-background w-48 rounded px-2 py-1 text-sm"
showIcon
>
<SelectValue placeholder="All Clusters" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all_clusters">All Clusters</SelectItem>
{clusters.map((cluster, index) => {
// Get cluster color from the same palette used in the graph
const palette = themeMode === "dark" ? nodeColorPalette.dark : nodeColorPalette.light;
const clusterColor = palette[index % palette.length];
return (
<SelectItem key={cluster.uuid} value={cluster.uuid}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: clusterColor }}
/>
<span>{cluster.name}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
{filteredTriplets.length > 0 ? (
<GraphClustering
ref={ref}
triplets={graphTriplets}
clusters={clusters}
width={width}
height={height}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onClusterClick={handleClusterClick}
onBlur={handlePopoverClose}
zoomOnMount={zoomOnMount}
labelColorMap={sharedLabelColorMap}
showClusterLabels={!selectedClusterId} // Show cluster labels when not filtering
enableClusterColors={true} // Always enable cluster colors
/>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No graph data to visualize.</p>
</div>
)}
{/* Standard Graph Popovers */}
<GraphPopovers
showNodePopup={showNodePopup}
showEdgePopup={showEdgePopup}
nodePopupContent={nodePopupContent}
edgePopupContent={edgePopupContent}
onOpenChange={handlePopoverClose}
labelColorMap={sharedLabelColorMap}
/>
</div>
);
},
);

View File

@ -0,0 +1,851 @@
import {
useEffect,
useRef,
useMemo,
useCallback,
useImperativeHandle,
forwardRef,
} from "react";
import Sigma from "sigma";
import GraphologyGraph from "graphology";
import forceAtlas2 from "graphology-layout-forceatlas2";
import FA2Layout from "graphology-layout-forceatlas2/worker";
import { EdgeLineProgram } from "sigma/rendering";
import colors from "tailwindcss/colors";
import type { GraphTriplet, IdValue, GraphNode } from "./type";
import {
createLabelColorMap,
getNodeColor as getNodeColorByLabel,
nodeColorPalette,
} from "./node-colors";
import { useTheme } from "remix-themes";
import { drawHover } from "./utils";
interface ClusterData {
uuid: string;
name: string;
description?: string;
size: number;
cohesionScore?: number;
}
export interface GraphClusteringProps {
triplets: GraphTriplet[];
clusters: ClusterData[];
width?: number;
height?: number;
zoomOnMount?: boolean;
onNodeClick?: (nodeId: string) => void;
onEdgeClick?: (edgeId: string) => void;
onClusterClick?: (clusterId: string) => void;
onBlur?: () => void;
labelColorMap?: Map<string, number>;
showClusterLabels?: boolean;
enableClusterColors?: boolean;
}
export interface GraphClusteringRef {
zoomToLinkById: (linkId: string) => void;
zoomToCluster: (clusterId: string) => void;
highlightCluster: (clusterId: string) => void;
resetHighlights: () => void;
}
// Use node-colors palette for cluster colors
const generateClusterColors = (
clusterCount: number,
isDarkMode: boolean,
): string[] => {
const palette = isDarkMode ? nodeColorPalette.dark : nodeColorPalette.light;
const colors: string[] = [];
for (let i = 0; i < clusterCount; i++) {
colors.push(palette[i % palette.length]);
}
return colors;
};
export const GraphClustering = forwardRef<
GraphClusteringRef,
GraphClusteringProps
>(
(
{
triplets,
clusters,
width = 1000,
height = 800,
zoomOnMount = false,
onNodeClick,
onEdgeClick,
onClusterClick,
onBlur,
labelColorMap: externalLabelColorMap,
showClusterLabels = true,
enableClusterColors = true,
},
ref,
) => {
const containerRef = useRef<HTMLDivElement>(null);
const sigmaRef = useRef<Sigma | null>(null);
const graphRef = useRef<GraphologyGraph | null>(null);
const clustersLayerRef = useRef<HTMLDivElement | null>(null);
const [themeMode] = useTheme();
const isInitializedRef = useRef(false);
const selectedNodeRef = useRef<string | null>(null);
const selectedEdgeRef = useRef<string | null>(null);
const selectedClusterRef = useRef<string | null>(null);
// Create cluster color mapping
const clusterColorMap = useMemo(() => {
if (!enableClusterColors) return new Map();
const clusterIds = clusters.map((c) => c.uuid);
const clusterColors = generateClusterColors(
clusterIds.length,
themeMode === "dark",
);
const colorMap = new Map<string, string>();
clusterIds.forEach((id, index) => {
colorMap.set(id, clusterColors[index]);
});
return colorMap;
}, [clusters, enableClusterColors, themeMode]);
// Memoize theme to prevent unnecessary recreation
const theme = useMemo(
() => ({
node: {
fill: colors.pink[500],
stroke: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
hover: "#646464",
text: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
selected: "#646464",
dimmed: colors.pink[300],
},
link: {
stroke: colors.gray[400],
selected: "#646464",
dimmed: themeMode === "dark" ? colors.slate[800] : colors.slate[200],
},
cluster: {
labelColor:
themeMode === "dark" ? colors.slate[100] : colors.slate[900],
labelBg:
themeMode === "dark"
? colors.slate[800] + "CC"
: colors.slate[200] + "CC",
},
background:
themeMode === "dark" ? colors.slate[900] : colors.slate[100],
}),
[themeMode],
);
// Extract all unique labels from triplets
const allLabels = useMemo(() => {
if (externalLabelColorMap) return [];
const labels = new Set<string>();
labels.add("Entity");
triplets.forEach((triplet) => {
if (triplet.source.primaryLabel)
labels.add(triplet.source.primaryLabel);
if (triplet.target.primaryLabel)
labels.add(triplet.target.primaryLabel);
});
return Array.from(labels);
}, [triplets, externalLabelColorMap]);
// Create a mapping of label to color
const labelColorMap = useMemo(() => {
return externalLabelColorMap || createLabelColorMap(allLabels);
}, [allLabels, externalLabelColorMap]);
// Create a mapping of node IDs to their data
const nodeDataMap = useMemo(() => {
const result = new Map<string, GraphNode>();
triplets.forEach((triplet) => {
result.set(triplet.source.id, triplet.source);
result.set(triplet.target.id, triplet.target);
});
return result;
}, [triplets]);
// Function to get node color (with cluster coloring support)
const getNodeColor = useCallback(
(node: any): string => {
if (!node) {
return getNodeColorByLabel(null, themeMode === "dark", labelColorMap);
}
const nodeData = nodeDataMap.get(node.id) || node;
// Check if this is a Statement node
const isStatementNode =
nodeData.attributes.nodeType === "Statement" ||
(nodeData.labels && nodeData.labels.includes("Statement"));
if (isStatementNode) {
// Statement nodes with cluster IDs use cluster colors
if (
enableClusterColors &&
nodeData.clusterId &&
clusterColorMap.has(nodeData.clusterId)
) {
return clusterColorMap.get(nodeData.clusterId)!;
}
// Unclustered statement nodes use a specific light color
return themeMode === "dark" ? "#2b9684" : "#54935b"; // Teal/Green from palette
}
// Entity nodes use light gray
return themeMode === "dark" ? "#6B7280" : "#9CA3AF"; // Tailwind gray-500/gray-400
},
[
labelColorMap,
nodeDataMap,
themeMode,
enableClusterColors,
clusterColorMap,
],
);
// Process graph data for Sigma
const { nodes, edges } = useMemo(() => {
const nodeMap = new Map<string, any>();
triplets.forEach((triplet) => {
if (!nodeMap.has(triplet.source.id)) {
const nodeColor = getNodeColor(triplet.source);
const isStatementNode =
triplet.source.attributes?.nodeType === "Statement" ||
(triplet.source.labels &&
triplet.source.labels.includes("Statement"));
nodeMap.set(triplet.source.id, {
id: triplet.source.id,
label: triplet.source.value
? triplet.source.value.split(/\s+/).slice(0, 4).join(" ") +
(triplet.source.value.split(/\s+/).length > 4 ? " ..." : "")
: "",
size: isStatementNode ? 4 : 2, // Statement nodes slightly larger
color: nodeColor,
x: width,
y: height,
nodeData: triplet.source,
clusterId: triplet.source.clusterId,
// Enhanced border for visual appeal, thicker for Statement nodes
borderSize: 1,
borderColor: nodeColor,
});
}
if (!nodeMap.has(triplet.target.id)) {
const nodeColor = getNodeColor(triplet.target);
const isStatementNode =
triplet.target.attributes?.nodeType === "Statement" ||
(triplet.target.labels &&
triplet.target.labels.includes("Statement"));
nodeMap.set(triplet.target.id, {
id: triplet.target.id,
label: triplet.target.value
? triplet.target.value.split(/\s+/).slice(0, 4).join(" ") +
(triplet.target.value.split(/\s+/).length > 4 ? " ..." : "")
: "",
size: isStatementNode ? 4 : 2, // Statement nodes slightly larger
color: nodeColor,
x: width,
y: height,
nodeData: triplet.target,
clusterId: triplet.target.clusterId,
// Enhanced border for visual appeal, thicker for Statement nodes
borderSize: 1,
borderColor: nodeColor,
});
}
});
const linkGroups = triplets.reduce(
(groups, triplet) => {
if (triplet.relation.type === "_isolated_node_") {
return groups;
}
let key = `${triplet.source.id}-${triplet.target.id}`;
const reverseKey = `${triplet.target.id}-${triplet.source.id}`;
if (groups[reverseKey]) {
key = reverseKey;
}
if (!groups[key]) {
groups[key] = {
id: key,
source: triplet.source.id,
target: triplet.target.id,
relations: [],
relationData: [],
label: "",
color: "#0000001A",
labelColor: "#0000001A",
size: 1,
};
}
groups[key].relations.push(triplet.relation.value);
groups[key].relationData.push(triplet.relation);
return groups;
},
{} as Record<string, any>,
);
return {
nodes: Array.from(nodeMap.values()),
edges: Object.values(linkGroups),
};
}, [triplets, getNodeColor, width, height]);
// Helper function to reset highlights without affecting camera
const resetHighlights = useCallback(() => {
if (!graphRef.current || !sigmaRef.current) return;
const graph = graphRef.current;
const sigma = sigmaRef.current;
// Store camera state before making changes
const camera = sigma.getCamera();
const currentState = camera.getState();
graph.forEachNode((node) => {
const nodeData = graph.getNodeAttribute(node, "nodeData");
const originalColor = getNodeColor(nodeData);
const isStatementNode =
nodeData?.attributes.nodeType === "Statement" ||
(nodeData?.labels && nodeData.labels.includes("Statement"));
graph.setNodeAttribute(node, "highlighted", false);
graph.setNodeAttribute(node, "color", originalColor);
graph.setNodeAttribute(node, "size", isStatementNode ? 4 : 2);
graph.setNodeAttribute(node, "zIndex", 1);
});
graph.forEachEdge((edge) => {
graph.setEdgeAttribute(edge, "highlighted", false);
graph.setEdgeAttribute(edge, "color", "#0000001A");
graph.setEdgeAttribute(edge, "size", 1);
});
// Restore camera state to prevent unwanted movements
camera.setState(currentState);
selectedNodeRef.current = null;
selectedEdgeRef.current = null;
selectedClusterRef.current = null;
}, [getNodeColor]);
// Highlight entire cluster
const highlightCluster = useCallback(
(clusterId: string) => {
if (!graphRef.current || !sigmaRef.current) return;
const graph = graphRef.current;
const sigma = sigmaRef.current;
resetHighlights();
selectedClusterRef.current = clusterId;
const clusterNodes: string[] = [];
const clusterColor =
clusterColorMap.get(clusterId) || theme.node.selected;
// Find all nodes in the cluster
graph.forEachNode((nodeId, attributes) => {
if (attributes.clusterId === clusterId) {
clusterNodes.push(nodeId);
graph.setNodeAttribute(nodeId, "highlighted", true);
graph.setNodeAttribute(nodeId, "color", clusterColor);
graph.setNodeAttribute(nodeId, "size", attributes.size * 1.75);
graph.setNodeAttribute(nodeId, "zIndex", 2);
} else {
// Dim other nodes
graph.setNodeAttribute(nodeId, "color", theme.node.dimmed);
graph.setNodeAttribute(nodeId, "size", attributes.size * 0.7);
graph.setNodeAttribute(nodeId, "zIndex", 0);
}
});
// Highlight edges within the cluster
graph.forEachEdge((edgeId, attributes, source, target) => {
const sourceInCluster = clusterNodes.includes(source);
const targetInCluster = clusterNodes.includes(target);
if (sourceInCluster && targetInCluster) {
graph.setEdgeAttribute(edgeId, "highlighted", true);
graph.setEdgeAttribute(edgeId, "color", clusterColor);
graph.setEdgeAttribute(edgeId, "size", 3);
} else {
graph.setEdgeAttribute(edgeId, "color", theme.link.dimmed);
graph.setEdgeAttribute(edgeId, "size", 1);
}
});
},
[graphRef, sigmaRef, clusterColorMap, theme, resetHighlights],
);
// Zoom to cluster
const zoomToCluster = useCallback(
(clusterId: string) => {
if (!graphRef.current || !sigmaRef.current) return;
const graph = graphRef.current;
const sigma = sigmaRef.current;
const clusterNodes: string[] = [];
// Find all nodes in the cluster
graph.forEachNode((nodeId, attributes) => {
if (attributes.clusterId === clusterId) {
clusterNodes.push(nodeId);
}
});
if (clusterNodes.length === 0) return;
// Calculate bounding box of cluster nodes
let minX = Infinity,
maxX = -Infinity;
let minY = Infinity,
maxY = -Infinity;
clusterNodes.forEach((nodeId) => {
const pos = sigma.getNodeDisplayData(nodeId);
if (pos) {
minX = Math.min(minX, pos.x);
maxX = Math.max(maxX, pos.x);
minY = Math.min(minY, pos.y);
maxY = Math.max(maxY, pos.y);
}
});
// Calculate center and zoom level
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const containerRect = containerRef.current?.getBoundingClientRect();
if (containerRect) {
const padding = 100;
const clusterWidth = maxX - minX + padding;
const clusterHeight = maxY - minY + padding;
const ratio = Math.min(
containerRect.width / clusterWidth,
containerRect.height / clusterHeight,
2.0, // Maximum zoom
);
sigma
.getCamera()
.animate({ x: centerX, y: centerY, ratio }, { duration: 500 });
}
highlightCluster(clusterId);
},
[highlightCluster],
);
// Expose methods via ref
useImperativeHandle(ref, () => ({
zoomToLinkById: (linkId: string) => {
// Implementation similar to original graph component
if (!sigmaRef.current || !graphRef.current) return;
// ... existing zoomToLinkById logic
},
zoomToCluster,
highlightCluster,
resetHighlights,
}));
// Calculate optimal ForceAtlas2 parameters based on graph properties
const calculateOptimalParameters = useCallback((graph: GraphologyGraph) => {
const nodeCount = graph.order;
const edgeCount = graph.size;
if (nodeCount === 0)
return { scalingRatio: 30, gravity: 5, iterations: 600 };
// Similar logic to original implementation
const maxPossibleEdges = (nodeCount * (nodeCount - 1)) / 2;
const density = maxPossibleEdges > 0 ? edgeCount / maxPossibleEdges : 0;
let scalingRatio: number;
if (nodeCount < 10) {
scalingRatio = 15;
} else if (nodeCount < 50) {
scalingRatio = 20 + (nodeCount - 10) * 0.5;
} else if (nodeCount < 200) {
scalingRatio = 40 + (nodeCount - 50) * 0.2;
} else {
scalingRatio = Math.min(80, 70 + (nodeCount - 200) * 0.05);
}
let gravity: number;
if (density > 0.3) {
gravity = 1 + density * 2;
} else if (density > 0.1) {
gravity = 3 + density * 5;
} else {
gravity = Math.min(8, 5 + (1 - density) * 3);
}
if (nodeCount < 20) {
gravity *= 1.5;
} else if (nodeCount > 100) {
gravity *= 0.8;
}
const complexity = nodeCount + edgeCount;
let durationSeconds: number;
if (complexity < 50) {
durationSeconds = 1.5;
} else if (complexity < 200) {
durationSeconds = 2.5;
} else if (complexity < 500) {
durationSeconds = 3.5;
} else {
durationSeconds = Math.min(6, 4 + (complexity - 500) * 0.004);
}
return {
scalingRatio: Math.round(scalingRatio * 10) / 10,
gravity: Math.round(gravity * 10) / 10,
duration: Math.round(durationSeconds * 100) / 100, // in seconds
};
}, []);
useEffect(() => {
if (isInitializedRef.current || !containerRef.current) return;
isInitializedRef.current = true;
// Create graphology graph
const graph = new GraphologyGraph();
graphRef.current = graph;
// Add nodes
nodes.forEach((node) => {
graph.addNode(node.id, node);
});
// Add edges
edges.forEach((edge) => {
if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
graph.addEdge(edge.source, edge.target, { ...edge });
}
});
// No virtual edges - let the natural graph structure determine layout
// Apply layout
if (graph.order > 0) {
// Strong cluster-based positioning for Statement nodes only
const clusterNodeMap = new Map<string, string[]>();
const entityNodes: string[] = [];
// Group Statement nodes by their cluster ID, separate Entity nodes
graph.forEachNode((nodeId, attributes) => {
const isStatementNode =
attributes.nodeData?.nodeType === "Statement" ||
(attributes.nodeData?.labels &&
attributes.nodeData.labels.includes("Statement"));
if (isStatementNode && attributes.clusterId) {
// Statement nodes with cluster IDs go into clusters
if (!clusterNodeMap.has(attributes.clusterId)) {
clusterNodeMap.set(attributes.clusterId, []);
}
clusterNodeMap.get(attributes.clusterId)!.push(nodeId);
} else {
// Entity nodes (or unclustered nodes) positioned separately
entityNodes.push(nodeId);
}
});
const clusterIds = Array.from(clusterNodeMap.keys());
if (clusterIds.length > 0) {
// Use a more aggressive clustering approach - create distinct regions
const padding = Math.min(width, height) * 0.1; // 10% padding
const availableWidth = width - 2 * padding;
const availableHeight = height - 2 * padding;
// Calculate optimal grid layout
const cols = Math.ceil(Math.sqrt(clusterIds.length));
const rows = Math.ceil(clusterIds.length / cols);
const cellWidth = availableWidth / cols;
const cellHeight = availableHeight / rows;
clusterIds.forEach((clusterId, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
// Calculate cluster region with more separation
const regionLeft = padding + col * cellWidth;
const regionTop = padding + row * cellHeight;
const regionCenterX = regionLeft + cellWidth / 2;
const regionCenterY = regionTop + cellHeight / 2;
// Get nodes in this cluster
const nodesInCluster = clusterNodeMap.get(clusterId)!;
const clusterSize = nodesInCluster.length;
// Create cluster radius with Marvel-style spacing - more generous
const maxRadius = Math.min(cellWidth, cellHeight) * 0.35;
const baseSpacing = 150; // Larger base spacing between nodes
const clusterRadius = Math.max(
baseSpacing,
Math.min(maxRadius, Math.sqrt(clusterSize) * baseSpacing * 1.2),
);
if (clusterSize === 1) {
// Single node at region center
graph.setNodeAttribute(nodesInCluster[0], "x", regionCenterX);
graph.setNodeAttribute(nodesInCluster[0], "y", regionCenterY);
} else if (clusterSize <= 6) {
// Small clusters - tight circle
nodesInCluster.forEach((nodeId, nodeIndex) => {
const angle = (nodeIndex / clusterSize) * 2 * Math.PI;
const x = regionCenterX + Math.cos(angle) * clusterRadius;
const y = regionCenterY + Math.sin(angle) * clusterRadius;
graph.setNodeAttribute(nodeId, "x", x);
graph.setNodeAttribute(nodeId, "y", y);
});
} else {
// Larger clusters - dense spiral pattern
nodesInCluster.forEach((nodeId, nodeIndex) => {
const spiralTurns = Math.ceil(clusterSize / 8);
const angle =
(nodeIndex / clusterSize) * 2 * Math.PI * spiralTurns;
const radius = (nodeIndex / clusterSize) * clusterRadius;
const x = regionCenterX + Math.cos(angle) * radius;
const y = regionCenterY + Math.sin(angle) * radius;
graph.setNodeAttribute(nodeId, "x", x);
graph.setNodeAttribute(nodeId, "y", y);
});
}
});
}
// Position Entity nodes using ForceAtlas2 natural positioning
// They will be positioned by the algorithm based on their connections to Statement nodes
entityNodes.forEach((nodeId) => {
// Give them initial random positions, ForceAtlas2 will adjust based on connections
graph.setNodeAttribute(nodeId, "x", Math.random() * width);
graph.setNodeAttribute(nodeId, "y", Math.random() * height);
});
const optimalParams = calculateOptimalParameters(graph);
const settings = forceAtlas2.inferSettings(graph);
console.log(optimalParams);
const layout = new FA2Layout(graph, {
settings: {
...settings,
barnesHutOptimize: true,
strongGravityMode: false, // Marvel doesn't use strong gravity
gravity: Math.max(0.1, optimalParams.gravity * 0.005), // Much weaker gravity like Marvel
scalingRatio: optimalParams.scalingRatio * 10, // Higher scaling for more spacing
slowDown: 20, // Much slower to preserve cluster positions
outboundAttractionDistribution: false, // Use standard distribution
linLogMode: false, // Linear mode
edgeWeightInfluence: 0, // Disable edge weight influence to maintain positioning
},
});
layout.start();
setTimeout(() => layout.stop(), (optimalParams.duration ?? 2) * 1000);
}
// Create Sigma instance
const sigma = new Sigma(graph, containerRef.current, {
renderEdgeLabels: true,
defaultEdgeColor: "#0000001A",
defaultNodeColor: theme.node.fill,
defaultEdgeType: "edges-fast",
edgeProgramClasses: {
"edges-fast": EdgeLineProgram,
},
renderLabels: false,
enableEdgeEvents: true,
minCameraRatio: 0.01,
defaultDrawNodeHover: drawHover,
maxCameraRatio: 2,
allowInvalidContainer: false,
});
sigmaRef.current = sigma;
// Set up camera for zoom on mount
if (zoomOnMount) {
setTimeout(() => {
sigma
.getCamera()
.animate(sigma.getCamera().getState(), { duration: 750 });
}, 100);
}
// Update cluster labels after any camera movement
sigma.getCamera().on("updated", () => {
if (showClusterLabels) {
}
});
// Drag and drop implementation (same as original)
let draggedNode: string | null = null;
let isDragging = false;
sigma.on("downNode", (e) => {
isDragging = true;
draggedNode = e.node;
graph.setNodeAttribute(draggedNode, "highlighted", true);
if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox());
});
sigma.on("moveBody", ({ event }) => {
if (!isDragging || !draggedNode) return;
const pos = sigma.viewportToGraph(event);
graph.setNodeAttribute(draggedNode, "x", pos.x);
graph.setNodeAttribute(draggedNode, "y", pos.y);
event.preventSigmaDefault?.();
event.original?.preventDefault?.();
event.original?.stopPropagation?.();
});
const handleUp = () => {
if (draggedNode) {
graph.removeNodeAttribute(draggedNode, "highlighted");
}
isDragging = false;
draggedNode = null;
};
sigma.on("upNode", handleUp);
sigma.on("upStage", handleUp);
// Node click handler
sigma.on("clickNode", (event) => {
const { node } = event;
// Store current camera state to prevent unwanted movements
const camera = sigma.getCamera();
const currentState = camera.getState();
resetHighlights(); // Clear previous highlights first
// Restore camera state after reset to prevent zoom changes
setTimeout(() => {
camera.setState(currentState);
}, 0);
if (onNodeClick) {
onNodeClick(node);
}
// Highlight the clicked node
graph.setNodeAttribute(node, "highlighted", true);
graph.setNodeAttribute(node, "color", theme.node.selected);
graph.setNodeAttribute(
node,
"size",
graph.getNodeAttribute(node, "size"),
);
// Enhanced border for selected node
graph.setNodeAttribute(node, "borderSize", 3);
graph.setNodeAttribute(node, "borderColor", theme.node.selected);
graph.setNodeAttribute(node, "zIndex", 3);
selectedNodeRef.current = node;
// Highlight connected edges and nodes
graph.forEachEdge(node, (edge, _attributes, source, target) => {
graph.setEdgeAttribute(edge, "highlighted", true);
graph.setEdgeAttribute(edge, "color", theme.link.selected);
graph.setEdgeAttribute(edge, "size", 2);
const otherNode = source === node ? target : source;
graph.setNodeAttribute(otherNode, "highlighted", true);
graph.setNodeAttribute(otherNode, "color", theme.node.hover);
graph.setNodeAttribute(
otherNode,
"size",
graph.getNodeAttribute(otherNode, "size"),
);
graph.setNodeAttribute(otherNode, "zIndex", 2);
});
});
// Edge click handler
sigma.on("clickEdge", (event) => {
const { edge } = event;
resetHighlights();
const edgeAttrs = graph.getEdgeAttributes(edge);
if (edgeAttrs.relationData && edgeAttrs.relationData.length > 0) {
const relation = edgeAttrs.relationData[0];
if (onEdgeClick) {
onEdgeClick(relation.id);
}
}
graph.setEdgeAttribute(edge, "highlighted", true);
graph.setEdgeAttribute(edge, "color", theme.link.selected);
selectedEdgeRef.current = edge;
const source = graph.source(edge);
const target = graph.target(edge);
graph.setNodeAttribute(source, "highlighted", true);
graph.setNodeAttribute(source, "color", theme.node.selected);
graph.setNodeAttribute(target, "highlighted", true);
graph.setNodeAttribute(target, "color", theme.node.selected);
});
// Background click handler
sigma.on("clickStage", (event) => {
// Store camera state before reset
const camera = sigma.getCamera();
const currentState = camera.getState();
resetHighlights();
// Restore camera state
camera.setState(currentState);
if (onBlur) {
onBlur();
}
});
// Cleanup function
return () => {
if (sigmaRef.current) {
sigmaRef.current.kill();
sigmaRef.current = null;
}
if (graphRef.current) {
graphRef.current.clear();
graphRef.current = null;
}
if (clustersLayerRef.current) {
clustersLayerRef.current.remove();
clustersLayerRef.current = null;
}
isInitializedRef.current = false;
};
}, [nodes, edges, clusters, showClusterLabels]);
return (
<div
ref={containerRef}
className=""
style={{
width: `${width}px`,
height: `${height}px`,
borderRadius: "8px",
cursor: "grab",
fontSize: "12px",
position: "relative",
}}
/>
);
},
);

View File

@ -36,9 +36,7 @@ interface GraphPopoversProps {
export function GraphPopovers({
showNodePopup,
showEdgePopup,
nodePopupContent,
edgePopupContent,
onOpenChange,
labelColorMap,
}: GraphPopoversProps) {
@ -52,8 +50,12 @@ export function GraphPopovers({
// Check if node has primaryLabel property (GraphNode)
const nodeAny = nodePopupContent.node as any;
if (nodeAny.primaryLabel && typeof nodeAny.primaryLabel === "string") {
return nodeAny.primaryLabel;
if (
nodeAny.attributes.nodeType &&
typeof nodeAny.attributes.nodeType === "string"
) {
return nodeAny.attributes.nodeType;
}
// Fall back to original logic with labels
@ -93,7 +95,7 @@ export function GraphPopovers({
<div className="pointer-events-none h-4 w-4" />
</PopoverTrigger>
<PopoverContent
className="h-60 max-w-80 overflow-auto"
className="h-35 max-w-80 overflow-auto"
side="bottom"
align="end"
sideOffset={5}
@ -104,7 +106,7 @@ export function GraphPopovers({
<h4 className="leading-none font-medium">Node Details</h4>
{primaryNodeLabel && (
<span
className="rounded-full px-2 py-1 text-xs font-medium text-white"
className="rounded-md px-2 py-1 text-xs font-medium text-white"
style={{ backgroundColor: labelColor }}
>
{primaryNodeLabel}
@ -118,7 +120,9 @@ export function GraphPopovers({
{attributesToDisplay.map(({ key, value }) => (
<p key={key} className="text-sm">
<span className="font-medium text-black dark:text-white">
{key}:
{key.charAt(0).toUpperCase() +
key.slice(1).toLowerCase()}
:
</span>{" "}
<span className="text-muted-foreground break-words">
{typeof value === "object"
@ -134,48 +138,6 @@ export function GraphPopovers({
</div>
</PopoverContent>
</Popover>
<Popover open={showEdgePopup} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<div className="pointer-events-none h-4 w-4" />
</PopoverTrigger>
<PopoverContent
className="w-80 overflow-hidden"
side="bottom"
align="end"
sideOffset={5}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="bg-grayAlpha-100 mb-4 rounded-md p-2">
<p className="text-sm break-all">
Episode {edgePopupContent?.target.name || "Unknown"}
</p>
</div>
<div className="space-y-2">
<h4 className="leading-none font-medium">Relationship</h4>
<div className="grid gap-2">
<p className="text-muted-foreground text-sm break-all">
<span className="mr-2 text-sm font-medium text-black dark:text-white">
UUID:
</span>
{edgePopupContent?.relation.uuid || "Unknown"}
</p>
<p className="text-muted-foreground text-sm break-all">
<span className="mr-2 text-sm font-medium text-black dark:text-white">
Type:
</span>
{edgePopupContent?.relation.type || "Unknown"}
</p>
<p className="text-muted-foreground text-sm break-all">
<span className="mr-2 text-sm font-medium text-black dark:text-white">
Created:
</span>
{formatDate(edgePopupContent?.relation.createdAt)}
</p>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -1,5 +1,3 @@
import colors from "tailwindcss/colors";
// Define a color palette for node coloring using hex values directly
export const nodeColorPalette = {
light: [

View File

@ -5,6 +5,7 @@ export interface Node {
labels?: string[];
attributes?: Record<string, any>;
createdAt: string;
clusterId?: string;
}
export interface Edge {
@ -25,6 +26,7 @@ export interface GraphNode extends Node {
id: string;
value: string;
primaryLabel?: string;
clusterId?: string; // Add cluster information
}
export interface GraphEdge extends Edge {

View File

@ -21,6 +21,7 @@ export function toGraphNode(node: Node): GraphNode {
summary: node.summary,
labels: node.labels,
primaryLabel,
clusterId: node?.clusterId, // Extract cluster ID from attributes
};
}
@ -44,62 +45,109 @@ export function toGraphTriplets(triplets: RawTriplet[]): GraphTriplet[] {
return triplets.map(toGraphTriplet);
}
export function createTriplets(edges: Edge[], nodes: Node[]): RawTriplet[] {
// Create a Set of node UUIDs that are connected by edges
const connectedNodeIds = new Set<string>();
// Create triplets from edges
const edgeTriplets = edges
.map((edge) => {
const sourceNode = nodes.find(
(node) => node.uuid === edge.source_node_uuid,
);
const targetNode = nodes.find(
(node) => node.uuid === edge.target_node_uuid,
);
if (!sourceNode || !targetNode) return null;
// Add source and target node IDs to connected set
connectedNodeIds.add(sourceNode.uuid);
connectedNodeIds.add(targetNode.uuid);
return {
sourceNode,
edge,
targetNode,
};
})
.filter(
(t): t is RawTriplet =>
t !== null && t.sourceNode !== undefined && t.targetNode !== undefined,
);
// Find isolated nodes (nodes that don't appear in any edge)
const isolatedNodes = nodes.filter(
(node) => !connectedNodeIds.has(node.uuid),
);
// For isolated nodes, create special triplets
const isolatedTriplets: RawTriplet[] = isolatedNodes.map((node) => {
// Create a special marker edge for isolated nodes
const virtualEdge: Edge = {
uuid: `isolated-node-${node.uuid}`,
source_node_uuid: node.uuid,
target_node_uuid: node.uuid,
// Use a special type that we can filter out in the Graph component
type: "_isolated_node_",
createdAt: node.createdAt,
};
return {
sourceNode: node,
edge: virtualEdge,
targetNode: node,
};
});
// Combine edge triplets with isolated node triplets
return [...edgeTriplets, ...isolatedTriplets];
export function drawRoundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
const TEXT_COLOR = "#000000";
export function drawHover(
context: CanvasRenderingContext2D,
data: any,
settings: any,
) {
const size = settings.labelSize;
const font = settings.labelFont;
const weight = settings.labelWeight;
const subLabelSize = size - 2;
const label = data.label;
const subLabel = data.tag !== "unknown" ? data.tag : "";
const entityLabel = data.nodeData.attributes.nodeType;
// Simulate the --shadow-1 Tailwind shadow:
// lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
// Canvas only supports a single shadow, so we approximate with the stronger one.
// lch(0 0 0 / 0.044) is roughly rgba(0,0,0,0.044)
context.beginPath();
context.fillStyle = "#fff";
context.shadowOffsetX = 0;
context.shadowOffsetY = 1;
context.shadowBlur = 1;
context.shadowColor = "rgba(0,0,0,0.044)";
context.font = `${weight} ${size}px ${font}`;
const labelWidth = context.measureText(label).width;
context.font = `${weight} ${subLabelSize}px ${font}`;
const subLabelWidth = subLabel ? context.measureText(subLabel).width : 0;
context.font = `${weight} ${subLabelSize}px ${font}`;
const entityLabelWidth = entityLabel
? context.measureText(entityLabel).width
: 0;
const textWidth = Math.max(labelWidth, subLabelWidth, entityLabelWidth);
const x = Math.round(data.x);
const y = Math.round(data.y);
const w = Math.round(textWidth + size / 2 + data.size + 3);
const hLabel = Math.round(size / 2 + 4);
const hSubLabel = subLabel ? Math.round(subLabelSize / 2 + 9) : 0;
const hentityLabel = Math.round(subLabelSize / 2 + 9);
drawRoundRect(
context,
x,
y - hSubLabel - 12,
w,
hentityLabel + hLabel + hSubLabel + 12,
5,
);
context.closePath();
context.fill();
// Remove shadow for text
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 0;
context.shadowColor = "transparent";
// And finally we draw the labels
context.fillStyle = TEXT_COLOR;
context.font = `${weight} ${size}px ${font}`;
context.fillText(label, data.x + data.size + 3, data.y + size / 3);
if (subLabel) {
context.fillStyle = TEXT_COLOR;
context.font = `${weight} ${subLabelSize}px ${font}`;
context.fillText(
subLabel,
data.x + data.size + 3,
data.y - (2 * size) / 3 - 2,
);
}
context.fillStyle = data.color;
context.font = `${weight} ${subLabelSize}px ${font}`;
context.fillText(
entityLabel,
data.x + data.size + 3,
data.y + size / 3 + 3 + subLabelSize,
);
}

View File

@ -6,6 +6,10 @@ import {
} from "@remixicon/react";
import { LayoutGrid } from "lucide-react";
import { LinearIcon, SlackIcon } from "./icons";
import { Cursor } from "./icons/cursor";
import { Claude } from "./icons/claude";
import { Cline } from "./icons/cline";
import { VSCode } from "./icons/vscode";
export const ICON_MAPPING = {
slack: SlackIcon,
@ -15,6 +19,10 @@ export const ICON_MAPPING = {
gmail: RiMailFill,
linear: LinearIcon,
cursor: Cursor,
claude: Claude,
cline: Cline,
vscode: VSCode,
// Default icon
integration: LayoutGrid,

View File

@ -0,0 +1,20 @@
import type { IconProps } from "./types";
export function Claude({ size = 18, className }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
height={size}
className={className}
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<title>Claude</title>
<path
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
fill="#D97757"
fill-rule="nonzero"
></path>
</svg>
);
}

View File

@ -0,0 +1,19 @@
import type { IconProps } from "./types";
export function Cline({ size = 18, className }: IconProps) {
return (
<svg
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
height={size}
className={className}
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<title>Cline</title>
<path d="M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z"></path>
<path d="M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z"></path>
</svg>
);
}

View File

@ -0,0 +1,64 @@
import type { IconProps } from "./types";
export function Cursor({ size = 18, className }: IconProps) {
return (
<svg
width={size}
viewBox="0 0 24 24"
className={className}
height={size}
xmlns="http://www.w3.org/2000/svg"
>
<title>Cursor</title>
<path
d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z"
fill="url(#lobe-icons-cursorundefined-fill-0)"
></path>
<path
d="M22.35 18V6L11.925 0v12l10.425 6z"
fill="url(#lobe-icons-cursorundefined-fill-1)"
></path>
<path
d="M11.925 0L1.5 6v12l10.425-6V0z"
fill="url(#lobe-icons-cursorundefined-fill-2)"
></path>
<path d="M22.35 6L11.925 24V12L22.35 6z" fill="#555"></path>
<path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="#000"></path>
<defs>
<linearGradient
gradientUnits="userSpaceOnUse"
id="lobe-icons-cursorundefined-fill-0"
x1="11.925"
x2="11.925"
y1="12"
y2="24"
>
<stop offset=".16" stop-color="#000" stop-opacity=".39"></stop>
<stop offset=".658" stop-color="#000" stop-opacity=".8"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
id="lobe-icons-cursorundefined-fill-1"
x1="22.35"
x2="11.925"
y1="6.037"
y2="12.15"
>
<stop offset=".182" stop-color="#000" stop-opacity=".31"></stop>
<stop offset=".715" stop-color="#000" stop-opacity="0"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
id="lobe-icons-cursorundefined-fill-2"
x1="11.925"
x2="1.5"
y1="0"
y2="18"
>
<stop stop-color="#000" stop-opacity=".6"></stop>
<stop offset=".667" stop-color="#000" stop-opacity=".22"></stop>
</linearGradient>
</defs>
</svg>
);
}

View File

@ -0,0 +1,26 @@
import type { IconProps } from "./types";
export function VSCode({ size = 18, className }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width={size}
className={className}
height={size}
>
<path
fill="#29b6f6"
d="M44,11.11v25.78c0,1.27-0.79,2.4-1.98,2.82l-8.82,4.14L34,33V15L33.2,4.15l8.82,4.14 C43.21,8.71,44,9.84,44,11.11z"
/>
<path
fill="#0277bd"
d="M9,33.896L34,15V5.353c0-1.198-1.482-1.758-2.275-0.86L4.658,29.239 c-0.9,0.83-0.849,2.267,0.107,3.032c0,0,1.324,1.232,1.803,1.574C7.304,34.37,8.271,34.43,9,33.896z"
/>
<path
fill="#0288d1"
d="M9,14.104L34,33v9.647c0,1.198-1.482,1.758-2.275,0.86L4.658,18.761 c-0.9-0.83-0.849-2.267,0.107-3.032c0,0,1.324-1.232,1.803-1.574C7.304,13.63,8.271,13.57,9,14.104z"
/>
</svg>
);
}

View File

@ -1,10 +1,7 @@
import React from "react";
import { Link } from "@remix-run/react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
@ -35,23 +32,24 @@ export function IntegrationCard({
>
<Card className="transition-all">
<CardHeader className="p-4">
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
<Component size={18} />
<div className="flex items-center justify-between">
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
<Component size={18} />
</div>
{isConnected && (
<div className="flex w-full items-center justify-end">
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
Connected
</Badge>
</div>
)}
</div>
<CardTitle className="text-base">{integration.name}</CardTitle>
<CardDescription className="line-clamp-2 text-xs">
{integration.description || `Connect to ${integration.name}`}
</CardDescription>
</CardHeader>
{isConnected && (
<CardFooter className="p-3">
<div className="flex w-full items-center justify-end">
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
Connected
</Badge>
</div>
</CardFooter>
)}
</Card>
</Link>
);

View File

@ -0,0 +1,34 @@
export const FIXED_INTEGRATIONS = [
{
id: "claude",
name: "Claude",
description: "AI assistant for coding, writing, and analysis",
icon: "claude",
slug: "claude",
spec: {},
},
{
id: "cursor",
name: "Cursor",
description: "AI-powered code editor",
icon: "cursor",
slug: "cursor",
spec: {},
},
{
id: "cline",
name: "Cline",
description: "AI coding assistant for terminal and command line",
icon: "cline",
slug: "cline",
spec: {},
},
{
id: "vscode",
name: "Visual Studio Code",
description: "Popular code editor with extensive extensions",
icon: "vscode",
slug: "vscode",
spec: {},
},
];

View File

@ -4,6 +4,7 @@ import { AlertCircle, Info, Trash } from "lucide-react";
import { cn } from "~/lib/utils";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
import { Button } from "../ui";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import {
AlertDialog,
AlertDialogAction,
@ -15,21 +16,42 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/alert-dialog";
import { Badge } from "../ui/badge";
import { type LogItem } from "~/hooks/use-logs";
interface LogTextCollapseProps {
text?: string;
error?: string;
logData: any;
log: LogItem;
id: string;
episodeUUID?: string;
}
const getStatusColor = (status: string) => {
switch (status) {
case "PROCESSING":
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
case "PENDING":
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
case "COMPLETED":
return "bg-success/10 text-success hover:bg-success/10 hover:text-success";
case "FAILED":
return "bg-destructive/10 text-destructive hover:bg-destructive/10 hover:text-destructive";
case "CANCELLED":
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
default:
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
}
};
export function LogTextCollapse({
episodeUUID,
text,
error,
id,
logData,
log,
}: LogTextCollapseProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -75,19 +97,28 @@ export function LogTextCollapse({
}
return (
<>
<div className="mb-2">
<p
<div className="flex w-full items-center">
<div
className={cn(
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow items-start gap-2 rounded-md px-4",
)}
>
<div
className={cn(
"whitespace-p-wrap pt-2 text-sm break-words",
isLong ? "max-h-16 overflow-hidden" : "",
"border-border flex w-full min-w-[0px] shrink flex-col border-b py-2",
)}
style={{ lineHeight: "1.5" }}
dangerouslySetInnerHTML={{ __html: text }}
/>
>
<div className="flex w-full items-center justify-between gap-4">
<div
className="inline-flex min-h-[24px] min-w-[0px] shrink cursor-pointer items-center justify-start"
onClick={() => setDialogOpen(true)}
>
<div
className={cn("truncate text-left")}
dangerouslySetInnerHTML={{ __html: text }}
></div>
</div>
{isLong && (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl p-4">
<DialogHeader>
@ -101,66 +132,84 @@ export function LogTextCollapse({
style={{ lineHeight: "1.5" }}
dangerouslySetInnerHTML={{ __html: text }}
/>
{error && (
<div className="mt-4 border-t px-3 py-2">
<div className="flex items-start gap-2 text-red-600">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<div>
<p className="mb-1 text-sm font-medium">
Error Details
</p>
<p className="text-sm break-words whitespace-pre-wrap">
{error}
</p>
</div>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
)}
</div>
<div
className={cn(
"text-muted-foreground flex items-center justify-end text-xs",
isLong && "justify-between",
)}
>
{isLong && (
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="-ml-2 rounded px-2"
onClick={() => setDialogOpen(true)}
>
<Info size={15} />
</Button>
{episodeUUID && (
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="rounded px-2">
<Trash size={15} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this episode? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<div className="text-muted-foreground flex items-center justify-end text-xs">
<div className="flex items-center">
<Badge
className={cn(
"mr-3 rounded text-xs",
getStatusColor(log.status),
)}
>
{log.status.charAt(0).toUpperCase() +
log.status.slice(1).toLowerCase()}
</Badge>
<div className="text-muted-foreground mr-3">
{new Date(log.time).toLocaleString()}
</div>
<Button
variant="ghost"
size="sm"
className="-ml-2 rounded px-2"
onClick={() => setDialogOpen(true)}
>
<Info size={15} />
</Button>
{episodeUUID && (
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="rounded px-2"
>
<Trash size={15} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this episode? This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
</div>
)}
{error && (
<div className="flex items-center gap-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span className="max-w-[200px] truncate" title={error}>
{error}
</span>
</div>
)}
</div>
</div>
</>
</div>
);
}

View File

@ -51,7 +51,7 @@ export function LogsFilters({
const handleBack = () => setStep("main");
return (
<div className="mb-4 flex w-full items-center justify-start gap-2">
<div className="mb-4 flex w-full items-center justify-start gap-2 px-5">
<Popover
open={popoverOpen}
onOpenChange={(open) => {

View File

@ -9,7 +9,6 @@ import {
} from "react-virtualized";
import { type LogItem } from "~/hooks/use-logs";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent } from "~/components/ui/card";
import { cn } from "~/lib/utils";
import { ScrollManagedList } from "../virtualized-list";
import { LogTextCollapse } from "./log-text-collapse";
@ -46,23 +45,6 @@ function LogItemRenderer(
);
}
const getStatusColor = (status: string) => {
switch (status) {
case "PROCESSING":
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
case "PENDING":
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
case "COMPLETED":
return "bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800";
case "FAILED":
return "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800";
case "CANCELLED":
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
default:
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
}
};
return (
<CellMeasurer
key={key}
@ -71,40 +53,17 @@ function LogItemRenderer(
parent={parent}
rowIndex={index}
>
<div key={key} style={style} className="pb-2">
<Card className="h-full">
<CardContent className="p-4">
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="rounded text-xs">
{log.source}
</Badge>
<div className="flex items-center gap-1">
<Badge
className={cn(
"rounded text-xs",
getStatusColor(log.status),
)}
>
{log.status.charAt(0).toUpperCase() +
log.status.slice(1).toLowerCase()}
</Badge>
</div>
</div>
<div className="text-muted-foreground text-xs">
{new Date(log.time).toLocaleString()}
</div>
</div>
<LogTextCollapse
text={log.ingestText}
error={log.error}
logData={log.data}
id={log.id}
episodeUUID={log.episodeUUID}
/>
</CardContent>
</Card>
<div key={key} style={style}>
<div className="group mx-2 flex cursor-default gap-2">
<LogTextCollapse
text={log.ingestText}
error={log.error}
logData={log.data}
log={log}
id={log.id}
episodeUUID={log.episodeUUID}
/>
</div>
</div>
</CellMeasurer>
);

View File

@ -9,6 +9,7 @@ const EnvironmentSchema = z.object({
z.literal("production"),
z.literal("test"),
]),
POSTGRES_DB: z.string(),
DATABASE_URL: z
.string()
.refine(

View File

@ -102,6 +102,153 @@ export const getNodeLinks = async (userId: string) => {
return triplets;
};
// Get graph data with cluster information for reified graph
export const getClusteredGraphData = async (userId: string) => {
const session = driver.session();
try {
// Get the proper reified graph structure: Entity -> Statement -> Entity
const result = await session.run(
`// Get all statements and their entity connections for reified graph
MATCH (s:Statement)
WHERE s.userId = $userId AND s.invalidAt IS NULL
// Get all entities connected to each statement
MATCH (s)-[:HAS_SUBJECT]->(subj:Entity)
MATCH (s)-[:HAS_PREDICATE]->(pred:Entity)
MATCH (s)-[:HAS_OBJECT]->(obj:Entity)
// Return both Entity->Statement and Statement->Entity relationships
WITH s, subj, pred, obj
UNWIND [
// Subject Entity -> Statement
{source: subj, target: s, type: 'HAS_SUBJECT', isEntityToStatement: true},
// Statement -> Predicate Entity
{source: s, target: pred, type: 'HAS_PREDICATE', isStatementToEntity: true},
// Statement -> Object Entity
{source: s, target: obj, type: 'HAS_OBJECT', isStatementToEntity: true}
] AS rel
RETURN DISTINCT
rel.source.uuid as sourceUuid,
rel.source.name as sourceName,
rel.source.labels as sourceLabels,
rel.source.type as sourceType,
rel.source.properties as sourceProperties,
rel.target.uuid as targetUuid,
rel.target.name as targetName,
rel.target.type as targetType,
rel.target.labels as targetLabels,
rel.target.properties as targetProperties,
rel.type as relationshipType,
s.uuid as statementUuid,
s.clusterId as clusterId,
s.fact as fact,
s.createdAt as createdAt,
rel.isEntityToStatement as isEntityToStatement,
rel.isStatementToEntity as isStatementToEntity`,
{ userId },
);
const triplets: RawTriplet[] = [];
const processedEdges = new Set<string>();
result.records.forEach((record) => {
const sourceUuid = record.get("sourceUuid");
const sourceName = record.get("sourceName");
const sourceType = record.get("sourceType");
const sourceLabels = record.get("sourceLabels") || [];
const sourceProperties = record.get("sourceProperties") || {};
const targetUuid = record.get("targetUuid");
const targetName = record.get("targetName");
const targetLabels = record.get("targetLabels") || [];
const targetProperties = record.get("targetProperties") || {};
const targetType = record.get("targetType");
const relationshipType = record.get("relationshipType");
const statementUuid = record.get("statementUuid");
const clusterId = record.get("clusterId");
const fact = record.get("fact");
const createdAt = record.get("createdAt");
// Create unique edge identifier to avoid duplicates
const edgeKey = `${sourceUuid}-${targetUuid}-${relationshipType}`;
if (processedEdges.has(edgeKey)) return;
processedEdges.add(edgeKey);
// Determine node types and add appropriate cluster information
const isSourceStatement =
sourceLabels.includes("Statement") || sourceUuid === statementUuid;
const isTargetStatement =
targetLabels.includes("Statement") || targetUuid === statementUuid;
// Statement nodes get cluster info, Entity nodes get default attributes
const sourceAttributes = isSourceStatement
? {
...sourceProperties,
clusterId,
nodeType: "Statement",
fact,
}
: {
...sourceProperties,
nodeType: "Entity",
type: sourceType,
name: sourceName,
};
const targetAttributes = isTargetStatement
? {
...targetProperties,
clusterId,
nodeType: "Statement",
fact,
}
: {
...targetProperties,
nodeType: "Entity",
type: targetType,
name: targetName,
};
triplets.push({
sourceNode: {
uuid: sourceUuid,
labels: sourceLabels,
attributes: sourceAttributes,
name: isSourceStatement ? fact : sourceName || sourceUuid,
clusterId,
createdAt: createdAt || "",
},
edge: {
uuid: `${sourceUuid}-${targetUuid}-${relationshipType}`,
type: relationshipType,
source_node_uuid: sourceUuid,
target_node_uuid: targetUuid,
createdAt: createdAt || "",
},
targetNode: {
uuid: targetUuid,
labels: targetLabels,
attributes: targetAttributes,
clusterId,
name: isTargetStatement ? fact : targetName || targetUuid,
createdAt: createdAt || "",
},
});
});
return triplets;
} catch (error) {
logger.error(
`Error getting clustered graph data for user ${userId}: ${error}`,
);
throw error;
} finally {
await session.close();
}
};
export async function initNeo4jSchemaOnce() {
if (schemaInitialized) return;
@ -141,6 +288,9 @@ const initializeSchema = async () => {
await runQuery(
"CREATE CONSTRAINT statement_uuid IF NOT EXISTS FOR (n:Statement) REQUIRE n.uuid IS UNIQUE",
);
await runQuery(
"CREATE CONSTRAINT cluster_uuid IF NOT EXISTS FOR (n:Cluster) REQUIRE n.uuid IS UNIQUE",
);
// Create indexes for better query performance
await runQuery(
@ -152,9 +302,18 @@ const initializeSchema = async () => {
await runQuery(
"CREATE INDEX statement_invalid_at IF NOT EXISTS FOR (n:Statement) ON (n.invalidAt)",
);
await runQuery(
"CREATE INDEX statement_cluster_id IF NOT EXISTS FOR (n:Statement) ON (n.clusterId)",
);
await runQuery(
"CREATE INDEX entity_name IF NOT EXISTS FOR (n:Entity) ON (n.name)",
);
await runQuery(
"CREATE INDEX cluster_user_id IF NOT EXISTS FOR (n:Cluster) ON (n.userId)",
);
await runQuery(
"CREATE INDEX cluster_aspect_type IF NOT EXISTS FOR (n:Cluster) ON (n.aspectType)",
);
// Create vector indexes for semantic search (if using Neo4j 5.0+)
await runQuery(`

View File

@ -0,0 +1,67 @@
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { requireUserId } from "~/services/session.server";
import { prisma } from "~/db.server";
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
// Get user's workspace
const user = await prisma.user.findUnique({
where: { id: userId },
select: { Workspace: { select: { id: true } } },
});
if (!user?.Workspace) {
throw new Response("Workspace not found", { status: 404 });
}
// Get activity data for the last year
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const activities = await prisma.ingestionQueue.findMany({
where: {
workspaceId: user.Workspace.id,
createdAt: {
gte: oneYearAgo,
},
},
select: {
createdAt: true,
status: true,
},
orderBy: {
createdAt: "desc",
},
});
// Group activities by date
const activityByDate = activities.reduce(
(acc, activity) => {
const date = activity.createdAt.toISOString().split("T")[0];
if (!acc[date]) {
acc[date] = { count: 0, status: activity.status };
}
acc[date].count += 1;
return acc;
},
{} as Record<string, { count: number; status: string }>,
);
// Convert to array format for the component
const contributionData = Object.entries(activityByDate).map(
([date, data]) => ({
date,
count: data.count,
status: data.status,
}),
);
return json({
success: true,
data: {
contributionData,
totalActivities: activities.length,
},
});
}

View File

@ -0,0 +1,50 @@
import { json } from "@remix-run/node";
import { z } from "zod";
import { logger } from "~/services/logger.service";
import {
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { ClusteringService } from "~/services/clustering.server";
const clusteringService = new ClusteringService();
const loader = createLoaderApiRoute(
{
allowJWT: true,
findResource: async () => 1, // Dummy resource
authorization: {
action: "search",
},
corsStrategy: "all",
params: z.object({
clusterId: z.string(),
}),
},
async ({ authentication, params }) => {
try {
const statements = await clusteringService.getClusterStatements(
params.clusterId,
authentication.userId,
);
return json({
success: true,
data: {
clusterId: params.clusterId,
statements: statements,
},
});
} catch (error) {
logger.error("Error getting cluster statements:", { error });
return json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 },
);
}
},
);
export { loader };

View File

@ -0,0 +1,40 @@
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { ClusteringService } from "~/services/clustering.server";
import { logger } from "~/services/logger.service";
import { requireUser } from "~/services/session.server";
const clusteringService = new ClusteringService();
export async function loader({ request, params }: LoaderFunctionArgs) {
try {
const user = await requireUser(request);
const { clusterId } = params;
if (!clusterId) {
return json(
{ success: false, error: "Cluster ID is required" },
{ status: 400 }
);
}
const statements = await clusteringService.getClusterStatements(clusterId, user.id);
return json({
success: true,
data: {
clusterId,
statements
}
});
} catch (error) {
logger.error("Error fetching cluster statements:", { error });
return json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,29 @@
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { ClusteringService } from "~/services/clustering.server";
import { logger } from "~/services/logger.service";
import { requireUser } from "~/services/session.server";
const clusteringService = new ClusteringService();
export async function loader({ request }: LoaderFunctionArgs) {
try {
const user = await requireUser(request);
const driftMetrics = await clusteringService.detectClusterDrift(user.id);
return json({
success: true,
data: driftMetrics
});
} catch (error) {
logger.error("Error checking cluster drift:", { error });
return json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,46 @@
import { json } from "@remix-run/node";
import { logger } from "~/services/logger.service";
import {
createHybridLoaderApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { getClusteredGraphData } from "~/lib/neo4j.server";
import { ClusteringService } from "~/services/clustering.server";
const clusteringService = new ClusteringService();
const loader = createHybridLoaderApiRoute(
{
allowJWT: true,
corsStrategy: "all",
findResource: async () => 1,
},
async ({ authentication }) => {
try {
// Get clustered graph data and cluster metadata in parallel
const [graphData, clusters] = await Promise.all([
getClusteredGraphData(authentication.userId),
clusteringService.getClusters(authentication.userId),
]);
return json({
success: true,
data: {
triplets: graphData,
clusters: clusters,
},
});
} catch (error) {
logger.error("Error in clustered graph loader:", { error });
return json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 },
);
}
},
);
export { loader };

View File

@ -4,6 +4,8 @@ import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.service";
import { prisma } from "~/db.server";
import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery";
import { scheduler } from "~/trigger/integrations/scheduler";
import { schedules } from "@trigger.dev/sdk";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
@ -28,6 +30,10 @@ export async function action({ request }: ActionFunctionArgs) {
},
});
const integrationAccountSettings = updatedAccount.settings as any;
await schedules.del(integrationAccountSettings.scheduleId);
await triggerIntegrationWebhook(
integrationAccountId,
userId,

View File

@ -17,22 +17,6 @@ const transports: {
};
} = {};
// Cleanup old sessions every 5 minutes
setInterval(
() => {
const now = Date.now();
const maxAge = 30 * 60 * 1000; // 30 minutes
Object.keys(transports).forEach((sessionId) => {
if (now - transports[sessionId].createdAt > maxAge) {
transports[sessionId].transport.close();
delete transports[sessionId];
}
});
},
5 * 60 * 1000,
);
// MCP request body schema
const MCPRequestSchema = z.object({}).passthrough();
const SourceParams = z.object({

View File

@ -1,11 +1,13 @@
import { useState, useEffect } from "react";
import React, { useState } from "react";
import { useFetcher } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { requireUserId } from "~/services/session.server";
import { useTypedLoaderData } from "remix-typedjson";
import { GraphVisualizationClient } from "~/components/graph/graph-client";
import { LoaderCircle } from "lucide-react";
import { PageHeader } from "~/components/common/page-header";
import { GraphVisualizationClient } from "~/components/graph/graph-client";
import { GraphNode } from "~/components/graph/type";
export async function loader({ request }: LoaderFunctionArgs) {
// Only return userId, not the heavy nodeLinks
@ -15,38 +17,31 @@ export async function loader({ request }: LoaderFunctionArgs) {
export default function Dashboard() {
const { userId } = useTypedLoaderData<typeof loader>();
const fetcher = useFetcher<any>();
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(
null,
);
// State for nodeLinks and loading
const [nodeLinks, setNodeLinks] = useState<any[] | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetchNodeLinks() {
setLoading(true);
try {
const res = await fetch(
"/node-links?userId=" +
encodeURIComponent("cmc0x85jv0000nu1wiu1yla73"),
);
if (!res.ok) throw new Error("Failed to fetch node links");
const data = await res.json();
if (!cancelled) {
setNodeLinks(data);
setLoading(false);
}
} catch (e) {
if (!cancelled) {
setNodeLinks([]);
setLoading(false);
}
}
// Kick off the fetcher on mount if not already done
React.useEffect(() => {
if (userId && fetcher.state === "idle" && !fetcher.data) {
fetcher.load("/api/v1/graph/clustered");
}
fetchNodeLinks();
return () => {
cancelled = true;
};
}, [userId]);
}, [userId, fetcher]);
// Determine loading state
const loading =
fetcher.state === "loading" ||
fetcher.state === "submitting" ||
!fetcher.data;
// Get graph data from fetcher
let graphData: any = null;
if (fetcher.data && fetcher.data.success) {
graphData = fetcher.data.data;
} else if (fetcher.data && !fetcher.data.success) {
graphData = { triplets: [], clusters: [] };
}
return (
<>
@ -60,7 +55,15 @@ export default function Dashboard() {
</div>
) : (
typeof window !== "undefined" &&
nodeLinks && <GraphVisualizationClient triplets={nodeLinks} />
graphData && (
<GraphVisualizationClient
triplets={graphData.triplets || []}
clusters={graphData.clusters || []}
selectedClusterId={selectedClusterId}
onClusterSelect={setSelectedClusterId}
className="h-full w-full"
/>
)
)}
</div>
</div>

View File

@ -1,10 +1,10 @@
import React, { useMemo } from "react";
import React, { useMemo, useState } from "react";
import {
json,
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useLoaderData, useParams } from "@remix-run/react";
import { requireUserId, requireWorkpace } from "~/services/session.server";
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
@ -21,7 +21,15 @@ import {
} from "~/services/ingestionRule.server";
import { Section } from "~/components/integrations/section";
import { PageHeader } from "~/components/common/page-header";
import { Plus } from "lucide-react";
import { Check, Copy, Plus } from "lucide-react";
import { FIXED_INTEGRATIONS } from "~/components/integrations/utils";
import {
IngestionRule,
type IntegrationAccount,
IntegrationDefinitionV2,
} from "@prisma/client";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@ -33,7 +41,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
getIntegrationAccounts(userId),
]);
const integration = integrationDefinitions.find(
// Combine fixed integrations with dynamic ones
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
const integration = allIntegrations.find(
(def) => def.slug === slug || def.id === slug,
);
@ -78,7 +89,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
getIntegrationAccounts(userId),
]);
const integration = integrationDefinitions.find(
// Combine fixed integrations with dynamic ones
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
const integration = allIntegrations.find(
(def) => def.slug === slug || def.id === slug,
);
@ -119,14 +133,311 @@ function parseSpec(spec: any) {
return spec;
}
export default function IntegrationDetail() {
const { integration, integrationAccounts, ingestionRule } =
useLoaderData<typeof loader>();
function CustomIntegrationContent({ integration }: { integration: any }) {
const memoryUrl = `https://core.heysol.ai/api/v1/mcp/memory?source=${integration.slug}`;
const [copied, setCopied] = useState(false);
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(memoryUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const getCustomContent = () => {
switch (integration.id) {
case "claude":
return {
title: "About Claude",
content: (
<div className="space-y-4">
<p className="leading-relaxed">
Claude is an AI assistant created by Anthropic. It can help with
a wide variety of tasks including:
</p>
<ul className="ml-4 list-inside list-disc space-y-1">
<li>Code generation and debugging</li>
<li>Writing and editing</li>
<li>Analysis and research</li>
<li>Problem-solving</li>
</ul>
<p>
For Claude Web, Desktop, and Code - OAuth authentication handled
automatically
</p>
<div className="bg-background-3 flex items-center rounded">
<Input
type="text"
id="memoryUrl"
value={memoryUrl}
readOnly
className="bg-background-3 block w-full text-base"
/>
<Button
type="button"
variant="link"
size="sm"
onClick={copyToClipboard}
className="px-3"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
),
};
case "cursor":
return {
title: "About Cursor",
content: (
<div className="space-y-4">
<p className="leading-relaxed">
Cursor is an AI-powered code editor that helps developers write
code faster and more efficiently.
</p>
<ul className="ml-4 list-inside list-disc space-y-1">
<li>AI-powered code completion</li>
<li>Natural language to code conversion</li>
<li>Code explanation and debugging</li>
<li>Refactoring assistance</li>
</ul>
<div className="bg-background-3 flex items-center rounded p-2">
<pre className="bg-background-3 m-0 block w-full p-0 text-base break-words whitespace-pre-wrap">
{JSON.stringify(
{
memory: {
url: memoryUrl,
},
},
null,
2,
)}
</pre>
<Button
type="button"
variant="link"
size="sm"
onClick={() =>
navigator.clipboard
.writeText(
JSON.stringify(
{
memory: {
url: memoryUrl,
},
},
null,
2,
),
)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
}
className="px-3"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
),
};
case "cline":
return {
title: "About Cline",
content: (
<div className="space-y-4">
<p className="leading-relaxed">
Cline is an AI coding assistant that works directly in your
terminal and command line environment.
</p>
<ul className="ml-4 list-inside list-disc space-y-1">
<li>Command line AI assistance</li>
<li>Terminal-based code generation</li>
<li>Shell script optimization</li>
<li>DevOps automation help</li>
</ul>
<div className="bg-background-3 flex items-center rounded">
<Input
type="text"
id="memoryUrl"
value={memoryUrl}
readOnly
className="bg-background-3 block w-full text-base"
/>
<Button
type="button"
variant="link"
size="sm"
onClick={copyToClipboard}
className="px-3"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
),
};
case "vscode":
return {
title: "About Visual Studio Code",
content: (
<div className="space-y-4">
<p className="leading-relaxed">
Visual Studio Code is a lightweight but powerful source code
editor with extensive extension support.
</p>
<ul className="ml-4 list-inside list-disc space-y-1">
<li>Intelligent code completion</li>
<li>Built-in Git integration</li>
<li>Extensive extension marketplace</li>
<li>Debugging and testing tools</li>
</ul>
<p>You need to enable MCP in settings</p>
<div className="bg-background-3 flex flex-col items-start gap-2 rounded p-2">
<pre>
{JSON.stringify(
{
"chat.mcp.enabled": true,
"chat.mcp.discovery.enabled": true,
},
null,
2,
)}
</pre>
</div>
<div className="bg-background-3 flex items-center rounded p-2">
<pre className="bg-background-3 m-0 block w-full p-0 text-base break-words whitespace-pre-wrap">
{JSON.stringify(
{
memory: {
type: "http",
url: memoryUrl,
},
},
null,
2,
)}
</pre>
<Button
type="button"
variant="link"
size="sm"
onClick={() =>
navigator.clipboard
.writeText(
JSON.stringify(
{
memory: {
type: "http",
url: memoryUrl,
},
},
null,
2,
),
)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
}
className="px-3"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
),
};
default:
return null;
}
};
const customContent = getCustomContent();
if (!customContent) return null;
const Component = getIcon(integration.icon as IconType);
return (
<div className="flex h-full flex-col overflow-hidden">
<PageHeader
title="Integrations"
breadcrumbs={[
{ label: "Integrations", href: "/home/integrations" },
{ label: integration?.name || "Untitled" },
]}
actions={[
{
label: "Request New Integration",
icon: <Plus size={14} />,
onClick: () =>
window.open(
"https://github.com/redplanethq/core/issues/new",
"_blank",
),
variant: "secondary",
},
]}
/>
<div className="flex h-[calc(100vh_-_56px)] flex-col items-center overflow-hidden p-4 px-5">
<div className="w-5xl">
<Section
title={integration.name}
description={integration.description}
icon={
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
<Component size={24} />
</div>
}
>
<div>{customContent.content}</div>
</Section>
</div>
</div>
</div>
);
}
interface IntegrationDetailProps {
integration: any;
integrationAccounts: any;
ingestionRule: any;
}
export function IntegrationDetail({
integration,
integrationAccounts,
ingestionRule,
}: IntegrationDetailProps) {
const activeAccount = useMemo(
() =>
integrationAccounts.find(
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
(acc: IntegrationAccount) =>
acc.integrationDefinitionId === integration.id && acc.isActive,
),
[integrationAccounts, integration.id],
);
@ -181,21 +492,21 @@ export default function IntegrationDetail() {
<div className="space-y-2">
{hasApiKey && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-2 text-sm">
<span className="inline-flex items-center gap-2">
<Checkbox checked /> API Key authentication
</span>
</div>
)}
{hasOAuth2 && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-2 text-sm">
<span className="inline-flex items-center gap-2">
<Checkbox checked />
OAuth 2.0 authentication
</span>
</div>
)}
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
<div className="text-muted-foreground text-sm">
<div className="text-muted-foreground">
No authentication method specified
</div>
)}
@ -226,7 +537,7 @@ export default function IntegrationDetail() {
)}
{/* Connected Account Info */}
<ConnectedAccountSection activeAccount={activeAccount} />
<ConnectedAccountSection activeAccount={activeAccount as any} />
{/* MCP Authentication Section */}
<MCPAuthSection
@ -247,3 +558,29 @@ export default function IntegrationDetail() {
</div>
);
}
export default function IntegrationDetailWrapper() {
const { integration, integrationAccounts, ingestionRule } =
useLoaderData<typeof loader>();
const { slug } = useParams();
// You can now use the `slug` param in your component
const fixedIntegration = FIXED_INTEGRATIONS.some(
(fixedInt) => fixedInt.slug === slug,
);
return (
<>
{fixedIntegration ? (
<CustomIntegrationContent integration={integration} />
) : (
<IntegrationDetail
integration={integration}
integrationAccounts={integrationAccounts}
ingestionRule={ingestionRule}
/>
)}
</>
);
}

View File

@ -8,6 +8,7 @@ import { getIntegrationAccounts } from "~/services/integrationAccount.server";
import { IntegrationGrid } from "~/components/integrations/integration-grid";
import { PageHeader } from "~/components/common/page-header";
import { Plus } from "lucide-react";
import { FIXED_INTEGRATIONS } from "~/components/integrations/utils";
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@ -18,8 +19,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
getIntegrationAccounts(userId),
]);
// Combine fixed integrations with dynamic ones
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
return json({
integrationDefinitions,
integrationDefinitions: allIntegrations,
integrationAccounts,
userId,
});

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { useNavigate } from "@remix-run/react";
import { useState, useEffect } from "react";
import { useNavigate, useFetcher } from "@remix-run/react";
import { useLogs } from "~/hooks/use-logs";
import { LogsFilters } from "~/components/logs/logs-filters";
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
@ -7,11 +7,13 @@ import { AppContainer, PageContainer } from "~/components/layout/app-layout";
import { Card, CardContent } from "~/components/ui/card";
import { Database, LoaderCircle } from "lucide-react";
import { PageHeader } from "~/components/common/page-header";
import { ContributionGraph } from "~/components/activity/contribution-graph";
export default function LogsAll() {
const navigate = useNavigate();
const [selectedSource, setSelectedSource] = useState<string | undefined>();
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
const contributionFetcher = useFetcher<any>();
const {
logs,
@ -26,17 +28,41 @@ export default function LogsAll() {
status: selectedStatus,
});
// Fetch contribution data on mount
useEffect(() => {
if (contributionFetcher.state === "idle" && !contributionFetcher.data) {
contributionFetcher.load("/api/v1/activity/contribution");
}
}, [contributionFetcher]);
// Get contribution data from fetcher
const contributionData = contributionFetcher.data?.success
? contributionFetcher.data.data.contributionData
: [];
const totalActivities = contributionFetcher.data?.success
? contributionFetcher.data.data.totalActivities
: 0;
const isContributionLoading =
contributionFetcher.state === "loading" || !contributionFetcher.data;
return (
<>
<PageHeader title="Logs" />
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col items-center space-y-6 p-4 px-5">
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col items-center space-y-6 py-4">
{/* Contribution Graph */}
<div className="mb-0 w-full max-w-5xl px-4">
{isContributionLoading ? (
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
) : (
<ContributionGraph data={contributionData} />
)}
</div>
{isInitialLoad ? (
<>
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
</>
) : (
<>
{" "}
{/* Filters */}
{logs.length > 0 && (
<LogsFilters

View File

@ -1,10 +0,0 @@
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { getNodeLinks } from "~/lib/neo4j.server";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) return json([], { status: 400 });
const nodeLinks = await getNodeLinks(userId);
return json(nodeLinks);
}

View File

@ -25,6 +25,7 @@ import { requireUserId } from "~/services/session.server";
import { updateUser } from "~/models/user.server";
import { Copy, Check } from "lucide-react";
import { addToQueue } from "~/lib/ingest.server";
import { cn } from "~/lib/utils";
const ONBOARDING_STEP_COOKIE = "onboardingStep";
const onboardingStepCookie = createCookie(ONBOARDING_STEP_COOKIE, {
@ -108,6 +109,9 @@ export default function Onboarding() {
const navigate = useNavigate();
const [copied, setCopied] = useState(false);
const [selectedSource, setSelectedSource] = useState<
"Claude" | "Cursor" | "Other"
>("Claude");
const [form, fields] = useForm({
lastSubmission: lastSubmission as any,
@ -117,7 +121,12 @@ export default function Onboarding() {
},
});
const memoryUrl = "https://core.heysol.ai/api/v1/mcp/memory";
const getMemoryUrl = (source: "Claude" | "Cursor" | "Other") => {
const baseUrl = "https://core.heysol.ai/api/v1/mcp/memory";
return `${baseUrl}?Source=${source}`;
};
const memoryUrl = getMemoryUrl(selectedSource);
const copyToClipboard = async () => {
try {
@ -144,7 +153,25 @@ export default function Onboarding() {
<CardContent className="pt-2 text-base">
<div className="space-y-4">
<div>
<div className="space-y-3">
<div className="bg-grayAlpha-100 flex space-x-1 rounded-lg p-1">
{(["Claude", "Cursor", "Other"] as const).map((source) => (
<Button
key={source}
onClick={() => setSelectedSource(source)}
variant="ghost"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition-all",
selectedSource === source
? "bg-accent text-accent-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{source}
</Button>
))}
</div>
<div className="bg-background-3 flex items-center rounded">
<Input
type="text"

View File

@ -1,6 +1,6 @@
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
const ResourceTypes = ["spaces"] as const;
const ResourceTypes = ["clusters"] as const;
export type AuthorizationResources = {
[key in (typeof ResourceTypes)[number]]?: string | string[];

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ import {
type Triple,
} from "@core/types";
import { logger } from "./logger.service";
import { ClusteringService } from "./clustering.server";
import crypto from "crypto";
import {
dedupeNodes,
@ -53,6 +54,12 @@ import { type PrismaClient } from "@prisma/client";
const DEFAULT_EPISODE_WINDOW = 5;
export class KnowledgeGraphService {
private clusteringService: ClusteringService;
constructor() {
this.clusteringService = new ClusteringService();
}
async getEmbedding(text: string) {
return getEmbedding(text);
}
@ -188,6 +195,26 @@ export class KnowledgeGraphService {
// Invalidate invalidated statements
await invalidateStatements({ statementIds: invalidatedStatements });
// Trigger incremental clustering process after successful ingestion
if (resolvedStatements.length > 0) {
try {
logger.info(
"Triggering incremental clustering process after episode ingestion",
);
const clusteringResult =
await this.clusteringService.performClustering(
params.userId,
false,
);
logger.info(
`Incremental clustering completed: ${clusteringResult.clustersCreated} clusters created, ${clusteringResult.statementsProcessed} statements processed`,
);
} catch (clusteringError) {
logger.error("Error in incremental clustering process:");
// Don't fail the entire ingestion if clustering fails
}
}
const endTime = Date.now();
const processingTimeMs = endTime - startTime;
logger.log(`Processing time: ${processingTimeMs} ms`);

View File

@ -957,3 +957,291 @@ export function createHybridActionApiRoute<
return { loader, action };
}
// Hybrid Loader API Route types and builder
type HybridLoaderRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TResource = never,
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
findResource: (
params: TParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
authentication: HybridAuthenticationResult,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined,
) => Promise<TResource | undefined>;
shouldRetryNotFound?: boolean;
authorization?: {
action: AuthorizationAction;
resource: (
resource: NonNullable<TResource>,
params: TParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined,
) => AuthorizationResources;
superScopes?: string[];
};
};
type HybridLoaderHandlerFunction<
TParamsSchema extends AnyZodSchema | undefined,
TSearchParamsSchema extends AnyZodSchema | undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TResource = never,
> = (args: {
params: TParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
authentication: HybridAuthenticationResult;
request: Request;
resource: NonNullable<TResource>;
}) => Promise<Response>;
export function createHybridLoaderApiRoute<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TResource = never,
>(
options: HybridLoaderRouteBuilderOptions<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TResource
>,
handler: HybridLoaderHandlerFunction<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TResource
>,
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
allowJWT = false,
corsStrategy = "none",
authorization,
findResource,
shouldRetryNotFound,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
try {
const authenticationResult = await authenticateHybridRequest(request, {
allowJWT,
});
if (!authenticationResult) {
return await wrapResponse(
request,
json({ error: "Authentication required" }, { status: 401 }),
corsStrategy !== "none",
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{
error: "Params Error",
details: fromZodError(parsed.error).details,
},
{ status: 400 },
),
corsStrategy !== "none",
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(
new URL(request.url).searchParams,
);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{
error: "Query Error",
details: fromZodError(parsed.error).details,
},
{ status: 400 },
),
corsStrategy !== "none",
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return await wrapResponse(
request,
json(
{
error: "Headers Error",
details: fromZodError(headers.error).details,
},
{ status: 400 },
),
corsStrategy !== "none",
);
}
parsedHeaders = headers.data;
}
// Find the resource
const resource = await findResource(
parsedParams,
authenticationResult,
parsedSearchParams,
);
if (!resource) {
return await wrapResponse(
request,
json(
{ error: "Not found" },
{
status: 404,
headers: {
"x-should-retry": shouldRetryNotFound ? "true" : "false",
},
},
),
corsStrategy !== "none",
);
}
// Authorization check - only applies to API key authentication
if (authorization && authenticationResult.type === "PRIVATE") {
const { action, resource: authResource, superScopes } = authorization;
const $authResource = authResource(
resource,
parsedParams,
parsedSearchParams,
parsedHeaders,
);
logger.debug("Checking authorization", {
action,
resource: $authResource,
superScopes,
scopes: authenticationResult.scopes,
});
const authorizationResult = checkAuthorization(authenticationResult);
if (!authorizationResult.authorized) {
return await wrapResponse(
request,
json(
{
error: `Unauthorized: ${authorizationResult.reason}`,
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 },
),
corsStrategy !== "none",
);
}
}
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
request,
resource,
});
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
if (error instanceof Response) {
return await wrapResponse(request, error, corsStrategy !== "none");
}
logger.error("Error in hybrid loader", {
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: String(error),
url: request.url,
});
return await wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none",
);
} catch (innerError) {
logger.error("[apiBuilder] Failed to handle error", {
error,
innerError,
});
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
};
}

View File

@ -1,4 +1,4 @@
import type { StatementNode } from "@core/types";
import type { EpisodicNode, StatementNode } from "@core/types";
import { logger } from "./logger.service";
import { applyCrossEncoderReranking, applyWeightedRRF } from "./search/rerank";
import {
@ -8,6 +8,8 @@ import {
performVectorSearch,
} from "./search/utils";
import { getEmbedding } from "~/lib/model.server";
import { prisma } from "~/db.server";
import { runQuery } from "~/lib/neo4j.server";
/**
* SearchService provides methods to search the reified + temporal knowledge graph
@ -30,6 +32,7 @@ export class SearchService {
userId: string,
options: SearchOptions = {},
): Promise<{ episodes: string[]; facts: string[] }> {
const startTime = Date.now();
// Default options
const opts: Required<SearchOptions> = {
@ -70,6 +73,21 @@ export class SearchService {
// 3. Return top results
const episodes = await getEpisodesByStatements(filteredResults);
// Log recall asynchronously (don't await to avoid blocking response)
const responseTime = Date.now() - startTime;
this.logRecallAsync(
query,
userId,
filteredResults,
opts,
responseTime,
).catch((error) => {
logger.error("Failed to log recall event:", error);
});
this.updateRecallCount(userId, episodes, filteredResults);
return {
episodes: episodes.map((episode) => episode.content),
facts: filteredResults.map((statement) => statement.fact),
@ -201,6 +219,100 @@ export class SearchService {
// Otherwise use weighted RRF for multiple sources
return applyWeightedRRF(results);
}
private async logRecallAsync(
query: string,
userId: string,
results: StatementNode[],
options: Required<SearchOptions>,
responseTime: number,
): Promise<void> {
try {
// Determine target type based on results
let targetType = "mixed_results";
if (results.length === 1) {
targetType = "statement";
} else if (results.length === 0) {
targetType = "no_results";
}
// Calculate average similarity score if available
let averageSimilarityScore: number | null = null;
const scoresWithValues = results
.map((result) => {
// Try to extract score from various possible score fields
const score =
(result as any).rrfScore ||
(result as any).mmrScore ||
(result as any).crossEncoderScore ||
(result as any).finalScore ||
(result as any).score;
return score && typeof score === "number" ? score : null;
})
.filter((score): score is number => score !== null);
if (scoresWithValues.length > 0) {
averageSimilarityScore =
scoresWithValues.reduce((sum, score) => sum + score, 0) /
scoresWithValues.length;
}
await prisma.recallLog.create({
data: {
accessType: "search",
query,
targetType,
searchMethod: "hybrid", // BM25 + Vector + BFS
minSimilarity: options.scoreThreshold,
maxResults: options.limit,
resultCount: results.length,
similarityScore: averageSimilarityScore,
context: JSON.stringify({
entityTypes: options.entityTypes,
predicateTypes: options.predicateTypes,
maxBfsDepth: options.maxBfsDepth,
includeInvalidated: options.includeInvalidated,
validAt: options.validAt.toISOString(),
startTime: options.startTime?.toISOString() || null,
endTime: options.endTime.toISOString(),
}),
source: "search_api",
responseTimeMs: responseTime,
userId,
},
});
logger.debug(
`Logged recall event for user ${userId}: ${results.length} results in ${responseTime}ms`,
);
} catch (error) {
logger.error("Error creating recall log entry:", { error });
// Don't throw - we don't want logging failures to affect the search response
}
}
private async updateRecallCount(
userId: string,
episodes: EpisodicNode[],
statements: StatementNode[],
) {
const episodeIds = episodes.map((episode) => episode.uuid);
const statementIds = statements.map((statement) => statement.uuid);
const cypher = `
MATCH (e:Episode)
WHERE e.uuid IN $episodeUuids and e.userId = $userId
SET e.recallCount = coalesce(e.recallCount, 0) + 1
`;
await runQuery(cypher, { episodeUuids: episodeIds, userId });
const cypher2 = `
MATCH (s:Statement)
WHERE s.uuid IN $statementUuids and s.userId = $userId
SET s.recallCount = coalesce(s.recallCount, 0) + 1
`;
await runQuery(cypher2, { statementUuids: statementIds, userId });
}
}
/**

View File

@ -465,6 +465,27 @@
}
@layer base {
.react-calendar-heatmap {
font-size: 9px;
}
.react-calendar-heatmap .react-calendar-heatmap-month-label {
font-size: 9px;
fill: hsl(var(--muted-foreground));
}
.react-calendar-heatmap .react-calendar-heatmap-weekday-label {
font-size: 9px;
fill: hsl(var(--muted-foreground));
}
.react-calendar-heatmap rect {
rx: 2;
}
.react-calendar-heatmap rect:hover {
}
.tiptap {
:first-child {
margin-top: 0;
@ -535,3 +556,4 @@
}
}
}

View File

@ -0,0 +1,115 @@
import { queue, task } from "@trigger.dev/sdk";
import { z } from "zod";
import { ClusteringService } from "~/services/clustering.server";
import { logger } from "~/services/logger.service";
const clusteringService = new ClusteringService();
// Define the payload schema for cluster tasks
export const ClusterPayload = z.object({
userId: z.string(),
mode: z.enum(["auto", "incremental", "complete", "drift"]).default("auto"),
forceComplete: z.boolean().default(false),
});
const clusterQueue = queue({
name: "cluster-queue",
concurrencyLimit: 10,
});
/**
* Single clustering task that handles all clustering operations based on payload mode
*/
export const clusterTask = task({
id: "cluster",
queue: clusterQueue,
maxDuration: 1800, // 30 minutes max
run: async (payload: z.infer<typeof ClusterPayload>) => {
logger.info(`Starting ${payload.mode} clustering task for user ${payload.userId}`);
try {
let result;
switch (payload.mode) {
case "incremental":
result = await clusteringService.performIncrementalClustering(
payload.userId,
);
logger.info(`Incremental clustering completed for user ${payload.userId}:`, {
newStatementsProcessed: result.newStatementsProcessed,
newClustersCreated: result.newClustersCreated,
});
break;
case "complete":
result = await clusteringService.performCompleteClustering(
payload.userId,
);
logger.info(`Complete clustering completed for user ${payload.userId}:`, {
clustersCreated: result.clustersCreated,
statementsProcessed: result.statementsProcessed,
});
break;
case "drift":
// First detect drift
const driftMetrics = await clusteringService.detectClusterDrift(
payload.userId,
);
if (driftMetrics.driftDetected) {
// Handle drift by splitting low-cohesion clusters
const driftResult = await clusteringService.handleClusterDrift(
payload.userId,
);
logger.info(`Cluster drift handling completed for user ${payload.userId}:`, {
driftDetected: true,
clustersProcessed: driftResult.clustersProcessed,
newClustersCreated: driftResult.newClustersCreated,
splitClusters: driftResult.splitClusters,
});
result = {
driftDetected: true,
...driftResult,
driftMetrics,
};
} else {
logger.info(`No cluster drift detected for user ${payload.userId}`);
result = {
driftDetected: false,
clustersProcessed: 0,
newClustersCreated: 0,
splitClusters: [],
driftMetrics,
};
}
break;
case "auto":
default:
result = await clusteringService.performClustering(
payload.userId,
payload.forceComplete,
);
logger.info(`Auto clustering completed for user ${payload.userId}:`, {
clustersCreated: result.clustersCreated,
statementsProcessed: result.statementsProcessed,
approach: result.approach,
});
break;
}
return {
success: true,
data: result,
};
} catch (error) {
logger.error(`${payload.mode} clustering failed for user ${payload.userId}:`, {
error,
});
throw error;
}
},
});

View File

@ -108,7 +108,7 @@ const Keys = [
export async function addEnvVariablesInTrigger() {
const {
APP_ORIGIN,
TRIGGER_DB,
POSTGRES_DB,
EMBEDDING_MODEL,
MODEL,
ENCRYPTION_KEY,
@ -121,7 +121,7 @@ export async function addEnvVariablesInTrigger() {
TRIGGER_SECRET_KEY,
} = env;
const DATABASE_URL = getDatabaseUrl(TRIGGER_DB);
const DATABASE_URL = getDatabaseUrl(POSTGRES_DB);
// Helper to replace 'localhost' with 'host.docker.internal'
function replaceLocalhost(val: string | undefined): string | undefined {

Binary file not shown.

View File

@ -75,6 +75,7 @@
"@tiptap/starter-kit": "2.11.9",
"@trigger.dev/react-hooks": "^4.0.0-v4-beta.22",
"@trigger.dev/sdk": "^4.0.0-v4-beta.22",
"@types/react-calendar-heatmap": "^1.9.0",
"ai": "4.3.14",
"axios": "^1.10.0",
"bullmq": "^5.53.2",
@ -111,6 +112,7 @@
"ollama-ai-provider": "1.2.0",
"posthog-js": "^1.116.6",
"react": "^18.2.0",
"react-calendar-heatmap": "^1.10.0",
"react-dom": "^18.2.0",
"react-resizable-panels": "^1.0.9",
"react-virtualized": "^9.22.6",

View File

@ -50,8 +50,8 @@ services:
image: neo4j:5.25-community
environment:
- NEO4J_AUTH=${NEO4J_AUTH}
- NEO4J_dbms_security_procedures_unrestricted=gds.*
- NEO4J_dbms_security_procedures_allowlist=gds.*
- NEO4J_dbms_security_procedures_unrestricted=gds.*,apoc.*
- NEO4J_dbms_security_procedures_allowlist=gds.*,apoc.*
ports:
- "7474:7474"
- "7687:7687"

View File

@ -1,4 +1,4 @@
VERSION=0.1.13
VERSION=0.1.14
# Nest run in docker, change host to database container name
DB_HOST=localhost

View File

@ -76,8 +76,13 @@ services:
image: neo4j:5
environment:
- NEO4J_AUTH=${NEO4J_AUTH}
- NEO4J_dbms_security_procedures_unrestricted=gds.*
- NEO4J_dbms_security_procedures_allowlist=gds.*
- NEO4J_dbms_security_procedures_unrestricted=gds.*,apoc.*
- NEO4J_dbms_security_procedures_allowlist=gds.*,apoc.*
- NEO4J_apoc_export_file_enabled=true # Enable file export
- NEO4J_apoc_import_file_enabled=true # Enable file import
- NEO4J_apoc_import_file_use_neo4j_config=true
- NEO4J_dbms_memory_heap_initial__size=2G
- NEO4J_dbms_memory_heap_max__size=4G
ports:
- "7474:7474"
- "7687:7687"

View File

@ -136,4 +136,4 @@ OBJECT_STORE_SECRET_ACCESS_KEY=very-safe-password
# TRAEFIK_HTTPS_PUBLISH_IP=0.0.0.0
# TRAEFIK_DASHBOARD_PUBLISH_IP=127.0.0.1
CORE_VERSION=0.1.13
CORE_VERSION=0.1.14

2
integrations/github/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
bin
node_modules

View File

@ -0,0 +1,22 @@
{
"arrowParens": "always",
"tabWidth": 2,
"useTabs": false,
"semi": true,
"bracketSpacing": true,
"jsxBracketSameLine": false,
"requirePragma": false,
"proseWrap": "preserve",
"singleQuote": true,
"formatOnSave": true,
"trailingComma": "all",
"printWidth": 100,
"overrides": [
{
"files": ".prettierrc",
"options": {
"parser": "json"
}
}
]
}

View File

@ -0,0 +1,98 @@
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const reactPlugin = require('eslint-plugin-react');
const jestPlugin = require('eslint-plugin-jest');
const importPlugin = require('eslint-plugin-import');
const prettierPlugin = require('eslint-plugin-prettier');
const unusedImportsPlugin = require('eslint-plugin-unused-imports');
const jsxA11yPlugin = require('eslint-plugin-jsx-a11y');
module.exports = [
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
react: reactPlugin,
jest: jestPlugin,
import: importPlugin,
prettier: prettierPlugin,
'unused-imports': unusedImportsPlugin,
'jsx-a11y': jsxA11yPlugin,
},
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'jsx-a11y/label-has-associated-control': 'error',
curly: 'warn',
'dot-location': 'warn',
eqeqeq: 'error',
'prettier/prettier': 'warn',
'unused-imports/no-unused-imports': 'warn',
'no-else-return': 'warn',
'no-lonely-if': 'warn',
'no-inner-declarations': 'off',
'no-unused-vars': 'off',
'no-useless-computed-key': 'warn',
'no-useless-return': 'warn',
'no-var': 'warn',
'object-shorthand': ['warn', 'always'],
'prefer-arrow-callback': 'warn',
'prefer-const': 'warn',
'prefer-destructuring': ['warn', { AssignmentExpression: { array: true } }],
'prefer-object-spread': 'warn',
'prefer-template': 'warn',
'spaced-comment': ['warn', 'always', { markers: ['/'] }],
yoda: 'warn',
'import/order': [
'warn',
{
'newlines-between': 'always',
groups: ['type', 'builtin', 'external', 'internal', ['parent', 'sibling'], 'index'],
pathGroupsExcludedImportTypes: ['builtin'],
pathGroups: [],
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'@typescript-eslint/array-type': ['warn', { default: 'array-simple' }],
'@typescript-eslint/ban-ts-comment': [
'warn',
{
'ts-expect-error': 'allow-with-description',
},
],
'@typescript-eslint/consistent-indexed-object-style': ['warn', 'record'],
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
'@typescript-eslint/no-unused-vars': 'warn',
'react/function-component-definition': [
'warn',
{
namedComponents: 'arrow-function',
unnamedComponents: 'arrow-function',
},
],
'react/jsx-boolean-value': 'warn',
'react/jsx-curly-brace-presence': 'warn',
'react/jsx-fragments': 'warn',
'react/jsx-no-useless-fragment': ['warn', { allowExpressions: true }],
'react/self-closing-comp': 'warn',
},
},
{
files: ['scripts/**/*'],
rules: {
'@typescript-eslint/no-var-requires': 'off',
},
},
];

View File

@ -0,0 +1,64 @@
{
"name": "@core/github",
"version": "0.1.0",
"description": "github extension for CORE",
"main": "./bin/index.js",
"module": "./bin/index.mjs",
"type": "module",
"files": [
"github",
"bin"
],
"bin": {
"github": "./bin/index.js"
},
"scripts": {
"build": "rimraf bin && npx tsup",
"lint": "eslint --ext js,ts,tsx backend/ frontend/ --fix",
"prettier": "prettier --config .prettierrc --write .",
"copy:spec": "cp spec.json bin/"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/preset-typescript": "^7.26.0",
"@types/node": "^18.0.20",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"prettier": "^3.4.2",
"rimraf": "^3.0.2",
"tslib": "^2.8.1",
"typescript": "^4.7.2",
"tsup": "^8.0.1",
"ncc": "0.3.6"
},
"publishConfig": {
"access": "public"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"axios": "^1.7.9",
"commander": "^12.0.0",
"openai": "^4.0.0",
"react-query": "^3.39.3",
"@redplanethq/sdk": "0.1.2"
}
}

4217
integrations/github/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import nodePolyfills from 'rollup-plugin-node-polyfills';
import { terser } from 'rollup-plugin-terser';
import typescript from 'rollup-plugin-typescript2';
export default [
{
input: 'backend/index.ts',
external: ['axios'],
output: [
{
file: 'dist/backend/index.js',
sourcemap: true,
format: 'cjs',
exports: 'named',
preserveModules: false,
},
],
plugins: [
nodePolyfills(),
json(),
resolve({ extensions: ['.js', '.ts'] }),
commonjs({
include: /\/node_modules\//,
}),
typescript({
tsconfig: 'tsconfig.json',
}),
terser(),
],
},
];

View File

@ -0,0 +1,25 @@
{
"name": "GitHub extension",
"key": "github",
"description": "Plan, track, and manage your agile and software development projects in GitHub. Customize your workflow, collaborate, and release great software.",
"icon": "github",
"schedule": {
"frequency": "*/5 * * * *"
},
"auth": {
"OAuth2": {
"token_url": "https://github.com/login/oauth/access_token",
"authorization_url": "https://github.com/login/oauth/authorize",
"scopes": ["user", "public_repo", "repo", "notifications", "gist", "read:org", "repo_hooks"],
"scope_separator": ","
}
},
"mcp": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer ${config:access_token}",
"Content-Type": "application/json"
}
}
}

View File

@ -0,0 +1,37 @@
import { getGithubData } from './utils';
export async function integrationCreate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any,
) {
const { oauthResponse } = data;
const integrationConfiguration = {
refresh_token: oauthResponse.refresh_token,
access_token: oauthResponse.access_token,
};
const user = await getGithubData(
'https://api.github.com/user',
integrationConfiguration.access_token,
);
return [
{
type: 'account',
data: {
settings: {
login: user.login,
username: user.login,
schedule: {
frequency: '*/15 * * * *',
},
},
accountId: user.id.toString(),
config: {
...integrationConfiguration,
mcp: { tokens: { access_token: integrationConfiguration.access_token } },
},
},
},
];
}

View File

@ -0,0 +1,69 @@
import { handleSchedule } from './schedule';
import { integrationCreate } from './account-create';
import {
IntegrationCLI,
IntegrationEventPayload,
IntegrationEventType,
Spec,
} from '@redplanethq/sdk';
export async function run(eventPayload: IntegrationEventPayload) {
switch (eventPayload.event) {
case IntegrationEventType.SETUP:
console.log(eventPayload.eventBody);
return await integrationCreate(eventPayload.eventBody);
case IntegrationEventType.SYNC:
return await handleSchedule(eventPayload.config, eventPayload.state);
default:
return { message: `The event payload type is ${eventPayload.event}` };
}
}
// CLI implementation that extends the base class
class GitHubCLI extends IntegrationCLI {
constructor() {
super('github', '1.0.0');
}
protected async handleEvent(eventPayload: IntegrationEventPayload): Promise<any> {
return await run(eventPayload);
}
protected async getSpec(): Promise<Spec> {
return {
name: 'GitHub extension',
key: 'github',
description:
'Plan, track, and manage your agile and software development projects in GitHub. Customize your workflow, collaborate, and release great software.',
icon: 'github',
auth: {
OAuth2: {
token_url: 'https://github.com/login/oauth/access_token',
authorization_url: 'https://github.com/login/oauth/authorize',
scopes: [
'user',
'public_repo',
'repo',
'notifications',
'gist',
'read:org',
'repo_hooks',
],
scope_separator: ',',
},
},
};
}
}
// Define a main function and invoke it directly.
// This works after bundling to JS and running with `node index.js`.
function main() {
const githubCLI = new GitHubCLI();
githubCLI.parse();
}
main();

View File

@ -0,0 +1,360 @@
import { getUserEvents, getGithubData } from './utils';
interface GitHubActivityCreateParams {
text: string;
sourceURL: string;
}
interface GitHubSettings {
lastSyncTime?: string;
lastUserEventTime?: string;
username?: string;
}
/**
* Creates an activity message based on GitHub data
*/
function createActivityMessage(params: GitHubActivityCreateParams) {
return {
type: 'activity',
data: {
text: params.text,
sourceURL: params.sourceURL,
},
};
}
/**
* Gets default sync time (24 hours ago)
*/
function getDefaultSyncTime(): string {
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
/**
* Fetches user information from GitHub
*/
async function fetchUserInfo(accessToken: string) {
try {
return await getGithubData('https://api.github.com/user', accessToken);
} catch (error) {
console.error('Error fetching GitHub user info:', error);
return null;
}
}
/**
* Processes GitHub notifications into activity messages
*/
async function processNotifications(accessToken: string, lastSyncTime: string): Promise<any[]> {
const activities = [];
const allowedReasons = [
'assign',
'review_requested',
'mention',
'state_change',
'subscribed',
'author',
'approval_requested',
'comment',
'ci_activity',
'invitation',
'member_feature_requested',
'security_alert',
'security_advisory_credit',
'team_mention',
];
let page = 1;
let hasMorePages = true;
while (hasMorePages) {
try {
const notifications = await getGithubData(
`https://api.github.com/notifications?page=${page}&per_page=50&all=true&since=${lastSyncTime}`,
accessToken,
);
if (!notifications || notifications.length === 0) {
hasMorePages = false;
break;
}
if (notifications.length < 50) {
hasMorePages = false;
} else {
page++;
}
for (const notification of notifications) {
try {
if (!allowedReasons.includes(notification.reason)) {
continue;
}
const repository = notification.repository;
const subject = notification.subject;
let title = '';
let sourceURL = '';
// Get the actual GitHub data for the notification
let githubData: any = {};
if (subject.url) {
try {
githubData = await getGithubData(subject.url, accessToken);
} catch (error) {
console.error('Error fetching GitHub data for notification:', error);
continue;
}
}
const url = githubData.html_url || notification.subject.url || '';
sourceURL = url;
const isIssue = subject.type === 'Issue';
const isPullRequest = subject.type === 'PullRequest';
const isComment = notification.reason === 'comment';
switch (notification.reason) {
case 'assign':
title = `${isIssue ? 'Issue' : 'PR'} assigned to you: #${githubData.number} - ${githubData.title}`;
break;
case 'author':
if (isComment) {
title = `New comment on your ${isIssue ? 'issue' : 'PR'} by ${githubData.user?.login}: ${githubData.body}`;
} else {
title = `You created this ${isIssue ? 'issue' : 'PR'}: #${githubData.number} - ${githubData.title}`;
}
break;
case 'comment':
title = `New comment by ${githubData.user?.login} in ${repository.full_name}: ${githubData.body}`;
break;
case 'manual':
title = `You subscribed to: #${githubData.number} - ${githubData.title}`;
break;
case 'mention':
title = `@mentioned by ${githubData.user?.login} in ${repository.full_name}: ${githubData.body}`;
break;
case 'review_requested':
title = `PR review requested in ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
break;
case 'state_change': {
let stateInfo = '';
if (githubData.state) {
stateInfo = `to ${githubData.state}`;
} else if (githubData.merged) {
stateInfo = 'to merged';
} else if (githubData.closed_at) {
stateInfo = 'to closed';
}
title = `State changed ${stateInfo} in ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
break;
}
case 'subscribed':
if (isComment) {
title = `New comment on watched ${isIssue ? 'issue' : 'PR'} in ${repository.full_name} by ${githubData.user?.login}: ${githubData.body}`;
} else if (isPullRequest) {
title = `New PR created in watched repo ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
} else if (isIssue) {
title = `New issue created in watched repo ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
} else {
title = `Update in watched repo ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
}
break;
case 'team_mention':
title = `Your team was mentioned in ${repository.full_name}`;
break;
default:
title = `GitHub notification: ${repository.full_name}`;
break;
}
if (title && sourceURL) {
activities.push(
createActivityMessage({
text: title,
sourceURL: sourceURL,
}),
);
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
hasMorePages = false;
}
}
return activities;
}
/**
* Processes user events (PRs, issues, comments) into activity messages
*/
async function processUserEvents(
username: string,
accessToken: string,
lastUserEventTime: string,
): Promise<any[]> {
const activities = [];
let page = 1;
let hasMorePages = true;
console.log('Processing user events');
while (hasMorePages) {
try {
const userEvents = await getUserEvents(username, page, accessToken, lastUserEventTime);
console.log('User events', userEvents);
if (!userEvents || userEvents.length === 0) {
hasMorePages = false;
break;
}
if (userEvents.length < 30) {
hasMorePages = false;
} else {
page++;
}
for (const event of userEvents) {
try {
let title = '';
const sourceURL = event.html_url || '';
switch (event.type) {
case 'pr':
title = `You created PR #${event.number}: ${event.title}`;
break;
case 'issue':
title = `You created issue #${event.number}: ${event.title}`;
break;
case 'pr_comment':
title = `You commented on PR #${event.number}: ${event.title}`;
break;
case 'issue_comment':
title = `You commented on issue #${event.number}: ${event.title}`;
break;
case 'self_assigned_issue':
title = `You assigned yourself to issue #${event.number}: ${event.title}`;
break;
default:
title = `GitHub activity: ${event.title || 'Unknown'}`;
break;
}
if (title && sourceURL) {
activities.push(
createActivityMessage({
text: title,
sourceURL: sourceURL,
}),
);
}
console.log('Activities', activities);
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
hasMorePages = false;
}
}
return activities;
}
export async function handleSchedule(config: any, state: any) {
try {
const integrationConfiguration = config;
// Check if we have a valid access token
if (!integrationConfiguration?.access_token) {
return [];
}
// Get settings or initialize if not present
let settings = (state || {}) as GitHubSettings;
// Default to 24 hours ago if no last sync times
const lastSyncTime = settings.lastSyncTime || getDefaultSyncTime();
const lastUserEventTime = settings.lastUserEventTime || getDefaultSyncTime();
// Fetch user info to get username if not available
let user;
try {
user = await fetchUserInfo(integrationConfiguration.access_token);
} catch (error) {
return [];
}
if (!user) {
return [];
}
// Update username in settings if not present
if (!settings.username && user.login) {
settings.username = user.login;
}
// Collect all messages
const messages = [];
// Process notifications
try {
const notificationActivities = await processNotifications(
integrationConfiguration.access_token,
lastSyncTime,
);
messages.push(...notificationActivities);
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
// Process user events if we have a username
if (settings.username) {
console.log('Processing user events');
try {
const userEventActivities = await processUserEvents(
settings.username,
integrationConfiguration.access_token,
lastUserEventTime,
);
messages.push(...userEventActivities);
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
}
// Update last sync times
const newSyncTime = new Date().toISOString();
// Add state message for saving settings
messages.push({
type: 'state',
data: {
...settings,
lastSyncTime: newSyncTime,
lastUserEventTime: newSyncTime,
},
});
return messages;
} catch (error) {
return [];
}
}

View File

@ -0,0 +1,97 @@
import axios from 'axios';
export async function getGithubData(url: string, accessToken: string) {
return (
await axios.get(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
})
).data;
}
/**
* Get user events (PRs, issues, comments) and also issues assigned to the user by themselves.
*/
export async function getUserEvents(
username: string,
page: number,
accessToken: string,
since?: string,
) {
try {
const formattedDate = since ? encodeURIComponent(since.split('T')[0]) : '';
// Search for user's PRs, issues, and comments since the last sync
const [
prsResponse,
issuesResponse,
commentsResponse,
// For self-assigned issues, we need to fetch issues assigned to the user and authored by the user
assignedIssuesResponse,
] = await Promise.all([
// Search for PRs created by user
getGithubData(
`https://api.github.com/search/issues?q=author:${username}+type:pr+created:>${formattedDate}&sort=created&order=desc&page=${page}&per_page=10`,
accessToken,
),
// Search for issues created by user
getGithubData(
`https://api.github.com/search/issues?q=author:${username}+type:issue+created:>${formattedDate}&sort=created&order=desc&page=${page}&per_page=10`,
accessToken,
),
// Search for issues/PRs the user commented on
getGithubData(
`https://api.github.com/search/issues?q=commenter:${username}+updated:>${formattedDate}&sort=updated&order=desc&page=${page}&per_page=10`,
accessToken,
),
// Search for issues assigned to the user and authored by the user (self-assigned)
getGithubData(
`https://api.github.com/search/issues?q=assignee:${username}+author:${username}+type:issue+updated:>${formattedDate}&sort=updated&order=desc&page=${page}&per_page=10`,
accessToken,
),
]);
console.log('PRs found:', prsResponse?.items?.length || 0);
console.log('Issues found:', issuesResponse?.items?.length || 0);
console.log('Comments found:', commentsResponse?.items?.length || 0);
console.log('Self-assigned issues found:', assignedIssuesResponse?.items?.length || 0);
// Return simplified results - combine PRs, issues, commented items, and self-assigned issues
const results = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(prsResponse?.items || []).map((item: any) => ({ ...item, type: 'pr' })),
...(issuesResponse?.items || [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((item: any) => !item.pull_request)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((item: any) => ({ ...item, type: 'issue' })),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(commentsResponse?.items || []).map((item: any) => ({
...item,
type: item.pull_request ? 'pr_comment' : 'issue_comment',
})),
// Add self-assigned issues, but only if not already present in issuesResponse
...(assignedIssuesResponse?.items || [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((item: any) => {
// Only include if not already in issuesResponse (by id)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return !(issuesResponse?.items || []).some((issue: any) => issue.id === item.id);
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((item: any) => ({
...item,
type: 'self_assigned_issue',
})),
];
// Sort by created_at descending
results.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return results;
} catch (error) {
console.error('Error fetching user activity via search:', error);
return [];
}
}

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "frontend",
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"strictNullChecks": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
"types": ["typePatches"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from 'tsup';
import { dependencies } from './package.json';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs'], // or esm if you're using that
bundle: true,
target: 'node16',
outDir: 'bin',
splitting: false,
shims: true,
clean: true,
name: 'github',
platform: 'node',
legacyOutput: false,
noExternal: Object.keys(dependencies || {}), // ⬅️ bundle all deps
treeshake: {
preset: 'recommended',
},
});

View File

@ -1,14 +1,14 @@
{
"name": "core",
"private": true,
"version": "0.1.13",
"version": "0.1.14",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "dotenv -- turbo run build",
"dev": "dotenv -- turbo run dev --filter=!@redplanethq/core",
"dev": "dotenv -- turbo run dev",
"lint": "dotenv -- turbo run lint",
"format": "dotenv -- prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "dotenv -- turbo run check-types",

View File

@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "RecallLog" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deleted" TIMESTAMP(3),
"accessType" TEXT NOT NULL,
"query" TEXT,
"targetType" TEXT,
"targetId" TEXT,
"searchMethod" TEXT,
"minSimilarity" DOUBLE PRECISION,
"maxResults" INTEGER,
"resultCount" INTEGER NOT NULL DEFAULT 0,
"similarityScore" DOUBLE PRECISION,
"context" TEXT,
"source" TEXT,
"sessionId" TEXT,
"responseTimeMs" INTEGER,
"userId" TEXT NOT NULL,
"workspaceId" TEXT,
"conversationId" TEXT,
"metadata" JSONB DEFAULT '{}',
CONSTRAINT "RecallLog_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "RecallLog" ADD CONSTRAINT "RecallLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RecallLog" ADD CONSTRAINT "RecallLog_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RecallLog" ADD CONSTRAINT "RecallLog_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -64,6 +64,7 @@ model Conversation {
status String @default("pending") // Can be "pending", "running", "completed", "failed", "need_attention"
ConversationHistory ConversationHistory[]
RecallLog RecallLog[]
}
model ConversationExecutionStep {
@ -423,6 +424,51 @@ model PersonalAccessToken {
authorizationCodes AuthorizationCode[]
}
model RecallLog {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted DateTime?
// Access details
accessType String // "search", "recall", "direct_access"
query String? // Search query (null for direct access)
// Target information
targetType String? // "episode", "statement", "entity", "mixed_results"
targetId String? // UUID of specific target (null for search with multiple results)
// Search/access parameters
searchMethod String? // "semantic", "keyword", "hybrid", "contextual", "graph_traversal"
minSimilarity Float? // Minimum similarity threshold used
maxResults Int? // Maximum results requested
// Results and interaction
resultCount Int @default(0) // Number of results returned
similarityScore Float? // Similarity score (for single result access)
// Context and source
context String? // Additional context
source String? // Source of the access (e.g., "chat", "api", "integration")
sessionId String? // Session identifier
// Performance metrics
responseTimeMs Int? // Response time in milliseconds
// Relations
user User @relation(fields: [userId], references: [id])
userId String
workspace Workspace? @relation(fields: [workspaceId], references: [id])
workspaceId String?
conversation Conversation? @relation(fields: [conversationId], references: [id])
conversationId String?
// Metadata for additional tracking data
metadata Json? @default("{}")
}
model Space {
id String @id @default(cuid())
name String
@ -505,6 +551,7 @@ model User {
oauthIntegrationGrants OAuthIntegrationGrant[]
oAuthClientInstallation OAuthClientInstallation[]
UserUsage UserUsage?
RecallLog RecallLog[]
}
model UserUsage {
@ -579,6 +626,7 @@ model Workspace {
OAuthAuthorizationCode OAuthAuthorizationCode[]
OAuthAccessToken OAuthAccessToken[]
OAuthRefreshToken OAuthRefreshToken[]
RecallLog RecallLog[]
}
enum AuthenticationMethod {

View File

@ -20,6 +20,7 @@ export interface EpisodicNode {
userId: string;
space?: string;
sessionId?: string;
recallCount?: number;
}
/**
@ -52,6 +53,7 @@ export interface StatementNode {
attributes: Record<string, any>;
userId: string;
space?: string;
recallCount?: number;
}
/**

276
pnpm-lock.yaml generated
View File

@ -463,6 +463,9 @@ importers:
'@trigger.dev/sdk':
specifier: ^4.0.0-v4-beta.22
version: 4.0.0-v4-beta.22(ai@4.3.14(react@18.3.1)(zod@3.23.8))(zod@3.23.8)
'@types/react-calendar-heatmap':
specifier: ^1.9.0
version: 1.9.0
ai:
specifier: 4.3.14
version: 4.3.14(react@18.3.1)(zod@3.23.8)
@ -571,6 +574,9 @@ importers:
react:
specifier: ^18.2.0
version: 18.3.1
react-calendar-heatmap:
specifier: ^1.10.0
version: 1.10.0(react@18.3.1)
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
@ -747,253 +753,6 @@ importers:
specifier: ^4.2.1
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.17.0)(yaml@2.8.0))
packages/core-cli:
dependencies:
'@clack/prompts':
specifier: ^0.10.0
version: 0.10.1
'@depot/cli':
specifier: 0.0.1-cli.2.80.0
version: 0.0.1-cli.2.80.0
'@opentelemetry/api':
specifier: 1.9.0
version: 1.9.0
'@opentelemetry/api-logs':
specifier: 0.52.1
version: 0.52.1
'@opentelemetry/exporter-logs-otlp-http':
specifier: 0.52.1
version: 0.52.1(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-trace-otlp-http':
specifier: 0.52.1
version: 0.52.1(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation':
specifier: 0.52.1
version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0)
'@opentelemetry/instrumentation-fetch':
specifier: 0.52.1
version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0)
'@opentelemetry/resources':
specifier: 1.25.1
version: 1.25.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs':
specifier: 0.52.1
version: 0.52.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-node':
specifier: 0.52.1
version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0)
'@opentelemetry/sdk-trace-base':
specifier: 1.25.1
version: 1.25.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-node':
specifier: 1.25.1
version: 1.25.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions':
specifier: 1.25.1
version: 1.25.1
ansi-escapes:
specifier: ^7.0.0
version: 7.0.0
braces:
specifier: ^3.0.3
version: 3.0.3
c12:
specifier: ^1.11.1
version: 1.11.2(magicast@0.3.5)
chalk:
specifier: ^5.2.0
version: 5.4.1
chokidar:
specifier: ^3.6.0
version: 3.6.0
cli-table3:
specifier: ^0.6.3
version: 0.6.5
commander:
specifier: ^9.4.1
version: 9.5.0
defu:
specifier: ^6.1.4
version: 6.1.4
dotenv:
specifier: ^16.4.5
version: 16.5.0
dotenv-expand:
specifier: ^12.0.2
version: 12.0.2
esbuild:
specifier: ^0.23.0
version: 0.23.1
eventsource:
specifier: ^3.0.2
version: 3.0.7
evt:
specifier: ^2.4.13
version: 2.5.9
fast-npm-meta:
specifier: ^0.2.2
version: 0.2.2
git-last-commit:
specifier: ^1.0.1
version: 1.0.1
gradient-string:
specifier: ^2.0.2
version: 2.0.2
has-flag:
specifier: ^5.0.1
version: 5.0.1
import-in-the-middle:
specifier: 1.11.0
version: 1.11.0
import-meta-resolve:
specifier: ^4.1.0
version: 4.1.0
ini:
specifier: ^5.0.0
version: 5.0.0
jsonc-parser:
specifier: 3.2.1
version: 3.2.1
knex:
specifier: 3.1.0
version: 3.1.0(pg@8.16.3)(supports-color@10.0.0)
magicast:
specifier: ^0.3.4
version: 0.3.5
minimatch:
specifier: ^10.0.1
version: 10.0.2
mlly:
specifier: ^1.7.1
version: 1.7.4
nanoid:
specifier: 3.3.8
version: 3.3.8
nypm:
specifier: ^0.5.4
version: 0.5.4
object-hash:
specifier: ^3.0.0
version: 3.0.0
open:
specifier: ^10.0.3
version: 10.2.0
p-limit:
specifier: ^6.2.0
version: 6.2.0
p-retry:
specifier: ^6.1.0
version: 6.2.1
partysocket:
specifier: ^1.0.2
version: 1.1.4
pg:
specifier: 8.16.3
version: 8.16.3
pkg-types:
specifier: ^1.1.3
version: 1.3.1
polka:
specifier: ^0.5.2
version: 0.5.2
resolve:
specifier: ^1.22.8
version: 1.22.10
semver:
specifier: ^7.5.0
version: 7.7.2
signal-exit:
specifier: ^4.1.0
version: 4.1.0
source-map-support:
specifier: 0.5.21
version: 0.5.21
std-env:
specifier: ^3.7.0
version: 3.9.0
supports-color:
specifier: ^10.0.0
version: 10.0.0
tiny-invariant:
specifier: ^1.2.0
version: 1.3.3
tinyexec:
specifier: ^0.3.1
version: 0.3.2
tinyglobby:
specifier: ^0.2.10
version: 0.2.14
uuid:
specifier: 11.1.0
version: 11.1.0
ws:
specifier: ^8.18.0
version: 8.18.3
xdg-app-paths:
specifier: ^8.3.0
version: 8.3.0
zod:
specifier: 3.23.8
version: 3.23.8
zod-validation-error:
specifier: ^1.5.0
version: 1.5.0(zod@3.23.8)
devDependencies:
'@epic-web/test-server':
specifier: ^0.1.0
version: 0.1.6
'@types/gradient-string':
specifier: ^1.1.2
version: 1.1.6
'@types/ini':
specifier: ^4.1.1
version: 4.1.1
'@types/object-hash':
specifier: 3.0.6
version: 3.0.6
'@types/polka':
specifier: ^0.5.7
version: 0.5.7
'@types/react':
specifier: ^18.2.48
version: 18.2.69
'@types/resolve':
specifier: ^1.20.6
version: 1.20.6
'@types/rimraf':
specifier: ^4.0.5
version: 4.0.5
'@types/semver':
specifier: ^7.5.0
version: 7.7.0
'@types/source-map-support':
specifier: 0.5.10
version: 0.5.10
'@types/ws':
specifier: ^8.5.3
version: 8.18.1
cpy-cli:
specifier: ^5.0.0
version: 5.0.0
execa:
specifier: ^8.0.1
version: 8.0.1
find-up:
specifier: ^7.0.0
version: 7.0.0
rimraf:
specifier: ^5.0.7
version: 5.0.10
ts-essentials:
specifier: 10.0.1
version: 10.0.1(typescript@5.8.3)
tshy:
specifier: ^3.0.2
version: 3.0.2
tsx:
specifier: 4.17.0
version: 4.17.0
packages/database:
dependencies:
'@prisma/client':
@ -5218,6 +4977,9 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react-calendar-heatmap@1.9.0':
resolution: {integrity: sha512-BH8M/nsXoLGa3hxWbrq3guPwlK0cV+w1i4c/ktrTxTzN5fBths6WbeUZ4dK0+tE76qiGoVSo9Tse8WVVuMIV+w==}
'@types/react-dom@18.2.18':
resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==}
@ -8519,6 +8281,9 @@ packages:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
memorystream@0.3.1:
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
engines: {node: '>= 0.10.0'}
@ -9902,6 +9667,11 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
react-calendar-heatmap@1.10.0:
resolution: {integrity: sha512-e5vcrzMWzKIF710egr1FpjWyuDEFeZm39nvV25muc8Wtqqi8iDOfqREELeQ9Wouqf9hhj939gq0i+iAxo7KdSw==}
peerDependencies:
react: '>=0.14.0'
react-css-styled@1.1.9:
resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==}
@ -16153,6 +15923,10 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react-calendar-heatmap@1.9.0':
dependencies:
'@types/react': 18.2.69
'@types/react-dom@18.2.18':
dependencies:
'@types/react': 18.2.69
@ -20145,6 +19919,8 @@ snapshots:
media-typer@1.1.0: {}
memoize-one@5.2.1: {}
memorystream@0.3.1: {}
meow@12.1.1: {}
@ -21735,6 +21511,12 @@ snapshots:
defu: 6.1.4
destr: 2.0.5
react-calendar-heatmap@1.10.0(react@18.3.1):
dependencies:
memoize-one: 5.2.1
prop-types: 15.8.1
react: 18.3.1
react-css-styled@1.1.9:
dependencies:
css-styled: 1.0.8