426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
800 lines
25 KiB
TypeScript
800 lines
25 KiB
TypeScript
/**
|
|
* 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<SessionSummaryLike | null>;
|
|
getLongTermMemories(
|
|
entityId: UUID,
|
|
category?: string,
|
|
limit?: number,
|
|
): Promise<LongTermMemoryLike[]>;
|
|
};
|
|
|
|
async function loadPlugin(name: string): Promise<Plugin | null> {
|
|
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<typeof createMessageMemory>,
|
|
timeoutMs = 120_000,
|
|
): Promise<string> {
|
|
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<string> {
|
|
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<T>(
|
|
label: string,
|
|
getValue: () => Promise<T>,
|
|
predicate: (value: T) => boolean,
|
|
timeoutMs = 60_000,
|
|
intervalMs = 1_000,
|
|
): Promise<T> {
|
|
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<void> {
|
|
await args.runtime.ensureWorldExists({
|
|
id: args.worldId,
|
|
name: `${args.source}-world`,
|
|
agentId: args.runtime.agentId,
|
|
} as Parameters<typeof args.runtime.ensureWorldExists>[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<ReturnType<LifeOpsService["listDefinitions"]>>,
|
|
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<string, string> = {};
|
|
|
|
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);
|
|
},
|
|
);
|