Feat: Space (#93)

* Feat: change space assignment from statement to episode

* feat: add default spaces and improve integration, space tools discovery in MCP

* feat: change spaces to episode based

* Feat: take multiple spaceIds while ingesting

* Feat: modify mcp tool descriptions, add spaceId in mcp url

* feat: add copy

* bump: new version 0.1.24

---------

Co-authored-by: Manoj <saimanoj58@gmail.com>
This commit is contained in:
Harshith Mullapudi 2025-10-09 12:38:42 +05:30 committed by GitHub
parent 27f8740691
commit bcc0560cf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 2451 additions and 1258 deletions

View File

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

View File

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

View File

@ -0,0 +1,71 @@
import { useState } from "react";
import { FileText, Plus } from "lucide-react";
import {
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "../ui/command";
import { AddMemoryDialog } from "./memory-dialog.client";
import { AddDocumentDialog } from "./document-dialog";
interface AddMemoryCommandProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddMemoryCommand({
open,
onOpenChange,
}: AddMemoryCommandProps) {
const [showAddMemory, setShowAddMemory] = useState(false);
const [showAddDocument, setShowAddDocument] = useState(false);
const handleAddMemory = () => {
onOpenChange(false);
setShowAddMemory(true);
};
const handleAddDocument = () => {
onOpenChange(false);
setShowAddDocument(true);
};
return (
<>
{/* Main Command Dialog */}
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search" className="py-1" />
<CommandList>
<CommandGroup heading="Add to Memory">
<CommandItem
onSelect={handleAddMemory}
className="flex items-center gap-2 py-1"
>
<Plus className="mr-2 h-4 w-4" />
<span>Add Memory</span>
</CommandItem>
<CommandItem
onSelect={handleAddDocument}
className="flex items-center gap-2 py-1"
>
<FileText className="mr-2 h-4 w-4" />
<span>Add Document</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
{showAddMemory && (
<AddMemoryDialog open={showAddMemory} onOpenChange={setShowAddMemory} />
)}
{/* Add Document Dialog */}
<AddDocumentDialog
open={showAddDocument}
onOpenChange={setShowAddDocument}
/>
</>
);
}

View File

@ -0,0 +1,27 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
interface AddDocumentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddDocumentDialog({
open,
onOpenChange,
}: AddDocumentDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add Document</DialogTitle>
</DialogHeader>
{/* TODO: Add document content here */}
<div className="border-border rounded-md border p-4">
<p className="text-muted-foreground text-sm">
Document upload content goes here...
</p>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,95 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
import { useEditor, EditorContent } from "@tiptap/react";
import {
extensionsForConversation,
getPlaceholder,
} from "../conversation/editor-extensions";
import { Button } from "../ui/button";
import { SpaceDropdown } from "../spaces/space-dropdown";
import React from "react";
import { useFetcher } from "@remix-run/react";
interface AddMemoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultSpaceId?: string;
}
export function AddMemoryDialog({
open,
onOpenChange,
defaultSpaceId,
}: AddMemoryDialogProps) {
const [spaceIds, setSpaceIds] = React.useState<string[]>(
defaultSpaceId ? [defaultSpaceId] : [],
);
const fetcher = useFetcher();
const editor = useEditor({
extensions: [
...extensionsForConversation,
getPlaceholder("Write your memory here..."),
],
editorProps: {
attributes: {
class:
"prose prose-sm focus:outline-none max-w-full min-h-[200px] p-4 py-0",
},
},
});
const handleAdd = async () => {
const content = editor?.getText();
if (!content?.trim()) return;
const payload = {
episodeBody: content,
referenceTime: new Date().toISOString(),
spaceIds: spaceIds,
source: "core",
};
fetcher.submit(payload, {
method: "POST",
action: "/api/v1/add",
encType: "application/json",
});
// Clear editor and close dialog
editor?.commands.clearContent();
setSpaceIds([]);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="pt-0 sm:max-w-[600px]">
<div className="overflow-hidden rounded-md">
<EditorContent editor={editor} />
</div>
<div className="flex justify-between gap-2 px-4 pb-4">
<div>
<SpaceDropdown
episodeIds={[]}
selectedSpaceIds={spaceIds}
onSpaceChange={(spaceIds) => {
setSpaceIds(spaceIds);
}}
/>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="secondary"
onClick={handleAdd}
isLoading={fetcher.state !== "idle"}
>
Add
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -9,6 +9,7 @@ import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row"; import TableRow from "@tiptap/extension-table-row";
import { all, createLowlight } from "lowlight"; import { all, createLowlight } from "lowlight";
import { mergeAttributes, type Extension } from "@tiptap/react"; import { mergeAttributes, type Extension } from "@tiptap/react";
import { Markdown } from "tiptap-markdown";
// create a lowlight instance with all languages loaded // create a lowlight instance with all languages loaded
export const lowlight = createLowlight(all); export const lowlight = createLowlight(all);
@ -136,4 +137,5 @@ export const extensionsForConversation = [
CodeBlockLowlight.configure({ CodeBlockLowlight.configure({
lowlight, lowlight,
}), }),
Markdown,
]; ];

View File

@ -83,10 +83,14 @@ export const GraphClusteringVisualization = forwardRef<
filtered = filtered.filter((triplet) => { filtered = filtered.filter((triplet) => {
const sourceMatches = const sourceMatches =
isEpisodeNode(triplet.sourceNode) && isEpisodeNode(triplet.sourceNode) &&
triplet.sourceNode.attributes?.content?.toLowerCase().includes(query); triplet.sourceNode.attributes?.content
?.toLowerCase()
.includes(query);
const targetMatches = const targetMatches =
isEpisodeNode(triplet.targetNode) && isEpisodeNode(triplet.targetNode) &&
triplet.targetNode.attributes?.content?.toLowerCase().includes(query); triplet.targetNode.attributes?.content
?.toLowerCase()
.includes(query);
return sourceMatches || targetMatches; return sourceMatches || targetMatches;
}); });

View File

@ -1,6 +1,6 @@
import { useState, useEffect, type ReactNode } from "react"; import { useState, useEffect, type ReactNode } from "react";
import { useFetcher } from "@remix-run/react"; import { useFetcher } from "@remix-run/react";
import { AlertCircle, Loader2 } from "lucide-react"; import { AlertCircle, File, Loader2, MessageSquare } from "lucide-react";
import { Badge, BadgeColor } from "../ui/badge"; import { Badge, BadgeColor } from "../ui/badge";
import { type LogItem } from "~/hooks/use-logs"; import { type LogItem } from "~/hooks/use-logs";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
@ -8,6 +8,7 @@ import { getIconForAuthorise } from "../icon-utils";
import { cn, formatString } from "~/lib/utils"; import { cn, formatString } from "~/lib/utils";
import { getStatusColor } from "./utils"; import { getStatusColor } from "./utils";
import { format } from "date-fns"; import { format } from "date-fns";
import { SpaceDropdown } from "../spaces/space-dropdown";
interface LogDetailsProps { interface LogDetailsProps {
log: LogItem; log: LogItem;
@ -33,13 +34,13 @@ function PropertyItem({
if (!value) return null; if (!value) return null;
return ( return (
<div className="flex items-center py-1"> <div className="flex items-center py-1 !text-base">
<span className="text-muted-foreground min-w-[160px]">{label}</span> <span className="text-muted-foreground min-w-[120px]">{label}</span>
{variant === "status" ? ( {variant === "status" ? (
<Badge <Badge
className={cn( className={cn(
"!bg-grayAlpha-100 text-muted-foreground h-7 rounded px-4 text-xs", "text-foreground h-7 items-center gap-2 rounded !bg-transparent px-4.5 !text-base",
className, className,
)} )}
> >
@ -49,7 +50,13 @@ function PropertyItem({
{value} {value}
</Badge> </Badge>
) : ( ) : (
<Badge variant={variant} className={cn("h-7 rounded px-4", className)}> <Badge
variant={variant}
className={cn(
"h-7 items-center gap-2 rounded bg-transparent px-4 !text-base",
className,
)}
>
{icon} {icon}
{value} {value}
</Badge> </Badge>
@ -73,10 +80,10 @@ interface EpisodeFactsResponse {
function getStatusValue(status: string) { function getStatusValue(status: string) {
if (status === "PENDING") { if (status === "PENDING") {
return "In Queue"; return formatString("IN QUEUE");
} }
return status; return formatString(status);
} }
export function LogDetails({ log }: LogDetailsProps) { export function LogDetails({ log }: LogDetailsProps) {
@ -113,6 +120,9 @@ export function LogDetails({ log }: LogDetailsProps) {
} else if (log.episodeUUID) { } else if (log.episodeUUID) {
setFactsLoading(true); setFactsLoading(true);
fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`); fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`);
} else {
setFacts([]);
setInvalidFacts([]);
} }
}, [log.episodeUUID, log.data?.type, log.data?.episodes, facts.length]); }, [log.episodeUUID, log.data?.type, log.data?.episodes, facts.length]);
@ -129,41 +139,8 @@ export function LogDetails({ log }: LogDetailsProps) {
return ( return (
<div className="flex h-full w-full flex-col items-center overflow-auto"> <div className="flex h-full w-full flex-col items-center overflow-auto">
<div className="max-w-4xl"> <div className="max-w-4xl">
<div className="px-4 pt-4"> <div className="mt-5 mb-5 px-4">
<div className="mb-4 flex w-full items-center justify-between">
<span>Episode Details</span>
</div>
</div>
<div className="mb-10 px-4">
<div className="space-y-1"> <div className="space-y-1">
{log.data?.type === "DOCUMENT" && log.data?.episodes ? (
<PropertyItem
label="Episodes"
value={
<div className="flex flex-wrap gap-1">
{log.data.episodes.map(
(episodeId: string, index: number) => (
<Badge
key={index}
variant="outline"
className="text-xs"
>
{episodeId}
</Badge>
),
)}
</div>
}
variant="secondary"
/>
) : (
<PropertyItem
label="Episode Id"
value={log.episodeUUID}
variant="secondary"
/>
)}
<PropertyItem <PropertyItem
label="Session Id" label="Session Id"
value={log.data?.sessionId?.toLowerCase()} value={log.data?.sessionId?.toLowerCase()}
@ -174,6 +151,13 @@ export function LogDetails({ log }: LogDetailsProps) {
value={formatString( value={formatString(
log.data?.type ? log.data.type.toLowerCase() : "conversation", log.data?.type ? log.data.type.toLowerCase() : "conversation",
)} )}
icon={
log.data?.type === "CONVERSATION" ? (
<MessageSquare size={16} />
) : (
<File size={16} />
)
}
variant="secondary" variant="secondary"
/> />
<PropertyItem <PropertyItem
@ -192,15 +176,28 @@ export function LogDetails({ log }: LogDetailsProps) {
variant="status" variant="status"
statusColor={log.status && getStatusColor(log.status)} statusColor={log.status && getStatusColor(log.status)}
/> />
{/* Space Assignment for CONVERSATION type */}
{log.data.type.toLowerCase() === "conversation" &&
log?.episodeUUID && (
<div className="mt-2 flex items-start py-1">
<span className="text-muted-foreground min-w-[120px]">
Spaces
</span>
<SpaceDropdown
className="px-3"
episodeIds={[log.episodeUUID]}
selectedSpaceIds={log.spaceIds || []}
/>
</div>
)}
</div> </div>
</div> </div>
{/* Error Details */} {/* Error Details */}
{log.error && ( {log.error && (
<div className="mb-6 px-4"> <div className="mb-6 px-4">
<div className="mb-2 flex w-full items-center justify-between">
<span>Error Details</span>
</div>
<div className="bg-destructive/10 rounded-md p-3"> <div className="bg-destructive/10 rounded-md p-3">
<div className="flex items-start gap-2 text-red-600"> <div className="flex items-start gap-2 text-red-600">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" /> <AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
@ -212,21 +209,63 @@ export function LogDetails({ log }: LogDetailsProps) {
</div> </div>
)} )}
<div className="flex flex-col items-center p-4 pt-0"> {log.data?.type === "CONVERSATION" && (
<div className="mb-2 flex w-full items-center justify-between"> <div className="flex flex-col items-center p-4 pt-0">
<span>Content</span> {/* Log Content */}
</div> <div className="mb-4 w-full break-words whitespace-pre-wrap">
{/* Log Content */} <div className="rounded-md">
<div className="mb-4 w-full text-sm break-words whitespace-pre-wrap"> <Markdown>{log.ingestText}</Markdown>
<div className="rounded-md"> </div>
<Markdown>{log.ingestText}</Markdown>
</div> </div>
</div> </div>
</div> )}
{/* Episodes List for DOCUMENT type */}
{log.data?.type === "DOCUMENT" && log.episodeDetails?.length > 0 && (
<div className="mb-6 px-4">
<div className="mb-2 flex w-full items-center justify-between font-medium">
<span>Episodes ({log.episodeDetails.length})</span>
</div>
<div className="flex flex-col gap-3">
{log.episodeDetails.map((episode: any, index: number) => (
<div
key={episode.uuid}
className="bg-grayAlpha-100 flex flex-col gap-3 rounded-md p-3"
>
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-muted-foreground text-xs">
Episode {index + 1}
</span>
<span className="truncate font-mono text-xs">
{episode.uuid}
</span>
</div>
<div className="flex-shrink-0">
<SpaceDropdown
episodeIds={[episode.uuid]}
selectedSpaceIds={episode.spaceIds || []}
/>
</div>
</div>
{/* Episode Content */}
<div className="border-grayAlpha-200 border-t pt-3">
<div className="text-muted-foreground mb-1 text-xs">
Content
</div>
<div className="text-sm break-words whitespace-pre-wrap">
<Markdown>{episode.content}</Markdown>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Episode Facts */} {/* Episode Facts */}
<div className="mb-6 px-4"> <div className="mb-6 px-4">
<div className="mb-2 flex w-full items-center justify-between"> <div className="mb-2 flex w-full items-center justify-between font-medium">
<span>Facts</span> <span>Facts</span>
</div> </div>
<div className="rounded-md"> <div className="rounded-md">

View File

@ -1,4 +1,4 @@
import { EllipsisVertical, Trash } from "lucide-react"; import { EllipsisVertical, Trash, Copy } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -18,6 +18,7 @@ import {
} from "../ui/alert-dialog"; } from "../ui/alert-dialog";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useFetcher, useNavigate } from "@remix-run/react"; import { useFetcher, useNavigate } from "@remix-run/react";
import { toast } from "~/hooks/use-toast";
interface LogOptionsProps { interface LogOptionsProps {
id: string; id: string;
@ -40,8 +41,24 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
}; };
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(id);
toast({
title: "Copied",
description: "Episode ID copied to clipboard",
});
} catch (err) {
console.error("Failed to copy:", err);
toast({
title: "Error",
description: "Failed to copy ID",
variant: "destructive",
});
}
};
useEffect(() => { useEffect(() => {
console.log(deleteFetcher.state, deleteFetcher.data);
if (deleteFetcher.state === "idle" && deleteFetcher.data?.success) { if (deleteFetcher.state === "idle" && deleteFetcher.data?.success) {
navigate(`/home/inbox`); navigate(`/home/inbox`);
} }
@ -49,16 +66,26 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
return ( return (
<> <>
<Button <div className="flex items-center gap-2">
variant="secondary" <Button
size="sm" variant="secondary"
className="gap-2 rounded" size="sm"
onClick={(e) => { className="gap-2 rounded"
setDeleteDialogOpen(true); onClick={handleCopy}
}} >
> <Copy size={15} /> Copy ID
<Trash size={15} /> Delete </Button>
</Button> <Button
variant="secondary"
size="sm"
className="gap-2 rounded"
onClick={(e) => {
setDeleteDialogOpen(true);
}}
>
<Trash size={15} /> Delete
</Button>
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>

View File

@ -4,6 +4,7 @@ import { type LogItem } from "~/hooks/use-logs";
import { getIconForAuthorise } from "../icon-utils"; import { getIconForAuthorise } from "../icon-utils";
import { useNavigate, useParams } from "@remix-run/react"; import { useNavigate, useParams } from "@remix-run/react";
import { getStatusColor, getStatusValue } from "./utils"; import { getStatusColor, getStatusValue } from "./utils";
import { File, MessageSquare } from "lucide-react";
interface LogTextCollapseProps { interface LogTextCollapseProps {
text?: string; text?: string;
@ -49,9 +50,13 @@ export function LogTextCollapse({ text, log }: LogTextCollapseProps) {
}; };
const getIngestType = (log: LogItem) => { const getIngestType = (log: LogItem) => {
const type = log.type ?? log.data.type ?? "Conversation"; const type = log.type ?? log.data.type ?? "CONVERSATION";
return type[0].toUpperCase(); return type === "CONVERSATION" ? (
<MessageSquare size={14} />
) : (
<File size={14} />
);
}; };
return ( return (
@ -100,7 +105,7 @@ export function LogTextCollapse({ text, log }: LogTextCollapseProps) {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Badge <Badge
className={cn( className={cn(
"!bg-grayAlpha-100 text-muted-foreground rounded text-xs", "text-muted-foreground rounded !bg-transparent text-xs",
)} )}
> >
{getIngestType(log)} {getIngestType(log)}

View File

@ -22,5 +22,5 @@ export function getStatusValue(status: string) {
return formatString("In Queue"); return formatString("In Queue");
} }
return status; return formatString(status);
} }

View File

@ -10,6 +10,7 @@ import {
import { type LogItem } from "~/hooks/use-logs"; import { type LogItem } from "~/hooks/use-logs";
import { ScrollManagedList } from "../virtualized-list"; import { ScrollManagedList } from "../virtualized-list";
import { LogTextCollapse } from "./log-text-collapse"; import { LogTextCollapse } from "./log-text-collapse";
import { LoaderCircle } from "lucide-react";
interface VirtualLogsListProps { interface VirtualLogsListProps {
logs: LogItem[]; logs: LogItem[];
@ -139,7 +140,7 @@ export function VirtualLogsList({
{isLoading && ( {isLoading && (
<div className="text-muted-foreground p-4 text-center text-sm"> <div className="text-muted-foreground p-4 text-center text-sm">
Loading more logs... <LoaderCircle size={18} className="mr-1 animate-spin" />
</div> </div>
)} )}
</div> </div>

View File

@ -139,6 +139,7 @@ export default function OnboardingQuestionComponent({
variant="ghost" variant="ghost"
size="xl" size="xl"
onClick={onPrevious} onClick={onPrevious}
disabled={loading}
className="rounded-lg px-4 py-2" className="rounded-lg px-4 py-2"
> >
Previous Previous
@ -151,7 +152,7 @@ export default function OnboardingQuestionComponent({
size="xl" size="xl"
onClick={onNext} onClick={onNext}
isLoading={!!loading} isLoading={!!loading}
disabled={!isValid()} disabled={!isValid() || loading}
className="rounded-lg px-4 py-2" className="rounded-lg px-4 py-2"
> >
{isLast ? "Complete Profile" : "Continue"} {isLast ? "Complete Profile" : "Continue"}

View File

@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { import {
Sidebar, Sidebar,
@ -12,14 +13,20 @@ import {
Columns3, Columns3,
Inbox, Inbox,
LayoutGrid, LayoutGrid,
LoaderCircle,
MessageSquare, MessageSquare,
Network, Network,
Plus,
} from "lucide-react"; } from "lucide-react";
import { NavMain } from "./nav-main"; import { NavMain } from "./nav-main";
import { useUser } from "~/hooks/useUser"; import { useUser } from "~/hooks/useUser";
import { NavUser } from "./nav-user"; import { NavUser } from "./nav-user";
import Logo from "../logo/logo"; import Logo from "../logo/logo";
import { ConversationList } from "../conversation"; import { ConversationList } from "../conversation";
import { Button } from "../ui";
import { Project } from "../icons/project";
import { AddMemoryCommand } from "../command-bar/add-memory-command";
import { AddMemoryDialog } from "../command-bar/memory-dialog.client";
const data = { const data = {
navMain: [ navMain: [
@ -41,7 +48,7 @@ const data = {
{ {
title: "Spaces", title: "Spaces",
url: "/home/space", url: "/home/space",
icon: Columns3, icon: Project,
}, },
{ {
title: "Integrations", title: "Integrations",
@ -54,33 +61,57 @@ const data = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const user = useUser(); const user = useUser();
return ( const [showAddMemory, setShowAddMemory] = React.useState(false);
<Sidebar
variant="inset"
{...props}
className="bg-background h-[100vh] py-2"
>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
<Logo size={20} />
C.O.R.E.
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<div className="mt-4 flex h-full flex-col">
<h2 className="text-muted-foreground px-4 text-sm"> History </h2>
<ConversationList />
</div>
</SidebarContent>
<SidebarFooter className="px-2"> // Open command bar with Meta+K (Cmd+K on Mac, Ctrl+K on Windows/Linux)
<NavUser user={user} /> useHotkeys("meta+k", (e) => {
</SidebarFooter> e.preventDefault();
</Sidebar> setShowAddMemory(true);
});
return (
<>
<Sidebar
variant="inset"
{...props}
className="bg-background h-[100vh] py-2"
>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem className="flex justify-center">
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
<Logo size={20} />
C.O.R.E.
</div>
<Button
variant="secondary"
isActive
size="sm"
className="rounded"
onClick={() => setShowAddMemory(true)}
>
<Plus size={16} />
</Button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<div className="mt-4 flex h-full flex-col">
<h2 className="text-muted-foreground px-4 text-sm"> History </h2>
<ConversationList />
</div>
</SidebarContent>
<SidebarFooter className="flex flex-col px-2">
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
{showAddMemory && (
<AddMemoryDialog open={showAddMemory} onOpenChange={setShowAddMemory} />
)}
</>
); );
} }

View File

@ -67,6 +67,15 @@ export function NavUser({ user }: { user: ExtendedUser }) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button
variant="ghost"
onClick={() => {
navigate("/settings/billing");
}}
>
<div>{user.availableCredits} credits</div>
</Button>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
); );

View File

@ -17,8 +17,8 @@ interface SpaceCardProps {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
autoMode: boolean; autoMode: boolean;
statementCount: number | null;
summary: string | null; summary: string | null;
contextCount?: number | null;
themes?: string[]; themes?: string[];
}; };
} }
@ -46,13 +46,17 @@ export function SpaceCard({ space }: SpaceCardProps) {
</div> </div>
<CardTitle className="text-base">{space.name}</CardTitle> <CardTitle className="text-base">{space.name}</CardTitle>
<CardDescription className="line-clamp-2 text-xs"> <CardDescription className="line-clamp-2 text-xs">
{space.description || space.summary || "Knowledge space"} <p
dangerouslySetInnerHTML={{
__html: space.description || space.summary || "Knowledge space",
}}
></p>
</CardDescription> </CardDescription>
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs"> <div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
{space.statementCount && space.statementCount > 0 && ( {space.contextCount && space.contextCount > 0 && (
<div> <div>
{space.statementCount} fact {space.contextCount} episode
{space.statementCount !== 1 ? "s" : ""} {space.contextCount !== 1 ? "s" : ""}
</div> </div>
)} )}
</div> </div>

View File

@ -0,0 +1,167 @@
import { useState, useEffect } from "react";
import { Check, Plus, X } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "~/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "~/components/ui/command";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import { useFetcher } from "@remix-run/react";
import { Project } from "../icons/project";
interface Space {
id: string;
name: string;
description?: string;
}
interface SpaceDropdownProps {
episodeIds: string[];
selectedSpaceIds?: string[];
onSpaceChange?: (spaceIds: string[]) => void;
className?: string;
}
export function SpaceDropdown({
episodeIds,
selectedSpaceIds = [],
onSpaceChange,
className,
}: SpaceDropdownProps) {
const [open, setOpen] = useState(false);
const [selectedSpaces, setSelectedSpaces] =
useState<string[]>(selectedSpaceIds);
const [spaces, setSpaces] = useState<Space[]>([]);
const spacesFetcher = useFetcher<{ spaces: Space[] }>();
const assignFetcher = useFetcher();
// Fetch all spaces
useEffect(() => {
spacesFetcher.load("/api/v1/spaces");
}, []);
// Update spaces when data is fetched
useEffect(() => {
if (spacesFetcher.data?.spaces) {
setSpaces(spacesFetcher.data.spaces);
}
}, [spacesFetcher.data]);
const handleSpaceToggle = (spaceId: string) => {
const newSelectedSpaces = selectedSpaces.includes(spaceId)
? selectedSpaces.filter((id) => id !== spaceId)
: [...selectedSpaces, spaceId];
setSelectedSpaces(newSelectedSpaces);
if (episodeIds) {
assignFetcher.submit(
{
episodeIds: JSON.stringify(episodeIds),
spaceId,
action: selectedSpaces.includes(spaceId) ? "remove" : "assign",
},
{
method: "post",
action: "/api/v1/episodes/assign-space",
encType: "application/json",
},
);
}
// Call the callback if provided
if (onSpaceChange) {
onSpaceChange(newSelectedSpaces);
}
};
const selectedSpaceObjects = spaces.filter((space) =>
selectedSpaces.includes(space.id),
);
const getTrigger = () => {
if (selectedSpaceObjects?.length === 1) {
return (
<>
<Project size={14} /> {selectedSpaceObjects[0].name}
</>
);
}
if (selectedSpaceObjects?.length > 1) {
return (
<>
<Project size={14} /> {selectedSpaceObjects.length} Spaces
</>
);
}
return (
<>
{" "}
<Project size={14} />
Spaces
</>
);
};
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
{/* + button to add more spaces */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="sm"
role="combobox"
aria-expanded={open}
className="h-7 gap-1 rounded"
>
{getTrigger()}
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent className="w-[250px] p-0" align="end">
<Command>
<CommandInput placeholder="Search spaces..." />
<CommandList>
<CommandEmpty>No spaces found.</CommandEmpty>
<CommandGroup>
{spaces.map((space) => (
<CommandItem
key={space.id}
value={space.name}
onSelect={() => handleSpaceToggle(space.id)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedSpaces.includes(space.id)
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="text-sm">{space.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
</div>
);
}

View File

@ -2,12 +2,27 @@ import { Calendar } from "lucide-react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import type { StatementNode } from "@core/types"; import type { StatementNode } from "@core/types";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useNavigate } from "@remix-run/react";
import Markdown from "react-markdown";
interface SpaceFactCardProps { export interface Episode {
fact: StatementNode; uuid: string;
content: string;
originalContent: string;
source: any;
createdAt: Date;
validAt: Date;
metadata: any;
sessionId: any;
logId?: any;
} }
export function SpaceFactCard({ fact }: SpaceFactCardProps) { interface SpaceFactCardProps {
episode: Episode;
}
export function SpaceEpisodeCard({ episode }: SpaceFactCardProps) {
const navigate = useNavigate();
const formatDate = (date: Date | string) => { const formatDate = (date: Date | string) => {
const d = new Date(date); const d = new Date(date);
return d.toLocaleDateString("en-US", { return d.toLocaleDateString("en-US", {
@ -17,18 +32,20 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
}); });
}; };
const displayText = fact.fact; const displayText = episode.originalContent;
const recallCount = const onClick = () => {
(fact.recallCount?.high ?? 0) + (fact.recallCount?.low ?? 0); navigate(`/home/inbox/${episode.logId}`);
};
return ( return (
<> <>
<div className="flex w-full items-center px-5 pr-2"> <div className="group flex w-full items-center px-5 pr-2">
<div <div
className={cn( className={cn(
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow items-start gap-2 rounded-md px-3", "group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow cursor-pointer items-start gap-2 rounded-md px-3",
)} )}
onClick={onClick}
> >
<div <div
className={cn( className={cn(
@ -37,19 +54,13 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
> >
<div className="flex w-full items-center justify-between gap-4"> <div className="flex w-full items-center justify-between gap-4">
<div className="inline-flex min-h-[24px] min-w-[0px] shrink items-center justify-start"> <div className="inline-flex min-h-[24px] min-w-[0px] shrink items-center justify-start">
<div className={cn("truncate text-left")}>{displayText}</div> <Markdown>{displayText}</Markdown>
</div> </div>
<div className="text-muted-foreground flex shrink-0 items-center justify-end gap-2 text-xs"> <div className="text-muted-foreground flex shrink-0 items-center justify-end gap-2 text-xs">
{!!recallCount && <span>Recalled: {recallCount} times</span>}
<Badge variant="secondary" className="rounded text-xs"> <Badge variant="secondary" className="rounded text-xs">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{formatDate(fact.validAt)} {formatDate(episode.validAt)}
</Badge> </Badge>
{fact.invalidAt && (
<Badge variant="destructive" className="rounded text-xs">
Invalid since {formatDate(fact.invalidAt)}
</Badge>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,7 +9,7 @@ import {
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
interface SpaceFactsFiltersProps { interface SpaceEpisodesFiltersProps {
selectedValidDate?: string; selectedValidDate?: string;
selectedSpaceFilter?: string; selectedSpaceFilter?: string;
onValidDateChange: (date?: string) => void; onValidDateChange: (date?: string) => void;
@ -22,34 +22,24 @@ const validDateOptions = [
{ value: "last_6_months", label: "Last 6 Months" }, { value: "last_6_months", label: "Last 6 Months" },
]; ];
const spaceFilterOptions = [ type FilterStep = "main" | "validDate";
{ value: "active", label: "Active Facts" },
{ value: "archived", label: "Archived Facts" },
{ value: "all", label: "All Facts" },
];
type FilterStep = "main" | "validDate" | "spaceFilter"; export function SpaceEpisodesFilters({
export function SpaceFactsFilters({
selectedValidDate, selectedValidDate,
selectedSpaceFilter, selectedSpaceFilter,
onValidDateChange, onValidDateChange,
onSpaceFilterChange, }: SpaceEpisodesFiltersProps) {
}: SpaceFactsFiltersProps) {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [step, setStep] = useState<FilterStep>("main"); const [step, setStep] = useState<FilterStep>("main");
const selectedValidDateLabel = validDateOptions.find( const selectedValidDateLabel = validDateOptions.find(
(d) => d.value === selectedValidDate, (d) => d.value === selectedValidDate,
)?.label; )?.label;
const selectedSpaceFilterLabel = spaceFilterOptions.find(
(f) => f.value === selectedSpaceFilter,
)?.label;
const hasFilters = selectedValidDate || selectedSpaceFilter; const hasFilters = selectedValidDate || selectedSpaceFilter;
return ( return (
<div className="mb-2 flex w-full items-center justify-start gap-2 px-5"> <>
<Popover <Popover
open={popoverOpen} open={popoverOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -79,13 +69,6 @@ export function SpaceFactsFilters({
> >
Valid Date Valid Date
</Button> </Button>
<Button
variant="ghost"
className="justify-start"
onClick={() => setStep("spaceFilter")}
>
Status
</Button>
</div> </div>
)} )}
@ -122,40 +105,6 @@ export function SpaceFactsFilters({
))} ))}
</div> </div>
)} )}
{step === "spaceFilter" && (
<div className="flex flex-col gap-1 p-2">
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => {
onSpaceFilterChange(undefined);
setPopoverOpen(false);
setStep("main");
}}
>
All Facts
</Button>
{spaceFilterOptions.map((option) => (
<Button
key={option.value}
variant="ghost"
className="w-full justify-start"
onClick={() => {
onSpaceFilterChange(
option.value === selectedSpaceFilter
? undefined
: option.value,
);
setPopoverOpen(false);
setStep("main");
}}
>
{option.label}
</Button>
))}
</div>
)}
</PopoverContent> </PopoverContent>
</PopoverPortal> </PopoverPortal>
</Popover> </Popover>
@ -172,17 +121,8 @@ export function SpaceFactsFilters({
/> />
</Badge> </Badge>
)} )}
{selectedSpaceFilter && (
<Badge variant="secondary" className="h-7 gap-1 rounded px-2">
{selectedSpaceFilterLabel}
<X
className="hover:text-destructive h-3.5 w-3.5 cursor-pointer"
onClick={() => onSpaceFilterChange(undefined)}
/>
</Badge>
)}
</div> </div>
)} )}
</div> </>
); );
} }

View File

@ -9,25 +9,24 @@ import {
} from "react-virtualized"; } from "react-virtualized";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import type { StatementNode } from "@core/types";
import { ScrollManagedList } from "../virtualized-list"; import { ScrollManagedList } from "../virtualized-list";
import { SpaceFactCard } from "./space-fact-card"; import { type Episode, SpaceEpisodeCard } from "./space-episode-card";
interface SpaceFactsListProps { interface SpaceEpisodesListProps {
facts: any[]; episodes: any[];
hasMore: boolean; hasMore: boolean;
loadMore: () => void; loadMore: () => void;
isLoading: boolean; isLoading: boolean;
height?: number; height?: number;
} }
function FactItemRenderer( function EpisodeItemRenderer(
props: ListRowProps, props: ListRowProps,
facts: StatementNode[], episodes: Episode[],
cache: CellMeasurerCache, cache: CellMeasurerCache,
) { ) {
const { index, key, style, parent } = props; const { index, key, style, parent } = props;
const fact = facts[index]; const episode = episodes[index];
return ( return (
<CellMeasurer <CellMeasurer
@ -38,23 +37,23 @@ function FactItemRenderer(
rowIndex={index} rowIndex={index}
> >
<div key={key} style={style} className="pb-2"> <div key={key} style={style} className="pb-2">
<SpaceFactCard fact={fact} /> <SpaceEpisodeCard episode={episode} />
</div> </div>
</CellMeasurer> </CellMeasurer>
); );
} }
export function SpaceFactsList({ export function SpaceEpisodesList({
facts, episodes,
hasMore, hasMore,
loadMore, loadMore,
isLoading, isLoading,
}: SpaceFactsListProps) { }: SpaceEpisodesListProps) {
// Create a CellMeasurerCache instance using useRef to prevent recreation // Create a CellMeasurerCache instance using useRef to prevent recreation
const cacheRef = useRef<CellMeasurerCache | null>(null); const cacheRef = useRef<CellMeasurerCache | null>(null);
if (!cacheRef.current) { if (!cacheRef.current) {
cacheRef.current = new CellMeasurerCache({ cacheRef.current = new CellMeasurerCache({
defaultHeight: 200, // Default row height for fact cards defaultHeight: 200, // Default row height for episode cards
fixedWidth: true, // Rows have fixed width but dynamic height fixedWidth: true, // Rows have fixed width but dynamic height
}); });
} }
@ -62,17 +61,17 @@ export function SpaceFactsList({
useEffect(() => { useEffect(() => {
cache.clearAll(); cache.clearAll();
}, [facts, cache]); }, [episodes, cache]);
if (facts.length === 0 && !isLoading) { if (episodes.length === 0 && !isLoading) {
return ( return (
<Card className="bg-background-2 w-full"> <Card className="bg-background-2 w-full">
<CardContent className="bg-background-2 flex w-full items-center justify-center py-16"> <CardContent className="bg-background-2 flex w-full items-center justify-center py-16">
<div className="text-center"> <div className="text-center">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No facts found</h3> <h3 className="mb-2 text-lg font-semibold">No Episodes found</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
This space doesn't contain any facts yet. This space doesn't contain any episodes yet.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -81,7 +80,7 @@ export function SpaceFactsList({
} }
const isRowLoaded = ({ index }: { index: number }) => { const isRowLoaded = ({ index }: { index: number }) => {
return !!facts[index]; return !!episodes[index];
}; };
const loadMoreRows = async () => { const loadMoreRows = async () => {
@ -92,14 +91,14 @@ export function SpaceFactsList({
}; };
const rowRenderer = (props: ListRowProps) => { const rowRenderer = (props: ListRowProps) => {
return FactItemRenderer(props, facts, cache); return EpisodeItemRenderer(props, episodes, cache);
}; };
const rowHeight = ({ index }: Index) => { const rowHeight = ({ index }: Index) => {
return cache.getHeight(index, 0); return cache.getHeight(index, 0);
}; };
const itemCount = hasMore ? facts.length + 1 : facts.length; const itemCount = hasMore ? episodes.length + 1 : episodes.length;
return ( return (
<div className="h-full grow overflow-hidden rounded-lg"> <div className="h-full grow overflow-hidden rounded-lg">
@ -131,7 +130,7 @@ export function SpaceFactsList({
{isLoading && ( {isLoading && (
<div className="text-muted-foreground p-4 text-center text-sm"> <div className="text-muted-foreground p-4 text-center text-sm">
Loading more facts... Loading more episodes...
</div> </div>
)} )}
</div> </div>

View File

@ -1,4 +1,4 @@
import { EllipsisVertical, RefreshCcw, Trash, Edit } from "lucide-react"; import { EllipsisVertical, RefreshCcw, Trash, Edit, Copy } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -19,6 +19,7 @@ import {
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useFetcher, useNavigate } from "@remix-run/react"; import { useFetcher, useNavigate } from "@remix-run/react";
import { EditSpaceDialog } from "./edit-space-dialog.client"; import { EditSpaceDialog } from "./edit-space-dialog.client";
import { toast } from "~/hooks/use-toast";
interface SpaceOptionsProps { interface SpaceOptionsProps {
id: string; id: string;
@ -64,6 +65,23 @@ export const SpaceOptions = ({ id, name, description }: SpaceOptionsProps) => {
// revalidator.revalidate(); // revalidator.revalidate();
}; };
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(id);
toast({
title: "Copied",
description: "Space ID copied to clipboard",
});
} catch (err) {
console.error("Failed to copy:", err);
toast({
title: "Error",
description: "Failed to copy ID",
variant: "destructive",
});
}
};
return ( return (
<> <>
<DropdownMenu> <DropdownMenu>
@ -79,6 +97,11 @@ export const SpaceOptions = ({ id, name, description }: SpaceOptionsProps) => {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleCopy}>
<Button variant="link" size="sm" className="gap-2 rounded">
<Copy size={15} /> Copy ID
</Button>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}> <DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
<Button variant="link" size="sm" className="gap-2 rounded"> <Button variant="link" size="sm" className="gap-2 rounded">
<Edit size={15} /> Edit <Edit size={15} /> Edit

View File

@ -9,8 +9,8 @@ interface SpacesGridProps {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
autoMode: boolean; autoMode: boolean;
statementCount: number | null;
summary: string | null; summary: string | null;
contextCount?: number | null;
themes?: string[]; themes?: string[];
}>; }>;
} }

View File

@ -40,7 +40,7 @@ const CommandDialog = ({
<Dialog {...props}> <Dialog {...props}>
<DialogContent className={cn("overflow-hidden p-0 font-sans")}> <DialogContent className={cn("overflow-hidden p-0 font-sans")}>
<Command <Command
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5" className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-10 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
{...commandProps} {...commandProps}
> >
{children} {children}
@ -141,7 +141,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item <CommandPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"command-item aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1 outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "command-item aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center rounded px-2 py-1 outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className, className,
)} )}
{...props} {...props}

View File

@ -2,3 +2,5 @@ export * from "./button";
export * from "./tabs"; export * from "./tabs";
export * from "./input"; export * from "./input";
export * from "./scrollarea"; export * from "./scrollarea";
export * from "./toast";
export * from "./toaster";

View File

@ -0,0 +1,133 @@
import { Cross2Icon } from "@radix-ui/react-icons";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import React from "react";
import { cn } from "../../lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-3 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
warning: "warning group border-warning bg-warning text-foreground",
success: "success group border-success bg-success text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(
toastVariants({ variant }),
className,
"shadow-1 rounded-md border-0 bg-gray-100 font-sans backdrop-blur-md",
)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("font-medium [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "~/components/ui/toast";
import { useToast } from "~/hooks/use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@ -15,6 +15,8 @@ export interface LogItem {
activityId?: string; activityId?: string;
episodeUUID?: string; episodeUUID?: string;
data?: any; data?: any;
spaceIds?: string[];
episodeDetails?: any;
} }
export interface LogsResponse { export interface LogsResponse {

View File

@ -0,0 +1,186 @@
import * as React from "react";
import type { ToastActionElement, ToastProps } from "~/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@ -5,7 +5,8 @@ import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData"; import { useTypedMatchesData } from "./useTypedMatchData";
export interface ExtendedUser extends User { export interface ExtendedUser extends User {
availableCredits?: number; availableCredits: number;
totalCredits: number;
} }
export function useIsImpersonating(matches?: UIMatch[]) { export function useIsImpersonating(matches?: UIMatch[]) {
@ -23,7 +24,11 @@ export function useOptionalUser(matches?: UIMatch[]): ExtendedUser | undefined {
}); });
return routeMatch?.user return routeMatch?.user
? { ...routeMatch?.user, availableCredits: routeMatch?.availableCredits } ? {
...routeMatch?.user,
availableCredits: routeMatch?.availableCredits,
totalCredits: routeMatch?.totalCredits,
}
: undefined; : undefined;
} }

View File

@ -29,7 +29,6 @@ export const addToQueue = async (
const queuePersist = await prisma.ingestionQueue.create({ const queuePersist = await prisma.ingestionQueue.create({
data: { data: {
spaceId: body.spaceId ? body.spaceId : null,
data: body, data: body,
type: body.type, type: body.type,
status: IngestionStatus.PENDING, status: IngestionStatus.PENDING,

View File

@ -145,10 +145,8 @@ export const getClusteredGraphData = async (userId: string) => {
rel.predicate as predicateLabel, rel.predicate as predicateLabel,
e.uuid as episodeUuid, e.uuid as episodeUuid,
e.content as episodeContent, e.content as episodeContent,
e.spaceIds as spaceIds,
s.uuid as statementUuid, s.uuid as statementUuid,
s.spaceIds as spaceIds,
s.fact as fact,
s.invalidAt as invalidAt,
s.validAt as validAt, s.validAt as validAt,
s.createdAt as createdAt`, s.createdAt as createdAt`,
{ userId }, { userId },
@ -169,13 +167,8 @@ export const getClusteredGraphData = async (userId: string) => {
const predicateLabel = record.get("predicateLabel"); const predicateLabel = record.get("predicateLabel");
const episodeUuid = record.get("episodeUuid"); const episodeUuid = record.get("episodeUuid");
const episodeContent = record.get("episodeContent");
const statementUuid = record.get("statementUuid");
const clusterIds = record.get("spaceIds"); const clusterIds = record.get("spaceIds");
const clusterId = clusterIds ? clusterIds[0] : undefined; const clusterId = clusterIds ? clusterIds[0] : undefined;
const fact = record.get("fact");
const invalidAt = record.get("invalidAt");
const validAt = record.get("validAt");
const createdAt = record.get("createdAt"); const createdAt = record.get("createdAt");
// Create unique edge identifier to avoid duplicates // Create unique edge identifier to avoid duplicates

View File

@ -2,7 +2,6 @@ import type { Prisma, User } from "@core/database";
import type { GoogleProfile } from "@coji/remix-auth-google"; import type { GoogleProfile } from "@coji/remix-auth-google";
import { prisma } from "~/db.server"; import { prisma } from "~/db.server";
import { env } from "~/env.server"; import { env } from "~/env.server";
import { ensureBillingInitialized } from "~/services/billing.server";
export type { User } from "@core/database"; export type { User } from "@core/database";
type FindOrCreateMagicLink = { type FindOrCreateMagicLink = {
@ -167,7 +166,12 @@ export async function findOrCreateGoogleUser({
} }
export async function getUserById(id: User["id"]) { export async function getUserById(id: User["id"]) {
const user = await prisma.user.findUnique({ where: { id } }); const user = await prisma.user.findUnique({
where: { id },
include: {
Workspace: true,
},
});
if (!user) { if (!user) {
return null; return null;

View File

@ -14,14 +14,26 @@ interface CreateWorkspaceDto {
const spaceService = new SpaceService(); const spaceService = new SpaceService();
const profileRule = ` const profileRule = `
Store the users stable, non-sensitive identity and preference facts that improve personalization across assistants. Facts must be long-lived (expected validity 3 months) and broadly useful across contexts (not app-specific). Purpose: Store my identity and preferences to improve personalization across assistants. It should be broadly useful across contexts (not app-specific).
Include (examples): Include (examples):
Preferred name, pronunciation, public handles (GitHub/Twitter/LinkedIn URLs), primary email domain Preferred name, pronunciation, public handles (GitHub/Twitter/LinkedIn URLs), primary email domain
Timezone, locale, working hours, meeting preferences (async/sync bias, default duration) Timezone, locale, working hours, meeting preferences (async/sync bias, default duration)
Role, team, company, office location (city-level only), seniority Role, team, company, office location (city-level only), seniority
Tooling defaults (editor, ticketing system, repo host), keyboard layout, OS Tooling defaults (editor, ticketing system, repo host), keyboard layout, OS
Communication preferences (tone, brevity vs. detail, summary-first) Communication preferences (tone, brevity vs. detail, summary-first)
Exclude: secrets/credentials; one-off or short-term states; health/financial/political/religious/sexual data; precise home address; raw event logs; app-specific analytics; anything the user did not explicitly consent to share.`; Exclude:
Sensitive: secrets, health/financial/political/religious/sexual data, precise address
Temporary: one-off states, troubleshooting sessions, query results
Context-specific: app behaviors, work conversations, project-specific preferences
Meta: discussions about this memory system, AI architecture, system design
Anything not explicitly consented to share
don't store anything the user did not explicitly consent to share.`;
const githubDescription = `Everything related to my GitHub work - repos I'm working on, projects I contribute to, code I'm writing, PRs I'm reviewing. Basically my coding life on GitHub.`;
const healthDescription = `My health and wellness stuff - how I'm feeling, what I'm learning about my body, experiments I'm trying, patterns I notice. Whatever matters to me about staying healthy.`;
const fitnessDescription = `My workouts and training - what I'm doing at the gym, runs I'm going on, progress I'm making, goals I'm chasing. Anything related to physical exercise and getting stronger.`;
export async function createWorkspace( export async function createWorkspace(
input: CreateWorkspaceDto, input: CreateWorkspaceDto,
@ -43,12 +55,33 @@ export async function createWorkspace(
await ensureBillingInitialized(workspace.id); await ensureBillingInitialized(workspace.id);
await spaceService.createSpace({ // Create default spaces
name: "Profile", await Promise.all([
description: profileRule, spaceService.createSpace({
userId: input.userId, name: "Profile",
workspaceId: workspace.id, description: profileRule,
}); userId: input.userId,
workspaceId: workspace.id,
}),
spaceService.createSpace({
name: "GitHub",
description: githubDescription,
userId: input.userId,
workspaceId: workspace.id,
}),
spaceService.createSpace({
name: "Health",
description: healthDescription,
userId: input.userId,
workspaceId: workspace.id,
}),
spaceService.createSpace({
name: "Fitness",
description: fitnessDescription,
userId: input.userId,
workspaceId: workspace.id,
}),
]);
try { try {
const response = await sendEmail({ email: "welcome", to: user.email }); const response = await sendEmail({ email: "welcome", to: user.email });

View File

@ -25,7 +25,7 @@ import {
type ToastMessage, type ToastMessage,
} from "./models/message.server"; } from "./models/message.server";
import { env } from "./env.server"; import { env } from "./env.server";
import { getUser, getUserRemainingCount } from "./services/session.server"; import { getUser } from "./services/session.server";
import { usePostHog } from "./hooks/usePostHog"; import { usePostHog } from "./hooks/usePostHog";
import { import {
AppContainer, AppContainer,
@ -40,6 +40,8 @@ import {
useTheme, useTheme,
} from "remix-themes"; } from "remix-themes";
import clsx from "clsx"; import clsx from "clsx";
import { getUsageSummary } from "./services/billing.server";
import { Toaster } from "./components/ui/toaster";
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
@ -50,12 +52,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const posthogProjectKey = env.POSTHOG_PROJECT_KEY; const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
const user = await getUser(request); const user = await getUser(request);
const usage = await getUserRemainingCount(request); const usageSummary = await getUsageSummary(user?.Workspace?.id as string);
return typedjson( return typedjson(
{ {
user: user, user: user,
availableCredits: usage?.availableCredits ?? 0, availableCredits: usageSummary?.credits.available ?? 0,
totalCredits: usageSummary?.credits.monthly ?? 0,
toastMessage, toastMessage,
theme: getTheme(), theme: getTheme(),
posthogProjectKey, posthogProjectKey,
@ -124,6 +127,7 @@ function App() {
</head> </head>
<body className="bg-background-2 h-[100vh] h-full w-[100vw] overflow-hidden font-sans"> <body className="bg-background-2 h-[100vh] h-full w-[100vw] overflow-hidden font-sans">
<Outlet /> <Outlet />
<Toaster />
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />

View File

@ -0,0 +1,66 @@
import { z } from "zod";
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { SpaceService } from "~/services/space.server";
import { json } from "@remix-run/node";
const spaceService = new SpaceService();
// Schema for assigning episodes to space
const AssignEpisodesSchema = z.object({
episodeIds: z.string().transform((val) => JSON.parse(val) as string[]),
spaceId: z.string(),
action: z.enum(["assign", "remove"]),
});
const { action } = createHybridActionApiRoute(
{
body: AssignEpisodesSchema,
allowJWT: true,
authorization: {
action: "manage",
},
corsStrategy: "all",
},
async ({ authentication, body }) => {
const userId = authentication.userId;
const { episodeIds, spaceId, action: actionType } = body;
try {
if (actionType === "assign") {
await spaceService.assignEpisodesToSpace(episodeIds, spaceId, userId);
return json({
success: true,
message: `Successfully assigned ${episodeIds.length} episode(s) to space`,
});
} else if (actionType === "remove") {
await spaceService.removeEpisodesFromSpace(episodeIds, spaceId, userId);
return json({
success: true,
message: `Successfully removed ${episodeIds.length} episode(s) from space`,
});
}
return json(
{
error: "Invalid action type",
success: false,
},
{ status: 400 },
);
} catch (error) {
console.error("Error managing episode space assignment:", error);
return json(
{
error:
error instanceof Error
? error.message
: "Failed to manage episode space assignment",
success: false,
},
{ status: 500 },
);
}
},
);
export { action };

View File

@ -24,8 +24,11 @@ const loader = createHybridLoaderApiRoute(
corsStrategy: "all", corsStrategy: "all",
allowJWT: true, allowJWT: true,
}, },
async ({ params }) => { async ({ params, authentication }) => {
const formattedLog = await getIngestionQueueForFrontend(params.logId); const formattedLog = await getIngestionQueueForFrontend(
params.logId,
authentication.userId,
);
return json({ log: formattedLog }); return json({ log: formattedLog });
}, },

View File

@ -2,6 +2,7 @@ import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { SpaceService } from "~/services/space.server"; import { SpaceService } from "~/services/space.server";
import { json } from "@remix-run/node"; import { json } from "@remix-run/node";
import { getSpaceEpisodeCount } from "~/services/graphModels/space";
const spaceService = new SpaceService(); const spaceService = new SpaceService();
@ -29,18 +30,20 @@ const { loader } = createActionApiRoute(
return json({ error: "Space not found" }, { status: 404 }); return json({ error: "Space not found" }, { status: 404 });
} }
// Get statements in the space // Get episodes in the space
const statements = await spaceService.getSpaceStatements(spaceId, userId); const episodes = await spaceService.getSpaceEpisodes(spaceId, userId);
const episodeCount = await getSpaceEpisodeCount(spaceId, userId);
return json({ return json({
statements, episodes,
space: { space: {
uuid: space.uuid, uuid: space.uuid,
name: space.name, name: space.name,
statementCount: statements.length description: space.description,
episodeCount,
} }
}); });
} }
); );
export { loader }; export { loader };

View File

@ -1,16 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
createActionApiRoute,
createHybridActionApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { SpaceService } from "~/services/space.server"; import { SpaceService } from "~/services/space.server";
import { json } from "@remix-run/node"; import { json } from "@remix-run/node";
import {
createSpace,
deleteSpace,
updateSpace,
} from "~/services/graphModels/space";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.service"; import { logger } from "~/services/logger.service";
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment"; import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
@ -33,45 +24,26 @@ const { loader, action } = createHybridActionApiRoute(
const { spaceId } = params; const { spaceId } = params;
const spaceService = new SpaceService(); const spaceService = new SpaceService();
// Verify space exists and belongs to user // Reset the space (clears all assignments, summary, and metadata)
const space = await prisma.space.findUnique({ const space = await spaceService.resetSpace(spaceId, userId);
where: {
id: spaceId,
},
});
if (!space) {
return json({ error: "Space not found" }, { status: 404 });
}
// Get statements in the space logger.info(`Reset space ${space.id} successfully`);
await deleteSpace(spaceId, userId);
await createSpace( // Trigger automatic episode assignment for the reset space
space.id,
space.name.trim(),
space.description?.trim(),
userId,
);
await spaceService.updateSpace(space.id, { status: "pending" }, userId);
logger.info(`Created space ${space.id} successfully`);
// Trigger automatic LLM assignment for the new space
try { try {
await triggerSpaceAssignment({ await triggerSpaceAssignment({
userId: userId, userId: userId,
workspaceId: space.workspaceId, workspaceId: space.workspaceId,
mode: "new_space", mode: "new_space",
newSpaceId: space.id, newSpaceId: space.id,
batchSize: 25, // Analyze recent statements for the new space batchSize: 20, // Analyze recent episodes for reassignment
}); });
logger.info(`Triggered LLM space assignment for new space ${space.id}`); logger.info(`Triggered space assignment for reset space ${space.id}`);
} catch (error) { } catch (error) {
// Don't fail space creation if LLM assignment fails // Don't fail space reset if assignment fails
logger.warn( logger.warn(
`Failed to trigger LLM assignment for space ${space.id}:`, `Failed to trigger assignment for space ${space.id}:`,
error as Record<string, unknown>, error as Record<string, unknown>,
); );
} }

View File

@ -16,19 +16,6 @@ const CreateSpaceSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
}); });
// Schema for bulk operations
const BulkOperationSchema = z.object({
intent: z.enum([
"assign_statements",
"remove_statements",
"bulk_assign",
"initialize_space_ids",
]),
spaceId: z.string().optional(),
statementIds: z.array(z.string()).optional(),
spaceIds: z.array(z.string()).optional(),
});
// Search query schema // Search query schema
const SearchParamsSchema = z.object({ const SearchParamsSchema = z.object({
q: z.string().optional(), q: z.string().optional(),
@ -36,7 +23,7 @@ const SearchParamsSchema = z.object({
const { action } = createHybridActionApiRoute( const { action } = createHybridActionApiRoute(
{ {
body: z.union([CreateSpaceSchema, BulkOperationSchema]), body: CreateSpaceSchema,
allowJWT: true, allowJWT: true,
authorization: { authorization: {
action: "manage", action: "manage",
@ -82,96 +69,6 @@ const { action } = createHybridActionApiRoute(
return json({ space, success: true }); return json({ space, success: true });
} }
if (request.method === "PUT") {
// Bulk operations
if (!body || !("intent" in body)) {
return json({ error: "Intent is required" }, { status: 400 });
}
switch (body.intent) {
case "assign_statements": {
if (!body.spaceId || !body.statementIds) {
return json(
{ error: "Space ID and statement IDs are required" },
{ status: 400 },
);
}
const result = await spaceService.assignStatementsToSpace(
body.statementIds,
body.spaceId,
authentication.userId,
);
if (result.success) {
return json({
success: true,
message: `Assigned ${result.statementsUpdated} statements to space`,
statementsUpdated: result.statementsUpdated,
});
} else {
return json({ error: result.error }, { status: 400 });
}
}
case "remove_statements": {
if (!body.spaceId || !body.statementIds) {
return json(
{ error: "Space ID and statement IDs are required" },
{ status: 400 },
);
}
const result = await spaceService.removeStatementsFromSpace(
body.statementIds,
body.spaceId,
authentication.userId,
);
if (result.success) {
return json({
success: true,
message: `Removed ${result.statementsUpdated} statements from space`,
statementsUpdated: result.statementsUpdated,
});
} else {
return json({ error: result.error }, { status: 400 });
}
}
case "bulk_assign": {
if (!body.statementIds || !body.spaceIds) {
return json(
{ error: "Statement IDs and space IDs are required" },
{ status: 400 },
);
}
const results = await spaceService.bulkAssignStatements(
body.statementIds,
body.spaceIds,
authentication.userId,
);
return json({ results, success: true });
}
case "initialize_space_ids": {
const updatedCount = await spaceService.initializeSpaceIds(
authentication.userId,
);
return json({
success: true,
message: `Initialized spaceIds for ${updatedCount} statements`,
updatedCount,
});
}
default:
return json({ error: "Invalid intent" }, { status: 400 });
}
}
return json({ error: "Method not allowed" }, { status: 405 }); return json({ error: "Method not allowed" }, { status: 405 });
}, },
); );

View File

@ -9,11 +9,11 @@ import { getIngestionQueueForFrontend } from "~/services/ingestionLogs.server";
import { requireUserId } from "~/services/session.server"; import { requireUserId } from "~/services/session.server";
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({ request, params }: LoaderFunctionArgs) {
await requireUserId(request); const userId = await requireUserId(request);
const logId = params.logId; const logId = params.logId;
try { try {
const log = await getIngestionQueueForFrontend(logId as string); const log = await getIngestionQueueForFrontend(logId as string, userId);
return json({ log: log }); return json({ log: log });
} catch (e) { } catch (e) {
return json({ log: null }); return json({ log: null });

View File

@ -3,11 +3,13 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useLoaderData } from "@remix-run/react"; import { useLoaderData } from "@remix-run/react";
import { requireUserId } from "~/services/session.server"; import { requireUserId } from "~/services/session.server";
import { SpaceService } from "~/services/space.server"; import { SpaceService } from "~/services/space.server";
import { SpaceFactsFilters } from "~/components/spaces/space-facts-filters"; import { SpaceEpisodesFilters } from "~/components/spaces/space-episode-filters";
import { SpaceFactsList } from "~/components/spaces/space-facts-list"; import { SpaceEpisodesList } from "~/components/spaces/space-episodes-list";
import { ClientOnly } from "remix-utils/client-only"; import { ClientOnly } from "remix-utils/client-only";
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import { getLogByEpisode } from "~/services/ingestionLogs.server";
import { Button } from "~/components/ui";
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request); const userId = await requireUserId(request);
@ -15,16 +17,27 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const spaceId = params.spaceId as string; const spaceId = params.spaceId as string;
const space = await spaceService.getSpace(spaceId, userId); const space = await spaceService.getSpace(spaceId, userId);
const statements = await spaceService.getSpaceStatements(spaceId, userId); const episodes = await spaceService.getSpaceEpisodes(spaceId, userId);
const episodesWithLogData = await Promise.all(
episodes.map(async (ep) => {
const log = await getLogByEpisode(ep.uuid);
return {
...ep,
logId: log?.id,
};
}),
);
return { return {
space, space,
statements: statements || [], episodes: episodesWithLogData || [],
}; };
} }
export default function Facts() { export default function Episodes() {
const { statements } = useLoaderData<typeof loader>(); const { episodes } = useLoaderData<typeof loader>();
const [selectedValidDate, setSelectedValidDate] = useState< const [selectedValidDate, setSelectedValidDate] = useState<
string | undefined string | undefined
>(); >();
@ -32,42 +45,27 @@ export default function Facts() {
string | undefined string | undefined
>(); >();
// Filter statements based on selected filters // Filter episodes based on selected filters
const filteredStatements = statements.filter((statement) => { const filteredEpisodes = episodes.filter((episode) => {
// Date filter // Date filter
if (selectedValidDate) { if (selectedValidDate) {
const now = new Date(); const now = new Date();
const statementDate = new Date(statement.validAt); const episodeDate = new Date(episode.createdAt);
switch (selectedValidDate) { switch (selectedValidDate) {
case "last_week": case "last_week":
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
if (statementDate < weekAgo) return false; if (episodeDate < weekAgo) return false;
break; break;
case "last_month": case "last_month":
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
if (statementDate < monthAgo) return false; if (episodeDate < monthAgo) return false;
break; break;
case "last_6_months": case "last_6_months":
const sixMonthsAgo = new Date( const sixMonthsAgo = new Date(
now.getTime() - 180 * 24 * 60 * 60 * 1000, now.getTime() - 180 * 24 * 60 * 60 * 1000,
); );
if (statementDate < sixMonthsAgo) return false; if (episodeDate < sixMonthsAgo) return false;
break;
}
}
// Status filter
if (selectedSpaceFilter) {
switch (selectedSpaceFilter) {
case "active":
if (statement.invalidAt) return false;
break;
case "archived":
if (!statement.invalidAt) return false;
break;
case "all":
default:
break; break;
} }
} }
@ -81,20 +79,22 @@ export default function Facts() {
return ( return (
<div className="flex h-full w-full flex-col pt-5"> <div className="flex h-full w-full flex-col pt-5">
<SpaceFactsFilters <div className="mb-2 flex w-full items-center justify-start gap-2 px-5">
selectedValidDate={selectedValidDate} <SpaceEpisodesFilters
selectedSpaceFilter={selectedSpaceFilter} selectedValidDate={selectedValidDate}
onValidDateChange={setSelectedValidDate} selectedSpaceFilter={selectedSpaceFilter}
onSpaceFilterChange={setSelectedSpaceFilter} onValidDateChange={setSelectedValidDate}
/> onSpaceFilterChange={setSelectedSpaceFilter}
/>
</div>
<div className="flex h-[calc(100vh_-_56px)] w-full"> <div className="flex h-[calc(100vh_-_56px)] w-full">
<ClientOnly <ClientOnly
fallback={<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />} fallback={<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
> >
{() => ( {() => (
<SpaceFactsList <SpaceEpisodesList
facts={filteredStatements} episodes={filteredEpisodes}
hasMore={false} // TODO: Implement real pagination hasMore={false} // TODO: Implement real pagination
loadMore={loadMore} loadMore={loadMore}
isLoading={false} isLoading={false}

View File

@ -148,7 +148,7 @@ export default function Overview() {
variant="ghost" variant="ghost"
className="text-muted-foreground mb-1 -ml-2 gap-1" className="text-muted-foreground mb-1 -ml-2 gap-1"
> >
Summary Context
<ChevronDown <ChevronDown
size={14} size={14}
className={`transition-transform duration-300 ${ className={`transition-transform duration-300 ${

View File

@ -7,6 +7,9 @@ import { useTypedLoaderData } from "remix-typedjson";
import { Outlet, useLocation, useNavigate } from "@remix-run/react"; import { Outlet, useLocation, useNavigate } from "@remix-run/react";
import { SpaceOptions } from "~/components/spaces/space-options"; import { SpaceOptions } from "~/components/spaces/space-options";
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import { Button } from "~/components/ui";
import React from "react";
import { AddMemoryDialog } from "~/components/command-bar/memory-dialog.client";
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request); const userId = await requireUserId(request);
@ -23,6 +26,7 @@ export default function Space() {
const space = useTypedLoaderData<typeof loader>(); const space = useTypedLoaderData<typeof loader>();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [showAddMemory, setShowAddMemory] = React.useState(false);
return ( return (
<> <>
@ -46,16 +50,10 @@ export default function Space() {
onClick: () => navigate(`/home/space/${space.id}/overview`), onClick: () => navigate(`/home/space/${space.id}/overview`),
}, },
{ {
label: "Facts", label: "Episodes",
value: "facts", value: "edpisodes",
isActive: location.pathname.includes("/facts"), isActive: location.pathname.includes("/episodes"),
onClick: () => navigate(`/home/space/${space.id}/facts`), onClick: () => navigate(`/home/space/${space.id}/episodes`),
},
{
label: "Patterns",
value: "patterns",
isActive: location.pathname.includes("/patterns"),
onClick: () => navigate(`/home/space/${space.id}/patterns`),
}, },
]} ]}
actionsNode={ actionsNode={
@ -67,17 +65,33 @@ export default function Space() {
} }
> >
{() => ( {() => (
<SpaceOptions <div className="flex items-center gap-2">
id={space.id as string} <Button
name={space.name} variant="secondary"
description={space.description} onClick={() => setShowAddMemory(true)}
/> >
Add episode
</Button>
<SpaceOptions
id={space.id as string}
name={space.name}
description={space.description}
/>
</div>
)} )}
</ClientOnly> </ClientOnly>
} }
/> />
<div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-start overflow-auto"> <div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-start overflow-auto">
<Outlet /> <Outlet />
{showAddMemory && (
<AddMemoryDialog
open={showAddMemory}
onOpenChange={setShowAddMemory}
defaultSpaceId={space.id}
/>
)}
</div> </div>
</> </>
); );

View File

@ -153,6 +153,7 @@ export default function Onboarding() {
setCurrentQuestion(currentQuestion + 1); setCurrentQuestion(currentQuestion + 1);
} else { } else {
setLoading(true); setLoading(true);
// Submit all answers // Submit all answers
submitAnswers(); submitAnswers();
} }

View File

@ -262,6 +262,7 @@ export default function BillingSettings() {
<Progress <Progress
segments={[{ value: 100 - usageSummary.credits.percentageUsed }]} segments={[{ value: 100 - usageSummary.credits.percentageUsed }]}
className="mb-2" className="mb-2"
color="#c15e50"
/> />
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{usageSummary.credits.percentageUsed}% used this period {usageSummary.credits.percentageUsed}% used this period
@ -452,7 +453,7 @@ export default function BillingSettings() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-6 p-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{/* Free Plan */} {/* Free Plan */}
<Card className="p-6"> <Card className="p-6">
<div className="mb-4"> <div className="mb-4">
@ -467,10 +468,10 @@ export default function BillingSettings() {
</div> </div>
<ul className="mb-6 space-y-2 text-sm"> <ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>Memory facts: 3k/mo</span> <span>Credits: 3k/mo</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>NO USAGE BASED</span> <span>No usage based</span>
</li> </li>
</ul> </ul>
<Button <Button
@ -504,14 +505,15 @@ export default function BillingSettings() {
</div> </div>
<ul className="mb-6 space-y-2 text-sm"> <ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>Memory facts: 15k/mo</span> <span>Credits: 15k/mo</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>$0.299 /1K ADDITIONAL FACTS</span> <span>$0.299 /1K Additional Credits</span>
</li> </li>
</ul> </ul>
<Button <Button
className="w-full" className="w-full"
variant="secondary"
disabled={ disabled={
usageSummary.plan.type === "PRO" || usageSummary.plan.type === "PRO" ||
fetcher.state === "submitting" fetcher.state === "submitting"
@ -540,14 +542,15 @@ export default function BillingSettings() {
</div> </div>
<ul className="mb-6 space-y-2 text-sm"> <ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>Memory facts: 100k/mo</span> <span>Credits: 100k/mo</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>$0.249 /1K ADDITIONAL FACTS</span> <span>$0.249 /1K Additional Credits</span>
</li> </li>
</ul> </ul>
<Button <Button
className="w-full" className="w-full"
variant="secondary"
disabled={ disabled={
usageSummary.plan.type === "MAX" || usageSummary.plan.type === "MAX" ||
fetcher.state === "submitting" fetcher.state === "submitting"

View File

@ -160,6 +160,10 @@ export async function ensureBillingInitialized(workspaceId: string) {
* Get workspace usage summary * Get workspace usage summary
*/ */
export async function getUsageSummary(workspaceId: string) { export async function getUsageSummary(workspaceId: string) {
if (!workspaceId) {
return null;
}
// Ensure billing records exist for existing accounts // Ensure billing records exist for existing accounts
await ensureBillingInitialized(workspaceId); await ensureBillingInitialized(workspaceId);

View File

@ -1,5 +1,9 @@
import { runQuery } from "~/lib/neo4j.server"; import { runQuery } from "~/lib/neo4j.server";
import { type StatementNode, type EntityNode, type EpisodicNode } from "@core/types"; import {
type StatementNode,
type EntityNode,
type EpisodicNode,
} from "@core/types";
export async function saveEpisode(episode: EpisodicNode): Promise<string> { export async function saveEpisode(episode: EpisodicNode): Promise<string> {
const query = ` const query = `
@ -72,6 +76,8 @@ export async function getEpisode(uuid: string): Promise<EpisodicNode | null> {
userId: episode.userId, userId: episode.userId,
space: episode.space, space: episode.space,
sessionId: episode.sessionId, sessionId: episode.sessionId,
recallCount: episode.recallCount,
spaceIds: episode.spaceIds,
}; };
} }
@ -140,7 +146,7 @@ export async function searchEpisodesByEmbedding(params: {
}) { }) {
const limit = params.limit || 100; const limit = params.limit || 100;
const query = ` const query = `
CALL db.index.vector.queryNodes('episode_embedding', ${limit*2}, $embedding) CALL db.index.vector.queryNodes('episode_embedding', ${limit * 2}, $embedding)
YIELD node AS episode YIELD node AS episode
WHERE episode.userId = $userId WHERE episode.userId = $userId
WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score
@ -285,7 +291,7 @@ export async function getRelatedEpisodesEntities(params: {
}) { }) {
const limit = params.limit || 100; const limit = params.limit || 100;
const query = ` const query = `
CALL db.index.vector.queryNodes('episode_embedding', ${limit*2}, $embedding) CALL db.index.vector.queryNodes('episode_embedding', ${limit * 2}, $embedding)
YIELD node AS episode YIELD node AS episode
WHERE episode.userId = $userId WHERE episode.userId = $userId
WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score

View File

@ -56,13 +56,12 @@ export async function getSpace(
const query = ` const query = `
MATCH (s:Space {uuid: $spaceId, userId: $userId}) MATCH (s:Space {uuid: $spaceId, userId: $userId})
WHERE s.isActive = true WHERE s.isActive = true
// Count statements in this space using optimized approach // Count episodes assigned to this space using direct relationship
OPTIONAL MATCH (stmt:Statement {userId: $userId}) OPTIONAL MATCH (s)-[:HAS_EPISODE]->(e:Episode {userId: $userId})
WHERE stmt.spaceIds IS NOT NULL AND $spaceId IN stmt.spaceIds AND stmt.invalidAt IS NULL
WITH s, count(e) as episodeCount
WITH s, count(stmt) as statementCount RETURN s, episodeCount
RETURN s, statementCount
`; `;
const result = await runQuery(query, { spaceId, userId }); const result = await runQuery(query, { spaceId, userId });
@ -71,7 +70,7 @@ export async function getSpace(
} }
const spaceData = result[0].get("s").properties; const spaceData = result[0].get("s").properties;
const statementCount = result[0].get("statementCount") || 0; const episodeCount = result[0].get("episodeCount") || 0;
return { return {
uuid: spaceData.uuid, uuid: spaceData.uuid,
@ -81,7 +80,7 @@ export async function getSpace(
createdAt: new Date(spaceData.createdAt), createdAt: new Date(spaceData.createdAt),
updatedAt: new Date(spaceData.updatedAt), updatedAt: new Date(spaceData.updatedAt),
isActive: spaceData.isActive, isActive: spaceData.isActive,
statementCount: Number(statementCount), contextCount: Number(episodeCount), // Episode count = context count
}; };
} }
@ -151,28 +150,53 @@ export async function deleteSpace(
} }
// 2. Clean up statement references (remove spaceId from spaceIds arrays) // 2. Clean up statement references (remove spaceId from spaceIds arrays)
const cleanupQuery = ` const cleanupStatementsQuery = `
MATCH (s:Statement {userId: $userId}) MATCH (s:Statement {userId: $userId})
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId] SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId]
RETURN count(s) as updatedStatements RETURN count(s) as updatedStatements
`; `;
const cleanupResult = await runQuery(cleanupQuery, { userId, spaceId }); const cleanupStatementsResult = await runQuery(cleanupStatementsQuery, {
const updatedStatements = cleanupResult[0]?.get("updatedStatements") || 0; userId,
spaceId,
});
const updatedStatements =
cleanupStatementsResult[0]?.get("updatedStatements") || 0;
// 3. Delete the space node // 3. Clean up episode references (remove spaceId from spaceIds arrays)
const cleanupEpisodesQuery = `
MATCH (e:Episode {userId: $userId})
WHERE e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
SET e.spaceIds = [id IN e.spaceIds WHERE id <> $spaceId]
RETURN count(e) as updatedEpisodes
`;
const cleanupEpisodesResult = await runQuery(cleanupEpisodesQuery, {
userId,
spaceId,
});
const updatedEpisodes =
cleanupEpisodesResult[0]?.get("updatedEpisodes") || 0;
// 4. Delete the space node and all its relationships
const deleteQuery = ` const deleteQuery = `
MATCH (space:Space {uuid: $spaceId, userId: $userId}) MATCH (space:Space {uuid: $spaceId, userId: $userId})
DELETE space DETACH DELETE space
RETURN count(space) as deletedSpaces RETURN count(space) as deletedSpaces
`; `;
await runQuery(deleteQuery, { userId, spaceId }); await runQuery(deleteQuery, { userId, spaceId });
logger.info(`Deleted space ${spaceId}`, {
userId,
statementsUpdated: updatedStatements,
episodesUpdated: updatedEpisodes,
});
return { return {
deleted: true, deleted: true,
statementsUpdated: Number(updatedStatements), statementsUpdated: Number(updatedStatements) + Number(updatedEpisodes),
}; };
} catch (error) { } catch (error) {
return { return {
@ -184,10 +208,10 @@ export async function deleteSpace(
} }
/** /**
* Assign statements to a space * Assign episodes to a space using intent-based matching
*/ */
export async function assignStatementsToSpace( export async function assignEpisodesToSpace(
statementIds: string[], episodeIds: string[],
spaceId: string, spaceId: string,
userId: string, userId: string,
): Promise<SpaceAssignmentResult> { ): Promise<SpaceAssignmentResult> {
@ -202,30 +226,48 @@ export async function assignStatementsToSpace(
}; };
} }
// Update episodes with spaceIds array AND create HAS_EPISODE relationships
// This hybrid approach enables both fast array lookups and graph traversal
const query = ` const query = `
MATCH (s:Statement {userId: $userId}) MATCH (space:Space {uuid: $spaceId, userId: $userId})
WHERE s.uuid IN $statementIds MATCH (e:Episode {userId: $userId})
SET s.spaceIds = CASE WHERE e.uuid IN $episodeIds
WHEN s.spaceIds IS NULL THEN [$spaceId] SET e.spaceIds = CASE
WHEN $spaceId IN s.spaceIds THEN s.spaceIds WHEN e.spaceIds IS NULL THEN [$spaceId]
ELSE s.spaceIds + [$spaceId] WHEN $spaceId IN e.spaceIds THEN e.spaceIds
ELSE e.spaceIds + [$spaceId]
END, END,
s.lastSpaceAssignment = datetime(), e.lastSpaceAssignment = datetime(),
s.spaceAssignmentMethod = CASE e.spaceAssignmentMethod = CASE
WHEN s.spaceAssignmentMethod IS NULL THEN 'manual' WHEN e.spaceAssignmentMethod IS NULL THEN 'intent_based'
ELSE s.spaceAssignmentMethod ELSE e.spaceAssignmentMethod
END END
RETURN count(s) as updated WITH e, space
MERGE (space)-[r:HAS_EPISODE]->(e)
ON CREATE SET
r.assignedAt = datetime(),
r.assignmentMethod = 'intent_based'
RETURN count(e) as updated
`; `;
const result = await runQuery(query, { statementIds, spaceId, userId }); const result = await runQuery(query, { episodeIds, spaceId, userId });
const updatedCount = result[0]?.get("updated") || 0; const updatedCount = result[0]?.get("updated") || 0;
logger.info(`Assigned ${updatedCount} episodes to space ${spaceId}`, {
episodeIds: episodeIds.length,
userId,
});
return { return {
success: true, success: true,
statementsUpdated: Number(updatedCount), statementsUpdated: Number(updatedCount),
}; };
} catch (error) { } catch (error) {
logger.error(`Error assigning episodes to space:`, {
error,
spaceId,
episodeIds: episodeIds.length,
});
return { return {
success: false, success: false,
statementsUpdated: 0, statementsUpdated: 0,
@ -235,22 +277,26 @@ export async function assignStatementsToSpace(
} }
/** /**
* Remove statements from a space * Remove episodes from a space
*/ */
export async function removeStatementsFromSpace( export async function removeEpisodesFromSpace(
statementIds: string[], episodeIds: string[],
spaceId: string, spaceId: string,
userId: string, userId: string,
): Promise<SpaceAssignmentResult> { ): Promise<SpaceAssignmentResult> {
try { try {
// Remove from both spaceIds array and HAS_EPISODE relationship
const query = ` const query = `
MATCH (s:Statement {userId: $userId}) MATCH (e:Episode {userId: $userId})
WHERE s.uuid IN $statementIds AND s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds WHERE e.uuid IN $episodeIds AND e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId] SET e.spaceIds = [id IN e.spaceIds WHERE id <> $spaceId]
RETURN count(s) as updated WITH e
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[r:HAS_EPISODE]->(e)
DELETE r
RETURN count(e) as updated
`; `;
const result = await runQuery(query, { statementIds, spaceId, userId }); const result = await runQuery(query, { episodeIds, spaceId, userId });
const updatedCount = result[0]?.get("updated") || 0; const updatedCount = result[0]?.get("updated") || 0;
return { return {
@ -267,199 +313,79 @@ export async function removeStatementsFromSpace(
} }
/** /**
* Get all statements in a space * Get all episodes in a space
*/ */
export async function getSpaceStatements(spaceId: string, userId: string) { export async function getSpaceEpisodes(spaceId: string, userId: string) {
const query = ` const query = `
MATCH (s:Statement {userId: $userId}) MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds AND s.invalidAt IS NULL RETURN e
MATCH (s)-[:HAS_SUBJECT]->(subj:Entity) ORDER BY e.createdAt DESC
MATCH (s)-[:HAS_PREDICATE]->(pred:Entity)
MATCH (s)-[:HAS_OBJECT]->(obj:Entity)
RETURN s, subj.name as subject, pred.name as predicate, obj.name as object
ORDER BY s.createdAt DESC
`; `;
const result = await runQuery(query, { spaceId, userId }); const result = await runQuery(query, { spaceId, userId });
return result.map((record) => { return result.map((record) => {
const statement = record.get("s").properties; const episode = record.get("e").properties;
return { return {
uuid: statement.uuid, uuid: episode.uuid,
fact: statement.fact, content: episode.content,
subject: record.get("subject"), originalContent: episode.originalContent,
predicate: record.get("predicate"), source: episode.source,
object: record.get("object"), createdAt: new Date(episode.createdAt),
createdAt: new Date(statement.createdAt), validAt: new Date(episode.validAt),
validAt: new Date(statement.validAt), metadata: JSON.parse(episode.metadata || "{}"),
invalidAt: statement.invalidAt sessionId: episode.sessionId,
? new Date(statement.invalidAt)
: undefined,
spaceIds: statement.spaceIds || [],
recallCount: statement.recallCount,
}; };
}); });
} }
/** /**
* Get real-time statement count for a space from Neo4j * Get episode count for a space
*/ */
export async function getSpaceStatementCount( export async function getSpaceEpisodeCount(
spaceId: string, spaceId: string,
userId: string, userId: string,
): Promise<number> { ): Promise<number> {
// Use spaceIds array for faster lookup instead of relationship traversal
const query = ` const query = `
MATCH (s:Statement {userId: $userId}) MATCH (e:Episode {userId: $userId})
WHERE s.spaceIds IS NOT NULL WHERE e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
AND $spaceId IN s.spaceIds RETURN count(e) as episodeCount
RETURN count(s) as statementCount
`; `;
const result = await runQuery(query, { spaceId, userId }); const result = await runQuery(query, { spaceId, userId });
return Number(result[0]?.get("statementCount") || 0); return Number(result[0]?.get("episodeCount") || 0);
} }
/** /**
* Check if a space should trigger pattern analysis based on growth thresholds * Get spaces for specific episodes
*/ */
export async function shouldTriggerSpacePattern( export async function getSpacesForEpisodes(
spaceId: string, episodeIds: string[],
userId: string, userId: string,
): Promise<{ ): Promise<Record<string, string[]>> {
shouldTrigger: boolean; const query = `
isNewSpace: boolean; UNWIND $episodeIds as episodeId
currentCount: number; MATCH (e:Episode {uuid: episodeId, userId: $userId})
}> { WHERE e.spaceIds IS NOT NULL AND size(e.spaceIds) > 0
try { RETURN episodeId, e.spaceIds as spaceIds
// Get current statement count from Neo4j `;
const currentCount = await getSpaceStatementCount(spaceId, userId);
// Get space data from PostgreSQL const result = await runQuery(query, { episodeIds, userId });
const space = await prisma.space.findUnique({
where: { id: spaceId },
select: {
lastPatternTrigger: true,
statementCountAtLastTrigger: true,
},
});
if (!space) { const spacesMap: Record<string, string[]> = {};
logger.warn(`Space ${spaceId} not found when checking pattern trigger`);
return { shouldTrigger: false, isNewSpace: false, currentCount };
}
const isNewSpace = !space.lastPatternTrigger; // Initialize all episodes with empty arrays
const previousCount = space.statementCountAtLastTrigger || 0; episodeIds.forEach((id) => {
const growth = currentCount - previousCount; spacesMap[id] = [];
});
// Trigger if: new space OR growth >= 100 statements // Fill in the spaceIds for episodes that have them
const shouldTrigger = isNewSpace || growth >= 100; result.forEach((record) => {
const episodeId = record.get("episodeId");
const spaceIds = record.get("spaceIds");
spacesMap[episodeId] = spaceIds || [];
});
logger.info(`Space pattern trigger check`, { return spacesMap;
spaceId,
currentCount,
previousCount,
growth,
isNewSpace,
shouldTrigger,
});
return { shouldTrigger, isNewSpace, currentCount };
} catch (error) {
logger.error(`Error checking space pattern trigger:`, {
error,
spaceId,
userId,
});
return { shouldTrigger: false, isNewSpace: false, currentCount: 0 };
}
}
/**
* Atomically update pattern trigger timestamp and statement count to prevent race conditions
*/
export async function atomicUpdatePatternTrigger(
spaceId: string,
currentCount: number,
): Promise<{ updated: boolean; isNewSpace: boolean } | null> {
try {
// Use a transaction to atomically check and update
const result = await prisma.$transaction(async (tx) => {
// Get current state
const space = await tx.space.findUnique({
where: { id: spaceId },
select: {
lastPatternTrigger: true,
statementCountAtLastTrigger: true,
},
});
if (!space) {
throw new Error(`Space ${spaceId} not found`);
}
const isNewSpace = !space.lastPatternTrigger;
const previousCount = space.statementCountAtLastTrigger || 0;
const growth = currentCount - previousCount;
// Double-check if we still need to trigger (race condition protection)
const shouldTrigger = isNewSpace || growth >= 100;
if (!shouldTrigger) {
return { updated: false, isNewSpace: false };
}
// Update the trigger timestamp and count atomically
await tx.space.update({
where: { id: spaceId },
data: {
lastPatternTrigger: new Date(),
statementCountAtLastTrigger: currentCount,
},
});
logger.info(`Atomically updated pattern trigger for space`, {
spaceId,
previousCount,
currentCount,
growth,
isNewSpace,
});
return { updated: true, isNewSpace };
});
return result;
} catch (error) {
logger.error(`Error in atomic pattern trigger update:`, {
error,
spaceId,
currentCount,
});
return null;
}
}
/**
* Initialize spaceIds array for existing statements (migration helper)
*/
export async function initializeStatementSpaceIds(
userId?: string,
): Promise<number> {
const query = userId
? `
MATCH (s:Statement {userId: $userId})
WHERE s.spaceIds IS NULL
SET s.spaceIds = []
RETURN count(s) as updated
`
: `
MATCH (s:Statement)
WHERE s.spaceIds IS NULL
SET s.spaceIds = []
RETURN count(s) as updated
`;
const result = await runQuery(query, userId ? { userId } : {});
return Number(result[0]?.get("updated") || 0);
} }

View File

@ -1,4 +1,6 @@
import { prisma } from "~/db.server"; import { prisma } from "~/db.server";
import { getEpisode } from "./graphModels/episode";
import { getSpacesForEpisodes } from "./graphModels/space";
export async function getIngestionLogs( export async function getIngestionLogs(
userId: string, userId: string,
@ -53,7 +55,10 @@ export const getIngestionQueue = async (id: string) => {
}); });
}; };
export const getIngestionQueueForFrontend = async (id: string) => { export const getIngestionQueueForFrontend = async (
id: string,
userId: string,
) => {
// Fetch the specific log by logId // Fetch the specific log by logId
const log = await prisma.ingestionQueue.findUnique({ const log = await prisma.ingestionQueue.findUnique({
where: { id: id }, where: { id: id },
@ -66,6 +71,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
type: true, type: true,
output: true, output: true,
data: true, data: true,
workspaceId: true,
activity: { activity: {
select: { select: {
text: true, text: true,
@ -94,7 +100,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
log.activity?.integrationAccount?.integrationDefinition; log.activity?.integrationAccount?.integrationDefinition;
const logData = log.data as any; const logData = log.data as any;
const formattedLog = { const formattedLog: any = {
id: log.id, id: log.id,
source: integrationDef?.name || logData?.source || "Unknown", source: integrationDef?.name || logData?.source || "Unknown",
ingestText: ingestText:
@ -112,9 +118,76 @@ export const getIngestionQueueForFrontend = async (id: string) => {
data: log.data, data: log.data,
}; };
// Fetch space data based on log type
if (logData?.type === "CONVERSATION" && formattedLog?.episodeUUID) {
// For CONVERSATION type: get spaceIds for the single episode
const spacesMap = await getSpacesForEpisodes(
[formattedLog.episodeUUID],
userId,
);
formattedLog.spaceIds = spacesMap[formattedLog.episodeUUID] || [];
} else if (
logData?.type === "DOCUMENT" &&
(log.output as any)?.episodes?.length > 0
) {
// For DOCUMENT type: get episode details and space information for all episodes
const episodeIds = (log.output as any)?.episodes;
// Fetch all episode details in parallel
const episodeDetailsPromises = episodeIds.map((episodeId: string) =>
getEpisode(episodeId).catch(() => null),
);
const episodeDetails = await Promise.all(episodeDetailsPromises);
// Get spaceIds for all episodes
const spacesMap = await getSpacesForEpisodes(episodeIds, userId);
// Combine episode details with space information
formattedLog.episodeDetails = episodeIds.map(
(episodeId: string, index: number) => {
const episode = episodeDetails[index];
return {
uuid: episodeId,
content: episode?.content || episode?.originalContent || "No content",
spaceIds: spacesMap[episodeId] || [],
};
},
);
}
return formattedLog; return formattedLog;
}; };
export const getLogByEpisode = async (episodeUuid: string) => {
// Find logs where the episode UUID matches either:
// 1. log.output.episodeUuid (single episode - CONVERSATION type)
// 2. log.output.episodes array (multiple episodes - DOCUMENT type)
const logs = await prisma.ingestionQueue.findMany({
where: {
OR: [
{
output: {
path: ["episodeUuid"],
equals: episodeUuid,
},
},
{
output: {
path: ["episodes"],
array_contains: episodeUuid,
},
},
],
},
orderBy: {
createdAt: "desc",
},
take: 1,
});
return logs[0] || null;
};
export const deleteIngestionQueue = async (id: string) => { export const deleteIngestionQueue = async (id: string) => {
return await prisma.ingestionQueue.delete({ return await prisma.ingestionQueue.delete({
where: { where: {

View File

@ -19,7 +19,8 @@ import { ensureBillingInitialized } from "./billing.server";
const QueryParams = z.object({ const QueryParams = z.object({
source: z.string().optional(), source: z.string().optional(),
integrations: z.string().optional(), // comma-separated slugs integrations: z.string().optional(), // comma-separated slugs
no_integrations: z.boolean().optional(), // comma-separated slugs no_integrations: z.boolean().optional(),
spaceId: z.string().optional(), // space UUID to associate memories with
}); });
// Create MCP server with memory tools + dynamic integration tools // Create MCP server with memory tools + dynamic integration tools
@ -27,6 +28,7 @@ async function createMcpServer(
userId: string, userId: string,
sessionId: string, sessionId: string,
source: string, source: string,
spaceId?: string,
) { ) {
const server = new Server( const server = new Server(
{ {
@ -40,19 +42,12 @@ async function createMcpServer(
}, },
); );
// Dynamic tool listing that includes integration tools // Dynamic tool listing - only expose memory tools and meta-tools
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
// Get integration tools // Only return memory tools (which now includes integration meta-tools)
let integrationTools: any[] = []; // Integration-specific tools are discovered via get_integration_actions
try {
integrationTools =
await IntegrationLoader.getAllIntegrationTools(sessionId);
} catch (error) {
logger.error(`Error loading integration tools: ${error}`);
}
return { return {
tools: [...memoryTools, ...integrationTools], tools: memoryTools,
}; };
}); });
@ -60,30 +55,21 @@ async function createMcpServer(
server.setRequestHandler(CallToolRequestSchema, async (request) => { server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params; const { name, arguments: args } = request.params;
// Handle memory tools // Handle memory tools and integration meta-tools
if (name.startsWith("memory_")) { if (
return await callMemoryTool(name, args, userId, source); name.startsWith("memory_") ||
} name === "get_integrations" ||
name === "get_integration_actions" ||
// Handle integration tools (prefixed with integration slug) name === "execute_integration_action"
if (name.includes("_") && !name.startsWith("memory_")) { ) {
try { // Get workspace for integration tools
return await IntegrationLoader.callIntegrationTool( const workspace = await getWorkspaceByUser(userId);
sessionId, return await callMemoryTool(
name, name,
args, { ...args, sessionId, workspaceId: workspace?.id, spaceId },
); userId,
} catch (error) { source,
return { );
content: [
{
type: "text",
text: `Error calling integration tool: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
} }
throw new Error(`Unknown tool: ${name}`); throw new Error(`Unknown tool: ${name}`);
@ -100,6 +86,7 @@ async function createTransport(
noIntegrations: boolean, noIntegrations: boolean,
userId: string, userId: string,
workspaceId: string, workspaceId: string,
spaceId?: string,
): Promise<StreamableHTTPServerTransport> { ): Promise<StreamableHTTPServerTransport> {
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId, sessionIdGenerator: () => sessionId,
@ -171,7 +158,7 @@ async function createTransport(
} }
// Create and connect MCP server // Create and connect MCP server
const server = await createMcpServer(userId, sessionId, source); const server = await createMcpServer(userId, sessionId, source, spaceId);
await server.connect(transport); await server.connect(transport);
return transport; return transport;
@ -191,6 +178,7 @@ export const handleMCPRequest = async (
: []; : [];
const noIntegrations = queryParams.no_integrations ?? false; const noIntegrations = queryParams.no_integrations ?? false;
const spaceId = queryParams.spaceId; // Extract spaceId from query params
const userId = authentication.userId; const userId = authentication.userId;
const workspace = await getWorkspaceByUser(userId); const workspace = await getWorkspaceByUser(userId);
@ -220,6 +208,7 @@ export const handleMCPRequest = async (
noIntegrations, noIntegrations,
userId, userId,
workspaceId, workspaceId,
spaceId,
); );
} else { } else {
throw new Error("Session not found in database"); throw new Error("Session not found in database");
@ -237,6 +226,7 @@ export const handleMCPRequest = async (
noIntegrations, noIntegrations,
userId, userId,
workspaceId, workspaceId,
spaceId,
); );
} else { } else {
// Invalid request // Invalid request

View File

@ -33,7 +33,7 @@ export class SearchService {
options: SearchOptions = {}, options: SearchOptions = {},
source?: string, source?: string,
): Promise<{ ): Promise<{
episodes: string[]; episodes: {content: string; createdAt: Date; spaceIds: string[]}[];
facts: { facts: {
fact: string; fact: string;
validAt: Date; validAt: Date;
@ -108,7 +108,11 @@ export class SearchService {
); );
return { return {
episodes: episodes.map((episode) => episode.originalContent), episodes: episodes.map((episode) => ({
content: episode.originalContent,
createdAt: episode.createdAt,
spaceIds: episode.spaceIds || [],
})),
facts: filteredResults.map((statement) => ({ facts: filteredResults.map((statement) => ({
fact: statement.statement.fact, fact: statement.statement.fact,
validAt: statement.statement.validAt, validAt: statement.statement.validAt,

View File

@ -33,14 +33,6 @@ export async function getUser(request: Request) {
throw await logout(request); throw await logout(request);
} }
export async function getUserRemainingCount(request: Request) {
const userId = await getUserId(request);
if (userId === undefined) return null;
const userUsage = await getUserLeftCredits(userId);
if (userUsage) return userUsage;
}
export async function requireUserId(request: Request, redirectTo?: string) { export async function requireUserId(request: Request, redirectTo?: string) {
const userId = await getUserId(request); const userId = await getUserId(request);
if (!userId) { if (!userId) {
@ -71,6 +63,7 @@ export async function requireUser(request: Request) {
confirmedBasicDetails: user.confirmedBasicDetails, confirmedBasicDetails: user.confirmedBasicDetails,
onboardingComplete: user.onboardingComplete, onboardingComplete: user.onboardingComplete,
isImpersonating: !!impersonationId, isImpersonating: !!impersonationId,
workspaceId: user.Workspace?.id,
}; };
} }

View File

@ -3,19 +3,18 @@ import {
type SpaceNode, type SpaceNode,
type CreateSpaceParams, type CreateSpaceParams,
type UpdateSpaceParams, type UpdateSpaceParams,
type SpaceAssignmentResult,
} from "@core/types"; } from "@core/types";
import { type Space } from "@prisma/client"; import { type Space } from "@prisma/client";
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment"; import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
import { import {
assignStatementsToSpace, assignEpisodesToSpace,
createSpace, createSpace,
deleteSpace, deleteSpace,
getSpace, getSpace,
getSpaceStatements, getSpaceEpisodeCount,
initializeStatementSpaceIds, getSpaceEpisodes,
removeStatementsFromSpace, removeEpisodesFromSpace,
updateSpace, updateSpace,
} from "./graphModels/space"; } from "./graphModels/space";
import { prisma } from "~/trigger/utils/prisma"; import { prisma } from "~/trigger/utils/prisma";
@ -182,13 +181,6 @@ export class SpaceService {
} }
} }
if (
updates.description !== undefined &&
updates.description.length > 1000
) {
throw new Error("Space description too long (max 1000 characters)");
}
const space = await prisma.space.update({ const space = await prisma.space.update({
where: { where: {
id: spaceId, id: spaceId,
@ -233,87 +225,101 @@ export class SpaceService {
} }
/** /**
* Assign statements to a space * Reset a space by clearing all episode assignments, summary, and metadata
*/ */
async assignStatementsToSpace( async resetSpace(spaceId: string, userId: string): Promise<Space> {
statementIds: string[], logger.info(`Resetting space ${spaceId} for user ${userId}`);
spaceId: string,
userId: string,
): Promise<SpaceAssignmentResult> {
logger.info(
`Assigning ${statementIds.length} statements to space ${spaceId} for user ${userId}`,
);
// Validate input // Get the space first to verify it exists and get its details
if (statementIds.length === 0) { const space = await prisma.space.findUnique({
throw new Error("No statement IDs provided"); where: {
id: spaceId,
},
});
if (!space) {
throw new Error("Space not found");
} }
if (statementIds.length > 1000) { if (space.name === "Profile") {
throw new Error("Too many statements (max 1000 per operation)"); throw new Error("Cannot reset Profile space");
} }
const result = await assignStatementsToSpace(statementIds, spaceId, userId); // Delete all relationships in Neo4j (episodes, statements, etc.)
await deleteSpace(spaceId, userId);
if (result.success) { // Recreate the space in Neo4j (clean slate)
logger.info( await createSpace(
`Successfully assigned ${result.statementsUpdated} statements to space ${spaceId}`, space.id,
); space.name.trim(),
} else { space.description?.trim(),
logger.warn(
`Failed to assign statements to space ${spaceId}: ${result.error}`,
);
}
return result;
}
/**
* Remove statements from a space
*/
async removeStatementsFromSpace(
statementIds: string[],
spaceId: string,
userId: string,
): Promise<SpaceAssignmentResult> {
logger.info(
`Removing ${statementIds.length} statements from space ${spaceId} for user ${userId}`,
);
// Validate input
if (statementIds.length === 0) {
throw new Error("No statement IDs provided");
}
if (statementIds.length > 1000) {
throw new Error("Too many statements (max 1000 per operation)");
}
const result = await removeStatementsFromSpace(
statementIds,
spaceId,
userId, userId,
); );
if (result.success) { // Reset all summary and metadata fields in PostgreSQL
logger.info( const resetSpace = await prisma.space.update({
`Successfully removed ${result.statementsUpdated} statements from space ${spaceId}`, where: {
); id: spaceId,
} else { },
logger.warn( data: {
`Failed to remove statements from space ${spaceId}: ${result.error}`, summary: null,
); themes: [],
} contextCount: null,
status: "pending",
summaryGeneratedAt: null,
lastPatternTrigger: null,
},
});
return result; logger.info(`Reset space ${spaceId} successfully`);
return resetSpace;
} }
/** /**
* Get all statements in a space * Get all episodes in a space
*/ */
async getSpaceStatements(spaceId: string, userId: string) { async getSpaceEpisodes(spaceId: string, userId: string) {
logger.info(`Fetching statements for space ${spaceId} for user ${userId}`); logger.info(`Fetching episodes for space ${spaceId} for user ${userId}`);
return await getSpaceStatements(spaceId, userId); return await getSpaceEpisodes(spaceId, userId);
}
/**
* Assign episodes to a space
*/
async assignEpisodesToSpace(
episodeIds: string[],
spaceId: string,
userId: string,
) {
logger.info(
`Assigning ${episodeIds.length} episodes to space ${spaceId} for user ${userId}`,
);
await assignEpisodesToSpace(episodeIds, spaceId, userId);
logger.info(
`Successfully assigned ${episodeIds.length} episodes to space ${spaceId}`,
);
}
/**
* Remove episodes from a space
*/
async removeEpisodesFromSpace(
episodeIds: string[],
spaceId: string,
userId: string,
) {
logger.info(
`Removing ${episodeIds.length} episodes from space ${spaceId} for user ${userId}`,
);
await removeEpisodesFromSpace(episodeIds, spaceId, userId);
logger.info(
`Successfully removed ${episodeIds.length} episodes from space ${spaceId}`,
);
} }
/** /**
@ -338,49 +344,6 @@ export class SpaceService {
}); });
} }
/**
* Get spaces that contain specific statements
*/
async getSpacesForStatements(
statementIds: string[],
userId: string,
): Promise<{ statementId: string; spaces: Space[] }[]> {
const userSpaces = await this.getUserSpaces(userId);
const result: { statementId: string; spaces: Space[] }[] = [];
for (const statementId of statementIds) {
const spacesContainingStatement = [];
for (const space of userSpaces) {
const statements = await this.getSpaceStatements(space.id, userId);
if (statements.some((stmt) => stmt.uuid === statementId)) {
spacesContainingStatement.push(space);
}
}
result.push({
statementId,
spaces: spacesContainingStatement,
});
}
return result;
}
/**
* Initialize spaceIds for existing statements (migration utility)
*/
async initializeSpaceIds(userId?: string): Promise<number> {
logger.info(
`Initializing spaceIds for ${userId ? `user ${userId}` : "all users"}`,
);
const updatedCount = await initializeStatementSpaceIds(userId);
logger.info(`Initialized spaceIds for ${updatedCount} statements`);
return updatedCount;
}
/** /**
* Validate space access * Validate space access
*/ */
@ -388,41 +351,4 @@ export class SpaceService {
const space = await this.getSpace(spaceId, userId); const space = await this.getSpace(spaceId, userId);
return space !== null && space.isActive; return space !== null && space.isActive;
} }
/**
* Bulk assign statements to multiple spaces
*/
async bulkAssignStatements(
statementIds: string[],
spaceIds: string[],
userId: string,
): Promise<{ spaceId: string; result: SpaceAssignmentResult }[]> {
logger.info(
`Bulk assigning ${statementIds.length} statements to ${spaceIds.length} spaces for user ${userId}`,
);
const results: { spaceId: string; result: SpaceAssignmentResult }[] = [];
for (const spaceId of spaceIds) {
try {
const result = await this.assignStatementsToSpace(
statementIds,
spaceId,
userId,
);
results.push({ spaceId, result });
} catch (error) {
results.push({
spaceId,
result: {
success: false,
statementsUpdated: 0,
error: error instanceof Error ? error.message : "Unknown error",
},
});
}
}
return results;
}
} }

View File

@ -181,7 +181,7 @@ export const ingestDocumentTask = task({
documentUuid: document.uuid, documentUuid: document.uuid,
}, },
source: documentBody.source, source: documentBody.source,
spaceId: documentBody.spaceId, spaceIds: documentBody.spaceIds,
sessionId: documentBody.sessionId, sessionId: documentBody.sessionId,
type: EpisodeTypeEnum.DOCUMENT, type: EpisodeTypeEnum.DOCUMENT,
}; };

View File

@ -9,13 +9,14 @@ import { triggerSpaceAssignment } from "../spaces/space-assignment";
import { prisma } from "../utils/prisma"; import { prisma } from "../utils/prisma";
import { EpisodeType } from "@core/types"; import { EpisodeType } from "@core/types";
import { deductCredits, hasCredits } from "../utils/utils"; import { deductCredits, hasCredits } from "../utils/utils";
import { assignEpisodesToSpace } from "~/services/graphModels/space";
export const IngestBodyRequest = z.object({ export const IngestBodyRequest = z.object({
episodeBody: z.string(), episodeBody: z.string(),
referenceTime: z.string(), referenceTime: z.string(),
metadata: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), metadata: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
source: z.string(), source: z.string(),
spaceId: z.string().optional(), spaceIds: z.array(z.string()).optional(),
sessionId: z.string().optional(), sessionId: z.string().optional(),
type: z type: z
.enum([EpisodeType.CONVERSATION, EpisodeType.DOCUMENT]) .enum([EpisodeType.CONVERSATION, EpisodeType.DOCUMENT])
@ -148,23 +149,49 @@ export const ingestTask = task({
); );
} }
// Trigger space assignment after successful ingestion // Handle space assignment after successful ingestion
try { try {
logger.info(`Triggering space assignment after successful ingestion`, { // If spaceIds were explicitly provided, immediately assign the episode to those spaces
userId: payload.userId, if (episodeBody.spaceIds && episodeBody.spaceIds.length > 0 && episodeDetails.episodeUuid) {
workspaceId: payload.workspaceId, logger.info(`Assigning episode to explicitly provided spaces`, {
episodeId: episodeDetails?.episodeUuid,
});
if (
episodeDetails.episodeUuid &&
currentStatus === IngestionStatus.COMPLETED
) {
await triggerSpaceAssignment({
userId: payload.userId, userId: payload.userId,
workspaceId: payload.workspaceId, episodeId: episodeDetails.episodeUuid,
mode: "episode", spaceIds: episodeBody.spaceIds,
episodeIds: episodeUuids,
}); });
// Assign episode to each space
for (const spaceId of episodeBody.spaceIds) {
await assignEpisodesToSpace(
[episodeDetails.episodeUuid],
spaceId,
payload.userId,
);
}
logger.info(
`Skipping LLM space assignment - episode explicitly assigned to ${episodeBody.spaceIds.length} space(s)`,
);
} else {
// Only trigger automatic LLM space assignment if no explicit spaceIds were provided
logger.info(
`Triggering LLM space assignment after successful ingestion`,
{
userId: payload.userId,
workspaceId: payload.workspaceId,
episodeId: episodeDetails?.episodeUuid,
},
);
if (
episodeDetails.episodeUuid &&
currentStatus === IngestionStatus.COMPLETED
) {
await triggerSpaceAssignment({
userId: payload.userId,
workspaceId: payload.workspaceId,
mode: "episode",
episodeIds: episodeUuids,
});
}
} }
} catch (assignmentError) { } catch (assignmentError) {
// Don't fail the ingestion if assignment fails // Don't fail the ingestion if assignment fails

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ import { triggerSpacePattern } from "./space-pattern";
import { getSpace, updateSpace } from "../utils/space-utils"; import { getSpace, updateSpace } from "../utils/space-utils";
import { EpisodeType } from "@core/types"; import { EpisodeType } from "@core/types";
import { getSpaceStatementCount } from "~/services/graphModels/space"; import { getSpaceEpisodeCount } from "~/services/graphModels/space";
import { addToQueue } from "../utils/queue"; import { addToQueue } from "../utils/queue";
interface SpaceSummaryPayload { interface SpaceSummaryPayload {
@ -35,7 +35,7 @@ interface SpaceSummaryData {
spaceId: string; spaceId: string;
spaceName: string; spaceName: string;
spaceDescription?: string; spaceDescription?: string;
statementCount: number; contextCount: number;
summary: string; summary: string;
keyEntities: string[]; keyEntities: string[];
themes: string[]; themes: string[];
@ -55,7 +55,7 @@ const SummaryResultSchema = z.object({
const CONFIG = { const CONFIG = {
maxEpisodesForSummary: 20, // Limit episodes for performance maxEpisodesForSummary: 20, // Limit episodes for performance
minEpisodesForSummary: 1, // Minimum episodes to generate summary minEpisodesForSummary: 1, // Minimum episodes to generate summary
summaryPromptTokenLimit: 4000, // Approximate token limit for prompt summaryEpisodeThreshold: 10, // Minimum new episodes required to trigger summary (configurable)
}; };
export const spaceSummaryQueue = queue({ export const spaceSummaryQueue = queue({
@ -85,7 +85,7 @@ export const spaceSummaryTask = task({
}); });
// Generate summary for the single space // Generate summary for the single space
const summaryResult = await generateSpaceSummary(spaceId, userId); const summaryResult = await generateSpaceSummary(spaceId, userId, triggerSource);
if (summaryResult) { if (summaryResult) {
// Store the summary // Store the summary
@ -98,36 +98,24 @@ export const spaceSummaryTask = task({
metadata: { metadata: {
triggerSource, triggerSource,
phase: "completed_summary", phase: "completed_summary",
statementCount: summaryResult.statementCount, contextCount: summaryResult.contextCount,
confidence: summaryResult.confidence, confidence: summaryResult.confidence,
}, },
}); });
logger.info(`Generated summary for space ${spaceId}`, { logger.info(`Generated summary for space ${spaceId}`, {
statementCount: summaryResult.statementCount, statementCount: summaryResult.contextCount,
confidence: summaryResult.confidence, confidence: summaryResult.confidence,
themes: summaryResult.themes.length, themes: summaryResult.themes.length,
triggerSource, triggerSource,
}); });
// Ingest summary as document if it exists and continue with patterns
if (!summaryResult.isIncremental && summaryResult.statementCount > 0) {
await processSpaceSummarySequentially({
userId,
workspaceId,
spaceId,
spaceName: summaryResult.spaceName,
summaryContent: summaryResult.summary,
triggerSource: "summary_complete",
});
}
return { return {
success: true, success: true,
spaceId, spaceId,
triggerSource, triggerSource,
summary: { summary: {
statementCount: summaryResult.statementCount, statementCount: summaryResult.contextCount,
confidence: summaryResult.confidence, confidence: summaryResult.confidence,
themesCount: summaryResult.themes.length, themesCount: summaryResult.themes.length,
}, },
@ -186,6 +174,7 @@ export const spaceSummaryTask = task({
async function generateSpaceSummary( async function generateSpaceSummary(
spaceId: string, spaceId: string,
userId: string, userId: string,
triggerSource?: "assignment" | "manual" | "scheduled",
): Promise<SpaceSummaryData | null> { ): Promise<SpaceSummaryData | null> {
try { try {
// 1. Get space details // 1. Get space details
@ -197,6 +186,35 @@ async function generateSpaceSummary(
return null; return null;
} }
// 2. Check episode count threshold (skip for manual triggers)
if (triggerSource !== "manual") {
const currentEpisodeCount = await getSpaceEpisodeCount(spaceId, userId);
const lastSummaryEpisodeCount = space.contextCount || 0;
const episodeDifference = currentEpisodeCount - lastSummaryEpisodeCount;
if (episodeDifference < CONFIG.summaryEpisodeThreshold) {
logger.info(
`Skipping summary generation for space ${spaceId}: only ${episodeDifference} new episodes (threshold: ${CONFIG.summaryEpisodeThreshold})`,
{
currentEpisodeCount,
lastSummaryEpisodeCount,
episodeDifference,
threshold: CONFIG.summaryEpisodeThreshold,
}
);
return null;
}
logger.info(
`Proceeding with summary generation for space ${spaceId}: ${episodeDifference} new episodes (threshold: ${CONFIG.summaryEpisodeThreshold})`,
{
currentEpisodeCount,
lastSummaryEpisodeCount,
episodeDifference,
}
);
}
// 2. Check for existing summary // 2. Check for existing summary
const existingSummary = await getExistingSummary(spaceId); const existingSummary = await getExistingSummary(spaceId);
const isIncremental = existingSummary !== null; const isIncremental = existingSummary !== null;
@ -296,14 +314,14 @@ async function generateSpaceSummary(
return null; return null;
} }
// Get the actual current statement count from Neo4j // Get the actual current counts from Neo4j
const currentStatementCount = await getSpaceStatementCount(spaceId, userId); const currentEpisodeCount = await getSpaceEpisodeCount(spaceId, userId);
return { return {
spaceId: space.uuid, spaceId: space.uuid,
spaceName: space.name, spaceName: space.name,
spaceDescription: space.description as string, spaceDescription: space.description as string,
statementCount: currentStatementCount, contextCount: currentEpisodeCount,
summary: summaryResult.summary, summary: summaryResult.summary,
keyEntities: summaryResult.keyEntities || [], keyEntities: summaryResult.keyEntities || [],
themes: summaryResult.themes, themes: summaryResult.themes,
@ -400,38 +418,48 @@ function createUnifiedSummaryPrompt(
return [ return [
{ {
role: "system", role: "system",
content: `You are an expert at analyzing and summarizing structured knowledge within semantic spaces. Your task is to ${isUpdate ? "update an existing summary by integrating new episodes" : "create a comprehensive summary of episodes"}. content: `You are an expert at analyzing and summarizing episodes within semantic spaces based on the space's intent and purpose. Your task is to ${isUpdate ? "update an existing summary by integrating new episodes" : "create a comprehensive summary of episodes"}.
CRITICAL RULES: CRITICAL RULES:
1. Base your summary ONLY on insights derived from the actual content/episodes provided 1. Base your summary ONLY on insights derived from the actual content/episodes provided
2. Use the space description only as contextual guidance, never copy or paraphrase it 2. Use the space's INTENT/PURPOSE (from description) to guide what to summarize and how to organize it
3. Write in a factual, neutral tone - avoid promotional language ("pivotal", "invaluable", "cutting-edge") 3. Write in a factual, neutral tone - avoid promotional language ("pivotal", "invaluable", "cutting-edge")
4. Be specific and concrete - reference actual content, patterns, and themes found in the episodes 4. Be specific and concrete - reference actual content, patterns, and insights found in the episodes
5. If episodes are insufficient for meaningful insights, state that more data is needed 5. If episodes are insufficient for meaningful insights, state that more data is needed
INTENT-DRIVEN SUMMARIZATION:
Your summary should SERVE the space's intended purpose. Examples:
- "Learning React" Summarize React concepts, patterns, techniques learned
- "Project X Updates" Summarize progress, decisions, blockers, next steps
- "Health Tracking" Summarize metrics, trends, observations, insights
- "Guidelines for React" Extract actionable patterns, best practices, rules
- "Evolution of design thinking" Track how thinking changed over time, decision points
The intent defines WHY this space exists - organize content to serve that purpose.
INSTRUCTIONS: INSTRUCTIONS:
${ ${
isUpdate isUpdate
? `1. Review the existing summary and themes carefully ? `1. Review the existing summary and themes carefully
2. Analyze the new episodes for patterns and insights 2. Analyze the new episodes for patterns and insights that align with the space's intent
3. Identify connecting points between existing knowledge and new episodes 3. Identify connecting points between existing knowledge and new episodes
4. Update the summary to seamlessly integrate new information while preserving valuable existing insights 4. Update the summary to seamlessly integrate new information while preserving valuable existing insights
5. Evolve themes by adding new ones or refining existing ones based on connections found 5. Evolve themes by adding new ones or refining existing ones based on the space's purpose
6. Update the markdown summary to reflect the enhanced themes and new insights` 6. Organize the summary to serve the space's intended use case`
: `1. Analyze the semantic content and relationships within the episodes : `1. Analyze the semantic content and relationships within the episodes
2. Identify the main themes and patterns across all episodes (themes must have at least 3 supporting episodes) 2. Identify topics/sections that align with the space's INTENT and PURPOSE
3. Create a coherent summary that captures the essence of this knowledge domain 3. Create a coherent summary that serves the space's intended use case
4. Generate a well-structured markdown summary organized by the identified themes` 4. Organize the summary based on the space's purpose (not generic frequency-based themes)`
} }
${isUpdate ? "7" : "6"}. Assess your confidence in the ${isUpdate ? "updated" : ""} summary quality (0.0-1.0) ${isUpdate ? "7" : "5"}. Assess your confidence in the ${isUpdate ? "updated" : ""} summary quality (0.0-1.0)
THEME IDENTIFICATION RULES: INTENT-ALIGNED ORGANIZATION:
- A theme must be supported by AT LEAST 3 related episodes to be considered valid - Organize sections based on what serves the space's purpose
- Themes should represent substantial, meaningful patterns rather than minor occurrences - Topics don't need minimum episode counts - relevance to intent matters most
- Each theme must capture a distinct semantic domain or conceptual area - Each section should provide value aligned with the space's intended use
- Only identify themes that have sufficient evidence in the data - For "guidelines" spaces: focus on actionable patterns
- If fewer than 3 episodes support a potential theme, do not include it - For "tracking" spaces: focus on temporal patterns and changes
- Themes will be used to organize the markdown summary into logical sections - For "learning" spaces: focus on concepts and insights gained
- Let the space's intent drive the structure, not rigid rules
${ ${
isUpdate isUpdate
@ -484,7 +512,7 @@ ${
role: "user", role: "user",
content: `SPACE INFORMATION: content: `SPACE INFORMATION:
Name: "${spaceName}" Name: "${spaceName}"
Description (for context only): ${spaceDescription || "No description provided"} Intent/Purpose: ${spaceDescription || "No specific intent provided - organize naturally based on content"}
${ ${
isUpdate isUpdate
@ -508,8 +536,8 @@ ${topEntities.join(", ")}`
${ ${
isUpdate isUpdate
? "Please identify connections between the existing summary and new episodes, then update the summary to integrate the new insights coherently. Remember: only summarize insights from the actual episode content, not the space description." ? "Please identify connections between the existing summary and new episodes, then update the summary to integrate the new insights coherently. Organize the summary to SERVE the space's intent/purpose. Remember: only summarize insights from the actual episode content."
: "Please analyze the episodes and provide a comprehensive summary that captures insights derived from the episode content provided. Use the description only as context. If there are too few episodes to generate meaningful insights, indicate that more data is needed rather than falling back on the description." : "Please analyze the episodes and provide a comprehensive summary that SERVES the space's intent/purpose. Organize sections based on what would be most valuable for this space's intended use case. If the intent is unclear, organize naturally based on content patterns. Only summarize insights from actual episode content."
}`, }`,
}, },
]; ];
@ -519,7 +547,7 @@ async function getExistingSummary(spaceId: string): Promise<{
summary: string; summary: string;
themes: string[]; themes: string[];
lastUpdated: Date; lastUpdated: Date;
statementCount: number; contextCount: number;
} | null> { } | null> {
try { try {
const existingSummary = await getSpace(spaceId); const existingSummary = await getSpace(spaceId);
@ -528,8 +556,8 @@ async function getExistingSummary(spaceId: string): Promise<{
return { return {
summary: existingSummary.summary, summary: existingSummary.summary,
themes: existingSummary.themes, themes: existingSummary.themes,
lastUpdated: existingSummary.lastPatternTrigger || new Date(), lastUpdated: existingSummary.summaryGeneratedAt || new Date(),
statementCount: existingSummary.statementCount || 0, contextCount: existingSummary.contextCount || 0,
}; };
} }
@ -547,24 +575,18 @@ async function getSpaceEpisodes(
userId: string, userId: string,
sinceDate?: Date, sinceDate?: Date,
): Promise<SpaceEpisodeData[]> { ): Promise<SpaceEpisodeData[]> {
// Build query to get distinct episodes that have statements in the space // Query episodes directly using Space-[:HAS_EPISODE]->Episode relationships
let whereClause =
"s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds AND s.invalidAt IS NULL";
const params: any = { spaceId, userId }; const params: any = { spaceId, userId };
// Store the sinceDate condition separately to apply after e is defined
let dateCondition = ""; let dateCondition = "";
if (sinceDate) { if (sinceDate) {
dateCondition = "e.createdAt > $sinceDate"; dateCondition = "AND e.createdAt > $sinceDate";
params.sinceDate = sinceDate.toISOString(); params.sinceDate = sinceDate.toISOString();
} }
const query = ` const query = `
MATCH (s:Statement{userId: $userId}) MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
WHERE ${whereClause} WHERE e IS NOT NULL ${dateCondition}
OPTIONAL MATCH (e:Episode{userId: $userId})-[:HAS_PROVENANCE]->(s)
WITH e
WHERE e IS NOT NULL ${dateCondition ? `AND ${dateCondition}` : ""}
RETURN DISTINCT e RETURN DISTINCT e
ORDER BY e.createdAt DESC ORDER BY e.createdAt DESC
`; `;
@ -654,7 +676,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
space.keyEntities = $keyEntities, space.keyEntities = $keyEntities,
space.themes = $themes, space.themes = $themes,
space.summaryConfidence = $confidence, space.summaryConfidence = $confidence,
space.summaryStatementCount = $statementCount, space.summaryContextCount = $contextCount,
space.summaryLastUpdated = datetime($lastUpdated) space.summaryLastUpdated = datetime($lastUpdated)
RETURN space RETURN space
`; `;
@ -665,7 +687,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
keyEntities: summaryData.keyEntities, keyEntities: summaryData.keyEntities,
themes: summaryData.themes, themes: summaryData.themes,
confidence: summaryData.confidence, confidence: summaryData.confidence,
statementCount: summaryData.statementCount, contextCount: summaryData.contextCount,
lastUpdated: summaryData.lastUpdated.toISOString(), lastUpdated: summaryData.lastUpdated.toISOString(),
}); });

View File

@ -31,7 +31,7 @@ export const updateSpace = async (summaryData: {
spaceId: string; spaceId: string;
summary: string; summary: string;
themes: string[]; themes: string[];
statementCount: number; contextCount: number;
}) => { }) => {
return await prisma.space.update({ return await prisma.space.update({
where: { where: {
@ -40,7 +40,8 @@ export const updateSpace = async (summaryData: {
data: { data: {
summary: summaryData.summary, summary: summaryData.summary,
themes: summaryData.themes, themes: summaryData.themes,
statementCount: summaryData.statementCount, contextCount: summaryData.contextCount,
summaryGeneratedAt: new Date().toISOString()
}, },
}); });
}; };

View File

@ -201,6 +201,45 @@ export class IntegrationLoader {
return allTools; return allTools;
} }
/**
* Get tools from a specific integration
*/
static async getIntegrationTools(sessionId: string, integrationSlug: string) {
const integrationTransports =
TransportManager.getSessionIntegrationTransports(sessionId);
if (integrationTransports.length === 0) {
throw new Error(
`No integration transports loaded for session ${sessionId}. Make sure integrations are connected and session is initialized properly.`,
);
}
const integrationTransport = integrationTransports.find(
(t) => t.slug === integrationSlug,
);
if (!integrationTransport) {
const availableSlugs = integrationTransports
.map((t) => t.slug)
.join(", ");
throw new Error(
`Integration '${integrationSlug}' not found or not connected. Available integrations: ${availableSlugs}`,
);
}
const result = await integrationTransport.client.listTools();
if (result.tools && Array.isArray(result.tools)) {
return result.tools.map((tool: any) => ({
name: tool.name,
description: tool.description || tool.name,
inputSchema: tool.inputSchema,
}));
}
return [];
}
/** /**
* Call a tool on a specific integration * Call a tool on a specific integration
*/ */

View File

@ -3,6 +3,7 @@ import { addToQueue } from "~/lib/ingest.server";
import { logger } from "~/services/logger.service"; import { logger } from "~/services/logger.service";
import { SearchService } from "~/services/search.server"; import { SearchService } from "~/services/search.server";
import { SpaceService } from "~/services/space.server"; import { SpaceService } from "~/services/space.server";
import { IntegrationLoader } from "./integration-loader";
const searchService = new SearchService(); const searchService = new SearchService();
const spaceService = new SpaceService(); const spaceService = new SpaceService();
@ -13,29 +14,31 @@ const SearchParamsSchema = {
properties: { properties: {
query: { query: {
type: "string", type: "string",
description: "The search query in third person perspective", description:
"Search query as a simple statement or question. Write what you want to find, not a command. GOOD: 'user preferences for code style' or 'previous bugs in authentication' or 'GitHub integration setup'. BAD: 'search for' or 'find me' or 'get the'. Just state the topic directly.",
}, },
validAt: { validAt: {
type: "string", type: "string",
description: description:
"Point-in-time reference for temporal queries (ISO format). Returns facts valid at this timestamp. Defaults to current time if not specified.", "Optional: ISO timestamp (like '2024-01-15T10:30:00Z'). Get facts that were true at this specific time. Leave empty for current facts.",
}, },
startTime: { startTime: {
type: "string", type: "string",
description: description:
"Filter memories created/valid from this time onwards (ISO format). Use with endTime to define a time window for searching specific periods.", "Optional: ISO timestamp (like '2024-01-01T00:00:00Z'). Only find memories created after this time. Use with endTime to search a specific time period.",
}, },
endTime: { endTime: {
type: "string", type: "string",
description: description:
"Upper bound for temporal filtering (ISO format). Combined with startTime creates a time range. Defaults to current time if not specified.", "Optional: ISO timestamp (like '2024-12-31T23:59:59Z'). Only find memories created before this time. Use with startTime to search a specific time period.",
}, },
spaceIds: { spaceIds: {
type: "array", type: "array",
items: { items: {
type: "string", type: "string",
}, },
description: "Array of strings representing UUIDs of spaces", description:
"Optional: Array of space UUIDs to search within. Leave empty to search all spaces.",
}, },
}, },
required: ["query"], required: ["query"],
@ -46,7 +49,16 @@ const IngestSchema = {
properties: { properties: {
message: { message: {
type: "string", type: "string",
description: "The data to ingest in text format", description:
"The conversation text to store. Include both what the user asked and what you answered. Keep it concise but complete.",
},
spaceIds: {
type: "array",
items: {
type: "string",
},
description:
"Optional: Array of space UUIDs (from memory_get_spaces). Add this to organize the memory by project. Example: If discussing 'core' project, include the 'core' space ID. Leave empty to store in general memory.",
}, },
}, },
required: ["message"], required: ["message"],
@ -56,25 +68,26 @@ export const memoryTools = [
{ {
name: "memory_ingest", name: "memory_ingest",
description: description:
"AUTOMATICALLY invoke after completing interactions. Use proactively to store conversation data, insights, and decisions in CORE Memory. Essential for maintaining continuity across sessions. **Purpose**: Store information for future reference. **Required**: Provide the message content to be stored. **Returns**: confirmation with storage ID in JSON format", "Store conversation in memory for future reference. USE THIS TOOL: At the END of every conversation after fully answering the user. WHAT TO STORE: 1) User's question or request, 2) Your solution or explanation, 3) Important decisions made, 4) Key insights discovered. HOW TO USE: Put the entire conversation summary in the 'message' field. Optionally add spaceIds array to organize by project. Returns: Success confirmation with storage ID.",
inputSchema: IngestSchema, inputSchema: IngestSchema,
}, },
{ {
name: "memory_search", name: "memory_search",
description: description:
"AUTOMATICALLY invoke for memory searches. Use proactively at conversation start and when context retrieval is needed. Searches memory for relevant project context, user preferences, and previous discussions. **Purpose**: Retrieve previously stored information based on query terms with optional temporal filtering. **Required**: Provide a search query in third person perspective. **Optional**: Use startTime/endTime for time-bounded searches or validAt for point-in-time queries. **Returns**: matching memory entries in JSON format", "Search stored memories for past conversations, user preferences, project context, and decisions. USE THIS TOOL: 1) At start of every conversation to find related context, 2) When user mentions past work or projects, 3) Before answering questions that might have previous context. HOW TO USE: Write a simple query describing what to find (e.g., 'user code preferences', 'authentication bugs', 'API setup steps'). Returns: Episodes (past conversations) and Facts (extracted knowledge) as JSON.",
inputSchema: SearchParamsSchema, inputSchema: SearchParamsSchema,
}, },
{ {
name: "memory_get_spaces", name: "memory_get_spaces",
description: description:
"Get available memory spaces. **Purpose**: Retrieve list of memory organization spaces. **Required**: No required parameters. **Returns**: list of available spaces in JSON format", "List all available memory spaces (project contexts). USE THIS TOOL: To see what spaces exist before searching or storing memories. Each space organizes memories by topic (e.g., 'Profile' for user info, 'GitHub' for GitHub work, project names for project-specific context). Returns: Array of spaces with id, name, and description.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
all: { all: {
type: "boolean", type: "boolean",
description: "Get all spaces", description:
"Set to true to get all spaces including system spaces. Leave empty for user spaces only.",
}, },
}, },
}, },
@ -82,17 +95,89 @@ export const memoryTools = [
{ {
name: "memory_about_user", name: "memory_about_user",
description: description:
"Get information about the user. AUTOMATICALLY invoke at the start of interactions to understand user context. Returns the user's background, preferences, work, interests, and other personal information. **Required**: No required parameters. **Returns**: User information as text.", "Get user's profile information (background, preferences, work, interests). USE THIS TOOL: At the start of conversations to understand who you're helping. This provides context about the user's technical preferences, work style, and personal details. Returns: User profile summary as text.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
profile: { profile: {
type: "boolean", type: "boolean",
description: "Get user profile", description:
"Set to true to get full profile. Leave empty for default profile view.",
}, },
}, },
}, },
}, },
{
name: "memory_get_space",
description:
"Get detailed information about a specific space including its full summary. USE THIS TOOL: When working on a project to get comprehensive context about that project. The summary contains consolidated knowledge about the space topic. HOW TO USE: Provide either spaceName (like 'core', 'GitHub', 'Profile') OR spaceId (UUID). Returns: Space details with full summary, description, and metadata.",
inputSchema: {
type: "object",
properties: {
spaceId: {
type: "string",
description:
"UUID of the space (use this if you have the ID from memory_get_spaces)",
},
spaceName: {
type: "string",
description:
"Name of the space (easier option). Examples: 'core', 'Profile', 'GitHub', 'Health'",
},
},
},
},
{
name: "get_integrations",
description:
"List all connected integrations (GitHub, Linear, Slack, etc.). USE THIS TOOL: Before using integration actions to see what's available. WORKFLOW: 1) Call this to see available integrations, 2) Call get_integration_actions with a slug to see what you can do, 3) Call execute_integration_action to do it. Returns: Array with slug, name, accountId, and hasMcp for each integration.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "get_integration_actions",
description:
"Get list of actions available for a specific integration. USE THIS TOOL: After get_integrations to see what operations you can perform. For example, GitHub integration has actions like 'get_pr', 'get_issues', 'create_issue'. HOW TO USE: Provide the integrationSlug from get_integrations (like 'github', 'linear', 'slack'). Returns: Array of actions with name, description, and inputSchema for each.",
inputSchema: {
type: "object",
properties: {
integrationSlug: {
type: "string",
description:
"Slug from get_integrations. Examples: 'github', 'linear', 'slack'",
},
},
required: ["integrationSlug"],
},
},
{
name: "execute_integration_action",
description:
"Execute an action on an integration (fetch GitHub PR, create Linear issue, send Slack message, etc.). USE THIS TOOL: After using get_integration_actions to see available actions. HOW TO USE: 1) Set integrationSlug (like 'github'), 2) Set action name (like 'get_pr'), 3) Set arguments object with required parameters from the action's inputSchema. Returns: Result of the action execution.",
inputSchema: {
type: "object",
properties: {
integrationSlug: {
type: "string",
description:
"Slug from get_integrations. Examples: 'github', 'linear', 'slack'",
},
action: {
type: "string",
description:
"Action name from get_integration_actions. Examples: 'get_pr', 'get_issues', 'create_issue'",
},
arguments: {
type: "object",
description:
"Parameters for the action. Check the action's inputSchema from get_integration_actions to see what's required.",
},
},
required: ["integrationSlug", "action"],
},
},
]; ];
// Function to call memory tools based on toolName // Function to call memory tools based on toolName
@ -112,6 +197,14 @@ export async function callMemoryTool(
return await handleMemoryGetSpaces(userId); return await handleMemoryGetSpaces(userId);
case "memory_about_user": case "memory_about_user":
return await handleUserProfile(userId); return await handleUserProfile(userId);
case "memory_get_space":
return await handleGetSpace({ ...args, userId });
case "get_integrations":
return await handleGetIntegrations({ ...args, userId });
case "get_integration_actions":
return await handleGetIntegrationActions({ ...args });
case "execute_integration_action":
return await handleExecuteIntegrationAction({ ...args });
default: default:
throw new Error(`Unknown memory tool: ${toolName}`); throw new Error(`Unknown memory tool: ${toolName}`);
} }
@ -160,12 +253,17 @@ async function handleUserProfile(userId: string) {
// Handler for memory_ingest // Handler for memory_ingest
async function handleMemoryIngest(args: any) { async function handleMemoryIngest(args: any) {
try { try {
// Use spaceIds from args if provided, otherwise use spaceId from query params
const spaceIds =
args.spaceIds || (args.spaceId ? [args.spaceId] : undefined);
const response = await addToQueue( const response = await addToQueue(
{ {
episodeBody: args.message, episodeBody: args.message,
referenceTime: new Date().toISOString(), referenceTime: new Date().toISOString(),
source: args.source, source: args.source,
type: EpisodeTypeEnum.CONVERSATION, type: EpisodeTypeEnum.CONVERSATION,
spaceIds,
}, },
args.userId, args.userId,
); );
@ -198,12 +296,17 @@ async function handleMemoryIngest(args: any) {
// Handler for memory_search // Handler for memory_search
async function handleMemorySearch(args: any) { async function handleMemorySearch(args: any) {
try { try {
// Use spaceIds from args if provided, otherwise use spaceId from query params
const spaceIds =
args.spaceIds || (args.spaceId ? [args.spaceId] : undefined);
const results = await searchService.search( const results = await searchService.search(
args.query, args.query,
args.userId, args.userId,
{ {
startTime: args.startTime ? new Date(args.startTime) : undefined, startTime: args.startTime ? new Date(args.startTime) : undefined,
endTime: args.endTime ? new Date(args.endTime) : undefined, endTime: args.endTime ? new Date(args.endTime) : undefined,
spaceIds,
}, },
args.source, args.source,
); );
@ -235,11 +338,17 @@ async function handleMemoryGetSpaces(userId: string) {
try { try {
const spaces = await spaceService.getUserSpaces(userId); const spaces = await spaceService.getUserSpaces(userId);
// Return id, name, and description for listing
const simplifiedSpaces = spaces.map((space) => ({
id: space.id,
name: space.name,
}));
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: JSON.stringify(spaces), text: JSON.stringify(simplifiedSpaces),
}, },
], ],
isError: false, isError: false,
@ -258,3 +367,182 @@ async function handleMemoryGetSpaces(userId: string) {
}; };
} }
} }
// Handler for memory_get_space
async function handleGetSpace(args: any) {
try {
const { spaceId, spaceName, userId } = args;
if (!spaceId && !spaceName) {
throw new Error("Either spaceId or spaceName is required");
}
let space;
if (spaceName) {
space = await spaceService.getSpaceByName(spaceName, userId);
} else {
space = await spaceService.getSpace(spaceId, userId);
}
if (!space) {
throw new Error(`Space not found: ${spaceName || spaceId}`);
}
// Return id, name, description, and summary for detailed view
const spaceDetails = {
id: space.id,
name: space.name,
summary: space.summary,
};
return {
content: [
{
type: "text",
text: JSON.stringify(spaceDetails),
},
],
isError: false,
};
} catch (error) {
logger.error(`MCP get space error: ${error}`);
return {
content: [
{
type: "text",
text: `Error getting space: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
// Handler for get_integrations
async function handleGetIntegrations(args: any) {
try {
const { userId, workspaceId } = args;
if (!workspaceId) {
throw new Error("workspaceId is required");
}
const integrations =
await IntegrationLoader.getConnectedIntegrationAccounts(
userId,
workspaceId,
);
const simplifiedIntegrations = integrations.map((account) => ({
slug: account.integrationDefinition.slug,
name: account.integrationDefinition.name,
accountId: account.id,
hasMcp: !!account.integrationDefinition.spec?.mcp,
}));
return {
content: [
{
type: "text",
text: JSON.stringify(simplifiedIntegrations),
},
],
isError: false,
};
} catch (error) {
logger.error(`MCP get integrations error: ${error}`);
return {
content: [
{
type: "text",
text: `Error getting integrations: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
// Handler for get_integration_actions
async function handleGetIntegrationActions(args: any) {
try {
const { integrationSlug, sessionId } = args;
if (!integrationSlug) {
throw new Error("integrationSlug is required");
}
if (!sessionId) {
throw new Error("sessionId is required");
}
const tools = await IntegrationLoader.getIntegrationTools(
sessionId,
integrationSlug,
);
return {
content: [
{
type: "text",
text: JSON.stringify(tools),
},
],
isError: false,
};
} catch (error) {
logger.error(`MCP get integration actions error: ${error}`);
return {
content: [
{
type: "text",
text: `Error getting integration actions: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
// Handler for execute_integration_action
async function handleExecuteIntegrationAction(args: any) {
try {
const { integrationSlug, action, arguments: actionArgs, sessionId } = args;
if (!integrationSlug) {
throw new Error("integrationSlug is required");
}
if (!action) {
throw new Error("action is required");
}
if (!sessionId) {
throw new Error("sessionId is required");
}
const toolName = `${integrationSlug}_${action}`;
const result = await IntegrationLoader.callIntegrationTool(
sessionId,
toolName,
actionArgs || {},
);
return result;
} catch (error) {
logger.error(`MCP execute integration action error: ${error}`);
return {
content: [
{
type: "text",
text: `Error executing integration action: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}

View File

@ -125,6 +125,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-resizable-panels": "^1.0.9", "react-resizable-panels": "^1.0.9",
"react-hotkeys-hook": "^4.5.0",
"react-virtualized": "^9.22.6", "react-virtualized": "^9.22.6",
"remix-auth": "^4.2.0", "remix-auth": "^4.2.0",
"remix-auth-oauth2": "^3.4.1", "remix-auth-oauth2": "^3.4.1",
@ -135,6 +136,7 @@
"stripe": "19.0.0", "stripe": "19.0.0",
"simple-oauth2": "^5.1.0", "simple-oauth2": "^5.1.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tiptap-markdown": "0.9.0",
"tailwind-scrollbar-hide": "^2.0.0", "tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tailwindcss-textshadow": "^2.1.3", "tailwindcss-textshadow": "^2.1.3",

View File

@ -1,4 +1,4 @@
VERSION=0.1.23 VERSION=0.1.24
# Nest run in docker, change host to database container name # Nest run in docker, change host to database container name
DB_HOST=postgres DB_HOST=postgres

View File

@ -1,7 +1,7 @@
{ {
"name": "core", "name": "core",
"private": true, "private": true,
"version": "0.1.23", "version": "0.1.24",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"

View File

@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `statementCount` on the `Space` table. All the data in the column will be lost.
- You are about to drop the column `statementCountAtLastTrigger` on the `Space` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Space" DROP COLUMN "statementCount",
DROP COLUMN "statementCountAtLastTrigger",
ADD COLUMN "contextCount" INTEGER,
ADD COLUMN "contextCountAtLastTrigger" INTEGER;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Space" ADD COLUMN "summaryGeneratedAt" TIMESTAMP(3);

View File

@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `spaceId` on the `IngestionQueue` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "IngestionQueue" DROP CONSTRAINT "IngestionQueue_spaceId_fkey";
-- AlterTable
ALTER TABLE "IngestionQueue" DROP COLUMN "spaceId";

View File

@ -113,10 +113,6 @@ model ConversationHistory {
model IngestionQueue { model IngestionQueue {
id String @id @default(cuid()) id String @id @default(cuid())
// Relations
space Space? @relation(fields: [spaceId], references: [id])
spaceId String?
// Queue metadata // Queue metadata
data Json // The actual data to be processed data Json // The actual data to be processed
output Json? // The processed output data output Json? // The processed output data
@ -472,20 +468,21 @@ model RecallLog {
} }
model Space { model Space {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
description String? description String?
autoMode Boolean @default(false) autoMode Boolean @default(false)
summary String? summary String?
themes String[] themes String[]
statementCount Int? contextCount Int? // Count of context items in this space (episodes, statements, etc.)
status String? status String?
icon String? icon String?
lastPatternTrigger DateTime? lastPatternTrigger DateTime?
statementCountAtLastTrigger Int? summaryGeneratedAt DateTime?
contextCountAtLastTrigger Int? // Context count when pattern was last triggered
// Relations // Relations
workspace Workspace @relation(fields: [workspaceId], references: [id]) workspace Workspace @relation(fields: [workspaceId], references: [id])
@ -493,7 +490,6 @@ model Space {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
IngestionQueue IngestionQueue[]
SpacePattern SpacePattern[] SpacePattern SpacePattern[]
} }

View File

@ -39,6 +39,7 @@ export interface EpisodicNode {
sessionId?: string; sessionId?: string;
recallCount?: number; recallCount?: number;
chunkIndex?: number; // Index of this chunk within the document chunkIndex?: number; // Index of this chunk within the document
spaceIds?: string[];
} }
/** /**

View File

@ -6,7 +6,7 @@ export interface SpaceNode {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
isActive: boolean; isActive: boolean;
statementCount?: number; // Computed field contextCount?: number; // Computed field - count of episodes assigned to this space
embedding?: number[]; // For future space similarity embedding?: number[]; // For future space similarity
} }

53
pnpm-lock.yaml generated
View File

@ -607,6 +607,9 @@ importers:
react-dom: react-dom:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-hotkeys-hook:
specifier: ^4.5.0
version: 4.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-markdown: react-markdown:
specifier: 10.1.0 specifier: 10.1.0
version: 10.1.0(@types/react@18.2.69)(react@18.3.1) version: 10.1.0(@types/react@18.2.69)(react@18.3.1)
@ -655,6 +658,9 @@ importers:
tiny-invariant: tiny-invariant:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.3 version: 1.3.3
tiptap-markdown:
specifier: 0.9.0
version: 0.9.0(@tiptap/core@2.25.0(@tiptap/pm@2.25.0))
zod: zod:
specifier: 3.25.76 specifier: 3.25.76
version: 3.25.76 version: 3.25.76
@ -5741,9 +5747,15 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/linkify-it@3.0.5':
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
'@types/linkify-it@5.0.0': '@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@13.0.9':
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
'@types/markdown-it@14.1.2': '@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
@ -5753,6 +5765,9 @@ packages:
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@1.0.5':
resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
'@types/mdurl@2.0.0': '@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@ -9066,6 +9081,9 @@ packages:
resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==} resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
markdown-it-task-lists@2.1.1:
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
markdown-it@14.1.0: markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true hasBin: true
@ -10584,6 +10602,12 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
react-hotkeys-hook@4.6.2:
resolution: {integrity: sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==}
peerDependencies:
react: '>=16.8.1'
react-dom: '>=16.8.1'
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -11556,6 +11580,11 @@ packages:
tiptap-extension-global-drag-handle@0.1.18: tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
tiptap-markdown@0.9.0:
resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
peerDependencies:
'@tiptap/core': ^3.0.1
tmp@0.0.33: tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'} engines: {node: '>=0.6.0'}
@ -18728,8 +18757,15 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/linkify-it@3.0.5': {}
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
'@types/markdown-it@13.0.9':
dependencies:
'@types/linkify-it': 3.0.5
'@types/mdurl': 1.0.5
'@types/markdown-it@14.1.2': '@types/markdown-it@14.1.2':
dependencies: dependencies:
'@types/linkify-it': 5.0.0 '@types/linkify-it': 5.0.0
@ -18743,6 +18779,8 @@ snapshots:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
'@types/mdurl@1.0.5': {}
'@types/mdurl@2.0.0': {} '@types/mdurl@2.0.0': {}
'@types/mdx@2.0.13': {} '@types/mdx@2.0.13': {}
@ -22608,6 +22646,8 @@ snapshots:
markdown-extensions@1.1.1: {} markdown-extensions@1.1.1: {}
markdown-it-task-lists@2.1.1: {}
markdown-it@14.1.0: markdown-it@14.1.0:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@ -24536,6 +24576,11 @@ snapshots:
- utf-8-validate - utf-8-validate
- webpack-cli - webpack-cli
react-hotkeys-hook@4.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is@16.13.1: {} react-is@16.13.1: {}
react-is@17.0.2: {} react-is@17.0.2: {}
@ -25790,6 +25835,14 @@ snapshots:
tiptap-extension-global-drag-handle@0.1.18: {} tiptap-extension-global-drag-handle@0.1.18: {}
tiptap-markdown@0.9.0(@tiptap/core@2.25.0(@tiptap/pm@2.25.0)):
dependencies:
'@tiptap/core': 2.25.0(@tiptap/pm@2.25.0)
'@types/markdown-it': 13.0.9
markdown-it: 14.1.0
markdown-it-task-lists: 2.1.1
prosemirror-markdown: 1.13.2
tmp@0.0.33: tmp@0.0.33:
dependencies: dependencies:
os-tmpdir: 1.0.2 os-tmpdir: 1.0.2