diff --git a/core/config/default.ts b/core/config/default.ts index 4ba2c1c55d..2f8031b586 100644 --- a/core/config/default.ts +++ b/core/config/default.ts @@ -184,6 +184,13 @@ export const defaultConfig: SerializedContinueConfig = { // }, contextProviders: defaultContextProvidersVsCode, slashCommands: defaultSlashCommandsVscode, + integrations: [ + { + name: "mem0", + description: "PearAI Personalized Chat powered by Mem0", + enabled: false, + } + ], }; export const defaultCustomCommands: CustomCommand[] = [ diff --git a/core/config/load.ts b/core/config/load.ts index d612f62ec0..4af5590c56 100644 --- a/core/config/load.ts +++ b/core/config/load.ts @@ -51,6 +51,7 @@ import { getConfigTsPath, getContinueDotEnv, readAllGlobalPromptFiles, + editConfigJson } from "../util/paths.js"; import { defaultConfig, @@ -120,6 +121,11 @@ function loadSerializedConfig( config.allowAnonymousTelemetry = true; } + // If integrations doesn't exist in config, write it to config.json + if (!config.integrations) { + config.integrations = []; + } + if (ideSettings.remoteConfigServerUrl) { try { const remoteConfigJson = resolveSerializedConfig( @@ -175,7 +181,7 @@ async function serializedToIntermediateConfig( const promptFolder = initial.experimental?.promptPath; if (loadPromptFiles) { - let promptFiles: { path: string; content: string }[] = []; + let promptFiles: { path: string; content: string } [] = []; promptFiles = ( await Promise.all( workspaceDirs.map((dir) => @@ -498,6 +504,7 @@ function finalToBrowserConfig( ui: final.ui, experimental: final.experimental, isBetaAccess: final?.isBetaAccess, + integrations: final.integrations || [] }; } @@ -592,6 +599,26 @@ function addDefaults(config: SerializedContinueConfig): void { addDefaultCustomCommands(config); addDefaultContextProviders(config); addDefaultSlashCommands(config); + addDefaultIntegrations(config); +} + +function addDefaultIntegrations(config: SerializedContinueConfig): void { + defaultConfig!.integrations!.forEach((defaultIntegration) => { + const integrationExists = config?.integrations?.some( + (configIntegration) => + configIntegration.name === defaultIntegration.name + ); + if (!integrationExists) { + config!.integrations!.push(defaultIntegration); + editConfigJson((configJson) => { + if (!configJson.integrations) { + configJson.integrations = []; + } + configJson.integrations.push(defaultIntegration); + return configJson; + }); + } + }); } function addDefaultModels(config: SerializedContinueConfig): void { diff --git a/core/config/util.ts b/core/config/util.ts index 1b2c397b71..bbe96371b8 100644 --- a/core/config/util.ts +++ b/core/config/util.ts @@ -49,3 +49,13 @@ export function deleteModel(title: string) { return config; }); } + +export function toggleIntegration(name: string) { + editConfigJson((config) => { + const integration = config!.integrations!.find((i: any) => i.name === name); + if (integration) { + integration.enabled = !integration.enabled; + } + return config; + }); +} diff --git a/core/core.ts b/core/core.ts index 20dbe56e23..a506e7dc5b 100644 --- a/core/core.ts +++ b/core/core.ts @@ -10,7 +10,7 @@ import { setupLocalMode, } from "./config/onboarding"; import { createNewPromptFile } from "./config/promptFile"; -import { addModel, addOpenAIKey, deleteModel } from "./config/util"; +import { addModel, addOpenAIKey, deleteModel, toggleIntegration } from "./config/util"; import { recentlyEditedFilesCache } from "./context/retrieval/recentlyEditedFilesCache"; import { ContinueServerClient } from "./continueServer/stubs/client"; import { getAuthUrlForTokenPage } from "./control-plane/auth/index"; @@ -242,6 +242,10 @@ export class Core { deleteModel(msg.data.title); this.configHandler.reloadConfig(); }); + on("config/toggleIntegration", (msg) => { + toggleIntegration(msg.data.name); + this.configHandler.reloadConfig(); + }); on("config/newPromptFile", async (msg) => { createNewPromptFile( diff --git a/core/index.d.ts b/core/index.d.ts index 9889c95cd5..c4f796a583 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -803,6 +803,12 @@ export interface ModelDescription { isDefault?: boolean; } +export interface IntegrationDescription { + name: string; + description?: string; + enabled: boolean; +} + export type EmbeddingsProviderName = | "huggingface-tei" | "transformers.js" @@ -945,6 +951,7 @@ export interface SerializedContinueConfig { env?: string[]; allowAnonymousTelemetry?: boolean; models: ModelDescription[]; + integrations?: IntegrationDescription[]; systemMessage?: string; completionOptions?: BaseCompletionOptions; requestOptions?: RequestOptions; @@ -1037,6 +1044,7 @@ export interface ContinueConfig { analytics?: AnalyticsConfig; docs?: SiteIndexingConfig[]; isBetaAccess?: boolean; + integrations?: IntegrationDescription[]; } export interface BrowserSerializedContinueConfig { @@ -1056,6 +1064,7 @@ export interface BrowserSerializedContinueConfig { experimental?: ExperimentalConfig; analytics?: AnalyticsConfig; isBetaAccess?: boolean; + integrations?: IntegrationDescription[]; } export interface PearAuth { diff --git a/core/llm/llms/PearAIServer.ts b/core/llm/llms/PearAIServer.ts index a98b7e256b..d757ac528e 100644 --- a/core/llm/llms/PearAIServer.ts +++ b/core/llm/llms/PearAIServer.ts @@ -19,8 +19,12 @@ import { pruneRawPromptFromTop, } from "./../countTokens.js"; import { PearAICredentials } from "../../pearaiServer/PearAICredentials.js"; +import { editConfigJson } from "../../util/paths.js"; +import { execSync } from "child_process"; import * as vscode from "vscode"; + + class PearAIServer extends BaseLLM { private credentials: PearAICredentials; @@ -54,9 +58,39 @@ class PearAIServer extends BaseLLM { // no-op } + private _getIntegrations(): any { + let integrations = {}; + editConfigJson((config) => { + integrations = config.integrations || {}; + return config; + }); + return integrations; + } + + public static _getRepoId(): string { + try { + const gitRepo = vscode.workspace.workspaceFolders?.[0]; + if (gitRepo) { + // Get the root commit hash + const rootCommitHash = execSync( + "git rev-list --max-parents=0 HEAD -n 1", + { cwd: gitRepo.uri.fsPath } + ).toString().trim().substring(0, 7); + return rootCommitHash; + } // if not git initialized, id will simply be user-id (uid) + return ""; + } catch (error) { + console.error("Failed to initialize project ID:", error); + console.error("Using user ID as project ID"); + return ""; + } + } + private _convertArgs(options: CompletionOptions): any { return { model: options.model, + integrations: this._getIntegrations(), + repoId: PearAIServer._getRepoId(), frequency_penalty: options.frequencyPenalty, presence_penalty: options.presencePenalty, max_tokens: options.maxTokens, diff --git a/core/protocol/core.ts b/core/protocol/core.ts index 1ff638ae29..1d4fd8066e 100644 --- a/core/protocol/core.ts +++ b/core/protocol/core.ts @@ -56,6 +56,7 @@ export type ToCoreFromIdeOrWebviewProtocol = { "config/deleteModel": [{ title: string }, void]; "config/reload": [undefined, BrowserSerializedContinueConfig]; "config/listProfiles": [undefined, ProfileDescription[]]; + "config/toggleIntegration": [{name: string}, void]; "context/getContextItems": [ { name: string; diff --git a/core/protocol/ideWebview.ts b/core/protocol/ideWebview.ts index e251371a03..8e37904cf5 100644 --- a/core/protocol/ideWebview.ts +++ b/core/protocol/ideWebview.ts @@ -1,5 +1,5 @@ import { AiderState } from "../../extensions/vscode/src/integrations/aider/types/aiderTypes.js"; -import { ToolType } from "../../extensions/vscode/src/util/integrationUtils.js"; +import { ToolType, Memory, MemoryChange } from "../../extensions/vscode/src/util/integrationUtils.js"; import type { RangeInFileWithContents } from "../commands/util.js"; import type { ContextSubmenuItem } from "../index.js"; import { ToIdeFromWebviewOrCoreProtocol } from "./ide.js"; @@ -58,6 +58,8 @@ export type ToIdeFromWebviewProtocol = ToIdeFromWebviewOrCoreProtocol & { openInventoryHome: [undefined, void]; getUrlTitle: [string, string]; pearAIinstallation: [{tools: ToolType[], installExtensions: boolean}, void]; + "mem0/getMemories": [undefined, Memory[]]; + "mem0/updateMemories": [{ changes: MemoryChange[] }, boolean]; }; export type ToWebviewFromIdeProtocol = ToWebviewFromIdeOrCoreProtocol & { @@ -101,6 +103,7 @@ export type ToWebviewFromIdeProtocol = ToWebviewFromIdeOrCoreProtocol & { addPerplexityContextinChat: [{ text: string, language: string }, void]; navigateToCreator: [undefined, void]; navigateToSearch: [undefined, void]; + navigateToMem0: [undefined, void]; toggleOverlay: [undefined, void]; navigateToInventoryHome: [undefined, void]; getCurrentTab: [undefined, string]; diff --git a/core/protocol/passThrough.ts b/core/protocol/passThrough.ts index 5fe14573a8..bf17796af2 100644 --- a/core/protocol/passThrough.ts +++ b/core/protocol/passThrough.ts @@ -21,6 +21,7 @@ export const WEBVIEW_TO_CORE_PASS_THROUGH: (keyof ToCoreFromWebviewProtocol)[] = "config/getSerializedProfileInfo", "config/deleteModel", "config/reload", + "config/toggleIntegration", "context/getContextItems", "context/loadSubmenuItems", "context/addDocs", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index be7b840519..c593084244 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -170,6 +170,12 @@ "title": "Toggle PearAI Search", "group": "PearAI" }, + { + "command": "pearai.toggleMem0", + "category": "PearAI", + "title": "Toggle PearAI Memory", + "group": "PearAI" + }, { "command": "pearai.toggleOverlay", "category": "PearAI", @@ -375,6 +381,11 @@ "mac": "cmd+3", "key": "ctrl+3" }, + { + "command": "pearai.toggleMem0", + "mac": "cmd+4", + "key": "ctrl+4" + }, { "command": "pearai.focusContinueInput", "mac": "cmd+l", diff --git a/extensions/vscode/src/activation/activate.ts b/extensions/vscode/src/activation/activate.ts index eb92c4df29..b6e8579bee 100644 --- a/extensions/vscode/src/activation/activate.ts +++ b/extensions/vscode/src/activation/activate.ts @@ -5,11 +5,11 @@ import * as vscode from "vscode"; import { VsCodeExtension } from "../extension/VsCodeExtension"; import registerQuickFixProvider from "../lang-server/codeActions"; import { getExtensionVersion } from "../util/util"; -import { getExtensionUri } from "../util/vscode"; import { VsCodeContinueApi } from "./api"; import { setupInlineTips } from "./inlineTips"; import { isFirstLaunch } from "../copySettings"; + export async function isVSCodeExtensionInstalled(extensionId: string): Promise { return vscode.extensions.getExtension(extensionId) !== undefined; }; @@ -50,7 +50,6 @@ export async function attemptUninstallExtension(extensionId: string): Promise { await handleIntegrationShortcutKey("navigateToSearch", "perplexityMode", sidebar, [PEAR_OVERLAY_VIEW_ID]); }, + "pearai.toggleMem0": async () => { + await handleIntegrationShortcutKey("navigateToMem0", "mem0Mode", sidebar, [PEAR_OVERLAY_VIEW_ID]); + }, "pearai.toggleOverlay": async () => { await handleIntegrationShortcutKey("toggleOverlay", "inventory", sidebar, [PEAR_OVERLAY_VIEW_ID, PEAR_CONTINUE_VIEW_ID]); }, diff --git a/extensions/vscode/src/extension/VsCodeMessenger.ts b/extensions/vscode/src/extension/VsCodeMessenger.ts index 977669a478..a9f19f44f0 100644 --- a/extensions/vscode/src/extension/VsCodeMessenger.ts +++ b/extensions/vscode/src/extension/VsCodeMessenger.ts @@ -28,7 +28,9 @@ import { getExtensionUri } from "../util/vscode"; import { VsCodeWebviewProtocol } from "../webviewProtocol"; import { attemptInstallExtension, attemptUninstallExtension, isVSCodeExtensionInstalled } from "../activation/activate"; import { checkAiderInstallation } from "../integrations/aider/aiderUtil"; +import { getMem0Memories, updateMem0Memories } from "../integrations/mem0/mem0Service"; import { TOOL_COMMANDS, ToolType } from "../util/integrationUtils"; +import PearAIServer from "core/llm/llms/PearAIServer"; /** * A shared messenger class between Core and Webview @@ -121,6 +123,15 @@ export class VsCodeMessenger { console.log("Aider installation status:", isAiderInstalled); return isAiderInstalled; }); + this.onWebview("mem0/getMemories", async (msg) => { + + const memories = await getMem0Memories(PearAIServer._getRepoId()); + return memories; + }); + this.onWebview("mem0/updateMemories", async (msg) => { + const response = await updateMem0Memories(PearAIServer._getRepoId(), msg.data.changes); + return response; + }); this.onWebview("is_vscode_extension_installed", async (msg) => { const isInstalled = await isVSCodeExtensionInstalled(msg.data.extensionId); console.log("VSCode extension installation status:", isInstalled); diff --git a/extensions/vscode/src/integrations/mem0/mem0Service.ts b/extensions/vscode/src/integrations/mem0/mem0Service.ts new file mode 100644 index 0000000000..6977698d4e --- /dev/null +++ b/extensions/vscode/src/integrations/mem0/mem0Service.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { SERVER_URL } from "core/util/parameters"; +import { getHeaders } from "core/pearaiServer/stubs/headers"; +import { MemoryChange } from "../../util/integrationUtils"; +import * as vscode from 'vscode'; + +export async function getMem0Memories(repo_id: string) { + try { + const baseHeaders = await getHeaders(); + const auth: any = await vscode.commands.executeCommand("pearai.getPearAuth"); + const response = await fetch(`${SERVER_URL}/integrations/memory/${repo_id}`, { + method: "GET", + headers: { + ...baseHeaders, + "Content-Type": "application/json", + Authorization: `Bearer ${auth.accessToken}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || `${response.statusText}`); + } + + const data = await response.json(); + return data; + } catch (error) { + // Show error message in VSCode + vscode.window.showErrorMessage(`Error fetching memories: ${(error as any).message}`); + } + } + +export async function updateMem0Memories(repo_id: string, changes: MemoryChange[]) { + const baseHeaders = await getHeaders(); + const auth: any = await vscode.commands.executeCommand("pearai.getPearAuth"); + + const response = await fetch(`${SERVER_URL}/integrations/memory/update`, { + method: "POST", + headers: { + ...baseHeaders, + "Content-Type": "application/json", + Authorization: `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify({ + id: repo_id, + updatedMemories: changes, + }), + }); + return await response.json(); +} \ No newline at end of file diff --git a/extensions/vscode/src/util/integrationUtils.ts b/extensions/vscode/src/util/integrationUtils.ts index d276a53fa7..e262575ecd 100644 --- a/extensions/vscode/src/util/integrationUtils.ts +++ b/extensions/vscode/src/util/integrationUtils.ts @@ -13,6 +13,24 @@ export interface ToolCommand { args?: any; } +export interface Memory { + id: string; + memory: string; + created_at: string; + updated_at: string; + total_memories: number; + owner: string; + organization: string; + metadata: any; + type: string; +} + +export interface MemoryChange { + type: 'edit' | 'delete' | 'new'; + id: string; + content?: string; +} + export type ToolType = typeof InstallableTool[keyof typeof InstallableTool]; export const TOOL_COMMANDS: Record = { diff --git a/gui/src/components/Layout.tsx b/gui/src/components/Layout.tsx index ab3c8e7c81..ef35390df0 100644 --- a/gui/src/components/Layout.tsx +++ b/gui/src/components/Layout.tsx @@ -150,6 +150,7 @@ const HIDE_FOOTER_ON_PAGES = [ "/inventory", "/inventory/aiderMode", "/inventory/perplexityMode", + "/inventory/mem0Mode", "/welcome" ]; diff --git a/gui/src/integrations/mem0/mem0gui.tsx b/gui/src/integrations/mem0/mem0gui.tsx new file mode 100644 index 0000000000..12a5ee8ce7 --- /dev/null +++ b/gui/src/integrations/mem0/mem0gui.tsx @@ -0,0 +1,607 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useSelector, useDispatch } from 'react-redux'; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Search, Plus, Brain, Sparkles, ExternalLink } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import HeaderButtonWithText from "@/components/HeaderButtonWithText"; +import { TrashIcon, Pencil2Icon, ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { Badge } from "../../components/ui/badge"; +import { useContext } from 'react'; +import { IdeMessengerContext } from '../../context/IdeMessenger'; +import { setMem0Memories } from "@/redux/slices/stateSlice"; +import { RootState, store } from "@/redux/store"; + + +export interface Memory { + id: string; + content: string; + timestamp: string; + isNew?: boolean; + isModified?: boolean; + isDeleted?: boolean; +} + +interface MemoryChange { + type: 'edit' | 'delete' | 'new'; + id: string; + content?: string; // For edits +} + +export const lightGray = "#999998"; + +interface StatusCardProps { + title: string; + description: string; + icon: 'brain' | 'search'; + showSparkles?: boolean; + animate?: boolean; + secondaryDescription?: string; +} + +function StatusCard({ title, description, icon, showSparkles = false, animate = false, secondaryDescription = "" }: StatusCardProps) { + return ( + +
+
+ {icon === 'brain' ? ( + + ) : ( + + )} + {showSparkles && ( + + )} +
+

{title}

+

+ {description} +

+ {secondaryDescription &&

+ {secondaryDescription} +

} +
+
+ ) + } + +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const oneWeekAgo = new Date(now); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + // Check if same day + if (date.toDateString() === now.toDateString()) { + return 'Today'; + } + // Check if yesterday + if (date.toDateString() === yesterday.toDateString()) { + return 'Yesterday'; + } + // Check if within last week + if (date > oneWeekAgo) { + return 'This week'; + } + // Otherwise return formatted date + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } + +export default function Mem0GUI() { + const [currentPage, setCurrentPage] = useState(1) + const [searchQuery, setSearchQuery] = useState(""); + const [isExpanded, setIsExpanded] = useState(false) + const [editingId, setEditingId] = useState(null); + const [editedContent, setEditedContent] = useState(""); + const ideMessenger = useContext(IdeMessengerContext); + const [isLoading, setIsLoading] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + const dispatch = useDispatch(); + const memories = useSelector( + (store: RootState) => store.state.memories, + ); + + // for batch edits + const [unsavedChanges, setUnsavedChanges] = useState([]); + const [originalMemories, setOriginalMemories] = useState([]); + + const searchRef = useRef(null) + const editCardRef = useRef(null); + const memoriesPerPage = 4; + + const fetchMemories = async () => { + try { + setIsLoading(true); + // get all memories + const response = await ideMessenger.request('mem0/getMemories', undefined); + const memories = response.map((memory) => ({ + id: memory.id, + content: memory.memory, + timestamp: memory.updated_at || memory.created_at, + isModified: false, + isDeleted: false, + isNew: false + })); + dispatch(setMem0Memories(memories)); + setOriginalMemories(memories); + } catch (error) { + console.error('Failed to fetch memories:', error); + } finally { + setIsLoading(false); + } + }; + + const handleAddNewMemory = () => { + // reset search query if any + setSearchQuery(''); + setIsExpanded(false); + + const newMemory: Memory = { + id: Date.now().toString(), // temporary ID generation, this should be the id value returned from the API + content: "", + timestamp: "Just now", + isNew: true // handle creation on BE + }; + dispatch(setMem0Memories([newMemory, ...memories])); // Add to beginning of list for edit mode on new memory + setUnsavedChanges(prev => [...prev, { + type: 'new', + id: newMemory.id, + content: "" + }]); + setEditedContent(""); // Clear edited content + setEditingId(newMemory.id); // Automatically enter edit mode + }; + + + // Handle edit button click + const onEdit = (memory: Memory) => { + setEditingId(memory.id); + setEditedContent(memory.content); + } + + const handleSaveAllChanges = async () => { + try { + setUnsavedChanges([]); + setIsUpdating(true); + setIsLoading(true); + const response = await ideMessenger.request('mem0/updateMemories', { + changes: unsavedChanges + }); + + if (response) { + await fetchMemories(); + } + } catch (error) { + console.error('Failed to save memories:', error); + } finally { + setIsLoading(false); + setIsUpdating(false); + } + + setEditingId(null); + setEditedContent(""); + }; + + const handleCancelAllChanges = () => { + dispatch(setMem0Memories(originalMemories.map(memory => ({ + ...memory, + isModified: false, + isDeleted: false, + isNew: false + })))); + + setUnsavedChanges([]); + setEditingId(null); + setEditedContent(""); + }; + + // batch edit + const handleUnsavedEdit = () => { + if (!editingId) return; + const memory = memories.find(m => m.id === editingId); + if (editedContent === memory.content) { + setEditingId(null); + setEditedContent(""); + return + }; + if (editedContent === '') { + handleDelete(memory.id); + setEditingId(null); + setEditedContent(""); + return + } + + // Update or add to unsaved changes + setUnsavedChanges(prev => { + const existingChangeIndex = prev.findIndex(change => change.id === editingId); + if (existingChangeIndex >= 0) { + // Update existing change + const newChanges = [...prev]; + newChanges[existingChangeIndex] = { + ...newChanges[existingChangeIndex], + content: editedContent + }; + return newChanges; + } else { + // Add new change + return [...prev, { + type: memory.isNew ? 'new' : 'edit', + id: editingId, + content: editedContent + }]; + } + }); + + dispatch(setMem0Memories( + memories.map(memory => + memory.id === editingId + ? { ...memory, content: editedContent, isModified: true } + : memory + ) + )); + + setEditingId(null); + setEditedContent(""); + }; + + // Handle cancel edit + const handleCancelEdit = (memory: Memory) => { + if (memory.content === "") { + // If this was a new memory, remove it + // setMemories(prev => prev.filter(m => m.id !== memory.id)); + dispatch(setMem0Memories(memories.filter(m => m.id !== memory.id))); + } + setEditingId(null); + setEditedContent(""); + } + + // Handle click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (editCardRef.current && !editCardRef.current.contains(event.target as Node)) { + if (editingId) { + const memory = memories.find(m => m.id === editingId); + handleCancelEdit(memory); + } + } + } + + if (editingId) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [editingId]); + + // Handle key press + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleUnsavedEdit(); + } + }; + + // Update filteredMemories to use memories state + const filteredMemories = useMemo(() => { + return memories.filter(memory => + memory.content.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [searchQuery, memories]); + + + // Get total pages based on filtered results + const totalPages = Math.ceil(filteredMemories.length / memoriesPerPage); + + // Reset to first page when search query changes + useEffect(() => { + setCurrentPage(1); + }, [searchQuery]); + + useEffect(() => { + if (memories.length === 0) { + fetchMemories(); + } + }, []); + + const getCurrentPageMemories = () => { + const startIndex = (currentPage - 1) * memoriesPerPage; + const endIndex = startIndex + memoriesPerPage; + return filteredMemories.slice(startIndex, endIndex); + } + + const handlePrevPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + } + + const handleNextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + } + + const handleDelete = (memoryId: string) => { + setUnsavedChanges(prev => { + // Remove any existing changes for this memory ID + const filteredChanges = prev.filter(change => change.id !== memoryId); + // Add the delete change + return [...filteredChanges, { type: 'delete', id: memoryId }]; + }); + + dispatch(setMem0Memories(memories.map(memory => + memory.id === memoryId + ? { ...memory, isDeleted: true } + : memory + ))); + }; + + // Handle clicking outside of search to collapse it + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setIsExpanded(false) + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + return ( +
+
+
+
+

