/** * Live-model e2e for LifeOps memory: advanced memory stays enabled by default, * confirmation-gated task creation, and cross-channel recall of stored * summaries/facts/long-term memories. Boots a real runtime against a live LLM. */ import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { buildCharacterFromConfig, createElizaPlugin, extractPlugin, type PluginModuleShape, } from "@elizaos/agent"; import { AgentRuntime, ChannelType, createMessageMemory, logger, type Memory, type Plugin, type UUID, } from "@elizaos/core"; import dotenv from "dotenv"; import { afterAll, beforeAll, expect, it } from "vitest"; import { describeIf } from "../../../packages/test/helpers/conditional-tests.ts"; import { saveEnv, sleep, withTimeout, } from "../../../packages/test/helpers/test-utils"; import { readLifeOpsOwnerProfile } from "../src/lifeops/owner-profile.js"; import { LifeOpsRepository } from "../src/lifeops/repository.js"; import { LifeOpsService } from "../src/lifeops/service.js"; import { personalAssistantPlugin } from "../src/plugin.js"; import { applyLocalEmbeddingDefaults, getLifeOpsLiveSetupWarnings, getSelectedLiveProviderEnv, LIVE_CLOUD_ENV_PREFIXES, LIVE_PROVIDER_ENV_KEYS, LIVE_TESTS_ENABLED, selectLifeOpsLiveProvider, } from "./helpers/lifeops-live-harness.ts"; const testDir = path.dirname(fileURLToPath(import.meta.url)); const packageRoot = path.resolve(testDir, ".."); dotenv.config({ path: path.resolve(packageRoot, ".env") }); dotenv.config({ path: path.resolve(packageRoot, "..", "..", ".env") }); type SessionSummaryLike = { id: UUID; summary: string; messageCount: number; topics?: string[]; }; type LongTermMemoryLike = { id: UUID; content: string; category: string; }; type MemoryServiceLike = { getCurrentSessionSummary(roomId: UUID): Promise; getLongTermMemories( entityId: UUID, category?: string, limit?: number, ): Promise; }; async function loadPlugin(name: string): Promise { try { return extractPlugin( (await import(name)) as PluginModuleShape, ) as Plugin | null; } catch (error) { logger.warn( `[lifeops-memory-live] failed to load ${name}: ${ error instanceof Error ? error.message : String(error) }`, ); return null; } } function normalizeText(text: string): string { return text.toLowerCase().replace(/\s+/g, " ").trim(); } function readPersistedOwnerName(configPath: string): string { if (!fs.existsSync(configPath)) { return ""; } try { const raw = fs.readFileSync(configPath, "utf-8"); const parsed = JSON.parse(raw) as { ui?: { ownerName?: unknown; }; }; return normalizeText(String(parsed.ui?.ownerName ?? "")); } catch { return ""; } } async function handleMessageAndCollectText( runtime: AgentRuntime, message: ReturnType, timeoutMs = 120_000, ): Promise { let responseText = ""; const result = await withTimeout( Promise.resolve( runtime.messageService?.handleMessage( runtime, message, async (content: { text?: string }) => { if (content.text) { responseText += content.text; } return []; }, ), ), timeoutMs, "handleMessage", ); return responseText || String(result?.responseContent?.text ?? ""); } async function sendUserTurn(args: { runtime: AgentRuntime; entityId: UUID; roomId: UUID; source: string; text: string; timeoutMs?: number; }): Promise { const message = createMessageMemory({ id: crypto.randomUUID() as UUID, entityId: args.entityId, roomId: args.roomId, metadata: { type: "user_message", entityName: "shaw", }, content: { text: args.text, source: args.source, channelType: ChannelType.DM, }, }); const responseText = await handleMessageAndCollectText( args.runtime, message, args.timeoutMs, ); return responseText; } async function waitForValue( label: string, getValue: () => Promise, predicate: (value: T) => boolean, timeoutMs = 60_000, intervalMs = 1_000, ): Promise { const deadline = Date.now() + timeoutMs; let lastValue: T | undefined; while (Date.now() < deadline) { lastValue = await getValue(); if (predicate(lastValue)) { return lastValue; } await sleep(intervalMs); } throw new Error( `Timed out waiting for ${label}: ${JSON.stringify(lastValue)}`, ); } async function ensureDmRoom(args: { runtime: AgentRuntime; entityId: UUID; roomId: UUID; worldId: UUID; source: string; channelId: string; userName: string; }): Promise { await args.runtime.ensureWorldExists({ id: args.worldId, name: `${args.source}-world`, agentId: args.runtime.agentId, } as Parameters[0]); await args.runtime.ensureConnection({ entityId: args.entityId, roomId: args.roomId, worldId: args.worldId, userName: args.userName, name: args.userName, source: args.source, channelId: args.channelId, type: ChannelType.DM, }); await args.runtime.ensureParticipantInRoom(args.runtime.agentId, args.roomId); await args.runtime.ensureParticipantInRoom(args.entityId, args.roomId); } function findDefinitionByTitle( definitions: Awaited>, title: string, ) { return ( definitions.find( (entry) => normalizeText(entry.definition.title) === normalizeText(title), ) ?? null ); } const selectedLiveProvider = await selectLifeOpsLiveProvider(); const selectedProviderEnv = getSelectedLiveProviderEnv(selectedLiveProvider); // Cerebras is a first-class live provider for this suite: the harness selects // it as its own named provider and routes it through @elizaos/plugin-openai's // OpenAI-compatible mode (OPENAI_BASE_URL → api.cerebras.ai), matching the // runtime's own "cerebras" provider id in the first-run catalog. const MEMORY_SUITE_PROVIDER_NAMES = new Set([ "cerebras", "openai", "openrouter", "google", "anthropic", ]); const MEMORY_SUITE_PROVIDER_SUPPORTED = selectedLiveProvider !== null && MEMORY_SUITE_PROVIDER_NAMES.has(selectedLiveProvider.name); const LIVE_SUITE_ENABLED = LIVE_TESTS_ENABLED && selectedLiveProvider !== null && MEMORY_SUITE_PROVIDER_SUPPORTED; if (!LIVE_SUITE_ENABLED) { const warnings = [ ...getLifeOpsLiveSetupWarnings(selectedLiveProvider), selectedLiveProvider && !MEMORY_SUITE_PROVIDER_SUPPORTED ? `selected provider "${selectedLiveProvider.name}" does not support the reflection/fact-extraction live suite; use Cerebras, OpenAI, OpenRouter, Google, or Anthropic` : null, ].filter((entry): entry is string => Boolean(entry)); console.info( `[lifeops-memory-live] suite skipped until setup is complete: ${warnings.join(" | ")}`, ); } describeIf(LIVE_SUITE_ENABLED)( "Live: LifeOps multi-turn memory and cross-channel behavior", () => { let runtime: AgentRuntime; let lifeOpsService: LifeOpsService; let memoryService: MemoryServiceLike; let envBackup: { restore: () => void }; let cloudEnvBackup: Record = {}; const ownerId = crypto.randomUUID() as UUID; const workspaceDir = fs.mkdtempSync( path.join(os.tmpdir(), "eliza-lifeops-live-workspace-"), ); const stateDir = fs.mkdtempSync( path.join(os.tmpdir(), "eliza-lifeops-live-state-"), ); const configPath = path.join(stateDir, "eliza.json"); const pgliteDir = fs.mkdtempSync( path.join(os.tmpdir(), "eliza-lifeops-live-pglite-"), ); const envKeys = [ ...LIVE_PROVIDER_ENV_KEYS, "PGLITE_DATA_DIR", "LOCAL_EMBEDDING_DIMENSIONS", "EMBEDDING_DIMENSION", "ELIZA_DISABLE_LOCAL_EMBEDDINGS", "ELIZA_STATE_DIR", "ELIZA_CONFIG_PATH", "ELIZA_PERSIST_CONFIG_PATH", ]; beforeAll(async () => { envBackup = saveEnv(...envKeys); process.env.PGLITE_DATA_DIR = pgliteDir; process.env.ELIZA_STATE_DIR = stateDir; process.env.ELIZA_CONFIG_PATH = configPath; process.env.ELIZA_PERSIST_CONFIG_PATH = configPath; delete process.env.ELIZA_STATE_DIR; delete process.env.ELIZA_CONFIG_PATH; delete process.env.ELIZA_PERSIST_CONFIG_PATH; process.env.LOG_LEVEL = process.env.ELIZA_E2E_LOG_LEVEL ?? "error"; applyLocalEmbeddingDefaults(process.env); cloudEnvBackup = Object.fromEntries( Object.entries(process.env).filter( ([key, value]) => typeof value === "string" && LIVE_CLOUD_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)), ), ); for (const key of Object.keys(process.env)) { if (LIVE_CLOUD_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) { delete process.env[key]; } } for (const key of LIVE_PROVIDER_ENV_KEYS) { delete process.env[key]; } Object.assign(process.env, selectedProviderEnv); const character = buildCharacterFromConfig({}); character.settings = { ...character.settings, ELIZA_ADMIN_ENTITY_ID: ownerId, MEMORY_SUMMARIZATION_THRESHOLD: 4, MEMORY_SUMMARIZATION_INTERVAL: 1, MEMORY_RETAIN_RECENT: 2, MEMORY_MAX_NEW_MESSAGES: 12, MEMORY_EXTRACTION_THRESHOLD: 4, MEMORY_EXTRACTION_INTERVAL: 1, }; character.secrets = { ...selectedProviderEnv, }; const sqlPlugin = await loadPlugin("@elizaos/plugin-sql"); const schedulingPlugin = await loadPlugin("@elizaos/plugin-scheduling"); const providerPlugin = selectedLiveProvider ? await loadPlugin(selectedLiveProvider.plugin) : null; if (!sqlPlugin || !schedulingPlugin || !providerPlugin) { throw new Error("Required live plugins were not available."); } // personalAssistantPlugin supplies the LifeOps schema migration and the // routine/reminder action surface the confirmation-gated flows drive; // plugin-scheduling hosts the ScheduledTaskRunnerService PA's runner // wiring expects (always loaded in production). runtime = new AgentRuntime({ character, plugins: [ providerPlugin, createElizaPlugin({ agentId: "main", workspaceDir, }), schedulingPlugin, personalAssistantPlugin as Plugin, ], conversationLength: 12, enableAutonomy: false, logLevel: process.env.ELIZA_E2E_LOG_LEVEL ?? "error", }); await runtime.registerPlugin(sqlPlugin); if (runtime.adapter && !(await runtime.adapter.isReady())) { await runtime.adapter.init(); } await runtime.initialize(); // Bootstrap the LifeOps schema (plus carve-out mirrors) before any // repository-backed reads/writes — listDefinitions and the routine // confirmation flow 42P01 if they outrun migration. await LifeOpsRepository.bootstrapSchema(runtime); lifeOpsService = new LifeOpsService(runtime, { ownerEntityId: ownerId, }); memoryService = (await runtime.getServiceLoadPromise( "memory", )) as MemoryServiceLike; }, 180_000); afterAll(async () => { if (runtime) { try { await withTimeout(runtime.stop(), 90_000, "runtime.stop()"); } catch (error) { logger.warn( `[lifeops-memory-live] runtime.stop failed: ${ error instanceof Error ? error.message : String(error) }`, ); } } envBackup?.restore(); for (const key of Object.keys(process.env)) { if (LIVE_CLOUD_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) { delete process.env[key]; } } for (const [key, value] of Object.entries(cloudEnvBackup)) { process.env[key] = value; } fs.rmSync(workspaceDir, { recursive: true, force: true }); fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(pgliteDir, { recursive: true, force: true }); }, 120_000); it("keeps advanced memory enabled by default in the live Eliza runtime", async () => { expect(runtime.character.advancedMemory).toBe(true); expect(memoryService).toBeTruthy(); expect( runtime.providers.some( (provider) => provider.name === "SUMMARIZED_CONTEXT", ), ).toBe(true); expect( runtime.providers.some( (provider) => provider.name === "LONG_TERM_MEMORY", ), ).toBe(true); expect( runtime.actions.some( (action) => action.name === "MEMORY_SUMMARIZATION", ), ).toBe(true); expect( runtime.actions.some( (action) => action.name === "LONG_TERM_MEMORY_EXTRACTION", ), ).toBe(true); expect( runtime.actions.some((action) => action.name === "REFLECTION"), ).toBe(true); }); it("starts with smalltalk, previews brush-teeth creation, then saves it only after confirmation", async () => { const roomId = crypto.randomUUID() as UUID; const worldId = crypto.randomUUID() as UUID; await ensureDmRoom({ runtime, entityId: ownerId, roomId, worldId, source: "telegram", channelId: `telegram-${roomId}`, userName: "shaw", }); const turn1 = await sendUserTurn({ runtime, entityId: ownerId, roomId, source: "telegram", text: "hey, mornings have been a little chaotic lately.", }); expect(turn1.trim().length).toBeGreaterThan(0); const turn2 = await sendUserTurn({ runtime, entityId: ownerId, roomId, source: "telegram", text: "the main thing i keep forgetting is brushing my teeth before i start working.", }); expect(turn2.trim().length).toBeGreaterThan(0); const beforePreviewDefinitions = await lifeOpsService.listDefinitions(); expect( findDefinitionByTitle(beforePreviewDefinitions, "Brush teeth"), ).toBeNull(); const createPrompt = "Please make that into a routine named Brush teeth with reminders around 8am and 9pm. Just preview the plan for now and do not save it yet."; const previewResponse = await sendUserTurn({ runtime, entityId: ownerId, roomId, source: "telegram", text: createPrompt, }); expect(previewResponse.trim().length).toBeGreaterThan(0); expect( findDefinitionByTitle( await lifeOpsService.listDefinitions(), "Brush teeth", ), ).toBeNull(); const confirmResponse = await sendUserTurn({ runtime, entityId: ownerId, roomId, source: "telegram", text: "Yes, save that Brush teeth routine now with reminders at 8am and 9pm.", }); expect(confirmResponse).toMatch(/saved/i); const brushTeeth = await waitForValue( "brush-teeth definition", async () => findDefinitionByTitle( await lifeOpsService.listDefinitions(), "Brush teeth", ), (entry) => entry !== null, ); expect(brushTeeth?.definition.cadence).toMatchObject({ kind: "times_per_day", slots: expect.arrayContaining([ expect.objectContaining({ minuteOfDay: 8 * 60 }), expect.objectContaining({ minuteOfDay: 21 * 60 }), ]), }); expect(brushTeeth?.reminderPlan?.id ?? null).not.toBeNull(); const preferencePrompt = "Now turn the Brush teeth reminder intensity down to minimal."; const preferenceResponse = await sendUserTurn({ runtime, entityId: ownerId, roomId, source: "telegram", text: preferencePrompt, }); expect(preferenceResponse).toMatch(/brush teeth/i); expect(preferenceResponse).toMatch(/minimal/i); const preference = await lifeOpsService.getReminderPreference( brushTeeth?.definition.id, ); expect(preference.effective.intensity).toBe("minimal"); }, 240_000); it("stores summaries, reflection facts, and long-term memories, then recalls them from another channel", async () => { const sourceRoomId = crypto.randomUUID() as UUID; const sourceWorldId = crypto.randomUUID() as UUID; const targetRoomId = crypto.randomUUID() as UUID; const targetWorldId = crypto.randomUUID() as UUID; await ensureDmRoom({ runtime, entityId: ownerId, roomId: sourceRoomId, worldId: sourceWorldId, source: "telegram", channelId: `telegram-${sourceRoomId}`, userName: "shaw", }); await ensureDmRoom({ runtime, entityId: ownerId, roomId: targetRoomId, worldId: targetWorldId, source: "discord", channelId: `discord-${targetRoomId}`, userName: "shaw", }); const setupTurns = [ "hey, quick check-in before we get into anything serious.", "small thing to remember: i always prefer text reminders and i do not want phone-call reminders.", "to be explicit, that is a stable preference for me: text reminders only, never phone calls.", "also, i wear Invisalign during the day and i usually forget to put it back in after lunch.", "that invisalign thing is a real recurring pattern for me, especially on weekdays after lunch.", "gentle nudges work better for me than aggressive ones.", "can you keep those preferences in mind for later?", ]; for (const text of setupTurns) { await sendUserTurn({ runtime, entityId: ownerId, roomId: sourceRoomId, source: "telegram", text, }); } const sessionSummary = await memoryService.getCurrentSessionSummary(sourceRoomId); if (sessionSummary) { expect(sessionSummary.summary.trim().length).toBeGreaterThan(0); } const reflectionFacts = await waitForValue( "reflection facts", async () => (await runtime.getMemories({ tableName: "facts", roomId: sourceRoomId, count: 20, unique: false, })) as Memory[], (facts) => facts.some((fact) => /text|phone|invisalign/i.test(String(fact.content?.text ?? "")), ), 120_000, ); expect(reflectionFacts.length).toBeGreaterThan(0); const relationships = await waitForValue( "reflection relationships", async () => await runtime.getRelationships({ entityIds: [ownerId], }), (entries) => Array.isArray(entries) && entries.length > 0, ); expect(relationships.length).toBeGreaterThan(0); const longTermMemories = await waitForValue( "long-term memories", async () => memoryService.getLongTermMemories(ownerId, undefined, 10), (memories) => memories.some((memory) => /text|phone|invisalign/i.test(memory.content), ), 90_000, ); expect(longTermMemories.length).toBeGreaterThan(0); const crossChannelResponse = await sendUserTurn({ runtime, entityId: ownerId, roomId: targetRoomId, source: "discord", text: "we switched channels. what reminder channel do i prefer, and what do i usually forget after lunch?", }); const normalizedResponse = normalizeText(crossChannelResponse); expect(normalizedResponse).toContain("text"); expect(normalizedResponse).toContain("invisalign"); }, 240_000); it("extracts, persists, updates, and protects the owner profile across channels with a live model", async () => { const sourceRoomId = crypto.randomUUID() as UUID; const sourceWorldId = crypto.randomUUID() as UUID; const targetRoomId = crypto.randomUUID() as UUID; const targetWorldId = crypto.randomUUID() as UUID; const intruderId = crypto.randomUUID() as UUID; const intruderRoomId = crypto.randomUUID() as UUID; const intruderWorldId = crypto.randomUUID() as UUID; await ensureDmRoom({ runtime, entityId: ownerId, roomId: sourceRoomId, worldId: sourceWorldId, source: "telegram", channelId: `telegram-${sourceRoomId}`, userName: "shaw", }); await ensureDmRoom({ runtime, entityId: ownerId, roomId: targetRoomId, worldId: targetWorldId, source: "discord", channelId: `discord-${targetRoomId}`, userName: "shaw", }); await ensureDmRoom({ runtime, entityId: intruderId, roomId: intruderRoomId, worldId: intruderWorldId, source: "telegram", channelId: `telegram-${intruderRoomId}`, userName: "mallory", }); const baselineOwnerProfile = await readLifeOpsOwnerProfile(runtime); expect(baselineOwnerProfile).toMatchObject({ relationshipStatus: "n/a", partnerName: "n/a", orientation: "n/a", gender: "n/a", age: "n/a", location: "n/a", updatedAt: null, }); expect(["", "admin"].includes(readPersistedOwnerName(configPath))).toBe( true, ); const setupTurns = [ "Please silently update my Life Ops owner profile with these exact stable fields: name=Shaw, relationshipStatus=single, orientation=straight, gender=male, age=34, location=Denver.", ]; for (const text of setupTurns) { await sendUserTurn({ runtime, entityId: ownerId, roomId: sourceRoomId, source: "telegram", text, }); } const initialOwnerProfile = await waitForValue( "lifeops owner profile", async () => readLifeOpsOwnerProfile(runtime), (profile) => normalizeText(profile.name).includes("shaw") && normalizeText(profile.relationshipStatus).includes("single") && /straight|heterosexual/.test(normalizeText(profile.orientation)) && /man|male/.test(normalizeText(profile.gender)) && normalizeText(profile.age).includes("34") && normalizeText(profile.location).includes("denver"), 120_000, ); expect(initialOwnerProfile.partnerName).toBe("n/a"); expect(initialOwnerProfile.updatedAt).not.toBeNull(); const persistedOwnerName = await waitForValue( "persisted owner name", async () => readPersistedOwnerName(configPath), (name) => name.includes("shaw"), 30_000, ); expect(persistedOwnerName).toContain("shaw"); const updateTurns = [ "Please silently update my Life Ops owner profile with these exact fields: relationshipStatus=partnered, partnerName=Alex, location=Boulder. Everything else stays the same.", ]; for (const text of updateTurns) { await sendUserTurn({ runtime, entityId: ownerId, roomId: targetRoomId, source: "discord", text, }); } const updatedOwnerProfile = await waitForValue( "updated lifeops owner profile", async () => readLifeOpsOwnerProfile(runtime), (profile) => normalizeText(profile.name).includes("shaw") && normalizeText(profile.relationshipStatus).includes("partner") && normalizeText(profile.partnerName).includes("alex") && /straight|heterosexual/.test(normalizeText(profile.orientation)) && /man|male/.test(normalizeText(profile.gender)) && normalizeText(profile.age).includes("34") && normalizeText(profile.location).includes("boulder"), 120_000, ); expect(updatedOwnerProfile.name).toBe(initialOwnerProfile.name); expect(updatedOwnerProfile.age).toBe(initialOwnerProfile.age); expect(updatedOwnerProfile.orientation).toBe( initialOwnerProfile.orientation, ); expect(updatedOwnerProfile.gender).toBe(initialOwnerProfile.gender); const baselineProtectedProfile = await readLifeOpsOwnerProfile(runtime); const intruderTurns = [ "For your records, my name is Mallory.", "I am married to Pat, I am 41, and I live in Austin.", "Please remember that as my profile.", ]; for (const text of intruderTurns) { await sendUserTurn({ runtime, entityId: intruderId, roomId: intruderRoomId, source: "telegram", text, }); } await sleep(5_000); const protectedProfile = await readLifeOpsOwnerProfile(runtime); expect(protectedProfile).toMatchObject({ name: baselineProtectedProfile.name, relationshipStatus: baselineProtectedProfile.relationshipStatus, partnerName: baselineProtectedProfile.partnerName, orientation: baselineProtectedProfile.orientation, gender: baselineProtectedProfile.gender, age: baselineProtectedProfile.age, location: baselineProtectedProfile.location, }); expect(readPersistedOwnerName(configPath)).toContain("shaw"); }, 360_000); }, );