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
DB_HOST=localhost

View File

@ -2,7 +2,7 @@ import { z } from "zod";
const EnvironmentSchema = z.object({
// Version
VERSION: z.string().default("0.1.14"),
VERSION: z.string().default("0.1.24"),
// Database
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 { all, createLowlight } from "lowlight";
import { mergeAttributes, type Extension } from "@tiptap/react";
import { Markdown } from "tiptap-markdown";
// create a lowlight instance with all languages loaded
export const lowlight = createLowlight(all);
@ -136,4 +137,5 @@ export const extensionsForConversation = [
CodeBlockLowlight.configure({
lowlight,
}),
Markdown,
];

View File

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

View File

@ -1,6 +1,6 @@
import { useState, useEffect, type ReactNode } from "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 { type LogItem } from "~/hooks/use-logs";
import Markdown from "react-markdown";
@ -8,6 +8,7 @@ import { getIconForAuthorise } from "../icon-utils";
import { cn, formatString } from "~/lib/utils";
import { getStatusColor } from "./utils";
import { format } from "date-fns";
import { SpaceDropdown } from "../spaces/space-dropdown";
interface LogDetailsProps {
log: LogItem;
@ -33,13 +34,13 @@ function PropertyItem({
if (!value) return null;
return (
<div className="flex items-center py-1">
<span className="text-muted-foreground min-w-[160px]">{label}</span>
<div className="flex items-center py-1 !text-base">
<span className="text-muted-foreground min-w-[120px]">{label}</span>
{variant === "status" ? (
<Badge
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,
)}
>
@ -49,7 +50,13 @@ function PropertyItem({
{value}
</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}
{value}
</Badge>
@ -73,10 +80,10 @@ interface EpisodeFactsResponse {
function getStatusValue(status: string) {
if (status === "PENDING") {
return "In Queue";
return formatString("IN QUEUE");
}
return status;
return formatString(status);
}
export function LogDetails({ log }: LogDetailsProps) {
@ -113,6 +120,9 @@ export function LogDetails({ log }: LogDetailsProps) {
} else if (log.episodeUUID) {
setFactsLoading(true);
fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`);
} else {
setFacts([]);
setInvalidFacts([]);
}
}, [log.episodeUUID, log.data?.type, log.data?.episodes, facts.length]);
@ -129,41 +139,8 @@ export function LogDetails({ log }: LogDetailsProps) {
return (
<div className="flex h-full w-full flex-col items-center overflow-auto">
<div className="max-w-4xl">
<div className="px-4 pt-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="mt-5 mb-5 px-4">
<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
label="Session Id"
value={log.data?.sessionId?.toLowerCase()}
@ -174,6 +151,13 @@ export function LogDetails({ log }: LogDetailsProps) {
value={formatString(
log.data?.type ? log.data.type.toLowerCase() : "conversation",
)}
icon={
log.data?.type === "CONVERSATION" ? (
<MessageSquare size={16} />
) : (
<File size={16} />
)
}
variant="secondary"
/>
<PropertyItem
@ -192,15 +176,28 @@ export function LogDetails({ log }: LogDetailsProps) {
variant="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>
{/* Error Details */}
{log.error && (
<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="flex items-start gap-2 text-red-600">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
@ -212,21 +209,63 @@ export function LogDetails({ log }: LogDetailsProps) {
</div>
)}
<div className="flex flex-col items-center p-4 pt-0">
<div className="mb-2 flex w-full items-center justify-between">
<span>Content</span>
</div>
{/* Log Content */}
<div className="mb-4 w-full text-sm break-words whitespace-pre-wrap">
<div className="rounded-md">
<Markdown>{log.ingestText}</Markdown>
{log.data?.type === "CONVERSATION" && (
<div className="flex flex-col items-center p-4 pt-0">
{/* Log Content */}
<div className="mb-4 w-full break-words whitespace-pre-wrap">
<div className="rounded-md">
<Markdown>{log.ingestText}</Markdown>
</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 */}
<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>
</div>
<div className="rounded-md">

View File

@ -1,4 +1,4 @@
import { EllipsisVertical, Trash } from "lucide-react";
import { EllipsisVertical, Trash, Copy } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@ -18,6 +18,7 @@ import {
} from "../ui/alert-dialog";
import { useState, useEffect } from "react";
import { useFetcher, useNavigate } from "@remix-run/react";
import { toast } from "~/hooks/use-toast";
interface LogOptionsProps {
id: string;
@ -40,8 +41,24 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
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(() => {
console.log(deleteFetcher.state, deleteFetcher.data);
if (deleteFetcher.state === "idle" && deleteFetcher.data?.success) {
navigate(`/home/inbox`);
}
@ -49,16 +66,26 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
return (
<>
<Button
variant="secondary"
size="sm"
className="gap-2 rounded"
onClick={(e) => {
setDeleteDialogOpen(true);
}}
>
<Trash size={15} /> Delete
</Button>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
className="gap-2 rounded"
onClick={handleCopy}
>
<Copy size={15} /> Copy ID
</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}>
<AlertDialogContent>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import * as React from "react";
import { useHotkeys } from "react-hotkeys-hook";
import {
Sidebar,
@ -12,14 +13,20 @@ import {
Columns3,
Inbox,
LayoutGrid,
LoaderCircle,
MessageSquare,
Network,
Plus,
} from "lucide-react";
import { NavMain } from "./nav-main";
import { useUser } from "~/hooks/useUser";
import { NavUser } from "./nav-user";
import Logo from "../logo/logo";
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 = {
navMain: [
@ -41,7 +48,7 @@ const data = {
{
title: "Spaces",
url: "/home/space",
icon: Columns3,
icon: Project,
},
{
title: "Integrations",
@ -54,33 +61,57 @@ const data = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const user = useUser();
return (
<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>
const [showAddMemory, setShowAddMemory] = React.useState(false);
<SidebarFooter className="px-2">
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
// Open command bar with Meta+K (Cmd+K on Mac, Ctrl+K on Windows/Linux)
useHotkeys("meta+k", (e) => {
e.preventDefault();
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>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
onClick={() => {
navigate("/settings/billing");
}}
>
<div>{user.availableCredits} credits</div>
</Button>
</SidebarMenuItem>
</SidebarMenu>
);

View File

@ -17,8 +17,8 @@ interface SpaceCardProps {
createdAt: string;
updatedAt: string;
autoMode: boolean;
statementCount: number | null;
summary: string | null;
contextCount?: number | null;
themes?: string[];
};
}
@ -46,13 +46,17 @@ export function SpaceCard({ space }: SpaceCardProps) {
</div>
<CardTitle className="text-base">{space.name}</CardTitle>
<CardDescription className="line-clamp-2 text-xs">
{space.description || space.summary || "Knowledge space"}
<p
dangerouslySetInnerHTML={{
__html: space.description || space.summary || "Knowledge space",
}}
></p>
</CardDescription>
<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>
{space.statementCount} fact
{space.statementCount !== 1 ? "s" : ""}
{space.contextCount} episode
{space.contextCount !== 1 ? "s" : ""}
</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 type { StatementNode } from "@core/types";
import { cn } from "~/lib/utils";
import { useNavigate } from "@remix-run/react";
import Markdown from "react-markdown";
interface SpaceFactCardProps {
fact: StatementNode;
export interface Episode {
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 d = new Date(date);
return d.toLocaleDateString("en-US", {
@ -17,18 +32,20 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
});
};
const displayText = fact.fact;
const displayText = episode.originalContent;
const recallCount =
(fact.recallCount?.high ?? 0) + (fact.recallCount?.low ?? 0);
const onClick = () => {
navigate(`/home/inbox/${episode.logId}`);
};
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
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
className={cn(
@ -37,19 +54,13 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
>
<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={cn("truncate text-left")}>{displayText}</div>
<Markdown>{displayText}</Markdown>
</div>
<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">
<Calendar className="h-3 w-3" />
{formatDate(fact.validAt)}
{formatDate(episode.validAt)}
</Badge>
{fact.invalidAt && (
<Badge variant="destructive" className="rounded text-xs">
Invalid since {formatDate(fact.invalidAt)}
</Badge>
)}
</div>
</div>
</div>

View File

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

View File

@ -9,25 +9,24 @@ import {
} from "react-virtualized";
import { Database } from "lucide-react";
import { Card, CardContent } from "~/components/ui/card";
import type { StatementNode } from "@core/types";
import { ScrollManagedList } from "../virtualized-list";
import { SpaceFactCard } from "./space-fact-card";
import { type Episode, SpaceEpisodeCard } from "./space-episode-card";
interface SpaceFactsListProps {
facts: any[];
interface SpaceEpisodesListProps {
episodes: any[];
hasMore: boolean;
loadMore: () => void;
isLoading: boolean;
height?: number;
}
function FactItemRenderer(
function EpisodeItemRenderer(
props: ListRowProps,
facts: StatementNode[],
episodes: Episode[],
cache: CellMeasurerCache,
) {
const { index, key, style, parent } = props;
const fact = facts[index];
const episode = episodes[index];
return (
<CellMeasurer
@ -38,23 +37,23 @@ function FactItemRenderer(
rowIndex={index}
>
<div key={key} style={style} className="pb-2">
<SpaceFactCard fact={fact} />
<SpaceEpisodeCard episode={episode} />
</div>
</CellMeasurer>
);
}
export function SpaceFactsList({
facts,
export function SpaceEpisodesList({
episodes,
hasMore,
loadMore,
isLoading,
}: SpaceFactsListProps) {
}: SpaceEpisodesListProps) {
// Create a CellMeasurerCache instance using useRef to prevent recreation
const cacheRef = useRef<CellMeasurerCache | null>(null);
if (!cacheRef.current) {
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
});
}
@ -62,17 +61,17 @@ export function SpaceFactsList({
useEffect(() => {
cache.clearAll();
}, [facts, cache]);
}, [episodes, cache]);
if (facts.length === 0 && !isLoading) {
if (episodes.length === 0 && !isLoading) {
return (
<Card className="bg-background-2 w-full">
<CardContent className="bg-background-2 flex w-full items-center justify-center py-16">
<div className="text-center">
<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">
This space doesn't contain any facts yet.
This space doesn't contain any episodes yet.
</p>
</div>
</CardContent>
@ -81,7 +80,7 @@ export function SpaceFactsList({
}
const isRowLoaded = ({ index }: { index: number }) => {
return !!facts[index];
return !!episodes[index];
};
const loadMoreRows = async () => {
@ -92,14 +91,14 @@ export function SpaceFactsList({
};
const rowRenderer = (props: ListRowProps) => {
return FactItemRenderer(props, facts, cache);
return EpisodeItemRenderer(props, episodes, cache);
};
const rowHeight = ({ index }: Index) => {
return cache.getHeight(index, 0);
};
const itemCount = hasMore ? facts.length + 1 : facts.length;
const itemCount = hasMore ? episodes.length + 1 : episodes.length;
return (
<div className="h-full grow overflow-hidden rounded-lg">
@ -131,7 +130,7 @@ export function SpaceFactsList({
{isLoading && (
<div className="text-muted-foreground p-4 text-center text-sm">
Loading more facts...
Loading more episodes...
</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 {
DropdownMenu,
DropdownMenuContent,
@ -19,6 +19,7 @@ import {
import { useEffect, useState } from "react";
import { useFetcher, useNavigate } from "@remix-run/react";
import { EditSpaceDialog } from "./edit-space-dialog.client";
import { toast } from "~/hooks/use-toast";
interface SpaceOptionsProps {
id: string;
@ -64,6 +65,23 @@ export const SpaceOptions = ({ id, name, description }: SpaceOptionsProps) => {
// 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 (
<>
<DropdownMenu>
@ -79,6 +97,11 @@ export const SpaceOptions = ({ id, name, description }: SpaceOptionsProps) => {
</DropdownMenuTrigger>
<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)}>
<Button variant="link" size="sm" className="gap-2 rounded">
<Edit size={15} /> Edit

View File

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

View File

@ -40,7 +40,7 @@ const CommandDialog = ({
<Dialog {...props}>
<DialogContent className={cn("overflow-hidden p-0 font-sans")}>
<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}
>
{children}
@ -141,7 +141,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
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,
)}
{...props}

View File

@ -2,3 +2,5 @@ export * from "./button";
export * from "./tabs";
export * from "./input";
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;
episodeUUID?: string;
data?: any;
spaceIds?: string[];
episodeDetails?: any;
}
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";
export interface ExtendedUser extends User {
availableCredits?: number;
availableCredits: number;
totalCredits: number;
}
export function useIsImpersonating(matches?: UIMatch[]) {
@ -23,7 +24,11 @@ export function useOptionalUser(matches?: UIMatch[]): ExtendedUser | undefined {
});
return routeMatch?.user
? { ...routeMatch?.user, availableCredits: routeMatch?.availableCredits }
? {
...routeMatch?.user,
availableCredits: routeMatch?.availableCredits,
totalCredits: routeMatch?.totalCredits,
}
: undefined;
}

View File

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

View File

@ -145,10 +145,8 @@ export const getClusteredGraphData = async (userId: string) => {
rel.predicate as predicateLabel,
e.uuid as episodeUuid,
e.content as episodeContent,
e.spaceIds as spaceIds,
s.uuid as statementUuid,
s.spaceIds as spaceIds,
s.fact as fact,
s.invalidAt as invalidAt,
s.validAt as validAt,
s.createdAt as createdAt`,
{ userId },
@ -169,13 +167,8 @@ export const getClusteredGraphData = async (userId: string) => {
const predicateLabel = record.get("predicateLabel");
const episodeUuid = record.get("episodeUuid");
const episodeContent = record.get("episodeContent");
const statementUuid = record.get("statementUuid");
const clusterIds = record.get("spaceIds");
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");
// 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 { prisma } from "~/db.server";
import { env } from "~/env.server";
import { ensureBillingInitialized } from "~/services/billing.server";
export type { User } from "@core/database";
type FindOrCreateMagicLink = {
@ -167,7 +166,12 @@ export async function findOrCreateGoogleUser({
}
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) {
return null;

View File

@ -14,14 +14,26 @@ interface CreateWorkspaceDto {
const spaceService = new SpaceService();
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):
Preferred name, pronunciation, public handles (GitHub/Twitter/LinkedIn URLs), primary email domain
Timezone, locale, working hours, meeting preferences (async/sync bias, default duration)
Role, team, company, office location (city-level only), seniority
Tooling defaults (editor, ticketing system, repo host), keyboard layout, OS
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(
input: CreateWorkspaceDto,
@ -43,12 +55,33 @@ export async function createWorkspace(
await ensureBillingInitialized(workspace.id);
await spaceService.createSpace({
name: "Profile",
description: profileRule,
userId: input.userId,
workspaceId: workspace.id,
});
// Create default spaces
await Promise.all([
spaceService.createSpace({
name: "Profile",
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 {
const response = await sendEmail({ email: "welcome", to: user.email });

View File

@ -25,7 +25,7 @@ import {
type ToastMessage,
} from "./models/message.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 {
AppContainer,
@ -40,6 +40,8 @@ import {
useTheme,
} from "remix-themes";
import clsx from "clsx";
import { getUsageSummary } from "./services/billing.server";
import { Toaster } from "./components/ui/toaster";
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 user = await getUser(request);
const usage = await getUserRemainingCount(request);
const usageSummary = await getUsageSummary(user?.Workspace?.id as string);
return typedjson(
{
user: user,
availableCredits: usage?.availableCredits ?? 0,
availableCredits: usageSummary?.credits.available ?? 0,
totalCredits: usageSummary?.credits.monthly ?? 0,
toastMessage,
theme: getTheme(),
posthogProjectKey,
@ -124,6 +127,7 @@ function App() {
</head>
<body className="bg-background-2 h-[100vh] h-full w-[100vw] overflow-hidden font-sans">
<Outlet />
<Toaster />
<ScrollRestoration />
<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",
allowJWT: true,
},
async ({ params }) => {
const formattedLog = await getIngestionQueueForFrontend(params.logId);
async ({ params, authentication }) => {
const formattedLog = await getIngestionQueueForFrontend(
params.logId,
authentication.userId,
);
return json({ log: formattedLog });
},

View File

@ -2,6 +2,7 @@ import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { SpaceService } from "~/services/space.server";
import { json } from "@remix-run/node";
import { getSpaceEpisodeCount } from "~/services/graphModels/space";
const spaceService = new SpaceService();
@ -29,18 +30,20 @@ const { loader } = createActionApiRoute(
return json({ error: "Space not found" }, { status: 404 });
}
// Get statements in the space
const statements = await spaceService.getSpaceStatements(spaceId, userId);
// Get episodes in the space
const episodes = await spaceService.getSpaceEpisodes(spaceId, userId);
const episodeCount = await getSpaceEpisodeCount(spaceId, userId);
return json({
statements,
return json({
episodes,
space: {
uuid: space.uuid,
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 {
createActionApiRoute,
createHybridActionApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { SpaceService } from "~/services/space.server";
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 { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
@ -33,45 +24,26 @@ const { loader, action } = createHybridActionApiRoute(
const { spaceId } = params;
const spaceService = new SpaceService();
// Verify space exists and belongs to user
const space = await prisma.space.findUnique({
where: {
id: spaceId,
},
});
if (!space) {
return json({ error: "Space not found" }, { status: 404 });
}
// Reset the space (clears all assignments, summary, and metadata)
const space = await spaceService.resetSpace(spaceId, userId);
// Get statements in the space
await deleteSpace(spaceId, userId);
logger.info(`Reset space ${space.id} successfully`);
await createSpace(
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
// Trigger automatic episode assignment for the reset space
try {
await triggerSpaceAssignment({
userId: userId,
workspaceId: space.workspaceId,
mode: "new_space",
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) {
// Don't fail space creation if LLM assignment fails
// Don't fail space reset if assignment fails
logger.warn(
`Failed to trigger LLM assignment for space ${space.id}:`,
`Failed to trigger assignment for space ${space.id}:`,
error as Record<string, unknown>,
);
}

View File

@ -16,19 +16,6 @@ const CreateSpaceSchema = z.object({
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
const SearchParamsSchema = z.object({
q: z.string().optional(),
@ -36,7 +23,7 @@ const SearchParamsSchema = z.object({
const { action } = createHybridActionApiRoute(
{
body: z.union([CreateSpaceSchema, BulkOperationSchema]),
body: CreateSpaceSchema,
allowJWT: true,
authorization: {
action: "manage",
@ -82,96 +69,6 @@ const { action } = createHybridActionApiRoute(
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 });
},
);

View File

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

View File

@ -148,7 +148,7 @@ export default function Overview() {
variant="ghost"
className="text-muted-foreground mb-1 -ml-2 gap-1"
>
Summary
Context
<ChevronDown
size={14}
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 { SpaceOptions } from "~/components/spaces/space-options";
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) {
const userId = await requireUserId(request);
@ -23,6 +26,7 @@ export default function Space() {
const space = useTypedLoaderData<typeof loader>();
const location = useLocation();
const navigate = useNavigate();
const [showAddMemory, setShowAddMemory] = React.useState(false);
return (
<>
@ -46,16 +50,10 @@ export default function Space() {
onClick: () => navigate(`/home/space/${space.id}/overview`),
},
{
label: "Facts",
value: "facts",
isActive: location.pathname.includes("/facts"),
onClick: () => navigate(`/home/space/${space.id}/facts`),
},
{
label: "Patterns",
value: "patterns",
isActive: location.pathname.includes("/patterns"),
onClick: () => navigate(`/home/space/${space.id}/patterns`),
label: "Episodes",
value: "edpisodes",
isActive: location.pathname.includes("/episodes"),
onClick: () => navigate(`/home/space/${space.id}/episodes`),
},
]}
actionsNode={
@ -67,17 +65,33 @@ export default function Space() {
}
>
{() => (
<SpaceOptions
id={space.id as string}
name={space.name}
description={space.description}
/>
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => setShowAddMemory(true)}
>
Add episode
</Button>
<SpaceOptions
id={space.id as string}
name={space.name}
description={space.description}
/>
</div>
)}
</ClientOnly>
}
/>
<div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-start overflow-auto">
<Outlet />
{showAddMemory && (
<AddMemoryDialog
open={showAddMemory}
onOpenChange={setShowAddMemory}
defaultSpaceId={space.id}
/>
)}
</div>
</>
);

View File

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

View File

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

View File

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

View File

@ -1,5 +1,9 @@
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> {
const query = `
@ -72,6 +76,8 @@ export async function getEpisode(uuid: string): Promise<EpisodicNode | null> {
userId: episode.userId,
space: episode.space,
sessionId: episode.sessionId,
recallCount: episode.recallCount,
spaceIds: episode.spaceIds,
};
}
@ -140,7 +146,7 @@ export async function searchEpisodesByEmbedding(params: {
}) {
const limit = params.limit || 100;
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
WHERE episode.userId = $userId
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 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
WHERE episode.userId = $userId
WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score

View File

@ -56,13 +56,12 @@ export async function getSpace(
const query = `
MATCH (s:Space {uuid: $spaceId, userId: $userId})
WHERE s.isActive = true
// Count statements in this space using optimized approach
OPTIONAL MATCH (stmt:Statement {userId: $userId})
WHERE stmt.spaceIds IS NOT NULL AND $spaceId IN stmt.spaceIds AND stmt.invalidAt IS NULL
WITH s, count(stmt) as statementCount
RETURN s, statementCount
// Count episodes assigned to this space using direct relationship
OPTIONAL MATCH (s)-[:HAS_EPISODE]->(e:Episode {userId: $userId})
WITH s, count(e) as episodeCount
RETURN s, episodeCount
`;
const result = await runQuery(query, { spaceId, userId });
@ -71,7 +70,7 @@ export async function getSpace(
}
const spaceData = result[0].get("s").properties;
const statementCount = result[0].get("statementCount") || 0;
const episodeCount = result[0].get("episodeCount") || 0;
return {
uuid: spaceData.uuid,
@ -81,7 +80,7 @@ export async function getSpace(
createdAt: new Date(spaceData.createdAt),
updatedAt: new Date(spaceData.updatedAt),
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)
const cleanupQuery = `
const cleanupStatementsQuery = `
MATCH (s:Statement {userId: $userId})
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId]
RETURN count(s) as updatedStatements
`;
const cleanupResult = await runQuery(cleanupQuery, { userId, spaceId });
const updatedStatements = cleanupResult[0]?.get("updatedStatements") || 0;
const cleanupStatementsResult = await runQuery(cleanupStatementsQuery, {
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 = `
MATCH (space:Space {uuid: $spaceId, userId: $userId})
DELETE space
DETACH DELETE space
RETURN count(space) as deletedSpaces
`;
await runQuery(deleteQuery, { userId, spaceId });
logger.info(`Deleted space ${spaceId}`, {
userId,
statementsUpdated: updatedStatements,
episodesUpdated: updatedEpisodes,
});
return {
deleted: true,
statementsUpdated: Number(updatedStatements),
statementsUpdated: Number(updatedStatements) + Number(updatedEpisodes),
};
} catch (error) {
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(
statementIds: string[],
export async function assignEpisodesToSpace(
episodeIds: string[],
spaceId: string,
userId: string,
): 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 = `
MATCH (s:Statement {userId: $userId})
WHERE s.uuid IN $statementIds
SET s.spaceIds = CASE
WHEN s.spaceIds IS NULL THEN [$spaceId]
WHEN $spaceId IN s.spaceIds THEN s.spaceIds
ELSE s.spaceIds + [$spaceId]
MATCH (space:Space {uuid: $spaceId, userId: $userId})
MATCH (e:Episode {userId: $userId})
WHERE e.uuid IN $episodeIds
SET e.spaceIds = CASE
WHEN e.spaceIds IS NULL THEN [$spaceId]
WHEN $spaceId IN e.spaceIds THEN e.spaceIds
ELSE e.spaceIds + [$spaceId]
END,
s.lastSpaceAssignment = datetime(),
s.spaceAssignmentMethod = CASE
WHEN s.spaceAssignmentMethod IS NULL THEN 'manual'
ELSE s.spaceAssignmentMethod
e.lastSpaceAssignment = datetime(),
e.spaceAssignmentMethod = CASE
WHEN e.spaceAssignmentMethod IS NULL THEN 'intent_based'
ELSE e.spaceAssignmentMethod
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;
logger.info(`Assigned ${updatedCount} episodes to space ${spaceId}`, {
episodeIds: episodeIds.length,
userId,
});
return {
success: true,
statementsUpdated: Number(updatedCount),
};
} catch (error) {
logger.error(`Error assigning episodes to space:`, {
error,
spaceId,
episodeIds: episodeIds.length,
});
return {
success: false,
statementsUpdated: 0,
@ -235,22 +277,26 @@ export async function assignStatementsToSpace(
}
/**
* Remove statements from a space
* Remove episodes from a space
*/
export async function removeStatementsFromSpace(
statementIds: string[],
export async function removeEpisodesFromSpace(
episodeIds: string[],
spaceId: string,
userId: string,
): Promise<SpaceAssignmentResult> {
try {
// Remove from both spaceIds array and HAS_EPISODE relationship
const query = `
MATCH (s:Statement {userId: $userId})
WHERE s.uuid IN $statementIds AND s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId]
RETURN count(s) as updated
MATCH (e:Episode {userId: $userId})
WHERE e.uuid IN $episodeIds AND e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
SET e.spaceIds = [id IN e.spaceIds WHERE id <> $spaceId]
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;
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 = `
MATCH (s:Statement {userId: $userId})
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds AND s.invalidAt IS NULL
MATCH (s)-[:HAS_SUBJECT]->(subj:Entity)
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
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
RETURN e
ORDER BY e.createdAt DESC
`;
const result = await runQuery(query, { spaceId, userId });
return result.map((record) => {
const statement = record.get("s").properties;
const episode = record.get("e").properties;
return {
uuid: statement.uuid,
fact: statement.fact,
subject: record.get("subject"),
predicate: record.get("predicate"),
object: record.get("object"),
createdAt: new Date(statement.createdAt),
validAt: new Date(statement.validAt),
invalidAt: statement.invalidAt
? new Date(statement.invalidAt)
: undefined,
spaceIds: statement.spaceIds || [],
recallCount: statement.recallCount,
uuid: episode.uuid,
content: episode.content,
originalContent: episode.originalContent,
source: episode.source,
createdAt: new Date(episode.createdAt),
validAt: new Date(episode.validAt),
metadata: JSON.parse(episode.metadata || "{}"),
sessionId: episode.sessionId,
};
});
}
/**
* 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,
userId: string,
): Promise<number> {
// Use spaceIds array for faster lookup instead of relationship traversal
const query = `
MATCH (s:Statement {userId: $userId})
WHERE s.spaceIds IS NOT NULL
AND $spaceId IN s.spaceIds
RETURN count(s) as statementCount
MATCH (e:Episode {userId: $userId})
WHERE e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
RETURN count(e) as episodeCount
`;
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(
spaceId: string,
export async function getSpacesForEpisodes(
episodeIds: string[],
userId: string,
): Promise<{
shouldTrigger: boolean;
isNewSpace: boolean;
currentCount: number;
}> {
try {
// Get current statement count from Neo4j
const currentCount = await getSpaceStatementCount(spaceId, userId);
): Promise<Record<string, string[]>> {
const query = `
UNWIND $episodeIds as episodeId
MATCH (e:Episode {uuid: episodeId, userId: $userId})
WHERE e.spaceIds IS NOT NULL AND size(e.spaceIds) > 0
RETURN episodeId, e.spaceIds as spaceIds
`;
// Get space data from PostgreSQL
const space = await prisma.space.findUnique({
where: { id: spaceId },
select: {
lastPatternTrigger: true,
statementCountAtLastTrigger: true,
},
});
const result = await runQuery(query, { episodeIds, userId });
if (!space) {
logger.warn(`Space ${spaceId} not found when checking pattern trigger`);
return { shouldTrigger: false, isNewSpace: false, currentCount };
}
const spacesMap: Record<string, string[]> = {};
const isNewSpace = !space.lastPatternTrigger;
const previousCount = space.statementCountAtLastTrigger || 0;
const growth = currentCount - previousCount;
// Initialize all episodes with empty arrays
episodeIds.forEach((id) => {
spacesMap[id] = [];
});
// Trigger if: new space OR growth >= 100 statements
const shouldTrigger = isNewSpace || growth >= 100;
// Fill in the spaceIds for episodes that have them
result.forEach((record) => {
const episodeId = record.get("episodeId");
const spaceIds = record.get("spaceIds");
spacesMap[episodeId] = spaceIds || [];
});
logger.info(`Space pattern trigger check`, {
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);
return spacesMap;
}

View File

@ -1,4 +1,6 @@
import { prisma } from "~/db.server";
import { getEpisode } from "./graphModels/episode";
import { getSpacesForEpisodes } from "./graphModels/space";
export async function getIngestionLogs(
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
const log = await prisma.ingestionQueue.findUnique({
where: { id: id },
@ -66,6 +71,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
type: true,
output: true,
data: true,
workspaceId: true,
activity: {
select: {
text: true,
@ -94,7 +100,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
log.activity?.integrationAccount?.integrationDefinition;
const logData = log.data as any;
const formattedLog = {
const formattedLog: any = {
id: log.id,
source: integrationDef?.name || logData?.source || "Unknown",
ingestText:
@ -112,9 +118,76 @@ export const getIngestionQueueForFrontend = async (id: string) => {
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;
};
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) => {
return await prisma.ingestionQueue.delete({
where: {

View File

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

View File

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

View File

@ -33,14 +33,6 @@ export async function getUser(request: 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) {
const userId = await getUserId(request);
if (!userId) {
@ -71,6 +63,7 @@ export async function requireUser(request: Request) {
confirmedBasicDetails: user.confirmedBasicDetails,
onboardingComplete: user.onboardingComplete,
isImpersonating: !!impersonationId,
workspaceId: user.Workspace?.id,
};
}

View File

@ -3,19 +3,18 @@ import {
type SpaceNode,
type CreateSpaceParams,
type UpdateSpaceParams,
type SpaceAssignmentResult,
} from "@core/types";
import { type Space } from "@prisma/client";
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
import {
assignStatementsToSpace,
assignEpisodesToSpace,
createSpace,
deleteSpace,
getSpace,
getSpaceStatements,
initializeStatementSpaceIds,
removeStatementsFromSpace,
getSpaceEpisodeCount,
getSpaceEpisodes,
removeEpisodesFromSpace,
updateSpace,
} from "./graphModels/space";
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({
where: {
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(
statementIds: string[],
spaceId: string,
userId: string,
): Promise<SpaceAssignmentResult> {
logger.info(
`Assigning ${statementIds.length} statements to space ${spaceId} for user ${userId}`,
);
async resetSpace(spaceId: string, userId: string): Promise<Space> {
logger.info(`Resetting space ${spaceId} for user ${userId}`);
// Validate input
if (statementIds.length === 0) {
throw new Error("No statement IDs provided");
// Get the space first to verify it exists and get its details
const space = await prisma.space.findUnique({
where: {
id: spaceId,
},
});
if (!space) {
throw new Error("Space not found");
}
if (statementIds.length > 1000) {
throw new Error("Too many statements (max 1000 per operation)");
if (space.name === "Profile") {
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) {
logger.info(
`Successfully assigned ${result.statementsUpdated} statements to space ${spaceId}`,
);
} else {
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,
// Recreate the space in Neo4j (clean slate)
await createSpace(
space.id,
space.name.trim(),
space.description?.trim(),
userId,
);
if (result.success) {
logger.info(
`Successfully removed ${result.statementsUpdated} statements from space ${spaceId}`,
);
} else {
logger.warn(
`Failed to remove statements from space ${spaceId}: ${result.error}`,
);
}
// Reset all summary and metadata fields in PostgreSQL
const resetSpace = await prisma.space.update({
where: {
id: spaceId,
},
data: {
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) {
logger.info(`Fetching statements for space ${spaceId} for user ${userId}`);
return await getSpaceStatements(spaceId, userId);
async getSpaceEpisodes(spaceId: string, userId: string) {
logger.info(`Fetching episodes for space ${spaceId} for user ${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
*/
@ -388,41 +351,4 @@ export class SpaceService {
const space = await this.getSpace(spaceId, userId);
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,
},
source: documentBody.source,
spaceId: documentBody.spaceId,
spaceIds: documentBody.spaceIds,
sessionId: documentBody.sessionId,
type: EpisodeTypeEnum.DOCUMENT,
};

View File

@ -9,13 +9,14 @@ import { triggerSpaceAssignment } from "../spaces/space-assignment";
import { prisma } from "../utils/prisma";
import { EpisodeType } from "@core/types";
import { deductCredits, hasCredits } from "../utils/utils";
import { assignEpisodesToSpace } from "~/services/graphModels/space";
export const IngestBodyRequest = z.object({
episodeBody: z.string(),
referenceTime: z.string(),
metadata: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
source: z.string(),
spaceId: z.string().optional(),
spaceIds: z.array(z.string()).optional(),
sessionId: z.string().optional(),
type: z
.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 {
logger.info(`Triggering space assignment after successful ingestion`, {
userId: payload.userId,
workspaceId: payload.workspaceId,
episodeId: episodeDetails?.episodeUuid,
});
if (
episodeDetails.episodeUuid &&
currentStatus === IngestionStatus.COMPLETED
) {
await triggerSpaceAssignment({
// If spaceIds were explicitly provided, immediately assign the episode to those spaces
if (episodeBody.spaceIds && episodeBody.spaceIds.length > 0 && episodeDetails.episodeUuid) {
logger.info(`Assigning episode to explicitly provided spaces`, {
userId: payload.userId,
workspaceId: payload.workspaceId,
mode: "episode",
episodeIds: episodeUuids,
episodeId: episodeDetails.episodeUuid,
spaceIds: episodeBody.spaceIds,
});
// 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) {
// 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 { EpisodeType } from "@core/types";
import { getSpaceStatementCount } from "~/services/graphModels/space";
import { getSpaceEpisodeCount } from "~/services/graphModels/space";
import { addToQueue } from "../utils/queue";
interface SpaceSummaryPayload {
@ -35,7 +35,7 @@ interface SpaceSummaryData {
spaceId: string;
spaceName: string;
spaceDescription?: string;
statementCount: number;
contextCount: number;
summary: string;
keyEntities: string[];
themes: string[];
@ -55,7 +55,7 @@ const SummaryResultSchema = z.object({
const CONFIG = {
maxEpisodesForSummary: 20, // Limit episodes for performance
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({
@ -85,7 +85,7 @@ export const spaceSummaryTask = task({
});
// Generate summary for the single space
const summaryResult = await generateSpaceSummary(spaceId, userId);
const summaryResult = await generateSpaceSummary(spaceId, userId, triggerSource);
if (summaryResult) {
// Store the summary
@ -98,36 +98,24 @@ export const spaceSummaryTask = task({
metadata: {
triggerSource,
phase: "completed_summary",
statementCount: summaryResult.statementCount,
contextCount: summaryResult.contextCount,
confidence: summaryResult.confidence,
},
});
logger.info(`Generated summary for space ${spaceId}`, {
statementCount: summaryResult.statementCount,
statementCount: summaryResult.contextCount,
confidence: summaryResult.confidence,
themes: summaryResult.themes.length,
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 {
success: true,
spaceId,
triggerSource,
summary: {
statementCount: summaryResult.statementCount,
statementCount: summaryResult.contextCount,
confidence: summaryResult.confidence,
themesCount: summaryResult.themes.length,
},
@ -186,6 +174,7 @@ export const spaceSummaryTask = task({
async function generateSpaceSummary(
spaceId: string,
userId: string,
triggerSource?: "assignment" | "manual" | "scheduled",
): Promise<SpaceSummaryData | null> {
try {
// 1. Get space details
@ -197,6 +186,35 @@ async function generateSpaceSummary(
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
const existingSummary = await getExistingSummary(spaceId);
const isIncremental = existingSummary !== null;
@ -296,14 +314,14 @@ async function generateSpaceSummary(
return null;
}
// Get the actual current statement count from Neo4j
const currentStatementCount = await getSpaceStatementCount(spaceId, userId);
// Get the actual current counts from Neo4j
const currentEpisodeCount = await getSpaceEpisodeCount(spaceId, userId);
return {
spaceId: space.uuid,
spaceName: space.name,
spaceDescription: space.description as string,
statementCount: currentStatementCount,
contextCount: currentEpisodeCount,
summary: summaryResult.summary,
keyEntities: summaryResult.keyEntities || [],
themes: summaryResult.themes,
@ -400,38 +418,48 @@ function createUnifiedSummaryPrompt(
return [
{
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:
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")
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
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:
${
isUpdate
? `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
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
6. Update the markdown summary to reflect the enhanced themes and new insights`
5. Evolve themes by adding new ones or refining existing ones based on the space's purpose
6. Organize the summary to serve the space's intended use case`
: `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)
3. Create a coherent summary that captures the essence of this knowledge domain
4. Generate a well-structured markdown summary organized by the identified themes`
2. Identify topics/sections that align with the space's INTENT and PURPOSE
3. Create a coherent summary that serves the space's intended use case
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:
- A theme must be supported by AT LEAST 3 related episodes to be considered valid
- Themes should represent substantial, meaningful patterns rather than minor occurrences
- Each theme must capture a distinct semantic domain or conceptual area
- Only identify themes that have sufficient evidence in the data
- If fewer than 3 episodes support a potential theme, do not include it
- Themes will be used to organize the markdown summary into logical sections
INTENT-ALIGNED ORGANIZATION:
- Organize sections based on what serves the space's purpose
- Topics don't need minimum episode counts - relevance to intent matters most
- Each section should provide value aligned with the space's intended use
- For "guidelines" spaces: focus on actionable patterns
- For "tracking" spaces: focus on temporal patterns and changes
- For "learning" spaces: focus on concepts and insights gained
- Let the space's intent drive the structure, not rigid rules
${
isUpdate
@ -484,7 +512,7 @@ ${
role: "user",
content: `SPACE INFORMATION:
Name: "${spaceName}"
Description (for context only): ${spaceDescription || "No description provided"}
Intent/Purpose: ${spaceDescription || "No specific intent provided - organize naturally based on content"}
${
isUpdate
@ -508,8 +536,8 @@ ${topEntities.join(", ")}`
${
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 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 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 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;
themes: string[];
lastUpdated: Date;
statementCount: number;
contextCount: number;
} | null> {
try {
const existingSummary = await getSpace(spaceId);
@ -528,8 +556,8 @@ async function getExistingSummary(spaceId: string): Promise<{
return {
summary: existingSummary.summary,
themes: existingSummary.themes,
lastUpdated: existingSummary.lastPatternTrigger || new Date(),
statementCount: existingSummary.statementCount || 0,
lastUpdated: existingSummary.summaryGeneratedAt || new Date(),
contextCount: existingSummary.contextCount || 0,
};
}
@ -547,24 +575,18 @@ async function getSpaceEpisodes(
userId: string,
sinceDate?: Date,
): Promise<SpaceEpisodeData[]> {
// Build query to get distinct episodes that have statements in the space
let whereClause =
"s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds AND s.invalidAt IS NULL";
// Query episodes directly using Space-[:HAS_EPISODE]->Episode relationships
const params: any = { spaceId, userId };
// Store the sinceDate condition separately to apply after e is defined
let dateCondition = "";
if (sinceDate) {
dateCondition = "e.createdAt > $sinceDate";
dateCondition = "AND e.createdAt > $sinceDate";
params.sinceDate = sinceDate.toISOString();
}
const query = `
MATCH (s:Statement{userId: $userId})
WHERE ${whereClause}
OPTIONAL MATCH (e:Episode{userId: $userId})-[:HAS_PROVENANCE]->(s)
WITH e
WHERE e IS NOT NULL ${dateCondition ? `AND ${dateCondition}` : ""}
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
WHERE e IS NOT NULL ${dateCondition}
RETURN DISTINCT e
ORDER BY e.createdAt DESC
`;
@ -654,7 +676,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
space.keyEntities = $keyEntities,
space.themes = $themes,
space.summaryConfidence = $confidence,
space.summaryStatementCount = $statementCount,
space.summaryContextCount = $contextCount,
space.summaryLastUpdated = datetime($lastUpdated)
RETURN space
`;
@ -665,7 +687,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
keyEntities: summaryData.keyEntities,
themes: summaryData.themes,
confidence: summaryData.confidence,
statementCount: summaryData.statementCount,
contextCount: summaryData.contextCount,
lastUpdated: summaryData.lastUpdated.toISOString(),
});

View File

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

View File

@ -201,6 +201,45 @@ export class IntegrationLoader {
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
*/

View File

@ -3,6 +3,7 @@ import { addToQueue } from "~/lib/ingest.server";
import { logger } from "~/services/logger.service";
import { SearchService } from "~/services/search.server";
import { SpaceService } from "~/services/space.server";
import { IntegrationLoader } from "./integration-loader";
const searchService = new SearchService();
const spaceService = new SpaceService();
@ -13,29 +14,31 @@ const SearchParamsSchema = {
properties: {
query: {
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: {
type: "string",
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: {
type: "string",
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: {
type: "string",
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: {
type: "array",
items: {
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"],
@ -46,7 +49,16 @@ const IngestSchema = {
properties: {
message: {
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"],
@ -56,25 +68,26 @@ export const memoryTools = [
{
name: "memory_ingest",
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,
},
{
name: "memory_search",
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,
},
{
name: "memory_get_spaces",
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: {
type: "object",
properties: {
all: {
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",
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: {
type: "object",
properties: {
profile: {
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
@ -112,6 +197,14 @@ export async function callMemoryTool(
return await handleMemoryGetSpaces(userId);
case "memory_about_user":
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:
throw new Error(`Unknown memory tool: ${toolName}`);
}
@ -160,12 +253,17 @@ async function handleUserProfile(userId: string) {
// Handler for memory_ingest
async function handleMemoryIngest(args: any) {
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(
{
episodeBody: args.message,
referenceTime: new Date().toISOString(),
source: args.source,
type: EpisodeTypeEnum.CONVERSATION,
spaceIds,
},
args.userId,
);
@ -198,12 +296,17 @@ async function handleMemoryIngest(args: any) {
// Handler for memory_search
async function handleMemorySearch(args: any) {
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(
args.query,
args.userId,
{
startTime: args.startTime ? new Date(args.startTime) : undefined,
endTime: args.endTime ? new Date(args.endTime) : undefined,
spaceIds,
},
args.source,
);
@ -235,11 +338,17 @@ async function handleMemoryGetSpaces(userId: string) {
try {
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 {
content: [
{
type: "text",
text: JSON.stringify(spaces),
text: JSON.stringify(simplifiedSpaces),
},
],
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-markdown": "10.1.0",
"react-resizable-panels": "^1.0.9",
"react-hotkeys-hook": "^4.5.0",
"react-virtualized": "^9.22.6",
"remix-auth": "^4.2.0",
"remix-auth-oauth2": "^3.4.1",
@ -135,6 +136,7 @@
"stripe": "19.0.0",
"simple-oauth2": "^5.1.0",
"tailwind-merge": "^2.6.0",
"tiptap-markdown": "0.9.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"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
DB_HOST=postgres

View File

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

View File

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

View File

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

53
pnpm-lock.yaml generated
View File

@ -607,6 +607,9 @@ importers:
react-dom:
specifier: ^18.2.0
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:
specifier: 10.1.0
version: 10.1.0(@types/react@18.2.69)(react@18.3.1)
@ -655,6 +658,9 @@ importers:
tiny-invariant:
specifier: ^1.3.1
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:
specifier: 3.25.76
version: 3.25.76
@ -5741,9 +5747,15 @@ packages:
'@types/json5@0.0.29':
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':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@13.0.9':
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
@ -5753,6 +5765,9 @@ packages:
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@1.0.5':
resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@ -9066,6 +9081,9 @@ packages:
resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==}
engines: {node: '>=0.10.0'}
markdown-it-task-lists@2.1.1:
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@ -10584,6 +10602,12 @@ packages:
engines: {node: '>=18.0.0'}
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:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -11556,6 +11580,11 @@ packages:
tiptap-extension-global-drag-handle@0.1.18:
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:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
@ -18728,8 +18757,15 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/linkify-it@3.0.5': {}
'@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':
dependencies:
'@types/linkify-it': 5.0.0
@ -18743,6 +18779,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/mdurl@1.0.5': {}
'@types/mdurl@2.0.0': {}
'@types/mdx@2.0.13': {}
@ -22608,6 +22646,8 @@ snapshots:
markdown-extensions@1.1.1: {}
markdown-it-task-lists@2.1.1: {}
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
@ -24536,6 +24576,11 @@ snapshots:
- utf-8-validate
- 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@17.0.2: {}
@ -25790,6 +25835,14 @@ snapshots:
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:
dependencies:
os-tmpdir: 1.0.2