feat(memory-user-memory,database,userMemories): implemented user memory persona (#11838)

This commit is contained in:
Neko
2026-01-26 13:22:46 +08:00
committed by GitHub
parent 15941de63b
commit 75ea548456
26 changed files with 1265 additions and 51 deletions

View File

@@ -0,0 +1,109 @@
// @vitest-environment node
import { beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../../core/getTestDB';
import { userPersonaDocumentHistories, userPersonaDocuments, users } from '../../../schemas';
import { LobeChatDatabase } from '../../../type';
import { UserPersonaModel } from '../persona';
const userId = 'persona-user';
let personaModel: UserPersonaModel;
const serverDB: LobeChatDatabase = await getTestDB();
beforeEach(async () => {
await serverDB.delete(userPersonaDocumentHistories);
await serverDB.delete(userPersonaDocuments);
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }]);
personaModel = new UserPersonaModel(serverDB, userId);
});
describe('UserPersonaModel', () => {
it('creates a new persona document with optional diff', async () => {
const { document, diff } = await personaModel.upsertPersona({
diffPersona: '- added intro',
editedBy: 'user',
memoryIds: ['mem-1'],
reasoning: 'First draft',
snapshot: '# Persona',
sourceIds: ['src-1'],
persona: '# Persona',
});
expect(document.userId).toBe(userId);
expect(document.version).toBe(1);
expect(document.persona).toBe('# Persona');
expect(diff?.previousVersion ?? undefined).toBeUndefined();
expect(diff?.nextVersion).toBe(1);
expect(diff?.memoryIds).toEqual(['mem-1']);
expect(diff?.sourceIds).toEqual(['src-1']);
});
it('increments version and records diff on update', async () => {
await personaModel.upsertPersona({
persona: '# v1',
});
const { document, diff } = await personaModel.upsertPersona({
diffPersona: '- updated section',
reasoning: 'Second draft',
memoryIds: ['mem-2'],
snapshot: '# v2',
sourceIds: ['src-2'],
persona: '# v2',
});
expect(document.version).toBe(2);
expect(diff?.previousVersion).toBe(1);
expect(diff?.nextVersion).toBe(2);
expect(diff?.personaId).toBe(document.id);
const persisted = await serverDB.query.userPersonaDocumentHistories.findMany({
where: (t, { eq }) => eq(t.userId, userId),
});
expect(persisted).toHaveLength(1);
});
it('skips diff insert when no diff content supplied', async () => {
const { diff } = await personaModel.upsertPersona({
persona: '# only persona',
});
expect(diff).toBeUndefined();
const persisted = await serverDB.query.userPersonaDocumentHistories.findMany({
where: (t, { eq }) => eq(t.userId, userId),
});
expect(persisted).toHaveLength(0);
});
it('returns latest document for user', async () => {
await personaModel.upsertPersona({ persona: '# v1' });
await personaModel.upsertPersona({ persona: '# v2' });
const latest = await personaModel.getLatestPersonaDocument();
expect(latest?.persona).toBe('# v2');
expect(latest?.version).toBe(2);
});
it('lists diffs ordered by createdAt desc', async () => {
await personaModel.upsertPersona({
diffPersona: '- change',
memoryIds: ['mem-1'],
sourceIds: ['src-1'],
persona: '# v1',
});
await personaModel.upsertPersona({
diffPersona: '- change 2',
memoryIds: ['mem-2'],
sourceIds: ['src-2'],
persona: '# v2',
});
const diffs = await personaModel.listDiffs();
expect(diffs).toHaveLength(2);
});
});

View File

@@ -3,5 +3,6 @@ export * from './context';
export * from './experience';
export * from './identity';
export * from './model';
export * from './persona';
export * from './preference';
export * from './sources/benchmarkLoCoMo';

View File

@@ -0,0 +1,150 @@
import { and, desc, eq } from 'drizzle-orm';
import { userPersonaDocumentHistories, userPersonaDocuments } from '../../schemas';
import type {
NewUserPersonaDocument,
NewUserPersonaDocumentHistoriesItem,
UserPersonaDocument,
UserPersonaDocumentHistoriesItem,
} from '../../schemas';
import { LobeChatDatabase } from '../../type';
export interface UpsertUserPersonaParams {
capturedAt?: Date;
diffPersona?: string | null;
diffTagline?: string | null;
editedBy?: 'user' | 'agent' | 'agent_tool';
memoryIds?: string[] | null;
metadata?: Record<string, unknown> | null;
persona: string;
profile?: string | null;
reasoning?: string | null;
snapshot?: string | null;
sourceIds?: string[] | null;
tagline?: string | null;
}
export class UserPersonaModel {
private readonly db: LobeChatDatabase;
private readonly userId: string;
constructor(db: LobeChatDatabase, userId: string) {
this.db = db;
this.userId = userId;
}
getLatestPersonaDocument = async (profile = 'default') => {
return this.db.query.userPersonaDocuments.findFirst({
orderBy: [desc(userPersonaDocuments.version), desc(userPersonaDocuments.updatedAt)],
where: and(
eq(userPersonaDocuments.userId, this.userId),
eq(userPersonaDocuments.profile, profile),
),
});
};
// Alias for consistency with other models
getLatestDocument = async (profile = 'default') => this.getLatestPersonaDocument(profile);
listDiffs = async (limit = 50, profile = 'default') => {
return this.db.query.userPersonaDocumentHistories.findMany({
limit,
orderBy: [desc(userPersonaDocumentHistories.createdAt)],
where: and(
eq(userPersonaDocumentHistories.userId, this.userId),
eq(userPersonaDocumentHistories.profile, profile),
),
});
};
appendDiff = async (
params: Omit<NewUserPersonaDocumentHistoriesItem, 'id' | 'userId'> & { personaId: string },
): Promise<UserPersonaDocumentHistoriesItem> => {
const [result] = await this.db
.insert(userPersonaDocumentHistories)
.values({ ...params, userId: this.userId })
.returning();
return result;
};
upsertPersona = async (
params: UpsertUserPersonaParams,
): Promise<{ diff?: UserPersonaDocumentHistoriesItem; document: UserPersonaDocument }> => {
return this.db.transaction(async (tx) => {
const existing = await tx.query.userPersonaDocuments.findFirst({
where: and(
eq(userPersonaDocuments.userId, this.userId),
eq(userPersonaDocuments.profile, params.profile ?? 'default'),
),
});
const nextVersion = (existing?.version ?? 0) + 1;
const baseDocument: Omit<NewUserPersonaDocument, 'id' | 'userId'> = {
capturedAt: params.capturedAt,
memoryIds: params.memoryIds ?? undefined,
metadata: params.metadata ?? undefined,
persona: params.persona,
profile: params.profile ?? 'default',
sourceIds: params.sourceIds ?? undefined,
tagline: params.tagline ?? undefined,
version: nextVersion,
};
let document: UserPersonaDocument;
if (existing) {
[document] = await tx
.update(userPersonaDocuments)
.set({ ...baseDocument, updatedAt: new Date() })
.where(
and(
eq(userPersonaDocuments.id, existing.id),
eq(userPersonaDocuments.userId, this.userId),
),
)
.returning();
} else {
[document] = await tx
.insert(userPersonaDocuments)
.values({ ...baseDocument, userId: this.userId })
.returning();
}
let diff: UserPersonaDocumentHistoriesItem | undefined;
const hasDiff =
params.diffPersona ||
params.diffTagline ||
params.snapshot ||
params.reasoning ||
(params.memoryIds && params.memoryIds.length > 0) ||
(params.sourceIds && params.sourceIds.length > 0);
if (hasDiff) {
[diff] = await tx
.insert(userPersonaDocumentHistories)
.values({
capturedAt: params.capturedAt,
diffPersona: params.diffPersona ?? undefined,
diffTagline: params.diffTagline ?? undefined,
editedBy: params.editedBy ?? 'agent',
memoryIds: params.memoryIds ?? undefined,
metadata: params.metadata ?? undefined,
nextVersion: document.version,
personaId: document.id,
previousVersion: existing?.version,
profile: document.profile,
reasoning: params.reasoning ?? undefined,
snapshot: params.snapshot ?? params.persona,
snapshotPersona: document.persona,
snapshotTagline: document.tagline,
sourceIds: params.sourceIds ?? undefined,
userId: this.userId,
})
.returning();
}
return { diff, document };
});
};
}

