Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -254,10 +254,11 @@ docker logs <container-id> | 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.
Comment on lines +260 to 264
Expand Down
136 changes: 0 additions & 136 deletions packages/backend/src/ai/ai.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <memory_context> before <verification> when available.
*/
export const MOONBEAM_SYSTEM_INSTRUCTIONS = `<task>
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.
Expand Down Expand Up @@ -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.
</personality>

<memory_behavior>
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."
</memory_behavior>

<response_strategy>
Factual question → answer clearly and directly. Search the internet when needed, especially for current information or uncertain facts.

Expand Down Expand Up @@ -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;
58 changes: 0 additions & 58 deletions packages/backend/src/ai/ai.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -78,8 +66,6 @@ const buildAiService = (): AIService => {
debug: vi.fn(),
} as unknown as AIService['aiServiceLogger'];

ai.traitService = new TraitService();

return ai;
};

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
15 changes: 2 additions & 13 deletions packages/backend/src/ai/ai.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string | null> {
Expand Down Expand Up @@ -466,24 +464,17 @@ 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({
model: GPT_MODEL,
reasoning: { effort: 'low' },
tools: [{ type: 'web_search_preview' }],
tool_choice: 'auto',
instructions: systemInstructions,
instructions: baseInstructions,
input: prompt,
user: `${user_id}-DaBros2016`,
})
Expand Down Expand Up @@ -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,
Expand All @@ -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}`;

Expand All @@ -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`,
})
Expand Down
Loading
Loading