diff --git a/README.md b/README.md index 8efe855b..8ee37ffb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ mocker/ │ │ # - REST APIs for Slack commands and events │ │ # - Search endpoint (team-scoped, requires OAuth token) │ │ # - Slack OAuth flow (/auth/slack, /auth/slack/callback) -│ │ # - Scheduled jobs (fun-fact, pricing, memory) +│ │ # - Scheduled jobs (fun-fact, pricing, event-alert) │ │ │ └── frontend/ # @mocker/frontend - React + Vite │ # - Message search UI @@ -254,10 +254,11 @@ docker logs | jq . ### AI Features (Optional) -- **Daily Memory Job** - Summarizes conversations daily at 3 AM (requires OpenAI API key) - **Sentiment Analysis** - Analyzes message tone - **AI Summaries** - Generates summaries of message threads +Schema changes are managed by TypeORM using your configured synchronization settings. + ### Scheduled Jobs Most scheduled jobs run inside the backend Node.js process using `node-cron`. They are started automatically when the server connects to the database. diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index eee10722..4a85e520 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -7,7 +7,6 @@ export const CORPO_SPEAK_INSTRUCTIONS = `Translate the following text into a Cor /** * Static system instructions for Moonbeam's persona. * The tagged message is appended to user input, NOT embedded here. - * Memory data is dynamically inserted inside before when available. */ export const MOONBEAM_SYSTEM_INSTRUCTIONS = ` Respond to the tagged message that appears after "---" at the end of the conversation history. Use the full chat history for context, including your own prior messages. Search the internet when a question involves current events, real-time data, or anything beyond your training knowledge. @@ -43,12 +42,6 @@ Be decisive and direct. Do not hedge unnecessarily. Do not moralize, lecture, or Do not use generational roleplay, meme-speak, or trend-chasing language. Keep responses straightforward and context-aware. - -You have memories about some people in this conversation. Use them only when they improve clarity or relevance. Reference prior context naturally without announcing that you are recalling memory. Note contradictions or changes in position when useful. - -A wrong or forced memory reference is worse than none — only use a memory when it genuinely fits the moment. Do not start responses with "I remember" or "As you've said before." - - Factual question → answer clearly and directly. Search the internet when needed, especially for current information or uncertain facts. @@ -78,132 +71,3 @@ export const REDPLOY_MOONBEAM_TEXT_PROMPT = `Provide a concise, professional mes export const REDPLOY_MOONBEAM_IMAGE_PROMPT = `An image depicting yourself with the understanding that your name is Moonbeam and you identify as a female. The art style can be any choice you would like. Feel free to be creative, and do not feel that you must always present yourself in humanoid form. Please do not include any text in the image.`; export const MOONBEAM_SLACK_ID = 'ULG8SJRFF'; - -export const GATE_MODEL = 'gpt-4.1-nano'; - -export const MEMORY_EXTRACTION_PROMPT = `You are a memory extraction tool analyzing a Slack conversation. Your job is to identify notable observations -about the people in this conversation that would be worth remembering for future interactions. - -The participant named "Moonbeam" (or "muzzle3") is the bot. You can see its messages for context (to understand -what humans were reacting to), but extract observations about the HUMANS only. - -PRIMARY GOAL: -extract only user-attributable memories that will help infer stable future TRAITS (preferences, convictions, -communication style, recurring social dynamics). if a memory would not improve future trait synthesis, skip it. - -YOUR DEFAULT ANSWER IS NONE. only extract something if you are confident it meets the criteria below. - -IDENTITY AND ATTRIBUTION RULES (STRICT): -- every memory must be tied to exactly one human slackId (the person who said/did the thing) -- do not create group-level memories (e.g. "they argued about x"); rewrite them as one person's behavior/stance toward a specific topic or person -- if attribution is ambiguous (you cannot confidently tell who holds the stance), SKIP it -- when two people discuss the same topic, create separate memories only if each person's stance/behavior is independently clear -- store observations as "what this person said or did"; never as narrator interpretation or a summary of the group -- if describing conflict or rapport, frame it only as this user's observable behavior toward another identified person (e.g. challenged, backed up, targeted, escalated with) - -TRAIT-BUILDING STANDARD: -only keep memories that are likely to generalize into stable traits later. prioritize: -- repeated or strongly argued preferences/beliefs (not a single casual mention) -- consistent patterns in how this person interacts with Moonbeam or specific users (challenging, backing up, directing, escalating) -- high-energy engagement that reveals what they care about (multi-message push, rebuttals, detailed arguments) -- explicit changes in stance over time (for EVOLVE) - -discard memories that are unlikely to matter for trait synthesis: -- one-off trivia, fleeting moods, isolated jokes, or factual Q&A -- weak topical mentions without conviction -- details that are specific but not behaviorally useful later - -WHAT TO EXTRACT: -- Specific statements or positions someone argued with conviction -- How someone interacts with Moonbeam and others (telling it off, asking it to settle arguments, testing it, - backing someone up, picking fights, giving instructions) -- Topics that clearly activate someone (they went from one-liners to paragraphs) -- Communication dynamics between people (who argued with who, and about what) - -WHAT TO SKIP: -- Idle chatter, one-liners, greetings, link shares without commentary -- Someone asking a question — asking about a topic is NOT the same as caring about it -- Names of partners, kids, or family members (e.g. "his wife Katie", "her son Jake") -- Addresses, workplaces, or job titles (e.g. "works at Capital One", "lives in Cranford") -- Medical info (e.g. "diagnosed with ADD", "had hernia surgery") -- One-off events ("had a bad day", "flight got delayed") -- Narrative characterizations ("is the group's social planner") — store what they DID, not what you think it MEANS -- Inferred personality traits ("calls it names as affection") — store the behavior, not your interpretation -- Single statements dressed up as recurring opinions - -HOW TO DECIDE: -look for signal + attribution + durability: -1) signal: did they show conviction/behavior (not just mention a topic)? -2) attribution: can you confidently attach it to one person? -3) durability: is this likely useful for future trait inference? - -if any answer is no, skip it. - -EXAMPLES OF NONE (do not extract from conversations like these): -- Someone asks Moonbeam a factual question and gets an answer -- Someone shares a link with no commentary -- A few people exchange short one-liners or greetings -- Someone makes a single joke or observation and moves on - -EXISTING MEMORIES (for context — do not duplicate these): -{existing_memories} - -For each observation, classify: -- NEW: not captured in existing memories -- REINFORCE: an existing memory came up again — only if the conversation shows genuine sustained engagement with the topic, not just a passing mention -- EVOLVE: contradicts or meaningfully updates an existing memory - -MODE GUIDANCE: -- NEW: genuinely new, trait-relevant signal for that specific person -- REINFORCE: clear repeated evidence of an existing memory for that same person -- EVOLVE: same person shows a meaningful shift or contradiction vs a prior memory -- never use REINFORCE/EVOLVE if person match is uncertain - -Return a JSON array, or the string NONE if nothing is worth extracting. Most of the time, NONE is the right answer. - -Format: [{"slackId": "U12345", "content": "description of what they said or did", "mode": "NEW|REINFORCE|EVOLVE", "existingMemoryId": null}] - -CONTENT WRITING RULES: -- keep each memory to 1-2 sentences -- include concrete behavior/claim, ideally with a brief quoted phrase when useful -- write in plain factual language tied to that user; avoid personality labels -- do not include private/sensitive details (family names, medical, workplace, address) - -QUALITY BAR: -if you are unsure whether a candidate memory is person-specific, durable, and trait-relevant, output NONE.`; - -export const TRAIT_EXTRACTION_PROMPT = `You are a trait synthesis tool. - -You are given a set of stored memories about one specific user from a group chat. -Your task is to infer that user's most stable, high-signal traits and beliefs. - -Goal: -- Return up to 10 traits that capture enduring preferences, convictions, communication patterns, and relationship dynamics. -- Focus on traits that would actually help produce better future responses in a chat context. - -Prioritize traits like: -- clear preferences ("prefers TypeScript over Python") -- recurring beliefs or stances ("strongly anti-Trump") -- consistent social dynamics ("often challenges Moonbeam when it hedges") - -Do NOT include: -- one-off events -- low-signal trivia -- private/sensitive details (addresses, medical details, workplaces, family names) -- contradictions unless a new stance clearly replaced an old one - -Requirements: -- Traits must be concise, concrete, and attributable to the user. -- No duplicates or near-duplicates. -- Treat semantically similar phrasings as duplicates, even if wording differs. -- If multiple candidates express the same underlying trait, keep exactly one canonical version and drop the rest. -- Canonical version should be the clearest, most specific, and most durable phrasing. -- Before finalizing, run a dedupe pass over the full list and remove any overlapping traits. -- Prefer quality over quantity. If only 4 strong traits exist, return 4. - -Output format: -- Return ONLY a JSON array of strings. -- Example: ["Prefers TypeScript as his primary programming language", "Strongly dislikes Donald Trump"] -- If no strong traits are present, return []`; - -export const DAILY_MEMORY_JOB_CONCURRENCY = 50; diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index 05b12232..9dc00741 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -5,18 +5,6 @@ import path from 'path'; import { AIService } from './ai.service'; import type { MessageWithName } from '../shared/models/message/message-with-name'; import { MOONBEAM_SLACK_ID } from './ai.constants'; -import { TraitService } from '../trait/trait.service'; - -const { getAllTraitsForUsers } = vi.hoisted(() => ({ - getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), -})); - -vi.mock('../trait/trait.persistence.service', async () => ({ - TraitPersistenceService: classMock(() => ({ - getAllTraitsForUsers, - getAllTraitsForUser: vi.fn().mockResolvedValue([]), - })), -})); const buildAiService = (): AIService => { const ai = new AIService(); @@ -78,8 +66,6 @@ const buildAiService = (): AIService => { debug: vi.fn(), } as unknown as AIService['aiServiceLogger']; - ai.traitService = new TraitService(); - return ai; }; @@ -363,28 +349,6 @@ describe('AIService', () => { expect.stringContaining('unable to send the requested text to Slack'), ); }); - - it('injects trait context when traits exist for participants', async () => { - (aiService.historyService.getHistory as Mock).mockResolvedValue([ - { name: 'Jane', slackId: 'U2', message: 'Hi there' }, - ]); - const traitPersistenceService = ( - aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } } - ).traitPersistenceService; - (traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( - new Map([['U2', [{ slackId: 'U2', content: 'prefers typescript' }]]]), - ); - const createSpy = aiService.openAi.responses.create as Mock; - createSpy.mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: 'Response text' }] }], - }); - - await aiService.promptWithHistory({ user_id: 'U1', team_id: 'T1', channel_id: 'C1', text: 'Summarize' } as never); - - const callArgs = createSpy.mock.calls[0][0] as { instructions: string }; - expect(callArgs.instructions).toContain('traits_context'); - expect(callArgs.instructions).toContain('prefers typescript'); - }); }); describe('handle', () => { @@ -692,28 +656,6 @@ describe('AIService', () => { { type: 'markdown', text: 'What do you think?' }, ]); }); - - it('injects trait context for participation prompts', async () => { - (aiService.historyService.getHistoryWithOptions as Mock).mockResolvedValue([ - { slackId: 'U2', name: 'Jane', message: 'hello' }, - ]); - const traitPersistenceService = ( - aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } } - ).traitPersistenceService; - (traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( - new Map([['U2', [{ slackId: 'U2', content: 'dislikes donald trump' }]]]), - ); - const createSpy = aiService.openAi.responses.create as Mock; - createSpy.mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: 'Participation response' }] }], - }); - - await aiService.participate('T1', 'C1', '<@moonbeam> hi'); - - const callArgs = createSpy.mock.calls[0][0] as { instructions: string }; - expect(callArgs.instructions).toContain('traits_context'); - expect(callArgs.instructions).toContain('dislikes donald trump'); - }); }); describe('participate with custom prompt', () => { diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index cf01e40b..b8786e4d 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -38,7 +38,6 @@ import type { Part } from '@google/genai'; import { GoogleGenAI } from '@google/genai'; import sharp from 'sharp'; import { extractParticipantSlackIds } from './helpers/extractParticipantSlackIds'; -import { TraitService } from '../trait/trait.service'; interface ReleaseCommit { sha: string; @@ -144,7 +143,6 @@ export class AIService { webService = new WebService(); slackService = new SlackService(); slackPersistenceService = new SlackPersistenceService(); - traitService = new TraitService(); aiServiceLogger = logger.child({ module: 'AIService' }); public decrementDaiyRequests(userId: string, teamId: string): Promise { @@ -466,16 +464,9 @@ export class AIService { const customPrompt = await this.slackPersistenceService.getCustomPrompt(user_id, team_id); const normalizedCustomPrompt = customPrompt?.trim() || null; - - const traitContext = await this.traitService.fetchTraitContext( - extractParticipantSlackIds(history, { includeSlackId: user_id }), - team_id, - history, - ); const baseInstructions = normalizedCustomPrompt ? `${normalizedCustomPrompt}\n\n${getHistoryInstructions(formattedHistory)}` : getHistoryInstructions(formattedHistory); - const systemInstructions = this.traitService.appendTraitContext(baseInstructions, traitContext); return this.openAi.responses .create({ @@ -483,7 +474,7 @@ export class AIService { reasoning: { effort: 'low' }, tools: [{ type: 'web_search_preview' }], tool_choice: 'auto', - instructions: systemInstructions, + instructions: baseInstructions, input: prompt, user: `${user_id}-DaBros2016`, }) @@ -565,7 +556,6 @@ export class AIService { const participantSlackIds = extractParticipantSlackIds(historyMessages, { excludeSlackIds: [MOONBEAM_SLACK_ID], }); - const traitContext = await this.traitService.fetchTraitContext(participantSlackIds, teamId, historyMessages); this.aiServiceLogger.info('Built participation context', { teamId, channelId, @@ -575,7 +565,6 @@ export class AIService { }); const baseInstructions = normalizedCustomPrompt ?? MOONBEAM_SYSTEM_INSTRUCTIONS; - const systemInstructions = this.traitService.appendTraitContext(baseInstructions, traitContext); const input = `${history}\n\n---\n[Tagged message to respond to]:\n${taggedMessage}`; @@ -585,7 +574,7 @@ export class AIService { reasoning: { effort: 'low' }, tools: [{ type: 'web_search_preview' }], tool_choice: 'auto', - instructions: systemInstructions, + instructions: baseInstructions, input, user: `participation-${channelId}-${teamId}-DaBros2016`, }) diff --git a/packages/backend/src/ai/memory/memory.job.spec.ts b/packages/backend/src/ai/memory/memory.job.spec.ts deleted file mode 100644 index 676adbb0..00000000 --- a/packages/backend/src/ai/memory/memory.job.spec.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MemoryJob } from './memory.job'; - -type ExtractMemoriesInvoker = { - extractMemories: ( - teamId: string, - channelId: string, - conversationHistory: string, - participantSlackIds: string[], - ) => Promise; -}; - -const invokeExtractMemories = ( - job: MemoryJob, - teamId: string, - channelId: string, - conversationHistory: string, - participantSlackIds: string[], -): Promise => - (job as unknown as ExtractMemoriesInvoker).extractMemories( - teamId, - channelId, - conversationHistory, - participantSlackIds, - ); - -describe('MemoryJob', () => { - let job: MemoryJob; - let memoryPersistenceService: { - getAllMemoriesForUsers: ReturnType; - saveMemories: ReturnType; - reinforceMemory: ReturnType; - deleteMemory: ReturnType; - }; - let redis: { - getValue: ReturnType; - setValueWithExpire: ReturnType; - }; - let traitJob: { - runForUsers: ReturnType; - }; - let jobLogger: { - info: ReturnType; - warn: ReturnType; - }; - let aiService: { - alertOnOpenAiRateLimit: ReturnType; - openAi: { - responses: { - create: ReturnType; - }; - }; - }; - - beforeEach(() => { - job = new MemoryJob({ formatHistory: vi.fn() } as never); - memoryPersistenceService = { - getAllMemoriesForUsers: vi.fn().mockResolvedValue(new Map()), - saveMemories: vi.fn().mockResolvedValue([]), - reinforceMemory: vi.fn().mockResolvedValue(true), - deleteMemory: vi.fn().mockResolvedValue(true), - }; - redis = { - getValue: vi.fn().mockResolvedValue(null), - setValueWithExpire: vi.fn().mockResolvedValue('OK'), - }; - traitJob = { - runForUsers: vi.fn().mockResolvedValue(undefined), - }; - jobLogger = { - info: vi.fn(), - warn: vi.fn(), - }; - aiService = { - alertOnOpenAiRateLimit: vi.fn().mockResolvedValue(undefined), - openAi: { - responses: { - create: vi.fn(), - }, - }, - }; - - (job as never as { memoryPersistenceService: unknown }).memoryPersistenceService = memoryPersistenceService; - (job as never as { redis: unknown }).redis = redis; - (job as never as { traitJob: unknown }).traitJob = traitJob; - (job as never as { jobLogger: unknown }).jobLogger = jobLogger; - (job as never as { aiService: unknown }).aiService = aiService; - }); - - it('returns early when extraction lock exists', async () => { - redis.getValue.mockResolvedValue('1'); - - await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U1']); - - expect(jobLogger.info).toHaveBeenCalled(); - }); - - it('does nothing when extractor returns NONE', async () => { - aiService.openAi.responses.create.mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: 'NONE' }] }], - }); - - await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U1']); - - expect(memoryPersistenceService.saveMemories).not.toHaveBeenCalled(); - }); - - it('processes NEW, REINFORCE, and EVOLVE extraction modes', async () => { - aiService.openAi.responses.create.mockResolvedValue({ - output: [ - { - type: 'message', - content: [ - { - type: 'output_text', - text: JSON.stringify([ - { slackId: 'U123ABC', content: 'new memory', mode: 'NEW' }, - { slackId: 'U123ABC', content: 'reinforce memory', mode: 'REINFORCE', existingMemoryId: 10 }, - { slackId: 'U123ABC', content: 'evolved memory', mode: 'EVOLVE', existingMemoryId: 11 }, - ]), - }, - ], - }, - ], - }); - - await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U123ABC']); - - expect(memoryPersistenceService.saveMemories).toHaveBeenCalled(); - expect(memoryPersistenceService.reinforceMemory).toHaveBeenCalledWith(10); - expect(memoryPersistenceService.deleteMemory).toHaveBeenCalledWith(11); - }); - - it('skips malformed extraction items and logs warnings', async () => { - aiService.openAi.responses.create.mockResolvedValue({ - output: [ - { - type: 'message', - content: [ - { - type: 'output_text', - text: JSON.stringify([ - { mode: 'NEW' }, - { slackId: 'invalid', content: 'x', mode: 'NEW' }, - { slackId: 'U123ABC', content: 'x', mode: 'UNKNOWN' }, - ]), - }, - ], - }, - ], - }); - - await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U123ABC']); - - expect(jobLogger.warn).toHaveBeenCalled(); - }); - - it('alerts on OpenAI 429 errors during extraction', async () => { - const rateLimitError = Object.assign(new Error('Rate limit exceeded'), { - status: 429, - error: { message: 'Too many requests.' }, - }); - aiService.openAi.responses.create.mockRejectedValue(rateLimitError); - - await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U1']); - - expect(aiService.alertOnOpenAiRateLimit).toHaveBeenCalledWith(rateLimitError, 'memory extraction'); - }); -}); diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts deleted file mode 100644 index 1ac41f8e..00000000 --- a/packages/backend/src/ai/memory/memory.job.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { getRepository } from 'typeorm'; -import { SlackChannel } from '../../shared/db/models/SlackChannel'; -import { HistoryPersistenceService } from '../../shared/services/history.persistence.service'; -import { MemoryPersistenceService } from './memory.persistence.service'; -import { RedisPersistenceService } from '../../shared/services/redis.persistence.service'; -import { AIService } from '../ai.service'; -import { logger } from '../../shared/logger/logger'; -import { DAILY_MEMORY_JOB_CONCURRENCY, GATE_MODEL, MEMORY_EXTRACTION_PROMPT } from '../ai.constants'; -import { MOONBEAM_SLACK_ID } from '../ai.constants'; -import type OpenAI from 'openai'; -import { extractParticipantSlackIds } from '../helpers/extractParticipantSlackIds'; - -interface ExtractionResult { - slackId: string; - content: string; - mode: 'NEW' | 'REINFORCE' | 'EVOLVE'; - existingMemoryId: number | null; -} - -const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => { - const textBlock = response.output.find((item) => item.type === 'message'); - if (textBlock && 'content' in textBlock) { - const outputText = textBlock.content.find((item) => item.type === 'output_text'); - return outputText?.text.trim(); - } - return undefined; -}; - -export class MemoryJob { - private historyService = new HistoryPersistenceService(); - private memoryPersistenceService = new MemoryPersistenceService(); - private redis = RedisPersistenceService.getInstance(); - private aiService: AIService; - private jobLogger = logger.child({ module: 'MemoryJob' }); - - constructor(aiService?: AIService) { - this.aiService = aiService ?? new AIService(); - } - - async run(): Promise { - this.jobLogger.info('Starting memory extraction job'); - - const channels = await getRepository(SlackChannel).find(); - - const results = await this.runWithConcurrencyLimit( - channels.map((channel) => () => this.extractMemoriesForChannel(channel.teamId, channel.channelId)), - DAILY_MEMORY_JOB_CONCURRENCY, - ); - - const failed = results - .map((result, index) => ({ result, index })) - .filter((item): item is { result: PromiseRejectedResult; index: number } => item.result.status === 'rejected'); - failed.forEach(({ result, index }) => { - const channel = channels[index]; - this.jobLogger.warn( - `Failed to extract memories for channel ${channel.channelId} (team ${channel.teamId}):`, - result.reason, - ); - }); - - const processed = results.length - failed.length; - this.jobLogger.info(`Memory extraction job complete: processed ${processed}/${channels.length} channels`); - } - - private async extractMemoriesForChannel(teamId: string, channelId: string): Promise { - const historyMessages = await this.historyService.getLast24HoursForChannel(teamId, channelId); - if (historyMessages.length === 0) return; - - const history = this.aiService.formatHistory(historyMessages); - const participantSlackIds = extractParticipantSlackIds(historyMessages, { - excludeSlackIds: [MOONBEAM_SLACK_ID], - }); - if (participantSlackIds.length === 0) return; - - await this.extractMemories(teamId, channelId, history, participantSlackIds); - } - - private async extractMemories( - teamId: string, - channelId: string, - conversationHistory: string, - participantSlackIds: string[], - ): Promise { - const lockKey = `memory_extraction_lock:${teamId}:${channelId}`; - const locked = await this.redis.getValue(lockKey); - if (locked) { - this.jobLogger.info(`Extraction lock active for ${channelId}-${teamId}, skipping`); - return; - } - await this.redis.setValueWithExpire(lockKey, 1, 'EX', 300000); // 5 Minutes - - try { - const existingMemoriesMap = await this.memoryPersistenceService.getAllMemoriesForUsers( - participantSlackIds, - teamId, - ); - const existingMemoriesText = - existingMemoriesMap.size > 0 - ? Array.from(existingMemoriesMap.entries()) - .map(([slackId, memories]) => { - const lines = memories.map((memory) => ` [ID:${memory.id}] "${memory.content}"`).join('\n'); - return `${slackId}:\n${lines}`; - }) - .join('\n\n') - : '(no existing memories)'; - - const prompt = MEMORY_EXTRACTION_PROMPT.replace('{existing_memories}', existingMemoriesText); - const result = await this.aiService.openAi.responses - .create({ - model: GATE_MODEL, - instructions: prompt, - input: conversationHistory, - }) - .catch(async (error) => { - await this.aiService.alertOnOpenAiRateLimit(error, 'memory extraction'); - throw error; - }) - .then((response) => extractAndParseOpenAiResponse(response)); - - if (!result) { - this.jobLogger.warn('Extraction returned no result'); - return; - } - - const trimmed = result.trim(); - if (trimmed === 'NONE' || trimmed === '"NONE"') { - return; - } - - const extractions = this.parseExtractionResults(trimmed); - if (!extractions) { - return; - } - - const touchedUsers = new Set(); - for (const extraction of extractions) { - const wasTouched = await this.applyExtraction(teamId, extraction); - if (wasTouched && extraction.slackId) { - touchedUsers.add(extraction.slackId); - } - } - - this.jobLogger.info(`Extraction complete for ${channelId}: ${extractions.length} observations processed`); - } catch (error) { - this.jobLogger.warn('Memory extraction failed:', error); - } - } - - private parseExtractionResults(trimmedResult: string): Array> | null { - try { - const parsed: Array> = JSON.parse(trimmedResult); - if (Array.isArray(parsed)) { - return parsed; - } - this.jobLogger.warn(`Extraction returned JSON but it was not an array: ${trimmedResult}`); - return null; - } catch { - this.jobLogger.warn(`Extraction returned malformed JSON: ${trimmedResult}`); - return null; - } - } - - private async applyExtraction(teamId: string, extraction: Partial): Promise { - if (!extraction.slackId || !extraction.content || !extraction.mode) { - this.jobLogger.warn('Extraction missing required fields, skipping:', extraction); - return false; - } - - if (!/^U[A-Z0-9]+$/.test(extraction.slackId)) { - this.jobLogger.warn(`Invalid slackId format: ${extraction.slackId}`); - return false; - } - - switch (extraction.mode) { - case 'NEW': - await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]); - return true; - case 'REINFORCE': - if (!extraction.existingMemoryId) { - this.jobLogger.warn('REINFORCE extraction missing existingMemoryId, skipping'); - return false; - } - await this.memoryPersistenceService.reinforceMemory(extraction.existingMemoryId); - return true; - case 'EVOLVE': - if (extraction.existingMemoryId) { - await this.memoryPersistenceService.deleteMemory(extraction.existingMemoryId); - } - await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]); - return true; - default: - this.jobLogger.warn(`Unknown extraction mode: ${String(extraction.mode)}`); - return false; - } - } - - private async runWithConcurrencyLimit( - tasks: Array<() => Promise>, - concurrency: number, - ): Promise[]> { - const results: PromiseSettledResult[] = new Array(tasks.length); - let nextIndex = 0; - - const runNext = async (): Promise => { - while (nextIndex < tasks.length) { - const index = nextIndex++; - try { - results[index] = { status: 'fulfilled', value: await tasks[index]() }; - } catch (reason) { - results[index] = { status: 'rejected', reason }; - } - } - }; - - await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => runNext())); - return results; - } -} diff --git a/packages/backend/src/ai/memory/memory.persistence.service.spec.ts b/packages/backend/src/ai/memory/memory.persistence.service.spec.ts deleted file mode 100644 index 86ba8a2a..00000000 --- a/packages/backend/src/ai/memory/memory.persistence.service.spec.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { vi } from 'vitest'; -import { MemoryPersistenceService } from './memory.persistence.service'; -import { Memory } from '../../shared/db/models/Memory'; -import { SlackUser } from '../../shared/db/models/SlackUser'; -import { getRepository } from 'typeorm'; - -vi.mock('typeorm', async () => ({ - getRepository: vi.fn(), - Entity: () => vi.fn(), - Column: () => vi.fn(), - PrimaryGeneratedColumn: () => vi.fn(), - ManyToOne: () => vi.fn(), - OneToMany: () => vi.fn(), - OneToOne: () => vi.fn(), - Unique: () => vi.fn(), - JoinColumn: () => vi.fn(), -})); - -vi.mock('../../shared/logger/logger', async () => ({ - logger: { - child: vi.fn().mockReturnValue({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - }, -})); - -describe('MemoryPersistenceService', () => { - let service: MemoryPersistenceService; - let mockSlackUserRepo: Record; - let mockMemoryRepo: Record; - - const mockUser: Partial = { - id: 1, - slackId: 'U123', - teamId: 'T456', - name: 'testuser', - isBot: false, - botId: '', - }; - - const mockMemory: Partial = { - id: 1, - userId: mockUser as SlackUser, - teamId: 'T456', - content: 'loves TypeScript', - createdAt: new Date(), - updatedAt: new Date(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - service = new MemoryPersistenceService(); - - mockSlackUserRepo = { - findOne: vi.fn(), - }; - - mockMemoryRepo = { - query: vi.fn(), - save: vi.fn(), - delete: vi.fn(), - }; - - (getRepository as Mock).mockImplementation((entity) => { - if (entity === SlackUser) return mockSlackUserRepo; - if (entity === Memory) return mockMemoryRepo; - return {}; - }); - }); - - describe('saveMemories', () => { - it('should save a single memory when given one item', async () => { - mockSlackUserRepo.findOne.mockResolvedValue(mockUser); - mockMemoryRepo.save.mockResolvedValue([mockMemory]); - - const result = await service.saveMemories('U123', 'T456', ['loves TypeScript']); - - expect(mockMemoryRepo.save).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ content: 'loves TypeScript' })]), - ); - expect(result).toHaveLength(1); - }); - - it('should save multiple memories for a valid user', async () => { - mockSlackUserRepo.findOne.mockResolvedValue(mockUser); - mockMemoryRepo.save.mockResolvedValue([mockMemory, { ...mockMemory, id: 2, content: 'hates CSS' }]); - - const result = await service.saveMemories('U123', 'T456', ['loves TypeScript', 'hates CSS']); - - expect(mockMemoryRepo.save).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ content: 'loves TypeScript' }), - expect.objectContaining({ content: 'hates CSS' }), - ]), - ); - expect(result).toHaveLength(2); - }); - - it('should return empty array if user not found', async () => { - mockSlackUserRepo.findOne.mockResolvedValue(null); - - const result = await service.saveMemories('UNKNOWN', 'T456', ['fact1']); - - expect(result).toEqual([]); - }); - - it('should return empty array on save error', async () => { - mockSlackUserRepo.findOne.mockResolvedValue(mockUser); - mockMemoryRepo.save.mockRejectedValue(new Error('DB error')); - - const result = await service.saveMemories('U123', 'T456', ['fact1']); - - expect(result).toEqual([]); - }); - }); - - describe('getAllMemoriesForUser', () => { - it('should return all memories without LIMIT', async () => { - mockMemoryRepo.query.mockResolvedValue([mockMemory]); - - const result = await service.getAllMemoriesForUser('U123', 'T456'); - - expect(mockMemoryRepo.query).toHaveBeenCalledWith(expect.not.stringContaining('LIMIT'), ['U123', 'T456']); - expect(result).toEqual([mockMemory]); - }); - - it('should return empty array on error', async () => { - mockMemoryRepo.query.mockRejectedValue(new Error('DB error')); - - const result = await service.getAllMemoriesForUser('U123', 'T456'); - - expect(result).toEqual([]); - }); - }); - - describe('getAllMemoriesForUsers', () => { - it('should return all memories grouped by slackId without LIMIT', async () => { - const memoryU123 = [mockMemory, { ...mockMemory, id: 2, content: 'hates CSS' }]; - const memoryU789 = [{ ...mockMemory, id: 3, content: 'Go expert' }]; - - mockMemoryRepo.query.mockResolvedValueOnce(memoryU123).mockResolvedValueOnce(memoryU789); - - const result = await service.getAllMemoriesForUsers(['U123', 'U789'], 'T456'); - - expect(result.get('U123')?.length).toBe(2); - expect(result.get('U789')?.length).toBe(1); - }); - }); - - describe('reinforceMemory', () => { - it('should update the timestamp and return true', async () => { - mockMemoryRepo.query.mockResolvedValue(undefined); - - const result = await service.reinforceMemory(1); - - expect(mockMemoryRepo.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE memory SET updatedAt'), [1]); - expect(result).toBe(true); - }); - - it('should return false on error', async () => { - mockMemoryRepo.query.mockRejectedValue(new Error('DB error')); - - const result = await service.reinforceMemory(1); - - expect(result).toBe(false); - }); - }); - - describe('deleteMemory', () => { - it('should return true when memory is deleted', async () => { - mockMemoryRepo.delete.mockResolvedValue({ affected: 1 }); - - const result = await service.deleteMemory(1); - - expect(result).toBe(true); - }); - - it('should return false when memory not found', async () => { - mockMemoryRepo.delete.mockResolvedValue({ affected: 0 }); - - const result = await service.deleteMemory(999); - - expect(result).toBe(false); - }); - - it('should return false on delete error', async () => { - mockMemoryRepo.delete.mockRejectedValue(new Error('DB error')); - - const result = await service.deleteMemory(1); - - expect(result).toBe(false); - }); - }); -}); diff --git a/packages/backend/src/ai/memory/memory.persistence.service.ts b/packages/backend/src/ai/memory/memory.persistence.service.ts deleted file mode 100644 index d5e36bdc..00000000 --- a/packages/backend/src/ai/memory/memory.persistence.service.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { getRepository } from 'typeorm'; -import type { MemoryWithSlackId } from '../../shared/db/models/Memory'; -import { Memory } from '../../shared/db/models/Memory'; -import { SlackUser } from '../../shared/db/models/SlackUser'; -import { logError } from '../../shared/logger/error-logging'; -import { logger } from '../../shared/logger/logger'; - -export class MemoryPersistenceService { - private logger = logger.child({ module: 'MemoryPersistenceService' }); - - async saveMemories(slackId: string, teamId: string, contents: string[]): Promise { - const user = await getRepository(SlackUser).findOne({ where: { slackId, teamId } }); - if (!user) { - this.logger.warn(`Cannot save memories: user ${slackId} not found in team ${teamId}`); - return []; - } - - const memories = contents.map((content) => { - const memory = new Memory(); - memory.userId = user; - memory.teamId = teamId; - memory.content = content; - return memory; - }); - - return getRepository(Memory) - .save(memories) - .catch((e) => { - logError(this.logger, 'Error saving memories', e, { - slackId, - teamId, - memoryCount: contents.length, - }); - return []; - }); - } - - async getAllMemoriesForUser(slackId: string, teamId: string): Promise { - return getRepository(Memory) - .query( - `SELECT m.*, u.slackId FROM memory m - INNER JOIN slack_user u ON m.userIdId = u.id - WHERE u.slackId = ? AND u.teamId = ? - ORDER BY m.updatedAt DESC`, - [slackId, teamId], - ) - .catch((e) => { - logError(this.logger, 'Error fetching all memories for user', e, { - slackId, - teamId, - }); - return []; - }); - } - - async getAllMemoriesForUsers(slackIds: string[], teamId: string): Promise> { - const result = new Map(); - - const queries = slackIds.map(async (slackId) => { - const memories = await this.getAllMemoriesForUser(slackId, teamId); - if (memories.length) { - result.set(slackId, memories); - } - }); - - await Promise.all(queries); - return result; - } - - async reinforceMemory(memoryId: number): Promise { - return getRepository(Memory) - .query('UPDATE memory SET updatedAt = CURRENT_TIMESTAMP WHERE id = ?', [memoryId]) - .then(() => true) - .catch((e) => { - logError(this.logger, 'Error reinforcing memory', e, { memoryId }); - return false; - }); - } - - async deleteMemory(memoryId: number): Promise { - return getRepository(Memory) - .delete({ id: memoryId }) - .then((result) => (result.affected ?? 0) > 0) - .catch((e) => { - logError(this.logger, 'Error deleting memory', e, { memoryId }); - return false; - }); - } -} diff --git a/packages/backend/src/dashboard/dashboard.controller.ts b/packages/backend/src/dashboard/dashboard.controller.ts index a3ded677..88b75844 100644 --- a/packages/backend/src/dashboard/dashboard.controller.ts +++ b/packages/backend/src/dashboard/dashboard.controller.ts @@ -6,14 +6,10 @@ import { logger } from '../shared/logger/logger'; import type { RequestWithAuthSession } from '../shared/models/express/RequestWithAuthSession'; import { DEFAULT_PERIOD, VALID_PERIODS } from './dashboard.const'; import type { TimePeriod } from './dashboard.model'; -import { MemoryPersistenceService } from '../ai/memory/memory.persistence.service'; -import { TraitPersistenceService } from '../trait/trait.persistence.service'; export const dashboardController: Router = express.Router(); const dashboardPersistenceService = new DashboardPersistenceService(); -const memoryPersistenceService = new MemoryPersistenceService(); -const traitPersistenceService = new TraitPersistenceService(); const dashboardLogger = logger.child({ module: 'DashboardController' }); function parsePeriod(value: unknown): TimePeriod { @@ -41,35 +37,3 @@ dashboardController.get('/', (req: RequestWithAuthSession, res) => { res.status(500).send(); }); }); - -dashboardController.get('/personal-context', (req: RequestWithAuthSession, res) => { - const { teamId, userId } = req.authSession || {}; - - if (!teamId || !userId) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } - - Promise.all([ - memoryPersistenceService.getAllMemoriesForUser(userId, teamId), - traitPersistenceService.getAllTraitsForUser(userId, teamId), - ]) - .then(([memories, traits]) => { - res.status(200).json({ - memories: memories.map((memory) => ({ - id: memory.id, - content: memory.content, - updatedAt: new Date(memory.updatedAt).toISOString(), - })), - traits: traits.map((trait) => ({ - id: trait.id, - content: trait.content, - updatedAt: new Date(trait.updatedAt).toISOString(), - })), - }); - }) - .catch((e: unknown) => { - logError(dashboardLogger, 'Failed to load personal context data', e, { userId, teamId }); - res.status(500).send(); - }); -}); diff --git a/packages/backend/src/dashboard/dashboard.model.ts b/packages/backend/src/dashboard/dashboard.model.ts index 7721f2c5..b0c5bda7 100644 --- a/packages/backend/src/dashboard/dashboard.model.ts +++ b/packages/backend/src/dashboard/dashboard.model.ts @@ -39,14 +39,3 @@ export interface DashboardResponse { leaderboard: LeaderboardEntry[]; repLeaderboard: RepLeaderboardEntry[]; } - -export interface PersonalContextEntry { - id: number; - content: string; - updatedAt: string; -} - -export interface PersonalContextResponse { - memories: PersonalContextEntry[]; - traits: PersonalContextEntry[]; -} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 46195b63..77683398 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -23,7 +23,6 @@ import { reactionController } from './reaction/reaction.controller'; import { storeController } from './store/store.controller'; import { summaryController } from './summary/summary.controller'; import { walkieController } from './walkie/walkie.controller'; -import { memoryController } from './memory/memory.controller'; import { SlackService } from './shared/services/slack/slack.service'; import { signatureVerificationMiddleware } from './shared/middleware/signatureVerification'; import { WebService } from './shared/services/web/web.service'; @@ -37,7 +36,6 @@ import { searchController } from './search/search.controller'; import { authController } from './auth/auth.controller'; import { authMiddleware } from './shared/middleware/authMiddleware'; import { dashboardController } from './dashboard/dashboard.controller'; -import { traitController } from './trait/trait.controller'; import { calendarController } from './calendar/calendar.controller'; const app: Application = express(); @@ -109,7 +107,6 @@ app.use('/define', defineController); app.use('/event', eventController); app.use('/hook', hookController); app.use('/list', listController); -app.use('/memory', memoryController); app.use('/mock', mockController); app.use('/muzzle', muzzleController); app.use('/portfolio', portfolioController); @@ -117,7 +114,6 @@ app.use('/quote', quoteController); app.use('/rep', reactionController); app.use('/store', storeController); app.use('/summary', summaryController); -app.use('/traits', traitController); app.use('/walkie', walkieController); const slackService = new SlackService(); diff --git a/packages/backend/src/job.service.spec.ts b/packages/backend/src/job.service.spec.ts index 6c201f12..6c2c5744 100644 --- a/packages/backend/src/job.service.spec.ts +++ b/packages/backend/src/job.service.spec.ts @@ -1,16 +1,12 @@ import { vi } from 'vitest'; import { loggerMock } from './test/mocks/logger.mock'; -const { memoryRunMock, traitRunMock, funFactRunMock, pricingRunMock, eventAlertRunMock, scheduleMock } = vi.hoisted( - () => ({ - memoryRunMock: vi.fn(), - traitRunMock: vi.fn(), - funFactRunMock: vi.fn(), - pricingRunMock: vi.fn(), - eventAlertRunMock: vi.fn(), - scheduleMock: vi.fn(), - }), -); +const { funFactRunMock, pricingRunMock, eventAlertRunMock, scheduleMock } = vi.hoisted(() => ({ + funFactRunMock: vi.fn(), + pricingRunMock: vi.fn(), + eventAlertRunMock: vi.fn(), + scheduleMock: vi.fn(), +})); vi.mock('node-cron', async () => ({ default: { @@ -18,18 +14,6 @@ vi.mock('node-cron', async () => ({ }, })); -vi.mock('./ai/memory/memory.job', async () => ({ - MemoryJob: classMock(() => ({ - run: memoryRunMock, - })), -})); - -vi.mock('./trait/trait.job', async () => ({ - TraitJob: classMock(() => ({ - run: traitRunMock, - })), -})); - vi.mock('./jobs/fun-fact.job', async () => ({ FunFactJob: classMock(() => ({ run: funFactRunMock, @@ -53,41 +37,18 @@ import { JobService } from './job.service'; describe('JobService', () => { beforeEach(() => { vi.clearAllMocks(); - memoryRunMock.mockResolvedValue(undefined); - traitRunMock.mockResolvedValue(undefined); funFactRunMock.mockResolvedValue(undefined); pricingRunMock.mockResolvedValue(undefined); eventAlertRunMock.mockResolvedValue(undefined); }); - it('runs memory and trait jobs sequentially', async () => { - const service = new JobService(); - - await service.runMemoryAndTraitJobs(); - - expect(memoryRunMock).toHaveBeenCalledOnce(); - expect(traitRunMock).toHaveBeenCalledOnce(); - }); - - it('throws when memory and trait sequence fails', async () => { - const service = new JobService(); - memoryRunMock.mockRejectedValueOnce(new Error('memory-fail')); - - await expect(service.runMemoryAndTraitJobs()).rejects.toThrow('memory-fail'); - expect(traitRunMock).not.toHaveBeenCalled(); - }); - it('runs each isolated job and bubbles errors', async () => { const service = new JobService(); - await service.runMemoryJob(); - await service.runTraitJob(); await service.runFunFactJob(); await service.runPricingJob(); await service.runEventAlertJob(); - expect(memoryRunMock).toHaveBeenCalled(); - expect(traitRunMock).toHaveBeenCalled(); expect(funFactRunMock).toHaveBeenCalled(); expect(pricingRunMock).toHaveBeenCalled(); expect(eventAlertRunMock).toHaveBeenCalled(); @@ -101,36 +62,31 @@ describe('JobService', () => { service.scheduleCronJobs(); - expect(scheduleMock).toHaveBeenCalledTimes(4); - expect(scheduleMock).toHaveBeenNthCalledWith(1, '0 3 * * *', expect.any(Function), { - timezone: 'America/New_York', - }); - expect(scheduleMock).toHaveBeenNthCalledWith(2, '0 9 * * *', expect.any(Function), { + expect(scheduleMock).toHaveBeenCalledTimes(3); + expect(scheduleMock).toHaveBeenNthCalledWith(1, '0 9 * * *', expect.any(Function), { timezone: 'America/New_York', }); - expect(scheduleMock).toHaveBeenNthCalledWith(3, '10 * * * *', expect.any(Function), { + expect(scheduleMock).toHaveBeenNthCalledWith(2, '10 * * * *', expect.any(Function), { timezone: 'America/New_York', }); - expect(scheduleMock).toHaveBeenNthCalledWith(4, '5 * * * *', expect.any(Function), { + expect(scheduleMock).toHaveBeenNthCalledWith(3, '5 * * * *', expect.any(Function), { timezone: 'America/New_York', }); }); it('logs errors from scheduled callbacks instead of throwing', async () => { const service = new JobService(); - const runMemoryAndTraitJobsSpy = vi - .spyOn(service, 'runMemoryAndTraitJobs') - .mockRejectedValueOnce(new Error('scheduled-failure')); + const runFunFactJobSpy = vi.spyOn(service, 'runFunFactJob').mockRejectedValueOnce(new Error('scheduled-failure')); service.scheduleCronJobs(); - const memoryCallback = scheduleMock.mock.calls[0]?.[1] as (() => void) | undefined; - expect(memoryCallback).toBeDefined(); + const funFactCallback = scheduleMock.mock.calls[0]?.[1] as (() => void) | undefined; + expect(funFactCallback).toBeDefined(); - memoryCallback?.(); + funFactCallback?.(); await Promise.resolve(); - expect(runMemoryAndTraitJobsSpy).toHaveBeenCalledOnce(); - expect(loggerMock.error).toHaveBeenCalledWith('Memory and trait job sequence failed:', expect.any(Error)); + expect(runFunFactJobSpy).toHaveBeenCalledOnce(); + expect(loggerMock.error).toHaveBeenCalledWith('Fun-fact job failed:', expect.any(Error)); }); }); diff --git a/packages/backend/src/job.service.ts b/packages/backend/src/job.service.ts index a80a8562..0ad6bd3a 100644 --- a/packages/backend/src/job.service.ts +++ b/packages/backend/src/job.service.ts @@ -1,52 +1,21 @@ import cron from 'node-cron'; -import { MemoryJob } from './ai/memory/memory.job'; import { FunFactJob } from './jobs/fun-fact.job'; import { PricingJob } from './jobs/pricing.job'; import { EventAlertJob } from './jobs/event-alert.job'; import { logger } from './shared/logger/logger'; -import { TraitJob } from './trait/trait.job'; export class JobService { - private memoryJob: MemoryJob; - private traitJob: TraitJob; private funFactJob: FunFactJob; private pricingJob: PricingJob; private eventAlertJob: EventAlertJob; private jobServiceLogger = logger.child({ module: 'JobService' }); constructor() { - this.memoryJob = new MemoryJob(); - this.traitJob = new TraitJob(); this.funFactJob = new FunFactJob(); this.pricingJob = new PricingJob(); this.eventAlertJob = new EventAlertJob(); } - /** - * Run the memory and trait jobs in sequence. - * Memory job runs first, then trait job runs only if memory job succeeds. - */ - async runMemoryAndTraitJobs(): Promise { - this.jobServiceLogger.info('Starting memory and trait job sequence'); - - try { - // Run memory job first - this.jobServiceLogger.info('Running memory job...'); - await this.memoryJob.run(); - this.jobServiceLogger.info('Memory job succeeded, proceeding with trait job'); - - // Run trait job only if memory job succeeds - this.jobServiceLogger.info('Running trait job...'); - await this.traitJob.run(); - this.jobServiceLogger.info('Trait job succeeded'); - - this.jobServiceLogger.info('Memory and trait job sequence completed successfully'); - } catch (error) { - this.jobServiceLogger.error('Memory and trait job sequence failed:', error); - throw error; - } - } - /** * Run the fun fact job */ @@ -89,55 +58,14 @@ export class JobService { } } - /** - * Run the memory job in isolation - */ - async runMemoryJob(): Promise { - this.jobServiceLogger.info('Running memory job in isolation'); - try { - await this.memoryJob.run(); - this.jobServiceLogger.info('Memory job completed successfully'); - } catch (error) { - this.jobServiceLogger.error('Memory job failed:', error); - throw error; - } - } - - /** - * Run the trait job in isolation - */ - async runTraitJob(): Promise { - this.jobServiceLogger.info('Running trait job in isolation'); - try { - await this.traitJob.run(); - this.jobServiceLogger.info('Trait job completed successfully'); - } catch (error) { - this.jobServiceLogger.error('Trait job failed:', error); - throw error; - } - } - /** * Schedule all cron jobs on startup. - * Memory and trait jobs run daily at 3AM. * Fun fact job runs daily at 9AM. * Pricing job runs every hour at minute 10. */ scheduleCronJobs(): void { this.jobServiceLogger.info('Scheduling cron jobs'); - // Memory and trait jobs: daily at 3AM America/New_York - cron.schedule( - '0 3 * * *', - () => { - this.runMemoryAndTraitJobs().catch((error) => { - this.jobServiceLogger.error('Memory and trait job sequence failed:', error); - }); - }, - { timezone: 'America/New_York' }, - ); - this.jobServiceLogger.info('Memory and trait job sequence scheduled daily at 3AM America/New_York time.'); - // Fun fact job: daily at 9AM America/New_York cron.schedule( '0 9 * * *', diff --git a/packages/backend/src/memory/memory.controller.spec.ts b/packages/backend/src/memory/memory.controller.spec.ts deleted file mode 100644 index 5769f4aa..00000000 --- a/packages/backend/src/memory/memory.controller.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { vi } from 'vitest'; -const mockGetAllMemoriesForUser = vi.fn(); -const mockSendEphemeral = vi.fn(); - -vi.mock('../ai/memory/memory.persistence.service', async () => ({ - MemoryPersistenceService: classMock(() => ({ - getAllMemoriesForUser: mockGetAllMemoriesForUser, - })), -})); - -vi.mock('../shared/services/web/web.service', async () => ({ - WebService: classMock(() => ({ - sendEphemeral: mockSendEphemeral, - })), -})); - -vi.mock('../shared/middleware/suppression', async () => ({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - suppressedMiddleware: (_req: any, _res: any, next: any) => next(), -})); - -vi.mock('../shared/logger/logger', async () => ({ - logger: { child: () => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() }) }, -})); - -import http from 'http'; -import type { AddressInfo } from 'net'; -import express from 'express'; -import { memoryController } from './memory.controller'; - -let server: http.Server; -let port: number; - -beforeAll(async () => { - const app = express(); - app.use(express.json()); - app.use(express.urlencoded({ extended: true })); - app.use('/memory', memoryController); - - await new Promise((resolve) => { - server = app.listen(0, () => { - port = (server.address() as AddressInfo).port; - resolve(); - }); - }); -}); - -afterAll(async () => { - await new Promise((resolve, reject) => { - server.close((err) => { - if (err !== undefined) { - reject(err); - return; - } - - resolve(); - }); - }); -}); - -function postMemory(body: Record): Promise { - return new Promise((resolve, reject) => { - const data = JSON.stringify(body); - const req = http.request( - { - hostname: '127.0.0.1', - port, - path: '/memory', - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, - }, - (res) => { - res.resume(); - resolve(res.statusCode!); - }, - ); - req.on('error', reject); - req.write(data); - req.end(); - }); -} - -describe('MemoryController', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should respond 200 and send ephemeral with formatted memories', async () => { - const memories = [ - { content: 'loves TypeScript', updatedAt: new Date('2026-02-15') }, - { content: 'works at Acme', updatedAt: new Date('2026-03-01') }, - ]; - mockGetAllMemoriesForUser.mockResolvedValue(memories); - - const status = await postMemory({ user_id: 'U001', team_id: 'T001', channel_id: 'C001' }); - - expect(status).toBe(200); - - // Wait for async work after res.send() - await new Promise((r) => setTimeout(r, 100)); - - expect(mockGetAllMemoriesForUser).toHaveBeenCalledWith('U001', 'T001'); - expect(mockSendEphemeral).toHaveBeenCalledWith('C001', expect.stringContaining('loves TypeScript'), 'U001'); - expect(mockSendEphemeral).toHaveBeenCalledWith('C001', expect.stringContaining('works at Acme'), 'U001'); - }); - - it('should send "no memories" ephemeral when user has none', async () => { - mockGetAllMemoriesForUser.mockResolvedValue([]); - - const status = await postMemory({ user_id: 'U002', team_id: 'T001', channel_id: 'C001' }); - await new Promise((r) => setTimeout(r, 100)); - - expect(status).toBe(200); - expect(mockSendEphemeral).toHaveBeenCalledWith('C001', "Moonbeam doesn't remember anything about you yet.", 'U002'); - }); - - it('should send error ephemeral when persistence throws', async () => { - mockGetAllMemoriesForUser.mockRejectedValue(new Error('DB down')); - - const status = await postMemory({ user_id: 'U003', team_id: 'T001', channel_id: 'C001' }); - await new Promise((r) => setTimeout(r, 100)); - - expect(status).toBe(200); - expect(mockSendEphemeral).toHaveBeenCalledWith( - 'C001', - 'Sorry, something went wrong fetching your memories.', - 'U003', - ); - }); -}); diff --git a/packages/backend/src/memory/memory.controller.ts b/packages/backend/src/memory/memory.controller.ts deleted file mode 100644 index 650e39c1..00000000 --- a/packages/backend/src/memory/memory.controller.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Router } from 'express'; -import express from 'express'; -import { MemoryPersistenceService } from '../ai/memory/memory.persistence.service'; -import { WebService } from '../shared/services/web/web.service'; -import { suppressedMiddleware } from '../shared/middleware/suppression'; -import { logError } from '../shared/logger/error-logging'; -import { logger } from '../shared/logger/logger'; - -export const memoryController: Router = express.Router(); -memoryController.use(suppressedMiddleware); - -const memoryPersistenceService = new MemoryPersistenceService(); -const webService = new WebService(); -const memoryLogger = logger.child({ module: 'MemoryController' }); - -memoryController.post('/', (req, res) => { - const { user_id, team_id, channel_id } = req.body; - - // Respond immediately — Slack requires a response within 3 seconds - res.status(200).send(''); - - void (async () => { - try { - const memories = await memoryPersistenceService.getAllMemoriesForUser(user_id, team_id); - - if (memories.length === 0) { - void webService.sendEphemeral(channel_id, "Moonbeam doesn't remember anything about you yet.", user_id); - return; - } - - const formattedMemories = memories - .map((memory, index) => { - const date = new Date(memory.updatedAt).toLocaleDateString('en-US', { - month: 'short', - year: 'numeric', - }); - return `${index + 1}. "${memory.content}" (${date.toLowerCase()})`; - }) - .join('\n'); - - const message = `What Moonbeam remembers about you:\n${formattedMemories}`; - void webService.sendEphemeral(channel_id, message, user_id); - } catch (e) { - logError(memoryLogger, 'Failed to fetch memories for /memory command', e, { - userId: user_id, - teamId: team_id, - channelId: channel_id, - }); - void webService.sendEphemeral(channel_id, 'Sorry, something went wrong fetching your memories.', user_id); - } - })(); -}); diff --git a/packages/backend/src/shared/db/models/Memory.ts b/packages/backend/src/shared/db/models/Memory.ts deleted file mode 100644 index 8b66b94b..00000000 --- a/packages/backend/src/shared/db/models/Memory.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; -import { SlackUser } from './SlackUser'; - -@Entity() -export class Memory { - @PrimaryGeneratedColumn() - public id!: number; - - @ManyToOne(() => SlackUser, (user) => user.memories) - public userId!: SlackUser; - - @Column({ default: 'NOT_AVAILABLE' }) - public teamId!: string; - - @Column('text') - public content!: string; - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - public createdAt!: Date; - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' }) - public updatedAt!: Date; -} - -/** Raw SQL result shape when JOINing memory with slack_user (includes slackId from the JOIN). */ -export interface MemoryWithSlackId extends Memory { - slackId: string; -} diff --git a/packages/backend/src/shared/db/models/SlackUser.ts b/packages/backend/src/shared/db/models/SlackUser.ts index e23ad367..1d816494 100644 --- a/packages/backend/src/shared/db/models/SlackUser.ts +++ b/packages/backend/src/shared/db/models/SlackUser.ts @@ -1,9 +1,7 @@ import { Column, Entity, PrimaryGeneratedColumn, Unique, OneToMany, OneToOne } from 'typeorm'; import { Activity } from './Activity'; -import { Memory } from './Memory'; import { Message } from './Message'; import { Portfolio } from './Portfolio'; -import { Trait } from './Trait'; import { CalendarEvent } from './CalendarEvent'; @Entity() @@ -30,12 +28,6 @@ export class SlackUser { @OneToMany(() => Activity, (activity) => activity.userId) public activity?: Activity[]; - @OneToMany(() => Memory, (memory) => memory.userId) - public memories?: Memory[]; - - @OneToMany(() => Trait, (trait) => trait.userId) - public traits?: Trait[]; - @OneToMany(() => Message, (message) => message.userId) public messages?: Message[]; diff --git a/packages/backend/src/shared/db/models/Trait.ts b/packages/backend/src/shared/db/models/Trait.ts deleted file mode 100644 index 2636c269..00000000 --- a/packages/backend/src/shared/db/models/Trait.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; -import { SlackUser } from './SlackUser'; - -@Entity() -export class Trait { - @PrimaryGeneratedColumn() - public id!: number; - - @ManyToOne(() => SlackUser, (user) => user.traits) - public userId!: SlackUser; - - @Column({ default: 'NOT_AVAILABLE' }) - public teamId!: string; - - @Column('text') - public content!: string; - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - public createdAt!: Date; - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' }) - public updatedAt!: Date; -} - -/** Raw SQL result shape when JOINing trait with slack_user (includes slackId from the JOIN). */ -export interface TraitWithSlackId extends Trait { - slackId: string; -} diff --git a/packages/backend/src/trait/trait.controller.spec.ts b/packages/backend/src/trait/trait.controller.spec.ts deleted file mode 100644 index a0c2617c..00000000 --- a/packages/backend/src/trait/trait.controller.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { vi } from 'vitest'; -import express from 'express'; -import request from 'supertest'; - -const { sendTraitsForUser } = vi.hoisted(() => ({ - sendTraitsForUser: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('./trait.service', async () => ({ - TraitService: classMock(() => ({ - sendTraitsForUser, - })), -})); - -vi.mock('../shared/middleware/suppression', async () => ({ - suppressedMiddleware: (_req: unknown, _res: unknown, next: () => void) => next(), -})); - -import { traitController } from './trait.controller'; - -describe('traitController', () => { - const app = express(); - app.use(express.json()); - app.use('/', traitController); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('handles /traits and responds immediately', async () => { - await request(app).post('/').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200, ''); - - expect(sendTraitsForUser).toHaveBeenCalledWith('U1', 'T1', 'C1'); - }); -}); diff --git a/packages/backend/src/trait/trait.controller.ts b/packages/backend/src/trait/trait.controller.ts deleted file mode 100644 index b455e0bb..00000000 --- a/packages/backend/src/trait/trait.controller.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Router } from 'express'; -import express from 'express'; -import { suppressedMiddleware } from '../shared/middleware/suppression'; -import { TraitService } from './trait.service'; - -export const traitController: Router = express.Router(); - -const traitService = new TraitService(); - -traitController.use(suppressedMiddleware); - -traitController.post('/', (req, res) => { - const { user_id, team_id, channel_id } = req.body; - - // Respond immediately — Slack requires a response within 3 seconds - res.status(200).send(''); - - void traitService.sendTraitsForUser(user_id, team_id, channel_id); -}); diff --git a/packages/backend/src/trait/trait.job.spec.ts b/packages/backend/src/trait/trait.job.spec.ts deleted file mode 100644 index 8650a8aa..00000000 --- a/packages/backend/src/trait/trait.job.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { TraitJob } from './trait.job'; - -describe('TraitJob', () => { - let job: TraitJob; - let traitPersistenceService: { - replaceTraitsForUser: ReturnType; - }; - let memoryPersistenceService: { - getAllMemoriesForUser: ReturnType; - }; - let aiService: { - openAi: { - responses: { - create: ReturnType; - }; - }; - }; - let jobLogger: { - info: ReturnType; - warn: ReturnType; - error: ReturnType; - }; - - beforeEach(() => { - job = new TraitJob({} as never); - traitPersistenceService = { - replaceTraitsForUser: vi.fn().mockResolvedValue([]), - }; - memoryPersistenceService = { - getAllMemoriesForUser: vi.fn().mockResolvedValue([]), - }; - aiService = { - openAi: { - responses: { - create: vi.fn(), - }, - }, - }; - jobLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - - (job as never as { traitPersistenceService: unknown }).traitPersistenceService = traitPersistenceService; - (job as never as { memoryPersistenceService: unknown }).memoryPersistenceService = memoryPersistenceService; - (job as never as { aiService: unknown }).aiService = aiService; - (job as never as { jobLogger: unknown }).jobLogger = jobLogger; - }); - - it('parses, de-duplicates, and caps extracted traits', () => { - const traits = ( - job as never as { parseTraitExtractionResult: (raw: string) => string[] } - ).parseTraitExtractionResult( - JSON.stringify([...Array.from({ length: 12 }, (_, index) => `trait-${index}`), 'trait-1']), - ); - - expect(traits).toHaveLength(10); - expect(new Set(traits).size).toBe(10); - }); - - it('returns empty traits for malformed extraction payload', () => { - const traits = ( - job as never as { parseTraitExtractionResult: (raw: string) => string[] } - ).parseTraitExtractionResult('{bad'); - - expect(traits).toEqual([]); - expect(jobLogger.warn).toHaveBeenCalled(); - }); - - it('regenerates traits for users from memories', async () => { - memoryPersistenceService.getAllMemoriesForUser - .mockResolvedValueOnce([{ content: 'JR-15 loves TypeScript' }]) - .mockResolvedValueOnce([]); - - const synthesizeTraits = vi.fn().mockResolvedValue(JSON.stringify(['JR-15 prefers TypeScript'])); - - await ( - job as never as { - regenerateTraitsForUsers: ( - teamId: string, - slackIds: string[], - synthesizeTraits: (input: string) => Promise, - ) => Promise; - } - ).regenerateTraitsForUsers('T1', ['U1', 'U2'], synthesizeTraits); - - expect(traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U1', 'T1', ['JR-15 prefers TypeScript']); - expect(traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U2', 'T1', []); - }); -}); diff --git a/packages/backend/src/trait/trait.job.ts b/packages/backend/src/trait/trait.job.ts deleted file mode 100644 index 3ab82a88..00000000 --- a/packages/backend/src/trait/trait.job.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { getRepository } from 'typeorm'; -import { TraitPersistenceService } from './trait.persistence.service'; -import { GATE_MODEL, TRAIT_EXTRACTION_PROMPT } from '../ai/ai.constants'; -import { AIService } from '../ai/ai.service'; -import { MemoryPersistenceService } from '../ai/memory/memory.persistence.service'; -import { SlackUser } from '../shared/db/models/SlackUser'; -import { logger } from '../shared/logger/logger'; - -export class TraitJob { - private traitPersistenceService = new TraitPersistenceService(); - private memoryPersistenceService = new MemoryPersistenceService(); - private aiService: AIService; - private jobLogger = logger.child({ module: 'TraitJob' }); - - constructor(aiService?: AIService) { - this.aiService = aiService ?? new AIService(); - } - - async run(): Promise { - this.jobLogger.info('Starting trait regeneration job'); - - try { - // Get all users - const users = await getRepository(SlackUser).find(); - - if (users.length === 0) { - this.jobLogger.info('No users found for trait regeneration'); - return; - } - - // Extract all team IDs to regenerate traits for - const teamIds = Array.from(new Set(users.map((u) => u.teamId))); - - let totalUsers = 0; - let processedUsers = 0; - - for (const teamId of teamIds) { - const teamUsers = users.filter((u) => u.teamId === teamId); - totalUsers += teamUsers.length; - - const slackIds = teamUsers.map((u) => u.slackId); - - try { - await this.runForUsers(teamId, slackIds); - processedUsers += slackIds.length; - } catch (error) { - this.jobLogger.warn(`Failed to regenerate traits for team ${teamId}:`, error); - } - } - - this.jobLogger.info(`Trait regeneration job complete: processed ${processedUsers}/${totalUsers} users`); - } catch (error) { - this.jobLogger.error('Trait regeneration job failed:', error); - throw error; - } - } - - async runForUsers(teamId: string, slackIds: string[]): Promise { - await this.regenerateTraitsForUsers(teamId, slackIds, async (input) => { - return this.aiService.openAi.responses - .create({ - model: GATE_MODEL, - instructions: TRAIT_EXTRACTION_PROMPT, - input, - user: `trait-job-${teamId}`, - }) - .then((response) => { - const textBlock = response.output.find((item) => item.type === 'message'); - if (textBlock && 'content' in textBlock) { - const outputText = textBlock.content.find((item) => item.type === 'output_text'); - return outputText?.text.trim(); - } - return undefined; - }); - }); - } - - private parseTraitExtractionResult(raw: string | undefined): string[] { - if (!raw) { - return []; - } - - try { - const parsed: unknown = JSON.parse(raw.trim()); - if (!Array.isArray(parsed)) { - return []; - } - - return Array.from( - new Set( - parsed - .filter((value): value is string => typeof value === 'string') - .map((value) => value.trim()) - .filter((value) => value.length > 0), - ), - ).slice(0, 10); - } catch { - this.jobLogger.warn(`Trait extraction returned malformed JSON: ${raw}`); - return []; - } - } - - private async regenerateTraitsForUsers( - teamId: string, - slackIds: string[], - synthesizeTraits: (input: string) => Promise, - ): Promise { - const uniqueSlackIds = Array.from(new Set(slackIds.filter((id) => /^U[A-Z0-9]+$/.test(id)))); - if (uniqueSlackIds.length === 0) { - return; - } - - await this.processWithConcurrencyLimit(uniqueSlackIds, 3, async (slackId) => { - const memories = await this.memoryPersistenceService.getAllMemoriesForUser(slackId, teamId); - if (memories.length === 0) { - await this.traitPersistenceService.replaceTraitsForUser(slackId, teamId, []); - return; - } - - const memoryText = memories.map((memory, index) => `${index + 1}. ${memory.content}`).join('\n'); - const input = `User Slack ID: ${slackId}\n\nMemories:\n${memoryText}`; - - const rawTraits = await synthesizeTraits(input).catch((error) => { - this.jobLogger.warn(`Trait synthesis failed for ${slackId} in ${teamId}:`, error); - return undefined; - }); - - const traits = this.parseTraitExtractionResult(rawTraits); - await this.traitPersistenceService.replaceTraitsForUser(slackId, teamId, traits); - }); - } - - private async processWithConcurrencyLimit( - items: T[], - concurrency: number, - worker: (item: T) => Promise, - ): Promise { - const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length)); - let nextIndex = 0; - - const runners = Array.from({ length: effectiveConcurrency }, async () => { - while (nextIndex < items.length) { - const currentIndex = nextIndex; - nextIndex += 1; - await worker(items[currentIndex]); - } - }); - - await Promise.all(runners); - } -} diff --git a/packages/backend/src/trait/trait.persistence.service.spec.ts b/packages/backend/src/trait/trait.persistence.service.spec.ts deleted file mode 100644 index 840c85cc..00000000 --- a/packages/backend/src/trait/trait.persistence.service.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { vi } from 'vitest'; -import { getRepository } from 'typeorm'; -import { TraitPersistenceService } from './trait.persistence.service'; -import { SlackUser } from '../shared/db/models/SlackUser'; -import { Trait } from '../shared/db/models/Trait'; - -vi.mock('typeorm', async () => ({ - getRepository: vi.fn(), - Entity: () => vi.fn(), - Column: () => vi.fn(), - PrimaryGeneratedColumn: () => vi.fn(), - ManyToOne: () => vi.fn(), - OneToMany: () => vi.fn(), - OneToOne: () => vi.fn(), - Unique: () => vi.fn(), - JoinColumn: () => vi.fn(), -})); - -vi.mock('../../shared/logger/logger', async () => ({ - logger: { - child: vi.fn().mockReturnValue({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - }, -})); - -describe('TraitPersistenceService', () => { - let service: TraitPersistenceService; - let mockSlackUserRepo: Record; - let mockTraitRepo: Record; - - const mockUser: Partial = { - id: 1, - slackId: 'U123', - teamId: 'T456', - name: 'testuser', - isBot: false, - botId: '', - }; - - beforeEach(() => { - vi.clearAllMocks(); - service = new TraitPersistenceService(); - - mockSlackUserRepo = { - findOne: vi.fn(), - }; - - mockTraitRepo = { - query: vi.fn(), - save: vi.fn(), - }; - - (getRepository as Mock).mockImplementation((entity) => { - if (entity === SlackUser) return mockSlackUserRepo; - if (entity === Trait) return mockTraitRepo; - return {}; - }); - }); - - describe('replaceTraitsForUser', () => { - it('replaces traits and caps output to 10', async () => { - mockSlackUserRepo.findOne.mockResolvedValue(mockUser); - mockTraitRepo.query.mockResolvedValue(undefined); - mockTraitRepo.save.mockResolvedValue([]); - - await service.replaceTraitsForUser( - 'U123', - 'T456', - Array.from({ length: 12 }, (_, i) => `trait-${i + 1}`), - ); - - expect(mockTraitRepo.query).toHaveBeenCalledWith('DELETE FROM trait WHERE userIdId = ? AND teamId = ?', [ - 1, - 'T456', - ]); - expect(mockTraitRepo.save).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ content: 'trait-1' })]), - ); - expect((mockTraitRepo.save as Mock).mock.calls[0][0]).toHaveLength(10); - }); - - it('returns empty array when user is missing', async () => { - mockSlackUserRepo.findOne.mockResolvedValue(null); - - const result = await service.replaceTraitsForUser('UNKNOWN', 'T456', ['a']); - - expect(result).toEqual([]); - expect(mockTraitRepo.query).not.toHaveBeenCalled(); - }); - - it('deletes traits and skips save for empty normalized traits', async () => { - mockSlackUserRepo.findOne.mockResolvedValue(mockUser); - mockTraitRepo.query.mockResolvedValue(undefined); - - const result = await service.replaceTraitsForUser('U123', 'T456', [' ', '']); - - expect(result).toEqual([]); - expect(mockTraitRepo.save).not.toHaveBeenCalled(); - }); - }); - - describe('getAllTraitsForUser', () => { - it('returns traits for user', async () => { - mockTraitRepo.query.mockResolvedValue([{ id: 1, content: 'likes TypeScript', slackId: 'U123' }]); - - const result = await service.getAllTraitsForUser('U123', 'T456'); - - expect(result).toEqual([{ id: 1, content: 'likes TypeScript', slackId: 'U123' }]); - }); - - it('returns empty array on query failure', async () => { - mockTraitRepo.query.mockRejectedValue(new Error('db fail')); - - const result = await service.getAllTraitsForUser('U123', 'T456'); - - expect(result).toEqual([]); - }); - }); - - describe('getAllTraitsForUsers', () => { - it('returns grouped traits by slack id', async () => { - mockTraitRepo.query.mockResolvedValueOnce([ - { id: 1, content: 'likes TypeScript', slackId: 'U123' }, - { id: 2, content: 'hates Java', slackId: 'U789' }, - ]); - - const result = await service.getAllTraitsForUsers(['U123', 'U789'], 'T456'); - - expect(result.get('U123')?.length).toBe(1); - expect(result.get('U789')?.length).toBe(1); - }); - }); -}); diff --git a/packages/backend/src/trait/trait.persistence.service.ts b/packages/backend/src/trait/trait.persistence.service.ts deleted file mode 100644 index 39cac408..00000000 --- a/packages/backend/src/trait/trait.persistence.service.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { getRepository } from 'typeorm'; -import { logger } from '../shared/logger/logger'; -import { SlackUser } from '../shared/db/models/SlackUser'; -import { Trait, type TraitWithSlackId } from '../shared/db/models/Trait'; -import { logError } from '../shared/logger/error-logging'; - -const MAX_TRAITS_PER_USER = 10; - -export class TraitPersistenceService { - private traitLogger = logger.child({ module: 'TraitPersistenceService' }); - - async replaceTraitsForUser(slackId: string, teamId: string, contents: string[]): Promise { - const user = await getRepository(SlackUser).findOne({ where: { slackId, teamId } }); - if (!user) { - this.traitLogger.warn(`Cannot save traits: user ${slackId} not found in team ${teamId}`); - return []; - } - - const normalizedContents = Array.from( - new Set(contents.map((content) => content.trim()).filter((content) => content.length > 0)), - ).slice(0, MAX_TRAITS_PER_USER); - - return getRepository(Trait) - .query('DELETE FROM trait WHERE userIdId = ? AND teamId = ?', [user.id, teamId]) - .then(async () => { - if (normalizedContents.length === 0) { - return []; - } - - const traits = normalizedContents.map((content) => { - const trait = new Trait(); - trait.userId = user; - trait.teamId = teamId; - trait.content = content; - return trait; - }); - - return getRepository(Trait).save(traits); - }) - .catch((e) => { - logError(this.traitLogger, 'Error replacing traits for user', e, { - slackId, - teamId, - traitCount: normalizedContents.length, - }); - return []; - }); - } - - async getAllTraitsForUser(slackId: string, teamId: string): Promise { - return getRepository(Trait) - .query( - `SELECT t.*, u.slackId FROM trait t - INNER JOIN slack_user u ON t.userIdId = u.id - WHERE u.slackId = ? AND u.teamId = ? - ORDER BY t.updatedAt DESC`, - [slackId, teamId], - ) - .catch((e) => { - logError(this.traitLogger, 'Error fetching all traits for user', e, { - slackId, - teamId, - }); - return []; - }); - } - - async getAllTraitsForUsers(slackIds: string[], teamId: string): Promise> { - const result = new Map(); - const uniqueSlackIds = Array.from(new Set(slackIds)); - - if (uniqueSlackIds.length === 0) { - return result; - } - - const placeholders = uniqueSlackIds.map(() => '?').join(', '); - - return getRepository(Trait) - .query( - `SELECT t.*, u.slackId FROM trait t - INNER JOIN slack_user u ON t.userIdId = u.id - WHERE u.teamId = ? AND u.slackId IN (${placeholders}) - ORDER BY t.updatedAt DESC`, - [teamId, ...uniqueSlackIds], - ) - .then((traits: TraitWithSlackId[]) => { - traits.forEach((trait) => { - const existingTraits = result.get(trait.slackId) ?? []; - existingTraits.push(trait); - result.set(trait.slackId, existingTraits); - }); - - return result; - }) - .catch((e) => { - logError(this.traitLogger, 'Error fetching all traits for users', e, { - teamId, - slackIdCount: uniqueSlackIds.length, - }); - return result; - }); - } -} diff --git a/packages/backend/src/trait/trait.service.spec.ts b/packages/backend/src/trait/trait.service.spec.ts deleted file mode 100644 index 3e319dd7..00000000 --- a/packages/backend/src/trait/trait.service.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { vi } from 'vitest'; -import { TraitService } from './trait.service'; - -const { getAllTraitsForUser, getAllTraitsForUsers, sendEphemeral } = vi.hoisted(() => ({ - getAllTraitsForUser: vi.fn().mockResolvedValue([]), - getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), - sendEphemeral: vi.fn().mockResolvedValue({ ok: true }), -})); - -vi.mock('./trait.persistence.service', async () => ({ - TraitPersistenceService: classMock(() => ({ - getAllTraitsForUser, - getAllTraitsForUsers, - })), -})); - -vi.mock('../shared/services/web/web.service', async () => ({ - WebService: classMock(() => ({ - sendEphemeral, - })), -})); - -describe('TraitService', () => { - let service: TraitService; - - beforeEach(() => { - vi.clearAllMocks(); - service = new TraitService(); - }); - - it('sends formatted traits when they exist', async () => { - getAllTraitsForUser.mockResolvedValue([ - { - content: 'JR-15 prefers TypeScript as his programming language', - updatedAt: new Date('2026-04-01T00:00:00.000Z'), - }, - ]); - - await service.sendTraitsForUser('U1', 'T1', 'C1'); - - expect(getAllTraitsForUser).toHaveBeenCalledWith('U1', 'T1'); - expect(sendEphemeral).toHaveBeenCalledWith( - 'C1', - expect.stringContaining("Moonbeam's core traits about you:"), - 'U1', - ); - }); - - it('sends no-traits message when user has no traits', async () => { - getAllTraitsForUser.mockResolvedValue([]); - - await service.sendTraitsForUser('U1', 'T1', 'C1'); - - expect(sendEphemeral).toHaveBeenCalledWith('C1', "Moonbeam doesn't have any core traits about you yet.", 'U1'); - }); - - it('sends fallback error message when trait retrieval fails', async () => { - getAllTraitsForUser.mockRejectedValueOnce(new Error('db fail')); - - await service.sendTraitsForUser('U1', 'T1', 'C1'); - - expect(sendEphemeral).toHaveBeenCalledWith('C1', 'Sorry, something went wrong fetching your traits.', 'U1'); - }); - - it('formats trait context grouped by participant name', () => { - const text = service.formatTraitContext( - [ - { slackId: 'U1', content: 'prefers typescript' } as never, - { slackId: 'U2', content: 'dislikes donald trump' } as never, - ], - [ - { slackId: 'U1', name: 'Alice', message: 'hi' } as never, - { slackId: 'U2', name: 'Bob', message: 'hello' } as never, - ], - ); - - expect(text).toContain('traits_context'); - expect(text).toContain('Alice'); - expect(text).toContain('prefers typescript'); - expect(text).toContain('Bob'); - }); - - it('returns base instructions when there is no trait context', () => { - expect(service.appendTraitContext('base', '')).toBe('base'); - }); - - it('inserts trait context before verification section', () => { - const base = 'instructions\n\nchecklist\n'; - const context = '\ntest trait\n'; - - const result = service.appendTraitContext(base, context); - - expect(result).toContain('test trait'); - expect(result.indexOf('traits_context')).toBeLessThan(result.indexOf('')); - }); - - it('fetches trait context from persistence layer', async () => { - getAllTraitsForUsers.mockResolvedValue(new Map([['U1', [{ slackId: 'U1', content: 'prefers typescript' }]]])); - - const context = await service.fetchTraitContext(['U1'], 'T1', [{ slackId: 'U1', name: 'Alice' } as never]); - - expect(context).toContain('prefers typescript'); - }); -}); diff --git a/packages/backend/src/trait/trait.service.ts b/packages/backend/src/trait/trait.service.ts deleted file mode 100644 index 3f4eb029..00000000 --- a/packages/backend/src/trait/trait.service.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { WebService } from '../shared/services/web/web.service'; -import { logError } from '../shared/logger/error-logging'; -import { logger } from '../shared/logger/logger'; -import type { MessageWithName } from '../shared/models/message/message-with-name'; -import type { TraitWithSlackId } from '../shared/db/models/Trait'; -import { TraitPersistenceService } from './trait.persistence.service'; - -export class TraitService { - private readonly traitPersistenceService = new TraitPersistenceService(); - private readonly webService = new WebService(); - private readonly traitLogger = logger.child({ module: 'TraitService' }); - - public async sendTraitsForUser(userId: string, teamId: string, channelId: string): Promise { - try { - const traits = await this.traitPersistenceService.getAllTraitsForUser(userId, teamId); - - if (traits.length === 0) { - await this.webService.sendEphemeral(channelId, "Moonbeam doesn't have any core traits about you yet.", userId); - return; - } - - const formattedTraits = traits - .map((trait, index) => { - const date = new Date(trait.updatedAt).toLocaleDateString('en-US', { - month: 'short', - year: 'numeric', - }); - return `${index + 1}. "${trait.content}" (${date.toLowerCase()})`; - }) - .join('\n'); - - const message = `Moonbeam's core traits about you:\n${formattedTraits}`; - await this.webService.sendEphemeral(channelId, message, userId); - } catch (e) { - logError(this.traitLogger, 'Failed to fetch traits for /traits command', e, { - userId, - teamId, - channelId, - }); - await this.webService.sendEphemeral(channelId, 'Sorry, something went wrong fetching your traits.', userId); - } - } - - public formatTraitContext(traits: TraitWithSlackId[], history: MessageWithName[]): string { - if (traits.length === 0) return ''; - - const nameMap = new Map(); - history.forEach((msg) => { - if (msg.slackId && msg.name) nameMap.set(msg.slackId, msg.name); - }); - - const grouped = new Map(); - for (const trait of traits) { - const slackId = trait.slackId || 'unknown'; - if (!grouped.has(slackId)) grouped.set(slackId, []); - grouped.get(slackId)!.push(trait); - } - - const lines = Array.from(grouped.entries()) - .map(([slackId, userTraits]) => { - const name = nameMap.get(slackId) || slackId; - const traitLines = userTraits.map((trait) => `"${trait.content}"`).join(', '); - return `- ${name}: ${traitLines}`; - }) - .join('\n'); - - return `\ncore beliefs and stable traits for people in this conversation:\n${lines}\n`; - } - - public async fetchTraitContext( - participantSlackIds: string[], - teamId: string, - history: MessageWithName[], - ): Promise { - if (participantSlackIds.length === 0) return ''; - const traitsMap = await this.traitPersistenceService.getAllTraitsForUsers(participantSlackIds, teamId); - const traits = Array.from(traitsMap.values()).flat(); - return this.formatTraitContext(traits, history); - } - - public appendTraitContext(baseInstructions: string, traitContext: string): string { - if (!traitContext) return baseInstructions; - - // Insert trait data before so the checklist remains the last thing the model sees. - const verificationTag = ''; - const insertionPoint = baseInstructions.lastIndexOf(verificationTag); - if (insertionPoint !== -1) { - return `${baseInstructions.slice(0, insertionPoint)}${traitContext}\n\n${baseInstructions.slice(insertionPoint)}`; - } - - return `${baseInstructions}\n\n${traitContext}`; - } -} diff --git a/packages/frontend/src/app.model.ts b/packages/frontend/src/app.model.ts index 9fc9c174..bc39c995 100644 --- a/packages/frontend/src/app.model.ts +++ b/packages/frontend/src/app.model.ts @@ -65,17 +65,6 @@ export interface DashboardResponse { repLeaderboard: RepLeaderboardEntry[]; } -export interface PersonalContextEntry { - id: number; - content: string; - updatedAt: string; -} - -export interface PersonalContextResponse { - memories: PersonalContextEntry[]; - traits: PersonalContextEntry[]; -} - export type FrontendRecurrenceFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly'; export interface FrontendRecurrenceRule { diff --git a/packages/frontend/src/components/AppShell.model.ts b/packages/frontend/src/components/AppShell.model.ts index 54088aee..e03fd2d5 100644 --- a/packages/frontend/src/components/AppShell.model.ts +++ b/packages/frontend/src/components/AppShell.model.ts @@ -1,6 +1,6 @@ import type { ElementType } from 'react'; -export type Page = 'home' | 'message-search' | 'personal-context' | 'calendar'; +export type Page = 'home' | 'message-search' | 'calendar'; export interface NavItemProps { icon: ElementType; diff --git a/packages/frontend/src/components/AppShell.spec.tsx b/packages/frontend/src/components/AppShell.spec.tsx index f779d870..1c3702fe 100644 --- a/packages/frontend/src/components/AppShell.spec.tsx +++ b/packages/frontend/src/components/AppShell.spec.tsx @@ -29,13 +29,6 @@ describe('AppShell', () => { expect(screen.queryByRole('heading', { name: /^home$/i })).not.toBeInTheDocument(); }); - it('switches to the Memories + Traits page when its nav button is clicked', () => { - render(); - fireEvent.click(screen.getByRole('button', { name: /memories \+ traits/i })); - expect(screen.getByRole('heading', { name: /your memory \+ traits/i })).toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: /^home$/i })).not.toBeInTheDocument(); - }); - it('calls onLogout when the Sign out button is clicked', () => { const onLogout = vi.fn(); render(); diff --git a/packages/frontend/src/components/AppShell.tsx b/packages/frontend/src/components/AppShell.tsx index cf439b6c..54c7a685 100644 --- a/packages/frontend/src/components/AppShell.tsx +++ b/packages/frontend/src/components/AppShell.tsx @@ -1,10 +1,9 @@ import { useState } from 'react'; -import { Home, Search, Brain, CalendarDays, LogOut } from 'lucide-react'; +import { Home, Search, CalendarDays, LogOut } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { HomePage } from '@/pages/HomePage'; import { MessageSearchPage } from '@/pages/MessageSearchPage'; -import { PersonalContextPage } from '@/pages/PersonalContextPage'; import { CalendarPage } from '@/pages/CalendarPage'; import type { Page, NavItemProps, AppShellProps } from '@/components/AppShell.model'; @@ -44,12 +43,6 @@ export function AppShell({ onLogout }: AppShellProps) { active={currentPage === 'message-search'} onClick={() => setCurrentPage('message-search')} /> - setCurrentPage('personal-context')} - /> {currentPage === 'home' && } {currentPage === 'message-search' && } - {currentPage === 'personal-context' && } {currentPage === 'calendar' && } diff --git a/packages/frontend/src/hooks/usePersonalContext.spec.ts b/packages/frontend/src/hooks/usePersonalContext.spec.ts deleted file mode 100644 index 9883a665..00000000 --- a/packages/frontend/src/hooks/usePersonalContext.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { usePersonalContext } from '@/hooks/usePersonalContext'; -import { AUTH_TOKEN_KEY } from '@/app.const'; - -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -const mockData = { - memories: [{ id: 1, content: 'Loves TypeScript', updatedAt: '2026-04-20T00:00:00.000Z' }], - traits: [{ id: 2, content: 'Direct communicator', updatedAt: '2026-04-20T00:00:00.000Z' }], -}; - -beforeEach(() => { - localStorage.setItem(AUTH_TOKEN_KEY, 'test-token'); - mockFetch.mockReset(); -}); - -describe('usePersonalContext', () => { - it('starts loading with null data and no error', () => { - mockFetch.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => usePersonalContext(vi.fn())); - expect(result.current.isLoading).toBe(true); - expect(result.current.data).toBeNull(); - expect(result.current.error).toBeNull(); - }); - - it('returns data on success', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => mockData }); - const { result } = renderHook(() => usePersonalContext(vi.fn())); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data).toEqual(mockData); - expect(result.current.error).toBeNull(); - }); - - it('calls onLogout on 401', async () => { - const onLogout = vi.fn(); - mockFetch.mockResolvedValue({ ok: false, status: 401 }); - renderHook(() => usePersonalContext(onLogout)); - await waitFor(() => expect(onLogout).toHaveBeenCalledOnce()); - }); - - it('sets an error on non-401 failures', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }); - const { result } = renderHook(() => usePersonalContext(vi.fn())); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.error).toMatch(/failed to load personal context/i); - expect(result.current.data).toBeNull(); - }); - - it('uses fallback error for non-Error rejections', async () => { - mockFetch.mockRejectedValue('broken'); - const { result } = renderHook(() => usePersonalContext(vi.fn())); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.error).toBe('Failed to load personal context'); - }); - - it('sends auth token in Authorization header', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => mockData }); - renderHook(() => usePersonalContext(vi.fn())); - await waitFor(() => expect(mockFetch).toHaveBeenCalledOnce()); - const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]; - expect(url).toContain('/dashboard/personal-context'); - expect((options.headers as Record)['Authorization']).toBe('Bearer test-token'); - }); -}); diff --git a/packages/frontend/src/hooks/usePersonalContext.ts b/packages/frontend/src/hooks/usePersonalContext.ts deleted file mode 100644 index f26cbbb9..00000000 --- a/packages/frontend/src/hooks/usePersonalContext.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { AUTH_TOKEN_KEY } from '@/app.const'; -import { API_BASE_URL } from '@/config'; -import type { PersonalContextResponse } from '@/app.model'; - -export interface UsePersonalContextReturn { - data: PersonalContextResponse | null; - isLoading: boolean; - error: string | null; -} - -export function usePersonalContext(onLogout: () => void): UsePersonalContextReturn { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const onLogoutRef = useRef(onLogout); - onLogoutRef.current = onLogout; - - useEffect(() => { - const abortController = new AbortController(); - - const fetchPersonalContext = async () => { - setIsLoading(true); - setError(null); - - try { - const token = localStorage.getItem(AUTH_TOKEN_KEY) ?? ''; - const response = await fetch(`${API_BASE_URL}/dashboard/personal-context`, { - headers: { Authorization: `Bearer ${token}` }, - signal: abortController.signal, - }); - - if (response.status === 401) { - onLogoutRef.current(); - return; - } - - if (!response.ok) { - throw new Error(`Failed to load personal context: ${response.statusText}`); - } - - const json = (await response.json()) as PersonalContextResponse; - setData(json); - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - return; - } - - setError(err instanceof Error ? err.message : 'Failed to load personal context'); - } finally { - if (!abortController.signal.aborted) { - setIsLoading(false); - } - } - }; - - void fetchPersonalContext(); - - return () => { - abortController.abort(); - }; - }, []); - - return { data, isLoading, error }; -} diff --git a/packages/frontend/src/pages/PersonalContextPage.model.ts b/packages/frontend/src/pages/PersonalContextPage.model.ts deleted file mode 100644 index c9b32562..00000000 --- a/packages/frontend/src/pages/PersonalContextPage.model.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface PersonalContextPageProps { - onLogout: () => void; -} diff --git a/packages/frontend/src/pages/PersonalContextPage.spec.tsx b/packages/frontend/src/pages/PersonalContextPage.spec.tsx deleted file mode 100644 index 8a3a091a..00000000 --- a/packages/frontend/src/pages/PersonalContextPage.spec.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { PersonalContextPage } from '@/pages/PersonalContextPage'; - -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -const fullData = { - memories: [{ id: 1, content: 'Prefers concise summaries', updatedAt: '2026-04-20T00:00:00.000Z' }], - traits: [{ id: 2, content: 'Strong systems thinker', updatedAt: '2026-04-19T00:00:00.000Z' }], -}; - -const emptyData = { - memories: [], - traits: [], -}; - -beforeEach(() => { - mockFetch.mockReset(); -}); - -describe('PersonalContextPage', () => { - it('shows heading and renders fetched memories/traits', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => fullData }); - render(); - - expect(screen.getByRole('heading', { name: /your memory \+ traits/i })).toBeInTheDocument(); - await waitFor(() => expect(screen.getByText('Prefers concise summaries')).toBeInTheDocument()); - expect(screen.getByText('Strong systems thinker')).toBeInTheDocument(); - }); - - it('shows empty state messages when there is no data', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => emptyData }); - render(); - - await waitFor(() => expect(screen.getByText(/does not have any memories about you yet/i)).toBeInTheDocument()); - expect(screen.getByText(/does not have any core traits about you yet/i)).toBeInTheDocument(); - }); - - it('shows an error banner when fetch fails', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' }); - render(); - - await waitFor(() => expect(screen.getByText(/failed to load personal context/i)).toBeInTheDocument()); - }); - - it('calls onLogout when response is 401', async () => { - const onLogout = vi.fn(); - mockFetch.mockResolvedValue({ ok: false, status: 401 }); - render(); - - await waitFor(() => expect(onLogout).toHaveBeenCalledOnce()); - }); -}); diff --git a/packages/frontend/src/pages/PersonalContextPage.tsx b/packages/frontend/src/pages/PersonalContextPage.tsx deleted file mode 100644 index 8a891079..00000000 --- a/packages/frontend/src/pages/PersonalContextPage.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { AlertCircle, BookHeart, Sparkles } from 'lucide-react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { usePersonalContext } from '@/hooks/usePersonalContext'; -import type { PersonalContextEntry } from '@/app.model'; -import type { PersonalContextPageProps } from '@/pages/PersonalContextPage.model'; - -function formatDateLabel(isoDate: string): string { - return new Date(isoDate).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); -} - -function ContextList({ - heading, - description, - emptyMessage, - entries, -}: { - heading: string; - description: string; - emptyMessage: string; - entries: PersonalContextEntry[]; -}) { - return ( - - - {heading} - {description} - - - {entries.length === 0 ? ( -

{emptyMessage}

- ) : ( -
    - {entries.map((entry, index) => ( -
  • -

    - {index + 1}. - {entry.content} -

    -

    Updated {formatDateLabel(entry.updatedAt)}

    -
  • - ))} -
- )} -
-
- ); -} - -export function PersonalContextPage({ onLogout }: PersonalContextPageProps) { - const { data, isLoading, error } = usePersonalContext(onLogout); - - return ( -
-
-

Your Memory + Traits

-

- See what Moonbeam remembers about you and which core traits it currently uses for personalization. -

-
- - {error && ( - - - - - )} - - {isLoading ? ( -
- - - - - - -

Loading memories...

-
-
- - - - - - -

Loading traits...

-
-
-
- ) : ( -
- - -
- )} -
- ); -}