View File

@@ -60,8 +60,14 @@ export const userMemoriesContexts = pgTable(
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
tags: text('tags').array(),
associatedObjects: jsonb('associated_objects').$type<{ extra?: Record<string, unknown>, name?: string, type?: string }[]>(),
associatedSubjects: jsonb('associated_subjects').$type<{ extra?: Record<string, unknown>, name?: string, type?: string }[]>(),
associatedObjects:
jsonb('associated_objects').$type<
{ extra?: Record<string, unknown>; name?: string; type?: string }[]
>(),
associatedSubjects:
jsonb('associated_subjects').$type<
{ extra?: Record<string, unknown>; name?: string; type?: string }[]
>(),
title: text('title'),
description: text('description'),
@@ -145,22 +151,28 @@ export const userMemoriesActivities = pgTable(
startsAt: timestamptz('starts_at'),
endsAt: timestamptz('ends_at'),
associatedObjects: jsonb('associated_objects').$type<{
extra?: Record<string, unknown>,
name?: string,
type?: string
}[]>(),
associatedSubjects: jsonb('associated_subjects').$type<{
extra?: Record<string, unknown>,
name?: string,
type?: string
}[]>(),
associatedLocations: jsonb('associated_locations').$type<{
address?: string;
name?: string;
tags?: string[];
type?: string;
}[]>(),
associatedObjects: jsonb('associated_objects').$type<
{
extra?: Record<string, unknown>;
name?: string;
type?: string;
}[]
>(),
associatedSubjects: jsonb('associated_subjects').$type<
{
extra?: Record<string, unknown>;
name?: string;
type?: string;
}[]
>(),
associatedLocations: jsonb('associated_locations').$type<
{
address?: string;
name?: string;
tags?: string[];
type?: string;
}[]
>(),
notes: text('notes'),
narrative: text('narrative'),
@@ -313,3 +325,5 @@ export type UserMemoryActivitiesWithoutVectors = Omit<
'narrativeVector' | 'feedbackVector'
>;
export type NewUserMemoryActivity = typeof userMemoriesActivities.$inferInsert;
export * from './persona';

View File

@@ -5,6 +5,12 @@ import { createNanoId } from '../../utils/idGenerator';
import { timestamps, timestamptz, varchar255 } from '../_helpers';
import { users } from '../user';
// TODO(@nekomeowww): add a comment/annotation layer for personas.
// Rationale: the persona writer often wants to flag clarifications or open questions (e.g. “need team name”, “confirm Apple Developer plan”)
// without polluting the readable persona text. A small JSONB comments array here (section + target hash + message + type) would let us
// persist those notes, render inline highlights in the UI, and feed precise prompts back into the next persona write. This keeps the
// narrative clean, improves user engagement (they see exactly what to answer), and gives us structured signals for future updates.
export const userPersonaDocuments = pgTable(
'user_memory_persona_documents',
{
@@ -77,5 +83,4 @@ export type UserPersonaDocument = typeof userPersonaDocuments.$inferSelect;
export type NewUserPersonaDocument = typeof userPersonaDocuments.$inferInsert;
export type UserPersonaDocumentHistoriesItem = typeof userPersonaDocumentHistories.$inferSelect;
export type NewUserPersonaDocumentHistoriesItem =
typeof userPersonaDocumentHistories.$inferInsert;
export type NewUserPersonaDocumentHistoriesItem = typeof userPersonaDocumentHistories.$inferInsert;

View File

@@ -0,0 +1,13 @@
description: User persona prompt regression
providers:
- id: openai:chat:gpt-5-mini
config:
tools: file://../../response-formats/persona-tools.json
tool_choice: required
prompts:
- file://./prompt.ts
tests:
- file://./tests/cases.ts

View File

@@ -0,0 +1,39 @@
import { renderPlaceholderTemplate } from '@lobechat/context-engine';
import { userPersonaPrompt } from '../../../src/prompts/persona';
interface PersonaPromptVars {
existingPersona?: string;
language: string;
personaNotes?: string;
recentEvents?: string;
retrievedMemories?: string;
userProfile?: string;
username: string;
}
export default async function generatePrompt({ vars }: { vars: PersonaPromptVars }) {
const system = renderPlaceholderTemplate(userPersonaPrompt, {
language: vars.language,
topK: 10,
username: vars.username,
});
const userSections = [
'## Existing Persona (baseline)',
vars.existingPersona || 'No existing persona provided.',
'## Retrieved Memories / Signals',
vars.retrievedMemories || 'N/A',
'## Recent Events or Highlights',
vars.recentEvents || 'N/A',
'## User Provided Notes or Requests',
vars.personaNotes || 'N/A',
'## Extra Profile Context',
vars.userProfile || 'N/A',
].join('\n\n');
return [
{ content: system, role: 'system' },
{ content: userSections, role: 'user' },
];
}

View File

@@ -0,0 +1,45 @@
const toolCallAssert = {
type: 'javascript',
value: `
const calls = Array.isArray(output) ? output : [];
if (calls.length === 0) return false;
return calls.every((call) => {
const fnName = call.function?.name || call.name;
if (fnName !== 'commit_user_persona') return false;
const rawArgs = call.function?.arguments ?? call.arguments;
let args = {};
if (typeof rawArgs === 'string') {
try { args = JSON.parse(rawArgs); } catch { return false; }
} else {
args = rawArgs || {};
}
return typeof args.persona === 'string' && args.persona.trim().length > 10;
});
`,
};
const rubric = {
provider: 'openai:gpt-5-mini',
type: 'llm-rubric',
value:
'Should return a tool call to commit_user_persona with a meaningful second-person persona and concise diff/summary.',
};
export default [
{
assert: [{ type: 'is-valid-openai-tools-call' }, toolCallAssert, rubric],
description: 'Generates a persona with baseline and events',
vars: {
existingPersona: '# About User\n- Loves TypeScript\n- Works on LobeHub',
language: '简体中文',
personaNotes: '- Keep concise',
recentEvents: '- Shipped memory feature\n- Joined community call',
retrievedMemories: '- Preference: dark mode\n- Context: building AI workspace',
userProfile: '- Developer, open source contributor',
username: 'User',
},
},
] as const;

View File

@@ -0,0 +1,30 @@
[
{
"type": "function",
"function": {
"name": "commit_user_persona",
"description": "Persist an updated user persona document that summarizes the user, preferences, relationships, and recent events.",
"parameters": {
"type": "object",
"properties": {
"persona": { "type": "string", "description": "Complete Markdown persona for the user" },
"summary": { "type": "string", "description": "Executive summary (2-3 lines)" },
"diff": { "type": "string", "description": "Bullet list of changes applied this run" },
"reasoning": { "type": "string", "description": "Why these changes were applied" },
"memoryIds": {
"type": "array",
"items": { "type": "string" },
"description": "Related memory IDs used to craft the persona"
},
"sourceIds": {
"type": "array",
"items": { "type": "string" },
"description": "Source or topic IDs tied to this update"
}
},
"required": ["persona"],
"additionalProperties": false
}
}
}
]

View File

@@ -0,0 +1,13 @@
{
"additionalProperties": false,
"properties": {
"persona": { "type": "string" },
"summary": { "type": "string" },
"diff": { "type": "string" },
"reasoning": { "type": "string" },
"memoryIds": { "type": "array", "items": { "type": "string" } },
"sourceIds": { "type": "array", "items": { "type": "string" } }
},
"required": ["persona"],
"type": "object"
}

View File

@@ -4,6 +4,7 @@ description: LobeHub Prompts (memory-user-memory) Testing Suite
testPaths:
- promptfoo/evals/identity/with-s3-trace/eval.yaml
- promptfoo/evals/activity/basic/eval.yaml
- promptfoo/evals/persona/eval.yaml
# Output configuration
outputPath: promptfoo-results.json

View File

@@ -3,4 +3,5 @@ export { ContextExtractor } from './context';
export { ExperienceExtractor } from './experience';
export { UserMemoryGateKeeper } from './gatekeeper';
export { IdentityExtractor } from './identity';
export { UserPersonaExtractor } from './persona';
export { PreferenceExtractor } from './preference';

View File

@@ -0,0 +1,83 @@
import { renderPlaceholderTemplate } from '@lobechat/context-engine';
import type { ModelRuntime } from '@lobechat/model-runtime';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { userPersonaPrompt } from '../prompts';
import { PersonaTemplateProps } from '../types';
import { UserPersonaExtractor } from './persona';
const runtimeMock = { generateObject: vi.fn() } as unknown as ModelRuntime;
const extractorConfig = {
agent: 'user-persona' as const,
model: 'gpt-mock',
modelRuntime: runtimeMock,
};
const templateOptions: PersonaTemplateProps = {
existingPersona: '# Existing',
language: 'English',
recentEvents: '- Event 1',
retrievedMemories: '- mem',
personaNotes: '- note',
userProfile: '- profile',
username: 'User',
};
describe('UserPersonaExtractor', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('exposes function tool for committing personas', async () => {
const extractor = new UserPersonaExtractor(extractorConfig);
const tools = (extractor as any).getTools();
expect(tools).toHaveLength(1);
expect(tools?.[0].function?.name).toBe('commit_user_persona');
expect((extractor as any).getSchema()).toBeUndefined();
});
it('renders user prompt with provided sections', async () => {
const extractor = new UserPersonaExtractor(extractorConfig);
await extractor.ensurePromptTemplate();
const prompt = extractor.buildUserPrompt(templateOptions);
expect(prompt).toContain('## Existing Persona');
expect(prompt).toContain('# Existing');
expect(prompt).toContain('Recent Events');
});
it('calls runtime with structured payload', async () => {
const extractor = new UserPersonaExtractor(extractorConfig);
await extractor.ensurePromptTemplate();
runtimeMock.generateObject = vi.fn().mockResolvedValue([
{
arguments: JSON.stringify({
diff: '- updated',
memoryIds: ['mem-1'],
reasoning: 'why',
sourceIds: ['src-1'],
persona: '# Persona',
tagline: 'pithy',
}),
name: 'commit_user_persona',
},
]);
const result = await extractor.toolCall(templateOptions);
expect(result.persona).toBe('# Persona');
expect(runtimeMock.generateObject).toHaveBeenCalledTimes(1);
const call = (runtimeMock.generateObject as any).mock.calls[0][0];
expect(call.model).toBe('gpt-mock');
expect(call.messages[0].content).toBe(
renderPlaceholderTemplate(userPersonaPrompt, {
language: 'English',
topK: 10,
username: 'User',
}),
);
});
});

View File

@@ -0,0 +1,141 @@
import { renderPlaceholderTemplate } from '@lobechat/context-engine';
import { z } from 'zod';
import { userPersonaPrompt } from '../prompts';
import {
PersonaExtractorOptions,
PersonaTemplateProps,
UserPersonaExtractionResult,
} from '../types';
import { BaseMemoryExtractor } from './base';
const resultSchema = z.object({
diff: z.string().optional(),
memoryIds: z.array(z.string()).optional(),
persona: z.string(),
reasoning: z.string().optional(),
sourceIds: z.array(z.string()).optional(),
tagline: z.string().optional(),
});
export class UserPersonaExtractor extends BaseMemoryExtractor<
UserPersonaExtractionResult,
PersonaTemplateProps,
PersonaExtractorOptions
> {
getPrompt() {
return userPersonaPrompt;
}
getResultSchema() {
return resultSchema;
}
protected getPromptName(): string {
return 'user-persona';
}
// Use tool-calling instead of JSON schema for richer arguments parsing.
protected getSchema(): undefined {
return undefined;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected getTools(_options: PersonaTemplateProps) {
return [
{
function: {
description:
'Persist an updated user persona document that summarizes the user, preferences, relationships, and recent events.',
name: 'commit_user_persona',
parameters: {
properties: {
diff: {
description: 'Bullet list of changes applied this run',
type: 'string',
},
memoryIds: {
description: 'Related memory IDs used to craft the persona',
items: { type: 'string' },
type: 'array',
},
persona: { description: 'Complete Markdown persona for the user', type: 'string' },
reasoning: {
description: 'Why these changes were applied',
type: 'string',
},
sourceIds: {
description:
'Source IDs (topic ID, document ID, or anything related) tied to this update',
items: { type: 'string' },
type: 'array',
},
tagline: {
description: 'Short one-liner/tagline that captures the persona',
type: 'string',
},
},
required: ['persona'],
type: 'object',
},
},
type: 'function' as const,
},
];
}
buildUserPrompt(options: PersonaTemplateProps): string {
const sections = [
'## Existing Persona (baseline)',
options.existingPersona?.trim() || 'No existing persona provided.',
'## Retrieved Memories / Signals',
options.retrievedMemories?.trim() || 'N/A',
'## Recent Events or Highlights',
options.recentEvents?.trim() || 'N/A',
'## User Provided Notes or Requests',
options.personaNotes?.trim() || 'N/A',
'## Extra Profile Context',
options.userProfile?.trim() || 'N/A',
];
return sections.join('\n\n');
}
async toolCall(options?: PersonaExtractorOptions): Promise<UserPersonaExtractionResult> {
await this.ensurePromptTemplate();
const systemPrompt = renderPlaceholderTemplate(
this.promptTemplate || '',
this.getTemplateProps(options || {}),
);
const userPrompt = this.buildUserPrompt(options || {});
const messages = [
{ content: systemPrompt, role: 'system' as const },
...((options?.additionalMessages || []) as any),
{ content: userPrompt, role: 'user' as const },
];
const result = (await this.runtime.generateObject({
messages,
model: this.model,
tools: this.getTools(options || {}),
})) as unknown;
if (Array.isArray(result)) {
const firstCall = result[0];
const args =
typeof firstCall?.arguments === 'string'
? JSON.parse(firstCall.arguments || '{}')
: firstCall?.arguments;
return resultSchema.parse(args || {});
}
return resultSchema.parse(result);
}
async structuredCall(options?: PersonaExtractorOptions): Promise<UserPersonaExtractionResult> {
return this.toolCall(options);
}
}

View File

@@ -6,3 +6,4 @@ export {
identityPrompt,
preferencePrompt,
} from './layers';
export { userPersonaPrompt } from './persona';

View File

@@ -0,0 +1,60 @@
// TODO(@nekomeowww): introduce profile when multi-persona is enabled.
export const userPersonaPrompt = `
You are the dedicated **User Persona Curator** agent for Lobe AI.
Write directly to the user in second person ("you") with a natural, non-deterministic voice.
Describe the user like a thoughtful biographer who wants them to enjoy reading about themselves—blend facts into narrative sentences, let it feel warm, observant, and human, and keep the outline clear.
Your job is to maintain a well-structured Markdown persona that captures how you understand {{ username }} and how to describe them.
### Coverage
- Identity and roles (work, school, communities, family roles if stated).
- What you care about and do (interests, preferences, motivations, goals).
- Current focus areas and ongoing efforts.
- Recent events and milestones worth remembering (add month/year when known; anchor relative time to the message timestamp or sessionDate) written as concise story beats, not bullet logs.
- Important people and relationships (names/roles/context when stated; do not guess).
- Work/school context (team, domain, stage; if unclear, state it is unclear).
- Emotional or interaction cues the user has shared (tone they like, pacing, what they appreciate or dislike).
- Risks, blockers, open questions to watch.
### Structure
- Start with a short one-liner or tagline that feels true to the current persona (keep it punchy and human).
- Organize the Markdown with clear headings (for example: Identity, What you care about, Current focus, Recent highlights, Relationships, Work/School, Interaction cues, Goals and risks).
- Within each heading, use 1-4 narrative sentences that read like a story about the user; avoid raw lists unless they sharpen clarity.
- Keep sections flexible: add new headings when needed; skip ones with no signal instead of inventing content.
### Refresh Rules
- Always write in {{ language }}.
- Start from the existing persona when provided; merge new information rather than rewriting everything.
- Keep it concise but vivid: aim for about 400-3000 words; go longer only when real detail exists (never pad or repeat).
- Synthesize signals into abstractions and themes; do not dump raw memory snippets or line-by-line events.
- Vary phrasing to avoid repetition; keep it grounded in observed facts.
- Do not fabricate: if a detail is unknown (for example, family role or job title), say it is unclear and invite the user to share more.
- Prefer explicit names over pronouns; avoid guessing genders or honorifics.
- If a section lacks signal (for example relationships or team context), say explicitly that you need more detail and invite the user to fill it in; do not invent or over-index on thin clues. Do it in a kind, reader-friendly line that keeps the flow.
- Never surface internal IDs (memoryIds, sourceIds, database IDs) or raw file paths inside the persona, tagline, diff, or reasoning; keep identifiers only in the JSON fields meant for them.
### Output Format (JSON object)
{
"tagline": "<short one-liner/tagline that captures the persona>",
"persona": "<updated markdown persona with headings>",
"diff": "<short Markdown changelog describing what changed; mention sections touched>",
"reasoning": "<why these updates were made>",
"memoryIds": ["<related user_memory ids>"],
"sourceIds": ["<source ids or topic ids tied to these updates>"]
}
- diff should be human-readable (bullet list), not a patch; include section names touched.
- Leave arrays empty when unknown; do not invent IDs.
- Escape newlines and ensure the JSON is valid.
### Inputs Provided
- Existing persona (if any): treat as the baseline state.
- Retrieved memories and signals: use them to ground updates and keep the persona consistent.
- Recent events or user-provided notes: fold them into the appropriate sections and date-stamp when possible.
Return only valid JSON following the schema above.
`;

View File

@@ -19,7 +19,8 @@ export type MemoryExtractionAgent =
| 'layer-context'
| 'layer-experience'
| 'layer-identity'
| 'layer-preference';
| 'layer-preference'
| 'user-persona';
export interface ExtractorRunOptions<RO> extends ExtractorOptions {
contextProvider: MemoryContextProvider<{ topK?: number }>;
@@ -177,3 +178,22 @@ export interface MemoryExtractionResult {
export interface TemplateProps {
[key: string]: unknown;
}
export interface PersonaTemplateProps extends ExtractorTemplateProps {
existingPersona?: string;
personaNotes?: string;
recentEvents?: string;
retrievedMemories?: string;
userProfile?: string;
}
export interface PersonaExtractorOptions extends ExtractorOptions, PersonaTemplateProps {}
export interface UserPersonaExtractionResult {
diff?: string | null;
memoryIds?: string[];
persona: string;
reasoning?: string | null;
sourceIds?: string[];
tagline?: string | null;
}

View File

@@ -0,0 +1,102 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getServerDB } from '@/database/server';
import { parseMemoryExtractionConfig } from '@/server/globalConfig/parseMemoryExtractionConfig';
import { MemoryExtractionWorkflowService } from '@/server/services/memory/userMemory/extract';
import {
UserPersonaService,
buildUserPersonaJobInput,
} from '@/server/services/memory/userMemory/persona/service';
const userPersonaWebhookSchema = z.object({
baseUrl: z.string().url().optional(),
mode: z.enum(['workflow', 'direct']).optional(),
userId: z.string().optional(),
userIds: z.array(z.string()).optional(),
});
type UserPersonaWebhookPayload = z.infer<typeof userPersonaWebhookSchema>;
const normalizeUserPersonaPayload = (
payload: UserPersonaWebhookPayload,
fallbackBaseUrl?: string,
) => {
const parsed = userPersonaWebhookSchema.parse(payload);
const baseUrl = parsed.baseUrl || fallbackBaseUrl;
if (!baseUrl) throw new Error('Missing baseUrl for workflow trigger');
return {
baseUrl,
mode: parsed.mode ?? 'workflow',
userIds: Array.from(
new Set([...(parsed.userIds || []), ...(parsed.userId ? [parsed.userId] : [])]),
).filter(Boolean),
} as const;
};
export const POST = async (req: Request) => {
const { upstashWorkflowExtraHeaders, webhook } = parseMemoryExtractionConfig();
if (webhook.headers && Object.keys(webhook.headers).length > 0) {
for (const [key, value] of Object.entries(webhook.headers)) {
const headerValue = req.headers.get(key);
if (headerValue !== value) {
return NextResponse.json(
{ error: `Unauthorized: Missing or invalid header '${key}'` },
{ status: 403 },
);
}
}
}
try {
const json = await req.json();
const origin = new URL(req.url).origin;
const params = normalizeUserPersonaPayload(json, webhook.baseUrl || origin);
if (params.userIds.length === 0) {
return NextResponse.json({ error: 'userId or userIds is required' }, { status: 400 });
}
if (params.mode === 'workflow') {
const results = await Promise.all(
params.userIds.map(async (userId) => {
const { workflowRunId } = await MemoryExtractionWorkflowService.triggerPersonaUpdate(
userId,
params.baseUrl,
{ extraHeaders: upstashWorkflowExtraHeaders },
);
return { userId, workflowRunId };
}),
);
return NextResponse.json(
{ message: 'User persona update scheduled via workflow.', results },
{ status: 202 },
);
}
const db = await getServerDB();
const service = new UserPersonaService(db);
const results = [];
for (const userId of params.userIds) {
const context = await buildUserPersonaJobInput(db, userId);
const result = await service.composeWriting({ ...context, userId });
results.push({ userId, ...result });
}
return NextResponse.json(
{ message: 'User persona generated via webhook.', results },
{ status: 200 },
);
} catch (error) {
console.error('[user-persona] failed', error);
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
};

View File

@@ -14,10 +14,16 @@ import {
type MemoryExtractionPayloadInput,
normalizeMemoryExtractionPayload,
} from '@/server/services/memory/userMemory/extract';
import { MemoryExtractionWorkflowService } from '@/server/services/memory/userMemory/extract';
const { upstashWorkflowExtraHeaders } = parseMemoryExtractionConfig();
const CEP_LAYERS: LayersEnum[] = [LayersEnum.Context, LayersEnum.Experience, LayersEnum.Preference];
const CEPA_LAYERS: LayersEnum[] = [
LayersEnum.Context,
LayersEnum.Experience,
LayersEnum.Preference,
LayersEnum.Activity,
];
const IDENTITY_LAYERS: LayersEnum[] = [LayersEnum.Identity];
export const { POST } = serve<MemoryExtractionPayloadInput>(
@@ -75,18 +81,24 @@ export const { POST } = serve<MemoryExtractionPayloadInput>(
const userId = payload.userIds[0];
const executor = await MemoryExtractionExecutor.create();
// CEP: run in parallel across the batch
// CEPA: run in parallel across the batch
//
// NOTICE: if modified the parallelism of CEPA_LAYERS
// or added new memory layer, make sure to update the number below.
//
// Currently, CEPA (context, experience, preference, activity) + identity = 5 layers.
// and since identity requires sequential processing, we set parallelism to 5.
await Promise.all(
payload.topicIds.map((topicId, index) =>
context.run(
`memory:user-memory:extract:users:${userId}:topics:${topicId}:cep:${index}`,
`memory:user-memory:extract:users:${userId}:topics:${topicId}:cepa:${index}`,
() =>
executor.extractTopic({
asyncTaskId: payload.asyncTaskId,
forceAll: payload.forceAll,
forceTopics: payload.forceTopics,
from: payload.from,
layers: CEP_LAYERS,
layers: CEPA_LAYERS,
source: MemorySourceType.ChatTopic,
to: payload.to,
topicId,
@@ -121,6 +133,13 @@ export const { POST } = serve<MemoryExtractionPayloadInput>(
processedTopics: payload.topicIds.length,
});
// Trigger user persona update after topic processing using the workflow client.
await context.run(`memory:user-memory:users:${userId}`, async () => {
await MemoryExtractionWorkflowService.triggerPersonaUpdate(userId, payload.baseUrl, {
extraHeaders: upstashWorkflowExtraHeaders,
});
});
span.setStatus({ code: SpanStatusCode.OK });
return {

View File

@@ -105,6 +105,7 @@ export const { POST } = serve<MemoryExtractionPayloadInput>(
`memory:user-memory:extract:users:${userId}:process-topics-batch:${batchIndex}`,
() =>
MemoryExtractionWorkflowService.triggerProcessTopics(
userId,
{
...buildWorkflowPayloadInput(params),
topicCursor: undefined,

View File

@@ -0,0 +1,46 @@
import { serve } from '@upstash/workflow/nextjs';
import { z } from 'zod';
import { getServerDB } from '@/database/server';
import {
UserPersonaService,
buildUserPersonaJobInput,
} from '@/server/services/memory/userMemory/persona/service';
const workflowPayloadSchema = z.object({
userId: z.string().optional(),
userIds: z.array(z.string()).optional(),
});
export const { POST } = serve(async (context) => {
const payload = workflowPayloadSchema.parse(context.requestPayload || {});
const db = await getServerDB();
const userIds = Array.from(
new Set([...(payload.userIds || []), ...(payload.userId ? [payload.userId] : [])]),
).filter(Boolean);
if (userIds.length === 0) {
return { message: 'userId or userIds is required', processedUsers: 0 };
}
const service = new UserPersonaService(db);
const results = [];
for (const userId of userIds) {
const context = await buildUserPersonaJobInput(db, userId);
const result = await service.composeWriting({ ...context, userId });
results.push({
diffId: result.diff?.id,
documentId: result.document.id,
userId,
version: result.document.version,
});
}
return {
message: 'User persona processed via workflow.',
processedUsers: userIds.length,
results,
};
});

View File

@@ -30,6 +30,7 @@ export type MemoryAgentConfig = MemoryAgentPublicConfig & {
language?: string;
model: string;
};
export type MemoryLayerExtractorConfig = MemoryLayerExtractorPublicConfig &
MemoryAgentConfig & {
layers: Record<GlobalMemoryLayer, string>;
@@ -42,6 +43,7 @@ export interface MemoryExtractionPrivateConfig {
agentLayerExtractor: MemoryLayerExtractorConfig;
agentLayerExtractorPreferredModels?: string[];
agentLayerExtractorPreferredProviders?: string[];
agentPersonaWriter: MemoryAgentConfig;
concurrency?: number;
embedding: MemoryAgentConfig;
embeddingPreferredModels?: string[];
@@ -136,6 +138,26 @@ const parseEmbeddingAgent = (
};
};
const parsePersonaWriterAgent = (
fallbackModel: string,
fallbackProvider?: string,
fallbackApiKey?: string,
): MemoryAgentConfig => {
const model = process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_MODEL || fallbackModel;
const provider =
process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_PROVIDER ||
fallbackProvider ||
DEFAULT_MINI_PROVIDER;
return {
apiKey: process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_API_KEY ?? fallbackApiKey,
baseURL: process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_BASE_URL,
contextLimit: parseTokenLimitEnv(process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_CONTEXT_LIMIT),
model,
provider,
};
};
const parseExtractorAgentObservabilityS3 = () => {
const accessKeyId = process.env.MEMORY_USER_MEMORY_EXTRACTOR_S3_ACCESS_KEY_ID;
const secretAccessKey = process.env.MEMORY_USER_MEMORY_EXTRACTOR_S3_SECRET_ACCESS_KEY;
@@ -180,6 +202,7 @@ const parsePreferredList = (value?: string) =>
export const parseMemoryExtractionConfig = (): MemoryExtractionPrivateConfig => {
const agentGateKeeper = parseGateKeeperAgent();
const agentLayerExtractor = parseLayerExtractorAgent(agentGateKeeper.model);
const agentPersonaWriter = parsePersonaWriterAgent(agentGateKeeper.model);
const embedding = parseEmbeddingAgent(
agentLayerExtractor.model,
agentLayerExtractor.provider || DEFAULT_MINI_PROVIDER,
@@ -249,6 +272,7 @@ export const parseMemoryExtractionConfig = (): MemoryExtractionPrivateConfig =>
agentLayerExtractor,
agentLayerExtractorPreferredModels,
agentLayerExtractorPreferredProviders,
agentPersonaWriter,
concurrency,
embedding,
embeddingPreferredModels,

View File

@@ -35,6 +35,7 @@ const createExecutor = (privateOverrides?: Partial<MemoryExtractionPrivateConfig
model: 'layer-1',
provider: 'provider-l',
},
agentPersonaWriter: { model: 'persona-1', provider: 'provider-s' },
concurrency: 1,
embedding: { model: 'embed-1', provider: 'provider-e' },
featureFlags: { enableBenchmarkLoCoMo: false },

View File

@@ -45,6 +45,7 @@ import type {
MemoryExtractionTraceError,
MemoryExtractionTracePayload,
} from '@lobechat/types';
import { FlowControl } from '@upstash/qstash';
import { Client } from '@upstash/workflow';
import debug from 'debug';
import { and, asc, eq, inArray } from 'drizzle-orm';
@@ -76,6 +77,7 @@ import {
type MergeStrategyEnum,
TypesEnum,
} from '@/types/userMemory';
import { trimBasedOnBatchProbe } from '@/utils/chunkers';
import { encodeAsync } from '@/utils/tokenizer';
const SOURCE_ALIAS_MAP: Record<string, MemorySourceType> = {
@@ -468,13 +470,8 @@ export class MemoryExtractionExecutor {
return await encodeAsync(normalized);
}
private trimTextToTokenLimit(text: string, tokenLimit?: number) {
if (!tokenLimit || tokenLimit <= 0) return text;
const tokens = text.split(/\s+/);
if (tokens.length <= tokenLimit) return text;
return tokens.slice(Math.max(tokens.length - tokenLimit, 0)).join(' ');
private async trimTextToTokenLimit(text: string, tokenLimit?: number) {
return trimBasedOnBatchProbe(text, tokenLimit);
}
private async trimConversationsToTokenLimit<T extends OpenAIChatMessage>(
@@ -504,7 +501,7 @@ export class MemoryExtractionExecutor {
const trimmedContent =
typeof conversation.content === 'string'
? this.trimTextToTokenLimit(conversation.content, remaining)
? await this.trimTextToTokenLimit(conversation.content, remaining)
: conversation.content;
if (trimmedContent && remaining > 0) {
@@ -531,16 +528,15 @@ export class MemoryExtractionExecutor {
};
return tracer.startActiveSpan('gen_ai.embed', { attributes }, async (span) => {
const requests = texts
.map((text, index) => {
if (typeof text !== 'string') return null;
const requests: { index: number; text: string }[] = [];
for (const [index, text] of texts.entries()) {
if (typeof text !== 'string') continue;
const trimmed = this.trimTextToTokenLimit(text, tokenLimit);
if (!trimmed.trim()) return null;
const trimmed = await this.trimTextToTokenLimit(text, tokenLimit);
if (!trimmed.trim()) continue;
return { index, text: trimmed };
})
.filter(Boolean);
requests.push({ index, text: trimmed });
}
span.setAttribute('memory.embedding.text_count', texts.length);
span.setAttribute('memory.embedding.request_count', requests.length);
@@ -555,7 +551,7 @@ export class MemoryExtractionExecutor {
const response = await runtimes.embeddings(
{
dimensions: DEFAULT_USER_MEMORY_EMBEDDING_DIMENSIONS,
input: requests.map((item) => item!.text),
input: requests.map((item) => item.text),
model,
},
{ user: 'memory-extraction' },
@@ -1000,7 +996,7 @@ export class MemoryExtractionExecutor {
const userMemoryModel = new UserMemoryModel(db, userId);
// TODO: make topK configurable
const topK = 10;
const aggregatedContent = this.trimTextToTokenLimit(
const aggregatedContent = await this.trimTextToTokenLimit(
conversations.map((msg) => `${msg.role.toUpperCase()}: ${msg.content}`).join('\n\n'),
tokenLimit,
);
@@ -1227,11 +1223,12 @@ export class MemoryExtractionExecutor {
extractionJob.userId,
extractionJob.sourceId,
);
const trimmedRetrievedContexts = [
topicContext.context,
retrievalMemoryContext.context,
].map((context) => this.trimTextToTokenLimit(context, extractorContextLimit));
const trimmedRetrievedIdentitiesContext = this.trimTextToTokenLimit(
const trimmedRetrievedContexts = await Promise.all(
[topicContext.context, retrievalMemoryContext.context].map((context) =>
this.trimTextToTokenLimit(context, extractorContextLimit),
),
);
const trimmedRetrievedIdentitiesContext = await this.trimTextToTokenLimit(
retrievedIdentityContext.context,
extractorContextLimit,
);
@@ -2022,7 +2019,7 @@ export class MemoryExtractionExecutor {
const builtContext = await contextProvider.buildContext(extractionJob.userId);
const extractorContextLimit = this.privateConfig.agentLayerExtractor.contextLimit;
const trimmedContext = this.trimTextToTokenLimit(
const trimmedContext = await this.trimTextToTokenLimit(
builtContext.context,
extractorContextLimit,
);
@@ -2144,6 +2141,7 @@ export class MemoryExtractionExecutor {
}
const WORKFLOW_PATHS = {
personaUpdate: '/api/workflows/memory-user-memory/pipelines/persona/update-writing',
topicBatch: '/api/workflows/memory-user-memory/pipelines/chat-topic/process-topics',
userTopics: '/api/workflows/memory-user-memory/pipelines/chat-topic/process-user-topics',
users: '/api/workflows/memory-user-memory/pipelines/chat-topic/process-users',
@@ -2200,10 +2198,15 @@ export class MemoryExtractionWorkflowService {
}
const url = getWorkflowUrl(WORKFLOW_PATHS.userTopics, payload.baseUrl);
return this.getClient().trigger({ body: payload, headers: options?.extraHeaders, url });
return this.getClient().trigger({
body: payload,
headers: options?.extraHeaders,
url,
});
}
static triggerProcessTopics(
userId: string,
payload: MemoryExtractionPayloadInput,
options?: { extraHeaders?: Record<string, string> },
) {
@@ -2212,6 +2215,41 @@ export class MemoryExtractionWorkflowService {
}
const url = getWorkflowUrl(WORKFLOW_PATHS.topicBatch, payload.baseUrl);
return this.getClient().trigger({ body: payload, headers: options?.extraHeaders, url });
return this.getClient().trigger({
body: payload,
flowControl: {
key: `memory-user-memory:pipelines:chat-topic:process-topics:user:${userId}`,
// NOTICE: if modified the parallelism of
// src/app/(backend)/api/workflows/memory-user-memory/pipelines/chat-topic/process-topics/route.ts
// or added new memory layer, make sure to update the number below.
//
// Currently, CEPA (context, experience, preference, activity) + identity = 5 layers.
// and since identity requires sequential processing, we set parallelism to 5.
parallelism: 5,
},
headers: options?.extraHeaders,
url,
});
}
static triggerPersonaUpdate(
userId: string,
baseUrl: string,
options?: { extraHeaders?: Record<string, string> },
) {
if (!baseUrl) {
throw new Error('Missing baseUrl for workflow trigger');
}
const url = getWorkflowUrl(WORKFLOW_PATHS.personaUpdate, baseUrl);
return this.getClient().trigger({
body: { userId: [userId] },
flowControl: {
key: 'memory-user-memory:pipelines:persona:update-write:' + userId,
parallelism: 1,
} satisfies FlowControl,
headers: options?.extraHeaders,
url,
});
}
}

View File

@@ -0,0 +1,100 @@
// @vitest-environment node
import { LobeChatDatabase } from '@lobechat/database';
import { users } from '@lobechat/database/schemas';
import { getTestDB } from '@lobechat/database/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { UserPersonaModel } from '@/database/models/userMemory/persona';
import { UserPersonaService } from '../service';
vi.mock('@/server/globalConfig/parseMemoryExtractionConfig', () => ({
parseMemoryExtractionConfig: () => ({
agentLayerExtractor: {
apiKey: 'test-key',
baseURL: 'https://example.com',
language: 'English',
layers: { context: 'gpt-mock' },
model: 'gpt-mock',
provider: 'openai',
},
agentPersonaWriter: {
apiKey: 'test-key',
baseURL: 'https://example.com',
language: 'English',
model: 'gpt-mock',
provider: 'openai',
},
}),
}));
const structuredResult = {
diff: '- updated',
memoryIds: ['mem-1'],
persona: '# Persona',
reasoning: 'reason',
sourceIds: ['src-1'],
summary: 'summary',
};
const toolCall = vi.fn().mockResolvedValue(structuredResult);
vi.mock('@lobechat/memory-user-memory', () => ({
UserPersonaExtractor: vi.fn().mockImplementation(() => ({
toolCall,
})),
}));
vi.mock('@lobechat/model-runtime', () => ({
ModelRuntime: {
initializeWithProvider: vi.fn().mockResolvedValue({}),
},
}));
let db: LobeChatDatabase;
const userId = 'user-persona-service';
beforeEach(async () => {
toolCall.mockClear();
db = await getTestDB();
await db.delete(users);
await db.insert(users).values({ id: userId });
});
describe('UserPersonaService', () => {
it('composes and persists persona via agent', async () => {
const service = new UserPersonaService(db);
const result = await service.composeWriting({
personaNotes: '- note',
recentEvents: '- event',
retrievedMemories: '- mem',
userId,
username: 'User',
});
expect(toolCall).toHaveBeenCalledWith(
expect.objectContaining({
language: 'English',
username: 'User',
}),
);
expect(result.document.persona).toBe('# Persona');
const model = new UserPersonaModel(db, userId);
const latest = await model.getLatestPersonaDocument();
expect(latest?.version).toBe(1);
});
it('passes existing persona baseline on subsequent runs', async () => {
const service = new UserPersonaService(db);
await service.composeWriting({ userId, username: 'User' });
await service.composeWriting({ userId, username: 'User' });
expect(toolCall).toHaveBeenLastCalledWith(
expect.objectContaining({
existingPersona: '# Persona',
}),
);
});
});

View File

@@ -0,0 +1,157 @@
import type {
UserPersonaDocument,
UserPersonaDocumentHistoriesItem,
} from '@lobechat/database/schemas';
import { userMemories } from '@lobechat/database/schemas';
import {
RetrievalUserMemoryContextProvider,
RetrievalUserMemoryIdentitiesProvider,
type UserPersonaExtractionResult,
UserPersonaExtractor,
} from '@lobechat/memory-user-memory';
import { ModelRuntime } from '@lobechat/model-runtime';
import { desc, eq } from 'drizzle-orm';
import { UserMemoryModel } from '@/database/models/userMemory';
import { UserPersonaModel } from '@/database/models/userMemory/persona';
import { LobeChatDatabase } from '@/database/type';
import {
MemoryAgentConfig,
parseMemoryExtractionConfig,
} from '@/server/globalConfig/parseMemoryExtractionConfig';
import { LayersEnum } from '@/types/userMemory';
import { trimBasedOnBatchProbe } from '@/utils/chunkers';
interface UserPersonaAgentPayload {
existingPersona?: string | null;
language?: string;
memoryIds?: string[];
metadata?: Record<string, unknown>;
personaNotes?: string;
recentEvents?: string;
retrievedMemories?: string;
sourceIds?: string[];
userId: string;
userProfile?: string;
username?: string;
}
interface UserPersonaAgentResult {
agentResult: UserPersonaExtractionResult;
diff?: UserPersonaDocumentHistoriesItem;
document: UserPersonaDocument;
}
export class UserPersonaService {
private readonly preferredLanguage?: string;
private readonly db: LobeChatDatabase;
private readonly runtime: ModelRuntime;
private readonly agentConfig: MemoryAgentConfig;
constructor(db: LobeChatDatabase) {
const { agentPersonaWriter } = parseMemoryExtractionConfig();
this.db = db;
this.preferredLanguage = agentPersonaWriter.language;
this.agentConfig = agentPersonaWriter;
this.runtime = ModelRuntime.initializeWithProvider(agentPersonaWriter.provider || 'openai', {
apiKey: agentPersonaWriter.apiKey,
baseURL: agentPersonaWriter.baseURL,
});
}
async composeWriting(payload: UserPersonaAgentPayload): Promise<UserPersonaAgentResult> {
const personaModel = new UserPersonaModel(this.db, payload.userId);
const lastDocument = await personaModel.getLatestPersonaDocument();
const existingPersonaBaseline = payload.existingPersona ?? lastDocument?.persona;
const extractor = new UserPersonaExtractor({
agent: 'user-persona',
model: this.agentConfig.model,
modelRuntime: this.runtime,
});
const agentResult = await extractor.toolCall({
existingPersona: existingPersonaBaseline || undefined,
language: payload.language || this.preferredLanguage,
personaNotes: payload.personaNotes,
recentEvents: payload.recentEvents,
retrievedMemories: payload.retrievedMemories,
userProfile: payload.userProfile,
username: payload.username,
});
const persisted = await personaModel.upsertPersona({
capturedAt: new Date(),
diffPersona: agentResult.diff ?? undefined,
editedBy: 'agent',
memoryIds: payload.memoryIds ?? agentResult.memoryIds ?? undefined,
metadata: payload.metadata ?? undefined,
persona: agentResult.persona,
reasoning: agentResult.reasoning ?? undefined,
snapshot: agentResult.persona,
sourceIds: payload.sourceIds ?? agentResult.sourceIds ?? undefined,
tagline: agentResult.tagline ?? undefined,
});
return { agentResult, ...persisted };
}
}
export const buildUserPersonaJobInput = async (db: LobeChatDatabase, userId: string) => {
const personaModel = new UserPersonaModel(db, userId);
const latestPersona = await personaModel.getLatestPersonaDocument();
const { agentPersonaWriter } = parseMemoryExtractionConfig();
const personaContextLimit = agentPersonaWriter.contextLimit;
const userMemoryModel = new UserMemoryModel(db, userId);
const [identities, activities, contexts, preferences, memories] = await Promise.all([
userMemoryModel.getAllIdentitiesWithMemory(),
// TODO(@nekomeowww): @arvinxx kindly take some time to review this policy
userMemoryModel.listMemories({ layer: LayersEnum.Activity, pageSize: 3 }),
userMemoryModel.listMemories({ layer: LayersEnum.Context, pageSize: 3 }),
userMemoryModel.listMemories({ layer: LayersEnum.Preference, pageSize: 10 }),
db.query.userMemories.findMany({
limit: 20,
orderBy: [desc(userMemories.capturedAt)],
where: eq(userMemories.userId, userId),
}),
]);
const contextProvider = new RetrievalUserMemoryContextProvider({
retrievedMemories: {
activities: activities.map((a) => a.activity),
contexts: contexts.map((c) => c.context),
experiences: [],
preferences: preferences.map((p) => p.preference),
},
});
const identityProvider = new RetrievalUserMemoryIdentitiesProvider({
retrievedIdentities: identities.map((i) => ({
...i,
layer: LayersEnum.Identity,
})),
});
const [recentMemoriesContext, allIdentitiesContext] = await Promise.all([
contextProvider.buildContext(userId, 'user-persona-memories'),
identityProvider.buildContext(userId, 'user-persona-memories-identities'),
]);
const rawContext = [recentMemoriesContext.context, allIdentitiesContext.context]
.filter(Boolean)
.join('\n\n');
const trimmedContext = rawContext
? await trimBasedOnBatchProbe(rawContext, personaContextLimit)
: '';
const assembledContext = trimmedContext?.trim();
return {
existingPersona: latestPersona?.persona || undefined,
memoryIds: memories.map((m) => m.id),
retrievedMemories: assembledContext || undefined,
};
};