+ PearAI Memory + + Beta + +

+
+
+ powered by mem0* +
+
+ {unsavedChanges.length > 0 && ( +
+
+
+

+ You have unsaved changes to memories +

+
+
+
+ )} +
+ +
+ + setSearchQuery(e.target.value)} + className="h-8 pl-10 text-foreground bg-input rounded-xl" + onFocus={() => setIsExpanded(true)} + /> +
+
+
+ +
+ {isLoading ? ( + isUpdating ? ( + + ) : ( + + ) + ) : memories.length === 0 ? ( + + ) : filteredMemories.length === 0 ? ( + + ) : + getCurrentPageMemories().map((memory: Memory) => ( + editingId !== memory.id && onEdit(memory)} + > +
+ {editingId === memory.id ? ( +
+
+ setEditedContent(e.target.value)} + className="w-full bg-background text-foreground border border-input" + placeholder="Write a memory..." + autoFocus + onKeyDown={handleKeyPress} + /> +
+
+ + +
+
+ ) : ( +
+
+

{memory.content}

+ { + memory.isNew ? ( + (new) + ) : memory.isDeleted ? ( +
+ (deleted) + +
+ ) : memory.isModified && (modified) + } +
+
+ )} + {!editingId && ( +
+ + { + e.stopPropagation(); + onEdit(memory) + }} + /> + + + { + e.stopPropagation(); + handleDelete(memory.id); + }} + /> + +
+ )} +
+ {editingId !== memory.id &&

{formatTimestamp(memory.timestamp)}

} +
+ ))} +
+ + +
+ {/* Centered Save/Cancel buttons */} + {unsavedChanges.length > 0 && ( +
+ + +
+ )} + +
+
+ {filteredMemories.length > 0 && ( + <> + + + + {`${currentPage} of ${totalPages}`} + + + + + )} +
+
+
+
+ ); +} diff --git a/gui/src/inventory/pages/HomePage.tsx b/gui/src/inventory/pages/HomePage.tsx index c5fc165487..0a583d24d8 100644 --- a/gui/src/inventory/pages/HomePage.tsx +++ b/gui/src/inventory/pages/HomePage.tsx @@ -47,6 +47,13 @@ export default function HomePage() { shortcut: {getMetaKeyLabel()}3, path: "/inventory/perplexityMode", }, + { + icon: "inventory-mem0.svg", + label: "Memory", + description: <>AI Personalization, + shortcut: {getMetaKeyLabel()}4, + path: "/inventory/mem0Mode", + } ]; useEffect(() => { @@ -65,7 +72,7 @@ export default function HomePage() { }} >
closeOverlay(e)}> -
+
{menuItems.map((item) => (
Promise; note?: string; + toggleable?: boolean; } const suggestedBuild = [ @@ -60,6 +61,11 @@ function AIToolCard({ onClick: () => void; onToggle: () => void; }) { + const handleSwitchClick = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click + onToggle(); + }; + return ( - {/* TODO: removed unfinished feature */} - {/* - - - - {!tool.comingSoon && ( - -

- Toggling coming soon -

-
- )} -
*/} +

@@ -102,6 +89,17 @@ function AIToolCard({ > {tool.comingSoon ? "Coming soon" : tool.description}

+ {tool.toggleable && !tool.comingSoon && ( + + + + + )} @@ -149,7 +147,7 @@ function AIToolCard({ export default function AIToolInventory() { const ideMessenger = useContext(IdeMessengerContext); const navigate = useNavigate(); - + const integrations = useSelector((state: RootState) => state.state.config.integrations || []); // const aiderProcessState = useSelector( // (state: RootState) => state.state.aiderProcessState, // ); @@ -166,6 +164,9 @@ export default function AIToolInventory() { } else if (tool.id === AIToolID.AUTOCOMPLETE) { // Supermaven's ID return { ...tool, isInstalled: isSuperMavenInstalled }; + } else if (tool.id === AIToolID.MEMORY) { + const mem0Integration = integrations.find(i => i.name === 'mem0'); + return { ...tool, enabled: mem0Integration?.enabled ?? false }; } else { return tool; } @@ -355,15 +356,15 @@ export default function AIToolInventory() { name: "Memory", description: ( - Personalization: let the AI remember your past thoughts (coming soon) + Personalization: let PearAI get to know your coding preferences ), icon: "inventory-mem0.svg", whenToUse: ( When you want the AI to remember insights from past prompts you've - given it. It can automatically remember details like what version of - for e.g. Python you're using, or other specific details of your + given it. It can automatically remember details such as + the Python version you're using, or other specific details of your codebase, like your coding styles, or your expertise level ), @@ -372,9 +373,10 @@ export default function AIToolInventory() { Increase in accuracy of results due to personalization, ], enabled: false, - comingSoon: true, + comingSoon: false, poweredBy: "Mem0", installNeeded: false, + toggleable: true, }, ]); @@ -398,6 +400,14 @@ export default function AIToolInventory() { tool.id === id ? { ...tool, enabled: !tool.enabled } : tool, ), ); + + switch(id) { + case AIToolID.MEMORY: + ideMessenger.post("config/toggleIntegration", {name: "mem0"}); + break; + default: + break; + } }; // TODO: Not used for now @@ -424,8 +434,6 @@ export default function AIToolInventory() { }; const handleOpen = (tool: AITool) => { - console.dir("handleOpen"); - console.dir(tool); switch (tool.id) { case AIToolID.CREATOR: navigate("/inventory/aiderMode"); @@ -433,6 +441,9 @@ export default function AIToolInventory() { case AIToolID.SEARCH: navigate("/inventory/perplexityMode"); break; + case AIToolID.MEMORY: + navigate("/inventory/mem0Mode"); + break; case AIToolID.AUTOCOMPLETE: ideMessenger.post("invokeVSCodeCommandById", { commandId: "supermaven.newConversationTab", // supermaven new chat command diff --git a/gui/src/pages/inventory.tsx b/gui/src/pages/inventory.tsx index 34d74d1b08..2aa66e82b3 100644 --- a/gui/src/pages/inventory.tsx +++ b/gui/src/pages/inventory.tsx @@ -3,6 +3,7 @@ import HomePage from "@/inventory/pages/HomePage"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import PerplexityGUI from "@/integrations/perplexity/perplexitygui"; import AiderGUI from "@/integrations/aider/aidergui"; +import Mem0GUI from "@/integrations/mem0/mem0gui"; import { useLocation, useNavigate } from "react-router-dom"; import { useEffect, useState, ReactNode } from "react"; import { useWebviewListener } from "@/hooks/useWebviewListener"; @@ -32,6 +33,12 @@ const tabs = [ component: , shortcut: <>SHIFT1 }, + { + id: "mem0Mode", + name: "Memory", + component: , + shortcut: 4 + } ]; interface TabButtonProps { @@ -56,6 +63,7 @@ export default function Inventory() { useWebviewListener("navigateToInventoryHome", () => handleTabChange("home"), []); useWebviewListener("navigateToCreator", () => handleTabChange("aiderMode"), []); useWebviewListener("navigateToSearch", () => handleTabChange("perplexityMode"), []); + useWebviewListener("navigateToMem0", () => handleTabChange("mem0Mode"), []); useWebviewListener("toggleOverlay", () => handleTabChange("inventory"), []); useWebviewListener("getCurrentTab", async () => activeTab, [activeTab]); @@ -96,6 +104,7 @@ export default function Inventory() {
+
diff --git a/gui/src/redux/slices/stateSlice.ts b/gui/src/redux/slices/stateSlice.ts index ac1c6b59ae..9506a353e8 100644 --- a/gui/src/redux/slices/stateSlice.ts +++ b/gui/src/redux/slices/stateSlice.ts @@ -16,6 +16,7 @@ import { createSelector } from "reselect"; import { v4 } from "uuid"; import { RootState } from "../store"; import { getLocalStorage } from "@/util/localStorage"; +import { Memory } from "../../integrations/mem0/mem0gui" export const memoizedContextItemsSelector = createSelector( @@ -134,6 +135,7 @@ type State = { visitedSteps: number[] }; showInteractiveContinueTutorial: boolean; + memories: Memory[]; // mem0 memories }; const initialState: State = { @@ -178,6 +180,7 @@ const initialState: State = { visitedSteps: [0] }, showInteractiveContinueTutorial: getLocalStorage("showTutorialCard") ?? false, + memories: [], }; export const stateSlice = createSlice({ @@ -666,6 +669,11 @@ export const stateSlice = createSlice({ setShowInteractiveContinueTutorial: (state, action: PayloadAction) => { state.showInteractiveContinueTutorial = action.payload; }, + setMem0Memories: (state, { payload}: PayloadAction) => { + state.memories = payload.sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + } }, }); @@ -703,5 +711,6 @@ export const { deleteMessage, setOnboardingState, setShowInteractiveContinueTutorial, + setMem0Memories, } = stateSlice.actions; export default stateSlice.reducer;