diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts
index cc697249..65ede081 100644
--- a/packages/backend/src/ai/ai.constants.ts
+++ b/packages/backend/src/ai/ai.constants.ts
@@ -56,22 +56,6 @@ unclear intent → make your best guess and commit. do not ask for clarification
message doesn't need you → stay quiet.
-
-these demonstrate your best responses — vary the language but match the feel:
-
-factual (concise, conversational, specific):
-- "short answer: no — they're different tools for different problems."
-- "because windows + active directory gives enterprises centralized identity, device management, and legacy app support at massive scale. it's boring, deeply unsexy, and extremely reliable."
-- "rsync + backblaze b2 is solid for unraid — cheap, reliable, and the plugin makes it pretty painless. duplicacy is worth a look too if you want versioning."
-
-taking a side (committed, specific, no hedging):
-- "jr is more factually correct about how llms actually work, but neal is more correct about the moral pressure to keep improving safety."
-
-humor (situational, cutting, uses real details):
-- "if you took mcdonalds napkins instead of buying them at the store for 25 years you'd probably save like $50 to $100 but you'd have to factor in the emotional cost of living like that for a quarter century."
-- "yes — but in the deeply spiritual way only a man personally betrayed by a typescript union type can overreact."
-
-
before sending any response, check:
1. does it start with the actual answer, not a name or greeting?
@@ -91,23 +75,6 @@ export const MOONBEAM_SLACK_ID = 'ULG8SJRFF';
export const GATE_MODEL = 'gpt-4.1-nano';
-export const MEMORY_SELECTION_PROMPT = `You are selecting which stored memories are relevant to a conversation that is about to get a response.
-You are NOT responding — you are picking useful context.
-
-STORED MEMORIES:
-{all_memories_grouped_by_user}
-
-Return the IDs of memories that are relevant to what's being discussed, or that would enable:
-- A callback to something someone said before
-- Catching a contradiction or shift in position
-- Playing into a known dynamic between people
-- Adjusting tone based on how someone engages
-
-Return a JSON array of memory IDs: [1, 4, 17, 23]
-Or return an empty array [] if nothing is relevant.
-
-Most conversations will need 0-5 memories. Do not force relevance where there is none.`;
-
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.
@@ -162,4 +129,35 @@ Format: [{"slackId": "U12345", "content": "description of what they said or did"
Keep each memory to 1-2 sentences. Be specific — include what was actually said, not a summary of the topic.`;
+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.
+- Write each trait in third person using their Slack ID placeholder if provided context supports it.
+- No duplicates or near-duplicates.
+- Prefer quality over quantity. If only 4 strong traits exist, return 4.
+
+Output format:
+- Return ONLY a JSON array of strings.
+- Example: ["JR-15 prefers TypeScript as his primary programming language", "JR-15 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.controller.spec.ts b/packages/backend/src/ai/ai.controller.spec.ts
index 4a55f071..f0440310 100644
--- a/packages/backend/src/ai/ai.controller.spec.ts
+++ b/packages/backend/src/ai/ai.controller.spec.ts
@@ -12,6 +12,10 @@ const { generateText, generateImage, promptWithHistory, sendEphemeral, setCustom
clearCustomPrompt: vi.fn().mockResolvedValue(true),
}));
+const { getAllTraitsForUser } = vi.hoisted(() => ({
+ getAllTraitsForUser: vi.fn().mockResolvedValue([]),
+}));
+
vi.mock('./ai.service', async () => ({
AIService: classMock(() => ({
generateText,
@@ -28,6 +32,12 @@ vi.mock('../shared/services/web/web.service', async () => ({
})),
}));
+vi.mock('./trait/trait.persistence.service', async () => ({
+ TraitPersistenceService: classMock(() => ({
+ getAllTraitsForUser,
+ })),
+}));
+
vi.mock('../shared/middleware/suppression', async () => ({
suppressedMiddleware: (_req: unknown, _res: unknown, next: () => void) => next(),
}));
@@ -51,6 +61,7 @@ describe('aiController', () => {
vi.clearAllMocks();
setCustomPrompt.mockResolvedValue(true);
clearCustomPrompt.mockResolvedValue(true);
+ getAllTraitsForUser.mockResolvedValue([]);
});
it('handles /text', async () => {
@@ -87,6 +98,48 @@ describe('aiController', () => {
expect(sendEphemeral).toHaveBeenCalled();
});
+ describe('/traits', () => {
+ it('returns immediate 200 and sends formatted traits ephemerally', async () => {
+ getAllTraitsForUser.mockResolvedValue([
+ {
+ content: 'JR-15 prefers TypeScript as his programming language',
+ updatedAt: new Date('2026-04-01T00:00:00.000Z'),
+ },
+ ]);
+
+ await request(app).post('/traits').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200);
+
+ await Promise.resolve();
+
+ 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 request(app).post('/traits').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200);
+
+ await Promise.resolve();
+
+ 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 request(app).post('/traits').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200);
+
+ await Promise.resolve();
+
+ expect(sendEphemeral).toHaveBeenCalledWith('C1', 'Sorry, something went wrong fetching your traits.', 'U1');
+ });
+ });
+
describe('/set-prompt', () => {
it('clears prompt when text is "clear"', async () => {
const res = await request(app)
diff --git a/packages/backend/src/ai/ai.controller.ts b/packages/backend/src/ai/ai.controller.ts
index b3aad1b3..e40e6aa0 100644
--- a/packages/backend/src/ai/ai.controller.ts
+++ b/packages/backend/src/ai/ai.controller.ts
@@ -8,14 +8,55 @@ import { aiMiddleware } from './middleware/aiMiddleware';
import type { SlashCommandRequest } from '../shared/models/slack/slack-models';
import { logError } from '../shared/logger/error-logging';
import { logger } from '../shared/logger/logger';
+import { TraitPersistenceService } from './trait/trait.persistence.service';
export const aiController: Router = express.Router();
const webService = new WebService();
const aiService = new AIService();
+const traitPersistenceService = new TraitPersistenceService();
const aiLogger = logger.child({ module: 'AIController' });
aiController.use(suppressedMiddleware);
+
+aiController.post('/traits', (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 traits = await traitPersistenceService.getAllTraitsForUser(user_id, team_id);
+
+ if (traits.length === 0) {
+ void webService.sendEphemeral(channel_id, "Moonbeam doesn't have any core traits about you yet.", user_id);
+ 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}`;
+ void webService.sendEphemeral(channel_id, message, user_id);
+ } catch (e) {
+ logError(aiLogger, 'Failed to fetch traits for /ai/traits command', e, {
+ userId: user_id,
+ teamId: team_id,
+ channelId: channel_id,
+ });
+ void webService.sendEphemeral(channel_id, 'Sorry, something went wrong fetching your traits.', user_id);
+ }
+ })();
+});
+
aiController.use(textMiddleware);
aiController.use(aiMiddleware);
diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts
index a3fff8fa..e6ea99a6 100644
--- a/packages/backend/src/ai/ai.service.spec.ts
+++ b/packages/backend/src/ai/ai.service.spec.ts
@@ -37,11 +37,17 @@ const buildAiService = (): AIService => {
ai.memoryPersistenceService = {
getAllMemoriesForUsers: vi.fn().mockResolvedValue(new Map()),
+ getAllMemoriesForUser: vi.fn().mockResolvedValue([]),
saveMemories: vi.fn().mockResolvedValue([]),
reinforceMemory: vi.fn().mockResolvedValue(true),
deleteMemory: vi.fn().mockResolvedValue(true),
} as unknown as AIService['memoryPersistenceService'];
+ ai.traitPersistenceService = {
+ getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()),
+ replaceTraitsForUser: vi.fn().mockResolvedValue([]),
+ } as unknown as AIService['traitPersistenceService'];
+
ai.historyService = {
getHistory: vi.fn().mockResolvedValue([]),
getHistoryWithOptions: vi.fn().mockResolvedValue([]),
@@ -339,6 +345,25 @@ 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' },
+ ]);
+ (aiService.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', () => {
@@ -522,6 +547,25 @@ describe('AIService', () => {
await expect(aiService.participate('T1', 'C1', 'hi')).rejects.toThrow('model fail');
expect(aiService.redis.removeParticipationInFlight).toHaveBeenCalledWith('C1', 'T1');
});
+
+ it('injects trait context for participation prompts', async () => {
+ (aiService.historyService.getHistoryWithOptions as Mock).mockResolvedValue([
+ { slackId: 'U2', name: 'Jane', message: 'hello' },
+ ]);
+ (aiService.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', () => {
@@ -687,27 +731,24 @@ describe('AIService', () => {
});
});
- describe('memory helpers', () => {
+ describe('memory and trait helpers', () => {
type AiServicePrivate = typeof aiService & {
extractParticipantSlackIds: (
messages: Array<{ slackId: string; name: string; message: string }>,
options: { includeSlackId?: string; excludeSlackIds?: string[] },
) => string[];
- formatMemoryContext: (
- memories: Array<{ id: number; slackId: string; content: string }>,
+ formatTraitContext: (
+ traits: Array<{ slackId: string; content: string }>,
messages: Array<{ slackId: string; name: string; message: string }>,
) => string;
- appendMemoryContext: (base: string, context: string) => string;
- selectRelevantMemories: (
- conversation: string,
- memoryMap: Map>,
- ) => Promise;
- fetchMemoryContext: (
+ appendTraitContext: (base: string, context: string) => string;
+ fetchTraitContext: (
participantIds: string[],
teamId: string,
- conversation: string,
messages: Array<{ slackId: string; name: string; message: string }>,
) => Promise;
+ parseTraitExtractionResult: (raw: string | undefined) => string[];
+ regenerateTraitsForUsers: (teamId: string, slackIds: string[]) => Promise;
extractMemories: (teamId: string, channelId: string, history: string, participantIds: string[]) => Promise;
};
@@ -724,11 +765,11 @@ describe('AIService', () => {
expect(ids).toEqual(['U2', 'U3']);
});
- it('formats memory context grouped by participant', () => {
- const text = (aiService as unknown as AiServicePrivate).formatMemoryContext(
+ it('formats trait context grouped by participant', () => {
+ const text = (aiService as unknown as AiServicePrivate).formatTraitContext(
[
- { id: 1, slackId: 'U1', content: 'likes coffee' },
- { id: 2, slackId: 'U2', content: 'works on backend' },
+ { slackId: 'U1', content: 'prefers typescript' },
+ { slackId: 'U2', content: 'dislikes donald trump' },
],
[
{ slackId: 'U1', name: 'Alice', message: 'hi' },
@@ -736,150 +777,70 @@ describe('AIService', () => {
],
);
+ expect(text).toContain('traits_context');
expect(text).toContain('Alice');
- expect(text).toContain('likes coffee');
+ expect(text).toContain('prefers typescript');
expect(text).toContain('Bob');
});
- it('returns base instructions when no memory context', () => {
- const result = (aiService as unknown as AiServicePrivate).appendMemoryContext('base', '');
+ it('returns base instructions when no context', () => {
+ const result = (aiService as unknown as AiServicePrivate).appendTraitContext('base', '');
expect(result).toBe('base');
});
- it('inserts memory context before tag', () => {
+ it('inserts context before tag', () => {
const base = 'some instructions\n\nchecklist\n';
- const memory = '\ntest memory\n';
- const result = (aiService as unknown as AiServicePrivate).appendMemoryContext(base, memory);
- expect(result).toContain('test memory');
- expect(result.indexOf('memory_context')).toBeLessThan(result.indexOf(''));
- });
-
- it('appends memory context at end when no tag', () => {
- const base = 'simple instructions without verification';
- const memory = '\ntest memory\n';
- const result = (aiService as unknown as AiServicePrivate).appendMemoryContext(base, memory);
- expect(result).toBe(`${base}\n\n${memory}`);
+ const traitContext = '\ntest trait\n';
+ const result = (aiService as unknown as AiServicePrivate).appendTraitContext(base, traitContext);
+ expect(result).toContain('test trait');
+ expect(result.indexOf('traits_context')).toBeLessThan(result.indexOf(''));
});
- it('selects relevant memories from model output ids', async () => {
- (aiService.openAi.responses.create as Mock).mockResolvedValue({
- output: [{ type: 'message', content: [{ type: 'output_text', text: '[1,3]' }] }],
- });
- const map = new Map([
- ['U1', [{ id: 1, slackId: 'U1', content: 'a' }]],
- ['U2', [{ id: 3, slackId: 'U2', content: 'b' }]],
- ]);
-
- const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories('conv', map);
-
- expect(selected).toHaveLength(2);
- });
-
- it('returns empty memory selection when model response is malformed', async () => {
- (aiService.openAi.responses.create as Mock).mockResolvedValue({
- output: [{ type: 'message', content: [{ type: 'output_text', text: '{not-json}' }] }],
- });
-
- const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories(
- 'conv',
- new Map([['U1', [{ id: 1 }]]]),
+ it('fetches trait context end-to-end', async () => {
+ (aiService.traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue(
+ new Map([['U1', [{ id: 1, slackId: 'U1', content: 'prefers typescript' }]]]),
);
- expect(selected).toEqual([]);
- });
-
- it('returns empty array without calling model when memoriesMap is empty', async () => {
- const createSpy = aiService.openAi.responses.create as Mock;
-
- const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories('conv', new Map());
- expect(selected).toEqual([]);
- expect(createSpy).not.toHaveBeenCalled();
- });
-
- it('passes conversation as input and memories as instructions (not duplicated)', async () => {
- const createSpy = aiService.openAi.responses.create as Mock;
- createSpy.mockResolvedValue({
- output: [{ type: 'message', content: [{ type: 'output_text', text: '[1]' }] }],
- });
- const conversation = 'Alice: hey what is up';
- const map = new Map([['U1', [{ id: 1, slackId: 'U1', content: 'likes tea' }]]]);
-
- await (aiService as unknown as AiServicePrivate).selectRelevantMemories(conversation, map);
-
- expect(createSpy).toHaveBeenCalledTimes(1);
- const callArgs = createSpy.mock.calls[0][0] as { instructions: string; input: string };
- expect(callArgs.input).toBe(conversation);
- expect(callArgs.instructions).not.toBe(conversation);
- expect(callArgs.instructions).not.toContain(conversation);
- });
-
- it('returns empty array and warns when model call throws', async () => {
- (aiService.openAi.responses.create as Mock).mockRejectedValue(new Error('model error'));
- const warnSpy = vi.spyOn(aiService.aiServiceLogger, 'warn');
-
- const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories(
- 'conv',
- new Map([['U1', [{ id: 1 }]]]),
- );
+ const context = await (aiService as unknown as AiServicePrivate).fetchTraitContext(['U1'], 'T1', [
+ { slackId: 'U1', name: 'Alice', message: 'msg' },
+ ]);
- expect(selected).toEqual([]);
- expect(warnSpy).toHaveBeenCalled();
+ expect(context).toContain('prefers typescript');
});
- it('returns empty array when model returns non-array JSON', async () => {
- (aiService.openAi.responses.create as Mock).mockResolvedValue({
- output: [{ type: 'message', content: [{ type: 'output_text', text: '{"ids":[1,2]}' }] }],
- });
-
- const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories(
- 'conv',
- new Map([['U1', [{ id: 1 }]]]),
+ it('parses, de-duplicates, and caps extracted traits', () => {
+ const traits = (aiService as unknown as AiServicePrivate).parseTraitExtractionResult(
+ JSON.stringify([...Array.from({ length: 12 }, (_, i) => `trait-${i}`), 'trait-1']),
);
- expect(selected).toEqual([]);
+ expect(traits.length).toBe(10);
+ expect(new Set(traits).size).toBe(10);
});
- it('returns empty array when model returns empty array', async () => {
- (aiService.openAi.responses.create as Mock).mockResolvedValue({
- output: [{ type: 'message', content: [{ type: 'output_text', text: '[]' }] }],
- });
-
- const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories(
- 'conv',
- new Map([['U1', [{ id: 1 }]]]),
- );
-
- expect(selected).toEqual([]);
+ it('returns empty trait list for malformed extraction payload', () => {
+ const traits = (aiService as unknown as AiServicePrivate).parseTraitExtractionResult('{nope');
+ expect(traits).toEqual([]);
});
- it('filters out IDs from model response that do not exist in memoriesMap', async () => {
+ it('regenerates traits for users based on current memories', async () => {
+ (aiService.memoryPersistenceService.getAllMemoriesForUser as Mock)
+ .mockResolvedValueOnce([{ content: 'JR-15 loves TypeScript' }])
+ .mockResolvedValueOnce([]);
(aiService.openAi.responses.create as Mock).mockResolvedValue({
- output: [{ type: 'message', content: [{ type: 'output_text', text: '[1, 99]' }] }],
+ output: [
+ {
+ type: 'message',
+ content: [{ type: 'output_text', text: JSON.stringify(['JR-15 prefers TypeScript']) }],
+ },
+ ],
});
- const map = new Map([['U1', [{ id: 1, slackId: 'U1', content: 'likes tea' }]]]);
- const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories('conv', map);
-
- expect(selected).toHaveLength(1);
- expect(selected[0]).toMatchObject({ id: 1 });
- });
+ await (aiService as unknown as AiServicePrivate).regenerateTraitsForUsers('T1', ['U1', 'U2']);
- it('fetches memory context end-to-end', async () => {
- (aiService.memoryPersistenceService.getAllMemoriesForUsers as Mock).mockResolvedValue(
- new Map([['U1', [{ id: 1, slackId: 'U1', content: 'likes tea' }]]]),
- );
- vi.spyOn(aiService as unknown as AiServicePrivate, 'selectRelevantMemories').mockResolvedValue([
- { id: 1, slackId: 'U1', content: 'likes tea' },
+ expect(aiService.traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U1', 'T1', [
+ 'JR-15 prefers TypeScript',
]);
-
- const context = await (aiService as unknown as AiServicePrivate).fetchMemoryContext(
- ['U1'],
- 'T1',
- 'conversation',
- [{ slackId: 'U1', name: 'Alice', message: 'msg' }],
- );
-
- expect(context).toContain('likes tea');
+ expect(aiService.traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U2', 'T1', []);
});
it('extractMemories returns early when lock exists', async () => {
@@ -900,6 +861,7 @@ describe('AIService', () => {
await (aiService as unknown as AiServicePrivate).extractMemories('T1', 'C1', 'history', ['U1']);
expect(aiService.memoryPersistenceService.saveMemories).not.toHaveBeenCalled();
+ expect(aiService.traitPersistenceService.replaceTraitsForUser).not.toHaveBeenCalled();
});
it('extractMemories processes NEW, REINFORCE and EVOLVE modes', async () => {
@@ -927,6 +889,7 @@ describe('AIService', () => {
expect(aiService.memoryPersistenceService.saveMemories).toHaveBeenCalled();
expect(aiService.memoryPersistenceService.reinforceMemory).toHaveBeenCalledWith(10);
expect(aiService.memoryPersistenceService.deleteMemory).toHaveBeenCalledWith(11);
+ expect(aiService.traitPersistenceService.replaceTraitsForUser).toHaveBeenCalled();
});
it('extractMemories skips malformed extraction items', async () => {
diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts
index f26d0ab9..9f4181e4 100644
--- a/packages/backend/src/ai/ai.service.ts
+++ b/packages/backend/src/ai/ai.service.ts
@@ -19,12 +19,13 @@ import {
REDPLOY_MOONBEAM_TEXT_PROMPT,
GATE_MODEL,
MOONBEAM_SLACK_ID,
- MEMORY_SELECTION_PROMPT,
MEMORY_EXTRACTION_PROMPT,
+ TRAIT_EXTRACTION_PROMPT,
GPT_MODEL,
} from './ai.constants';
import { MemoryPersistenceService } from './memory/memory.persistence.service';
-import type { MemoryWithSlackId } from '../shared/db/models/Memory';
+import { TraitPersistenceService } from './trait/trait.persistence.service';
+import type { TraitWithSlackId } from '../shared/db/models/Trait';
import { logError } from '../shared/logger/error-logging';
import { logger } from '../shared/logger/logger';
import { SlackService } from '../shared/services/slack/slack.service';
@@ -106,6 +107,7 @@ export class AIService {
slackService = new SlackService();
slackPersistenceService = new SlackPersistenceService();
memoryPersistenceService = new MemoryPersistenceService();
+ traitPersistenceService = new TraitPersistenceService();
aiServiceLogger = logger.child({ module: 'AIService' });
public decrementDaiyRequests(userId: string, teamId: string): Promise {
@@ -399,17 +401,15 @@ export class AIService {
const customPrompt = await this.slackPersistenceService.getCustomPrompt(user_id, team_id);
const normalizedCustomPrompt = customPrompt?.trim() || null;
- // Fetch and select relevant memories
- const memoryContext = await this.fetchMemoryContext(
+ const traitContext = await this.fetchTraitContext(
this.extractParticipantSlackIds(history, { includeSlackId: user_id }),
team_id,
- `${formattedHistory}\n\nUser prompt: ${prompt}`,
history,
);
const baseInstructions = normalizedCustomPrompt
? `${normalizedCustomPrompt}\n\n${getHistoryInstructions(formattedHistory)}`
: getHistoryInstructions(formattedHistory);
- const systemInstructions = this.appendMemoryContext(baseInstructions, memoryContext);
+ const systemInstructions = this.appendTraitContext(baseInstructions, traitContext);
return this.openAi.responses
.create({
@@ -487,13 +487,13 @@ export class AIService {
const customPrompt = userId ? await this.slackPersistenceService.getCustomPrompt(userId, teamId) : null;
const normalizedCustomPrompt = customPrompt?.trim() || null;
- // Fetch and select relevant memories
+ // Fetch stable user traits instead of raw memories to reduce context size.
const participantSlackIds = this.extractParticipantSlackIds(historyMessages, {
excludeSlackIds: [MOONBEAM_SLACK_ID],
});
- const memoryContext = await this.fetchMemoryContext(participantSlackIds, teamId, history, historyMessages);
+ const traitContext = await this.fetchTraitContext(participantSlackIds, teamId, historyMessages);
const baseInstructions = normalizedCustomPrompt ?? MOONBEAM_SYSTEM_INSTRUCTIONS;
- const systemInstructions = this.appendMemoryContext(baseInstructions, memoryContext);
+ const systemInstructions = this.appendTraitContext(baseInstructions, traitContext);
const input = `${history}\n\n---\n[Tagged message to respond to]:\n${taggedMessage}`;
@@ -533,77 +533,30 @@ export class AIService {
});
}
- private async selectRelevantMemories(
- conversation: string,
- memoriesMap: Map,
- ): Promise {
- if (memoriesMap.size === 0) return [];
-
- const formattedMemories = Array.from(memoriesMap.entries())
- .map(([slackId, memories]) => {
- const lines = memories.map((m) => ` [ID:${m.id}] "${m.content}"`).join('\n');
- return `${slackId}:\n${lines}`;
- })
- .join('\n\n');
-
- const prompt = MEMORY_SELECTION_PROMPT.replace('{all_memories_grouped_by_user}', formattedMemories);
-
- try {
- const raw = await this.openAi.responses
- .create({
- model: GATE_MODEL,
- instructions: prompt,
- input: conversation,
- })
- .then((x) => extractAndParseOpenAiResponse(x));
-
- if (!raw) return [];
-
- let parsed: number[];
-
- try {
- parsed = JSON.parse(raw);
- } catch {
- this.aiServiceLogger.warn(`Memory selection returned malformed JSON: ${raw}`);
- parsed = [];
- }
-
- if (!Array.isArray(parsed)) return [];
- const selectedIds = parsed.map(Number).filter((n) => !isNaN(n));
-
- return Array.from(memoriesMap.values())
- .flat()
- .filter((m) => selectedIds.includes(m.id));
- } catch (e) {
- this.aiServiceLogger.warn('Memory selection failed, proceeding without memories:', e);
- return [];
- }
- }
-
- private formatMemoryContext(memories: MemoryWithSlackId[], history: MessageWithName[]): string {
- if (memories.length === 0) return '';
+ private 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 mem of memories) {
- const slackId = mem.slackId || 'unknown';
+ 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(mem);
+ grouped.get(slackId)!.push(trait);
}
const lines = Array.from(grouped.entries())
- .map(([slackId, mems]) => {
+ .map(([slackId, userTraits]) => {
const name = nameMap.get(slackId) || slackId;
- const memLines = mems.map((m) => `"${m.content}"`).join(', ');
- return `- ${name}: ${memLines}`;
+ const traitLines = userTraits.map((trait) => `"${trait.content}"`).join(', ');
+ return `- ${name}: ${traitLines}`;
})
.join('\n');
- return `\nthings you remember about the people in this conversation:\n${lines}\n`;
+ return `\ncore beliefs and stable traits for people in this conversation:\n${lines}\n`;
}
private extractParticipantSlackIds(
@@ -620,19 +573,18 @@ export class AIService {
return ids;
}
- private async fetchMemoryContext(
+ private async fetchTraitContext(
participantSlackIds: string[],
teamId: string,
- conversation: string,
history: MessageWithName[],
): Promise {
if (participantSlackIds.length === 0) return '';
- const memoriesMap = await this.memoryPersistenceService.getAllMemoriesForUsers(participantSlackIds, teamId);
- const selectedMemories = await this.selectRelevantMemories(conversation, memoriesMap);
- return this.formatMemoryContext(selectedMemories, history);
+ const traitsMap = await this.traitPersistenceService.getAllTraitsForUsers(participantSlackIds, teamId);
+ const traits = Array.from(traitsMap.values()).flat();
+ return this.formatTraitContext(traits, history);
}
- private appendMemoryContext(baseInstructions: string, memoryContext: string): string {
+ private appendTraitContext(baseInstructions: string, memoryContext: string): string {
if (!memoryContext) return baseInstructions;
// Insert memory data before so the verification checklist remains the last thing the model sees
const verificationTag = '';
@@ -767,6 +719,88 @@ export class AIService {
};
}
+ 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.aiServiceLogger.warn(`Trait extraction returned malformed JSON: ${raw}`);
+ return [];
+ }
+ }
+
+ 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 () => {
+ const currentIndex = nextIndex;
+ nextIndex += 1;
+
+ if (currentIndex >= items.length) {
+ return;
+ }
+
+ await worker(items[currentIndex]);
+ });
+
+ await Promise.all(runners);
+ }
+
+ private async regenerateTraitsForUsers(teamId: string, slackIds: string[]): Promise {
+ const uniqueSlackIds = Array.from(new Set(slackIds.filter((id) => /^U[A-Z0-9]+$/.test(id))));
+ if (uniqueSlackIds.length === 0) {
+ return;
+ }
+
+ const traitRegenerationConcurrency = 3;
+
+ await this.processWithConcurrencyLimit(uniqueSlackIds, traitRegenerationConcurrency, 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 this.openAi.responses
+ .create({
+ model: GATE_MODEL,
+ instructions: TRAIT_EXTRACTION_PROMPT,
+ input,
+ })
+ .then((response) => extractAndParseOpenAiResponse(response))
+ .catch((error) => {
+ this.aiServiceLogger.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 extractMemories(
teamId: string,
channelId: string,
@@ -824,6 +858,8 @@ export class AIService {
return;
}
+ const touchedUsers = new Set();
+
for (const extraction of extractions) {
if (!extraction.slackId || !extraction.content || !extraction.mode) {
this.aiServiceLogger.warn('Extraction missing required fields, skipping:', extraction);
@@ -838,11 +874,13 @@ export class AIService {
switch (extraction.mode) {
case 'NEW':
await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]);
+ touchedUsers.add(extraction.slackId);
break;
case 'REINFORCE':
if (extraction.existingMemoryId) {
await this.memoryPersistenceService.reinforceMemory(extraction.existingMemoryId);
+ touchedUsers.add(extraction.slackId);
} else {
this.aiServiceLogger.warn('REINFORCE extraction missing existingMemoryId, skipping');
}
@@ -853,6 +891,7 @@ export class AIService {
await this.memoryPersistenceService.deleteMemory(extraction.existingMemoryId);
}
await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]);
+ touchedUsers.add(extraction.slackId);
break;
default:
@@ -860,6 +899,8 @@ export class AIService {
}
}
+ await this.regenerateTraitsForUsers(teamId, [...touchedUsers]);
+
this.aiServiceLogger.info(`Extraction complete for ${channelId}: ${extractions.length} observations processed`);
} catch (e) {
this.aiServiceLogger.warn('Memory extraction failed:', e);
diff --git a/packages/backend/src/ai/trait/trait.persistence.service.spec.ts b/packages/backend/src/ai/trait/trait.persistence.service.spec.ts
new file mode 100644
index 00000000..8caf1ea6
--- /dev/null
+++ b/packages/backend/src/ai/trait/trait.persistence.service.spec.ts
@@ -0,0 +1,136 @@
+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/ai/trait/trait.persistence.service.ts b/packages/backend/src/ai/trait/trait.persistence.service.ts
new file mode 100644
index 00000000..3da79b45
--- /dev/null
+++ b/packages/backend/src/ai/trait/trait.persistence.service.ts
@@ -0,0 +1,104 @@
+import { getRepository } from 'typeorm';
+import type { TraitWithSlackId } from '../../shared/db/models/Trait';
+import { Trait } from '../../shared/db/models/Trait';
+import { SlackUser } from '../../shared/db/models/SlackUser';
+import { logError } from '../../shared/logger/error-logging';
+import { logger } from '../../shared/logger/logger';
+
+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