diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index f6501945c..456ee18b9 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -36,10 +36,29 @@ import { SearchMemoryOptions, DeleteAllMemoryOptions, GetAllMemoryOptions, + UpdateProjectOptions, } from "./memory.types"; import { parse_vision_messages } from "../utils/memory"; import { HistoryManager } from "../storage/base"; import { captureClientEvent } from "../utils/telemetry"; +import { + detectScaleThresholdFromAddResult, + detectScaleThresholdFromTopK, + detectPerformanceSlowQuery, + detectTemporalUsageFromMetadata, + detectTemporalUsageFromSearch, + displayDecayUsageNotice, + displayFirstRunNotice, + displayPerformanceSlowQueryNotice, + displayScaleThresholdNotice, + displayTemporalUsageNotice, + getDecayFeatureErrorMessage, + getDecayUsageDeleteCountAfterSuccess, + getTemporalFeatureErrorMessage, + isDecayUsageDeleteEligible, + PerformanceSlowQueryTrigger, + ScaleThresholdTrigger, +} from "../utils/notices"; import { lemmatizeForBm25 } from "../utils/lemmatization"; import { extractEntities, @@ -494,6 +513,73 @@ export class Memory { } } + private async _displayFirstRunNotice(triggerFunction: string) { + try { + await this._getTelemetryId(); + await displayFirstRunNotice(this, triggerFunction); + } catch {} + } + + private async _displayDecayUsageNotice(trigger: { + triggerFunction: "delete" | "delete_all"; + triggerSource: "delete_count" | "delete_all"; + triggerReason: "repeated_deletes" | "bulk_delete"; + deleteCount?: number; + deletedCount?: number; + }) { + try { + await this._getTelemetryId(); + await displayDecayUsageNotice(this, trigger); + } catch {} + } + + private async _displayTemporalUsageNotice(trigger: { + triggerFunction: "add" | "search"; + triggerSource: "metadata" | "query" | "filter"; + triggerReason: + | "date_like_metadata" + | "relative_phrase" + | "date_like_query" + | "date_range_filter"; + }) { + try { + await this._getTelemetryId(); + await displayTemporalUsageNotice(this, trigger); + } catch {} + } + + private async _displayScaleThresholdNotice(trigger: ScaleThresholdTrigger) { + try { + await this._getTelemetryId(); + await displayScaleThresholdNotice(this, trigger); + } catch {} + } + + private async _displayPerformanceSlowQueryNotice( + trigger: PerformanceSlowQueryTrigger, + ) { + try { + await this._getTelemetryId(); + await displayPerformanceSlowQueryNotice(this, trigger); + } catch {} + } + + private async _getNoticeTelemetryId() { + try { + if ( + !this.telemetryId || + this.telemetryId === "anonymous" || + this.telemetryId === "anonymous-supabase" + ) { + this.telemetryId = (await getOrCreateMem0UserId()) || "anonymous"; + } + return this.telemetryId; + } catch { + this.telemetryId = "anonymous"; + return this.telemetryId; + } + } + static fromConfig(configDict: Record): Memory { try { const config = MemoryConfigSchema.parse(configDict); @@ -504,10 +590,29 @@ export class Memory { } } + async updateProject(options: UpdateProjectOptions = {}): Promise { + if (options?.decay === true) { + await this._getNoticeTelemetryId(); + throw new Error(await getDecayFeatureErrorMessage(this)); + } + + throw new Error("Project updates are not supported by the OSS Memory SDK."); + } + async add( messages: string | Message[], config: AddMemoryOptions, ): Promise { + if (config?.timestamp !== undefined) { + await this._getNoticeTelemetryId(); + throw new Error( + await getTemporalFeatureErrorMessage(this, { + triggerFunction: "add", + triggerParameter: "timestamp", + }), + ); + } + // Validate messages input if (messages === undefined || messages === null) { throw new Error( @@ -515,6 +620,10 @@ export class Memory { ); } + const temporalUsageNotice = detectTemporalUsageFromMetadata( + config?.metadata, + ); + await this._ensureInitialized(); await this._captureEvent("add", { message_count: Array.isArray(messages) ? messages.length : 1, @@ -554,6 +663,27 @@ export class Memory { infer, ); + if (temporalUsageNotice) { + await this._displayTemporalUsageNotice({ + triggerFunction: "add", + triggerSource: temporalUsageNotice.triggerSource, + triggerReason: temporalUsageNotice.triggerReason, + }); + } else { + const scaleThresholdNotice = await detectScaleThresholdFromAddResult( + this, + vectorStoreResult, + ); + if (scaleThresholdNotice) { + await this._displayScaleThresholdNotice({ + triggerFunction: "add", + ...scaleThresholdNotice, + }); + } else { + await this._displayFirstRunNotice("add"); + } + } + return { results: vectorStoreResult, }; @@ -996,7 +1126,10 @@ export class Memory { async get(memoryId: string): Promise { await this._ensureInitialized(); const memory = await this.vectorStore.get(memoryId); - if (!memory) return null; + if (!memory) { + await this._displayFirstRunNotice("get"); + return null; + } const filters = { ...(memory.payload.user_id && { user_id: memory.payload.user_id }), @@ -1031,13 +1164,30 @@ export class Memory { } } - return { ...memoryItem, ...filters }; + const result = { ...memoryItem, ...filters }; + await this._displayFirstRunNotice("get"); + return result; } async search( query: string, config: SearchMemoryOptions, ): Promise { + if (config?.referenceDate !== undefined) { + await this._getNoticeTelemetryId(); + throw new Error( + await getTemporalFeatureErrorMessage(this, { + triggerFunction: "search", + triggerParameter: "referenceDate", + }), + ); + } + + const temporalUsageNotice = detectTemporalUsageFromSearch( + query, + config?.filters, + ); + // Reject top-level entity params - must use filters instead rejectTopLevelEntityParams(config as Record, "search"); @@ -1105,6 +1255,8 @@ export class Memory { ); } + const searchStartMs = Date.now(); + // Step 1: Preprocess query const queryLemmatized = lemmatizeForBm25(query); const queryEntities = extractEntities(query); @@ -1280,9 +1432,41 @@ export class Memory { }; }); - return { + const result = { results, }; + const searchElapsedMs = Date.now() - searchStartMs; + if (temporalUsageNotice) { + await this._displayTemporalUsageNotice({ + triggerFunction: "search", + triggerSource: temporalUsageNotice.triggerSource, + triggerReason: temporalUsageNotice.triggerReason, + }); + } else { + const scaleThresholdNotice = detectScaleThresholdFromTopK(topK); + if (scaleThresholdNotice) { + await this._displayScaleThresholdNotice({ + triggerFunction: "search", + ...scaleThresholdNotice, + }); + } else { + const performanceSlowQueryNotice = detectPerformanceSlowQuery( + searchElapsedMs, + topK, + results.length, + ); + if (performanceSlowQueryNotice) { + await this._displayPerformanceSlowQueryNotice({ + triggerFunction: "search", + triggerReason: "slow_query", + ...performanceSlowQueryNotice, + }); + } else { + await this._displayFirstRunNotice("search"); + } + } + } + return result; } async update(memoryId: string, data: string): Promise<{ message: string }> { @@ -1290,14 +1474,28 @@ export class Memory { await this._captureEvent("update", { memory_id: memoryId }); const embedding = await this.embedder.embed(data); await this.updateMemory(memoryId, data, { [data]: embedding }); - return { message: "Memory updated successfully!" }; + const result = { message: "Memory updated successfully!" }; + await this._displayFirstRunNotice("update"); + return result; } async delete(memoryId: string): Promise<{ message: string }> { await this._ensureInitialized(); await this._captureEvent("delete", { memory_id: memoryId }); await this.deleteMemory(memoryId); - return { message: "Memory deleted successfully!" }; + const result = { message: "Memory deleted successfully!" }; + const deleteCount = getDecayUsageDeleteCountAfterSuccess(); + if (isDecayUsageDeleteEligible(deleteCount)) { + await this._displayDecayUsageNotice({ + triggerFunction: "delete", + triggerSource: "delete_count", + triggerReason: "repeated_deletes", + deleteCount, + }); + } else { + await this._displayFirstRunNotice("delete"); + } + return result; } async deleteAll( @@ -1328,12 +1526,25 @@ export class Memory { await this.deleteMemory(memory.id); } - return { message: "Memories deleted successfully!" }; + const result = { message: "Memories deleted successfully!" }; + if (memories.length > 0) { + await this._displayDecayUsageNotice({ + triggerFunction: "delete_all", + triggerSource: "delete_all", + triggerReason: "bulk_delete", + deletedCount: memories.length, + }); + } else { + await this._displayFirstRunNotice("delete_all"); + } + return result; } async history(memoryId: string): Promise { await this._ensureInitialized(); - return this.db.getHistory(memoryId); + const result = await this.db.getHistory(memoryId); + await this._displayFirstRunNotice("history"); + return result; } async reset(): Promise { @@ -1385,6 +1596,7 @@ export class Memory { console.error(this._initError); }); await this._initPromise; + await this._displayFirstRunNotice("reset"); } async getAll(config: GetAllMemoryOptions): Promise { @@ -1452,7 +1664,17 @@ export class Memory { ...(mem.payload.run_id && { run_id: mem.payload.run_id }), })); - return { results }; + const result = { results }; + const scaleThresholdNotice = detectScaleThresholdFromTopK(topK); + if (scaleThresholdNotice) { + await this._displayScaleThresholdNotice({ + triggerFunction: "get_all", + ...scaleThresholdNotice, + }); + } else { + await this._displayFirstRunNotice("get_all"); + } + return result; } private async createMemory( diff --git a/mem0-ts/src/oss/src/memory/memory.types.ts b/mem0-ts/src/oss/src/memory/memory.types.ts index 023b9d829..c125b61b3 100644 --- a/mem0-ts/src/oss/src/memory/memory.types.ts +++ b/mem0-ts/src/oss/src/memory/memory.types.ts @@ -11,6 +11,7 @@ export interface AddMemoryOptions extends Entity { metadata?: Record; filters?: SearchFilters; infer?: boolean; + timestamp?: number | string | Date | null; } export interface SearchMemoryOptions { @@ -18,6 +19,7 @@ export interface SearchMemoryOptions { filters?: SearchFilters; threshold?: number; explain?: boolean; + referenceDate?: number | string | Date | null; } export interface GetAllMemoryOptions { @@ -26,3 +28,8 @@ export interface GetAllMemoryOptions { } export interface DeleteAllMemoryOptions extends Entity {} + +export interface UpdateProjectOptions { + decay?: boolean; + [key: string]: any; +} diff --git a/mem0-ts/src/oss/src/utils/notices.ts b/mem0-ts/src/oss/src/utils/notices.ts new file mode 100644 index 000000000..868370e74 --- /dev/null +++ b/mem0-ts/src/oss/src/utils/notices.ts @@ -0,0 +1,1434 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + POSTHOG_API_KEY, + captureNoticeEvent, + isTelemetryEnabled, +} from "./telemetry"; +import type { TelemetryInstance } from "./telemetry.types"; + +export const NOTICE_FLAG_KEY = "mem0-oss-notices"; +export const NOTICE_EVENT_NAME = "mem0.notice_displayed"; +export const NOTICE_STATE_SECTION = "notice_state"; +export const FIRST_RUN_NOTICE_ID = "first_run"; +export const TEMPORAL_FEATURE_NOTICE_ID = "temporal_stub"; +export const TEMPORAL_USAGE_NOTICE_ID = "temporal_usage"; +export const DECAY_FEATURE_NOTICE_ID = "decay_stub"; +export const DECAY_USAGE_NOTICE_ID = "decay_usage"; +export const SCALE_THRESHOLD_NOTICE_ID = "scale_threshold"; +export const PERFORMANCE_SLOW_QUERY_NOTICE_ID = "performance_slow_query"; +export const NOTICE_CAP_LIMIT = 10; +export const NOTICE_CAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; +export const NOTICE_FLAG_TIMEOUT_MS = 500; +export const POSTHOG_FLAGS_URL = "https://us.i.posthog.com/flags?v=2"; +const DISPLAYED_VARIANT = "displayed"; +const HOLDOUT_VARIANT = "holdout"; +const LOG_LINE_NOTICE_TYPE = "log_line"; +const ERROR_NOTICE_TYPE = "error"; +const TEMPORAL_TIMESTAMP_PLAIN_ERROR = + "The timestamp parameter is not supported by the OSS Memory SDK."; +const TEMPORAL_REFERENCE_DATE_PLAIN_ERROR = + "The referenceDate parameter is not supported by the OSS Memory SDK."; +const DECAY_FEATURE_PLAIN_ERROR = + "The decay parameter is not supported by the OSS Memory SDK."; +const DECAY_USAGE_DELETE_THRESHOLD = 5; +export const SCALE_MEMORY_COUNT_THRESHOLD = 2000; +export const SCALE_MEMORY_COUNT_CHECK_INTERVAL = 100; +export const SCALE_TOP_K_THRESHOLD = 50; +export const PERFORMANCE_SLOW_QUERY_THRESHOLD_MS = 2000; +const MAX_TEMPORAL_DETECTION_DEPTH = 32; +const ISO_DATE_RE = + /\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/; +const RELATIVE_TIME_RE = + /\b(today|yesterday|tomorrow|last\s+(?:night|week|month|year)|this\s+(?:week|month|year)|next\s+(?:week|month|year)|(?:past|last)\s+\d+\s+(?:day|days|week|weeks|month|months|year|years)|(?:since|before|after|until)\s+(?:today|yesterday|tomorrow|\d{4}-\d{2}-\d{2}|last\s+(?:week|month|year)))\b/i; +const RANGE_OPERATORS = new Set(["gt", "gte", "lt", "lte"]); + +let firstRunConsumedInProcess = false; +let firstRunClaimInProgress = false; +let decayUsageSuccessfulDeleteCount = 0; +const noticeCapacityReachedInProcess = new Set(); +let scaleMemoryCountAddsSinceCheck = 0; +let scaleMemoryCountCheckedInProcess = false; +let scaleMemoryCountThresholdEvaluatedInProcess = false; + +export interface NoticePayloadConfig { + enabled?: boolean; + notice_type?: string; + copy?: string; + [key: string]: any; +} + +export interface NoticeFlagEvaluation { + variant: string; + payload?: Record; + flag?: Record; +} + +export interface NoticeCapEvent { + evaluated_at?: string; + [key: string]: any; +} + +interface NoticeDisplayDecision { + displayed: boolean; + noticeConfigFound: boolean; + copy?: string; + bypassReason?: string; + disabledReason?: string; +} + +export interface DecayUsageTrigger { + triggerFunction: "delete" | "delete_all"; + triggerSource: "delete_count" | "delete_all"; + triggerReason: "repeated_deletes" | "bulk_delete"; + deleteCount?: number; + deletedCount?: number; +} + +export interface TemporalFeatureErrorTrigger { + triggerFunction: "add" | "search"; + triggerParameter: "timestamp" | "referenceDate"; +} + +export interface TemporalUsageTrigger { + triggerFunction: "add" | "search"; + triggerSource: "metadata" | "query" | "filter"; + triggerReason: + | "date_like_metadata" + | "relative_phrase" + | "date_like_query" + | "date_range_filter"; +} + +export interface ScaleThresholdTrigger { + triggerFunction: "add" | "search" | "get_all"; + triggerSource: "top_k" | "memory_count"; + triggerReason: "high_top_k" | "memory_count_threshold"; + topK?: number; + memoryCount?: number; + threshold: number; +} + +export interface PerformanceSlowQueryTrigger { + triggerFunction: "search"; + triggerReason: "slow_query"; + elapsedMs: number; + thresholdMs: number; + topK: number; + resultCount: number; +} + +export function getMem0Dir(): string { + return process.env.MEM0_DIR || path.join(os.homedir(), ".mem0"); +} + +export function getMem0ConfigPath(): string { + return path.join(getMem0Dir(), "config.json"); +} + +export function loadMem0Config(): Record { + try { + const configPath = getMem0ConfigPath(); + if (!fs.existsSync(configPath)) return {}; + const parsed = JSON.parse(fs.readFileSync(configPath, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : {}; + } catch { + return {}; + } +} + +export function writeMem0ConfigAtomic(config: Record): boolean { + const configPath = getMem0ConfigPath(); + const dir = path.dirname(configPath); + const tempPath = path.join( + dir, + `.config.${process.pid}.${Date.now()}.${Math.random() + .toString(36) + .slice(2)}.tmp`, + ); + + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(tempPath, JSON.stringify(config, null, 4)); + fs.renameSync(tempPath, configPath); + return true; + } catch { + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch {} + return false; + } +} + +export function getNoticeState( + config: Record, + noticeId: string, +): Record { + const stateSection = + config[NOTICE_STATE_SECTION] && + typeof config[NOTICE_STATE_SECTION] === "object" && + !Array.isArray(config[NOTICE_STATE_SECTION]) + ? config[NOTICE_STATE_SECTION] + : {}; + const noticeState = stateSection[noticeId]; + return noticeState && + typeof noticeState === "object" && + !Array.isArray(noticeState) + ? noticeState + : {}; +} + +export function setNoticeState( + config: Record, + noticeId: string, + state: Record, +): Record { + const stateSection = + config[NOTICE_STATE_SECTION] && + typeof config[NOTICE_STATE_SECTION] === "object" && + !Array.isArray(config[NOTICE_STATE_SECTION]) + ? { ...config[NOTICE_STATE_SECTION] } + : {}; + return { + ...config, + [NOTICE_STATE_SECTION]: { + ...stateSection, + [noticeId]: state, + }, + }; +} + +function parsePayload(payload: unknown): Record | undefined { + try { + if (typeof payload === "string") { + const parsed = JSON.parse(payload); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : undefined; + } + return payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : undefined; + } catch { + return undefined; + } +} + +export function getNoticeConfigFromPayload( + payload: unknown, + noticeId: string, +): { + found: boolean; + config?: NoticePayloadConfig; + payload?: Record; +} { + const parsedPayload = parsePayload(payload); + const notices = parsedPayload?.notices; + if (!notices || typeof notices !== "object" || Array.isArray(notices)) { + return { found: false, payload: parsedPayload }; + } + + const noticeConfig = (notices as Record)[noticeId]; + if ( + !noticeConfig || + typeof noticeConfig !== "object" || + Array.isArray(noticeConfig) + ) { + return { found: false, payload: parsedPayload }; + } + + return { + found: true, + config: noticeConfig as NoticePayloadConfig, + payload: parsedPayload, + }; +} + +export async function evaluateNoticeFlag( + distinctId: string, + options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {}, +): Promise { + if (!isTelemetryEnabled()) return null; + + const timeoutMs = options.timeoutMs ?? NOTICE_FLAG_TIMEOUT_MS; + const fetchImpl = options.fetchImpl ?? fetch; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl(POSTHOG_FLAGS_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + api_key: POSTHOG_API_KEY, + distinct_id: distinctId, + }), + signal: controller.signal, + }); + + if (!response.ok) return null; + const data: any = await response.json(); + const flag = data?.flags?.[NOTICE_FLAG_KEY]; + if (!flag || flag.enabled === false) return null; + + const variant = typeof flag.variant === "string" ? flag.variant : null; + if (!variant) return null; + + return { + variant, + payload: parsePayload(flag.metadata?.payload), + flag, + }; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +function eventsInWindow( + events: unknown, + now: Date, + windowMs: number, +): NoticeCapEvent[] { + if (!Array.isArray(events)) return []; + const cutoff = now.getTime() - windowMs; + return events.filter((event): event is NoticeCapEvent => { + if (!event || typeof event !== "object" || Array.isArray(event)) { + return false; + } + const evaluatedAt = (event as NoticeCapEvent).evaluated_at; + if (typeof evaluatedAt !== "string") return false; + const timestamp = Date.parse(evaluatedAt); + return Number.isFinite(timestamp) && timestamp >= cutoff; + }); +} + +export function hasNoticeCapRoom( + state: Record, + options: { now?: Date; limit?: number; windowMs?: number } = {}, +): boolean { + const now = options.now ?? new Date(); + const limit = options.limit ?? NOTICE_CAP_LIMIT; + const windowMs = options.windowMs ?? NOTICE_CAP_WINDOW_MS; + return eventsInWindow(state.events, now, windowMs).length < limit; +} + +function isNoticeCapacityReachedInProcess(noticeId: string): boolean { + return noticeCapacityReachedInProcess.has(noticeId); +} + +function markNoticeCapacityReachedInProcess(noticeId: string): void { + noticeCapacityReachedInProcess.add(noticeId); +} + +function hasNoticeCapRoomForNotice( + noticeId: string, + state: Record, + options: { now?: Date; limit?: number; windowMs?: number } = {}, +): boolean { + const hasRoom = hasNoticeCapRoom(state, options); + if (!hasRoom) markNoticeCapacityReachedInProcess(noticeId); + return hasRoom; +} + +export function appendNoticeCapEvent( + state: Record, + event: NoticeCapEvent, + options: { now?: Date; limit?: number; windowMs?: number } = {}, +): Record | null { + const now = options.now ?? new Date(); + const limit = options.limit ?? NOTICE_CAP_LIMIT; + const windowMs = options.windowMs ?? NOTICE_CAP_WINDOW_MS; + const events = eventsInWindow(state.events, now, windowMs); + if (events.length >= limit) return null; + + return { + ...state, + events: [ + ...events, + { + evaluated_at: now.toISOString(), + ...event, + }, + ], + }; +} + +export function recordNoticeOpportunity( + noticeId: string, + event: NoticeCapEvent, + options: { now?: Date; limit?: number; windowMs?: number } = {}, +): boolean { + if (!isTelemetryEnabled()) return false; + if (isNoticeCapacityReachedInProcess(noticeId)) return false; + + const config = loadMem0Config(); + const state = getNoticeState(config, noticeId); + const nextState = appendNoticeCapEvent(state, event, options); + if (!nextState) { + markNoticeCapacityReachedInProcess(noticeId); + return false; + } + + const written = writeMem0ConfigAtomic( + setNoticeState(config, noticeId, nextState), + ); + if (written && !hasNoticeCapRoom(nextState, options)) { + markNoticeCapacityReachedInProcess(noticeId); + } + return written; +} + +function isFirstRunConsumed(config: Record): boolean { + return getNoticeState(config, FIRST_RUN_NOTICE_ID).consumed === true; +} + +function markFirstRunConsumed( + triggerFunction: string, + variant: string, +): boolean { + const config = loadMem0Config(); + const state = getNoticeState(config, FIRST_RUN_NOTICE_ID); + const nextState = { + ...state, + consumed: true, + consumed_at: new Date().toISOString(), + trigger_function: triggerFunction, + variant, + }; + return writeMem0ConfigAtomic( + setNoticeState(config, FIRST_RUN_NOTICE_ID, nextState), + ); +} + +function getDisplayDecision( + noticeId: string, + expectedNoticeType: string, + variant: string, + payload: unknown, +): NoticeDisplayDecision { + const parsed = getNoticeConfigFromPayload(payload, noticeId); + const copy = + typeof parsed.config?.copy === "string" ? parsed.config.copy : undefined; + + if (!parsed.found || !parsed.config) { + return { + displayed: false, + noticeConfigFound: false, + bypassReason: "missing_notice_config", + }; + } + + const noticeConfig = parsed.config; + if (noticeConfig.enabled === false) { + return { + displayed: false, + noticeConfigFound: true, + copy, + bypassReason: "payload_disabled", + disabledReason: "payload_disabled", + }; + } + + if (noticeConfig.notice_type !== expectedNoticeType) { + return { + displayed: false, + noticeConfigFound: true, + copy, + bypassReason: "invalid_notice_type", + }; + } + + if (!copy || copy.trim() === "") { + return { + displayed: false, + noticeConfigFound: true, + bypassReason: "missing_copy", + }; + } + + if (variant !== DISPLAYED_VARIANT) { + return { + displayed: false, + noticeConfigFound: true, + copy, + bypassReason: "holdout", + }; + } + + return { + displayed: true, + noticeConfigFound: true, + copy, + }; +} + +function renderScaleCopy( + template: unknown, + trigger: Pick, +): string | undefined { + if (typeof template !== "string" || template.trim() === "") return undefined; + return template + .replace(/\{top_k\}/g, String(trigger.topK ?? "")) + .replace(/\{topK\}/g, String(trigger.topK ?? "")) + .replace(/\{memory_count\}/g, String(trigger.memoryCount ?? "")); +} + +function getScaleDisplayDecision( + variant: string, + payload: unknown, + trigger: ScaleThresholdTrigger, +): NoticeDisplayDecision { + const parsed = getNoticeConfigFromPayload(payload, SCALE_THRESHOLD_NOTICE_ID); + const copies = + parsed.config?.copies && + typeof parsed.config.copies === "object" && + !Array.isArray(parsed.config.copies) + ? (parsed.config.copies as Record) + : {}; + const copyKey = + trigger.triggerSource === "memory_count" ? "memory_count" : "top_k"; + const copy = renderScaleCopy(copies[copyKey], trigger); + + if (!parsed.found || !parsed.config) { + return { + displayed: false, + noticeConfigFound: false, + bypassReason: "missing_notice_config", + }; + } + + const noticeConfig = parsed.config; + if (noticeConfig.enabled === false) { + return { + displayed: false, + noticeConfigFound: true, + copy, + bypassReason: "payload_disabled", + disabledReason: "payload_disabled", + }; + } + + if (noticeConfig.notice_type !== LOG_LINE_NOTICE_TYPE) { + return { + displayed: false, + noticeConfigFound: true, + copy, + bypassReason: "invalid_notice_type", + }; + } + + if (!copy || copy.trim() === "") { + return { + displayed: false, + noticeConfigFound: true, + bypassReason: "missing_copy", + }; + } + + if (variant !== DISPLAYED_VARIANT) { + return { + displayed: false, + noticeConfigFound: true, + copy, + bypassReason: "holdout", + }; + } + + return { + displayed: true, + noticeConfigFound: true, + copy, + }; +} + +function getFeatureErrorDecision( + noticeId: string, + expectedNoticeType: string, + variant: string, + payload: unknown, +): NoticeDisplayDecision { + const parsed = getNoticeConfigFromPayload(payload, noticeId); + const copy = + typeof parsed.config?.copy === "string" ? parsed.config.copy : undefined; + + if (!parsed.found || !parsed.config) { + return { + displayed: false, + noticeConfigFound: false, + bypassReason: "missing_notice_config", + }; + } + + const noticeConfig = parsed.config; + if (noticeConfig.enabled === false) { + return { + displayed: false, + noticeConfigFound: true, + copy, + bypassReason: "payload_disabled", + disabledReason: "payload_disabled", + }; + } + + if (noticeConfig.notice_type !== expectedNoticeType) { + return { + displayed: false, + noticeConfigFound: true, + copy, + bypassReason: "invalid_notice_type", + }; + } + + if (!copy || copy.trim() === "") { + return { + displayed: false, + noticeConfigFound: true, + bypassReason: "missing_copy", + }; + } + + if (variant !== DISPLAYED_VARIANT && variant !== HOLDOUT_VARIANT) { + return { + displayed: false, + noticeConfigFound: true, + copy, + bypassReason: "not_displayed", + }; + } + + return { + displayed: true, + noticeConfigFound: true, + copy, + }; +} + +export async function getDecayFeatureErrorMessage( + instance: TelemetryInstance, +): Promise { + if (!isTelemetryEnabled()) return DECAY_FEATURE_PLAIN_ERROR; + + try { + const flagEvaluation = await evaluateNoticeFlag(instance.telemetryId); + if (!flagEvaluation) return DECAY_FEATURE_PLAIN_ERROR; + + const decision = getFeatureErrorDecision( + DECAY_FEATURE_NOTICE_ID, + ERROR_NOTICE_TYPE, + flagEvaluation.variant, + flagEvaluation.payload, + ); + + await emitNoticeDisplayed(instance, { + notice_id: DECAY_FEATURE_NOTICE_ID, + notice_type: ERROR_NOTICE_TYPE, + flag_key: NOTICE_FLAG_KEY, + variant: flagEvaluation.variant, + displayed: decision.displayed, + payload: decision.copy, + bypass_reason: decision.bypassReason, + disabled_reason: decision.disabledReason, + notice_config_found: decision.noticeConfigFound, + sync_type: "async", + trigger_function: "update_project", + trigger_parameter: "decay", + }); + + if (decision.displayed && decision.copy) { + return decision.copy; + } + } catch {} + + return DECAY_FEATURE_PLAIN_ERROR; +} + +export async function getTemporalFeatureErrorMessage( + instance: TelemetryInstance, + trigger: TemporalFeatureErrorTrigger, +): Promise { + const plainError = + trigger.triggerParameter === "timestamp" + ? TEMPORAL_TIMESTAMP_PLAIN_ERROR + : TEMPORAL_REFERENCE_DATE_PLAIN_ERROR; + + if (!isTelemetryEnabled()) return plainError; + + try { + const flagEvaluation = await evaluateNoticeFlag(instance.telemetryId); + if (!flagEvaluation) return plainError; + + const decision = getFeatureErrorDecision( + TEMPORAL_FEATURE_NOTICE_ID, + ERROR_NOTICE_TYPE, + flagEvaluation.variant, + flagEvaluation.payload, + ); + + await emitNoticeDisplayed(instance, { + notice_id: TEMPORAL_FEATURE_NOTICE_ID, + notice_type: ERROR_NOTICE_TYPE, + flag_key: NOTICE_FLAG_KEY, + variant: flagEvaluation.variant, + displayed: decision.displayed, + payload: decision.copy, + bypass_reason: decision.bypassReason, + disabled_reason: decision.disabledReason, + notice_config_found: decision.noticeConfigFound, + sync_type: "async", + trigger_function: trigger.triggerFunction, + trigger_parameter: trigger.triggerParameter, + }); + + if (decision.displayed && decision.copy) { + return decision.copy; + } + } catch {} + + return plainError; +} + +export function getDecayUsageDeleteCountAfterSuccess(): number { + if (!isTelemetryEnabled()) return 0; + decayUsageSuccessfulDeleteCount += 1; + return decayUsageSuccessfulDeleteCount; +} + +export function isDecayUsageDeleteEligible(deleteCount: number): boolean { + return deleteCount >= DECAY_USAGE_DELETE_THRESHOLD; +} + +function isRecord(value: unknown): value is Record { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !(value instanceof Date) + ); +} + +function isTemporalKey(key: unknown): boolean { + const keyText = String(key).toLowerCase(); + return ( + [ + "date", + "time", + "timestamp", + "datetime", + "event_date", + "reference_date", + "referencedate", + "created_at", + "createdat", + "updated_at", + "updatedat", + "started_at", + "startedat", + "ended_at", + "endedat", + "expires_at", + "expiresat", + ].includes(keyText) || + keyText.endsWith("_date") || + keyText.endsWith("_time") || + keyText.endsWith("_at") || + keyText.includes("timestamp") + ); +} + +function looksTemporalValue(value: unknown, allowEpoch: boolean): boolean { + if (value instanceof Date) return !Number.isNaN(value.getTime()); + if (typeof value === "string") { + return ISO_DATE_RE.test(value) || RELATIVE_TIME_RE.test(value); + } + if (allowEpoch && typeof value === "number" && Number.isFinite(value)) { + return ( + (value >= 946684800 && value <= 4102444800) || + (value >= 946684800000 && value <= 4102444800000) + ); + } + return false; +} + +export function detectTemporalUsageFromMetadata( + metadata: unknown, +): Pick | null { + try { + if (!isRecord(metadata)) return null; + + const visited = new WeakSet(); + const stack: Array<{ value: unknown; parentKey?: string; depth: number }> = + [{ value: metadata, depth: 0 }]; + + while (stack.length > 0) { + const current = stack.pop()!; + if (current.depth > MAX_TEMPORAL_DETECTION_DEPTH) continue; + + if (Array.isArray(current.value)) { + for (const child of current.value) { + if (looksTemporalValue(child, false)) { + return { + triggerSource: "metadata", + triggerReason: "date_like_metadata", + }; + } + stack.push({ + value: child, + parentKey: current.parentKey, + depth: current.depth + 1, + }); + } + continue; + } + + if (!isRecord(current.value)) continue; + if (visited.has(current.value)) continue; + visited.add(current.value); + + for (const [key, value] of Object.entries(current.value)) { + const temporalKey = isTemporalKey(key); + if ( + (temporalKey && looksTemporalValue(value, true)) || + looksTemporalValue(value, false) + ) { + return { + triggerSource: "metadata", + triggerReason: "date_like_metadata", + }; + } + if (isRecord(value) || Array.isArray(value)) { + stack.push({ value, parentKey: key, depth: current.depth + 1 }); + } + } + } + } catch {} + + return null; +} + +function hasTemporalFilter(filters: unknown): boolean { + try { + if (!isRecord(filters)) return false; + + const visited = new WeakSet(); + const stack: Array<{ value: unknown; depth: number }> = [ + { value: filters, depth: 0 }, + ]; + + while (stack.length > 0) { + const current = stack.pop()!; + if (current.depth > MAX_TEMPORAL_DETECTION_DEPTH) continue; + + if (Array.isArray(current.value)) { + for (const child of current.value) { + stack.push({ value: child, depth: current.depth + 1 }); + } + continue; + } + + if (!isRecord(current.value)) continue; + if (visited.has(current.value)) continue; + visited.add(current.value); + + for (const [key, value] of Object.entries(current.value)) { + if (["AND", "OR", "NOT", "$and", "$or", "$not"].includes(key)) { + if (isRecord(value) || Array.isArray(value)) { + stack.push({ value, depth: current.depth + 1 }); + } + continue; + } + + const temporalKey = isTemporalKey(key); + if (isRecord(value)) { + const rangeValues = Object.entries(value) + .filter(([operator]) => RANGE_OPERATORS.has(operator)) + .map(([, rangeValue]) => rangeValue); + if ( + rangeValues.length > 0 && + (temporalKey || + rangeValues.some((rangeValue) => + looksTemporalValue(rangeValue, temporalKey), + )) + ) { + return true; + } + stack.push({ value, depth: current.depth + 1 }); + } else if (temporalKey && looksTemporalValue(value, true)) { + return true; + } + } + } + } catch {} + + return false; +} + +export function detectTemporalUsageFromSearch( + query: unknown, + filters: unknown, +): Pick | null { + try { + if (typeof query === "string") { + if (RELATIVE_TIME_RE.test(query)) { + return { triggerSource: "query", triggerReason: "relative_phrase" }; + } + if (ISO_DATE_RE.test(query)) { + return { triggerSource: "query", triggerReason: "date_like_query" }; + } + } + + if (hasTemporalFilter(filters)) { + return { triggerSource: "filter", triggerReason: "date_range_filter" }; + } + } catch {} + + return null; +} + +function coerceNonnegativeInteger(value: unknown): number | null { + if (typeof value === "boolean") return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + return null; + } + return parsed; +} + +function countAddedMemories(addResult: unknown): number { + const results = + isRecord(addResult) && Array.isArray(addResult.results) + ? addResult.results + : addResult; + + if (!Array.isArray(results)) return 0; + + return results.filter((item) => { + if (!isRecord(item)) return false; + const metadata = item.metadata; + return isRecord(metadata) && metadata.event === "ADD"; + }).length; +} + +function extractProviderCount(info: unknown): number | null { + if (!info) return null; + if (typeof info === "number") return coerceNonnegativeInteger(info); + + if (isRecord(info)) { + for (const key of [ + "count", + "points_count", + "vectors_count", + "indexed_vectors_count", + ]) { + const value = coerceNonnegativeInteger(info[key]); + if (value !== null) return value; + } + + const result = extractProviderCount(info.result); + if (result !== null) return result; + } + + return null; +} + +async function getProviderMemoryCount( + memoryInstance: unknown, +): Promise { + try { + const vectorStore = (memoryInstance as any)?.vectorStore; + if (!vectorStore) return null; + + if (typeof vectorStore.count === "function") { + const value = extractProviderCount(await vectorStore.count()); + if (value !== null) return value; + } + + const collectionName = vectorStore.collectionName; + const client = vectorStore.client; + + if (client && collectionName && typeof client.count === "function") { + const value = extractProviderCount( + await client.count(collectionName, { exact: true }), + ); + if (value !== null) return value; + } + + if ( + client && + collectionName && + typeof client.getCollection === "function" + ) { + const value = extractProviderCount( + await client.getCollection(collectionName), + ); + if (value !== null) return value; + } + } catch {} + + return null; +} + +function markScaleMemoryCountThresholdEvaluated(): boolean { + try { + const config = loadMem0Config(); + const state = getNoticeState(config, SCALE_THRESHOLD_NOTICE_ID); + if (state.memory_count_threshold_evaluated === true) { + scaleMemoryCountThresholdEvaluatedInProcess = true; + return false; + } + + const nextState = { + ...state, + memory_count_threshold_evaluated: true, + }; + const written = writeMem0ConfigAtomic( + setNoticeState(config, SCALE_THRESHOLD_NOTICE_ID, nextState), + ); + if (written) scaleMemoryCountThresholdEvaluatedInProcess = true; + return written; + } catch { + return false; + } +} + +export function detectScaleThresholdFromTopK( + topK: unknown, +): Omit | null { + const topKValue = coerceNonnegativeInteger(topK); + if (topKValue === null || topKValue < SCALE_TOP_K_THRESHOLD) return null; + + return { + triggerSource: "top_k", + triggerReason: "high_top_k", + topK: topKValue, + threshold: SCALE_TOP_K_THRESHOLD, + }; +} + +export async function detectScaleThresholdFromAddResult( + memoryInstance: unknown, + addResult: unknown, +): Promise | null> { + if (!isTelemetryEnabled()) return null; + + const addedCount = countAddedMemories(addResult); + if (addedCount === 0) return null; + + try { + if (scaleMemoryCountThresholdEvaluatedInProcess) return null; + + scaleMemoryCountAddsSinceCheck += addedCount; + const shouldCheck = + !scaleMemoryCountCheckedInProcess || + scaleMemoryCountAddsSinceCheck >= SCALE_MEMORY_COUNT_CHECK_INTERVAL; + if (!shouldCheck) return null; + + scaleMemoryCountCheckedInProcess = true; + scaleMemoryCountAddsSinceCheck = 0; + + const config = loadMem0Config(); + const state = getNoticeState(config, SCALE_THRESHOLD_NOTICE_ID); + if (state.memory_count_threshold_evaluated === true) { + scaleMemoryCountThresholdEvaluatedInProcess = true; + return null; + } + + if (!hasNoticeCapRoom(state)) return null; + } catch { + return null; + } + + const providerCount = await getProviderMemoryCount(memoryInstance); + if (providerCount === null || providerCount < SCALE_MEMORY_COUNT_THRESHOLD) { + return null; + } + + if (!markScaleMemoryCountThresholdEvaluated()) return null; + + return { + triggerSource: "memory_count", + triggerReason: "memory_count_threshold", + memoryCount: providerCount, + threshold: SCALE_MEMORY_COUNT_THRESHOLD, + }; +} + +export function detectPerformanceSlowQuery( + elapsedMs: unknown, + topK: unknown, + resultCount: unknown, +): Omit< + PerformanceSlowQueryTrigger, + "triggerFunction" | "triggerReason" +> | null { + const elapsedMsValue = coerceNonnegativeInteger( + Math.round(Number(elapsedMs)), + ); + const topKValue = coerceNonnegativeInteger(topK); + const resultCountValue = coerceNonnegativeInteger(resultCount); + + if ( + elapsedMsValue === null || + topKValue === null || + resultCountValue === null || + elapsedMsValue <= PERFORMANCE_SLOW_QUERY_THRESHOLD_MS + ) { + return null; + } + + return { + elapsedMs: elapsedMsValue, + thresholdMs: PERFORMANCE_SLOW_QUERY_THRESHOLD_MS, + topK: topKValue, + resultCount: resultCountValue, + }; +} + +export async function displayScaleThresholdNotice( + instance: TelemetryInstance, + trigger: ScaleThresholdTrigger, +): Promise { + if (!isTelemetryEnabled()) return; + if (isNoticeCapacityReachedInProcess(SCALE_THRESHOLD_NOTICE_ID)) return; + + try { + const config = loadMem0Config(); + const state = getNoticeState(config, SCALE_THRESHOLD_NOTICE_ID); + if (!hasNoticeCapRoomForNotice(SCALE_THRESHOLD_NOTICE_ID, state)) return; + + const flagEvaluation = await evaluateNoticeFlag(instance.telemetryId); + if (!flagEvaluation) return; + + const decision = getScaleDisplayDecision( + flagEvaluation.variant, + flagEvaluation.payload, + trigger, + ); + + const opportunity = { + variant: flagEvaluation.variant, + sync_type: "async", + trigger_function: trigger.triggerFunction, + trigger_source: trigger.triggerSource, + trigger_reason: trigger.triggerReason, + ...(trigger.topK !== undefined && { top_k: trigger.topK }), + ...(trigger.memoryCount !== undefined && { + memory_count: trigger.memoryCount, + }), + threshold: trigger.threshold, + }; + + if (!recordNoticeOpportunity(SCALE_THRESHOLD_NOTICE_ID, opportunity)) { + return; + } + + await emitNoticeDisplayed(instance, { + notice_id: SCALE_THRESHOLD_NOTICE_ID, + notice_type: LOG_LINE_NOTICE_TYPE, + flag_key: NOTICE_FLAG_KEY, + variant: flagEvaluation.variant, + displayed: decision.displayed, + payload: decision.copy, + bypass_reason: decision.bypassReason, + disabled_reason: decision.disabledReason, + notice_config_found: decision.noticeConfigFound, + sync_type: "async", + trigger_function: trigger.triggerFunction, + trigger_source: trigger.triggerSource, + trigger_reason: trigger.triggerReason, + top_k: trigger.topK, + memory_count: trigger.memoryCount, + threshold: trigger.threshold, + }); + + if (decision.displayed && decision.copy) { + process.stderr.write(`${decision.copy}\n`); + } + } catch {} +} + +export async function displayPerformanceSlowQueryNotice( + instance: TelemetryInstance, + trigger: PerformanceSlowQueryTrigger, +): Promise { + if (!isTelemetryEnabled()) return; + if (isNoticeCapacityReachedInProcess(PERFORMANCE_SLOW_QUERY_NOTICE_ID)) { + return; + } + + try { + const config = loadMem0Config(); + const state = getNoticeState(config, PERFORMANCE_SLOW_QUERY_NOTICE_ID); + if (!hasNoticeCapRoomForNotice(PERFORMANCE_SLOW_QUERY_NOTICE_ID, state)) { + return; + } + + const flagEvaluation = await evaluateNoticeFlag(instance.telemetryId); + if (!flagEvaluation) return; + + const decision = getDisplayDecision( + PERFORMANCE_SLOW_QUERY_NOTICE_ID, + LOG_LINE_NOTICE_TYPE, + flagEvaluation.variant, + flagEvaluation.payload, + ); + + const opportunity = { + variant: flagEvaluation.variant, + sync_type: "async", + trigger_function: trigger.triggerFunction, + trigger_reason: trigger.triggerReason, + }; + + if ( + !recordNoticeOpportunity(PERFORMANCE_SLOW_QUERY_NOTICE_ID, opportunity) + ) { + return; + } + + await emitNoticeDisplayed(instance, { + notice_id: PERFORMANCE_SLOW_QUERY_NOTICE_ID, + notice_type: LOG_LINE_NOTICE_TYPE, + flag_key: NOTICE_FLAG_KEY, + variant: flagEvaluation.variant, + displayed: decision.displayed, + payload: decision.copy, + bypass_reason: decision.bypassReason, + disabled_reason: decision.disabledReason, + notice_config_found: decision.noticeConfigFound, + sync_type: "async", + trigger_function: trigger.triggerFunction, + trigger_reason: trigger.triggerReason, + elapsed_ms: trigger.elapsedMs, + threshold_ms: trigger.thresholdMs, + top_k: trigger.topK, + result_count: trigger.resultCount, + }); + + if (decision.displayed && decision.copy) { + process.stderr.write(`${decision.copy}\n`); + } + } catch {} +} + +export async function displayTemporalUsageNotice( + instance: TelemetryInstance, + trigger: TemporalUsageTrigger, +): Promise { + if (!isTelemetryEnabled()) return; + if (isNoticeCapacityReachedInProcess(TEMPORAL_USAGE_NOTICE_ID)) return; + + try { + const config = loadMem0Config(); + const state = getNoticeState(config, TEMPORAL_USAGE_NOTICE_ID); + if (!hasNoticeCapRoomForNotice(TEMPORAL_USAGE_NOTICE_ID, state)) return; + + const flagEvaluation = await evaluateNoticeFlag(instance.telemetryId); + if (!flagEvaluation) return; + + const decision = getDisplayDecision( + TEMPORAL_USAGE_NOTICE_ID, + LOG_LINE_NOTICE_TYPE, + flagEvaluation.variant, + flagEvaluation.payload, + ); + + const opportunity = { + variant: flagEvaluation.variant, + sync_type: "async", + trigger_function: trigger.triggerFunction, + trigger_source: trigger.triggerSource, + trigger_reason: trigger.triggerReason, + }; + + if (!recordNoticeOpportunity(TEMPORAL_USAGE_NOTICE_ID, opportunity)) { + return; + } + + await emitNoticeDisplayed(instance, { + notice_id: TEMPORAL_USAGE_NOTICE_ID, + notice_type: LOG_LINE_NOTICE_TYPE, + flag_key: NOTICE_FLAG_KEY, + variant: flagEvaluation.variant, + displayed: decision.displayed, + payload: decision.copy, + bypass_reason: decision.bypassReason, + disabled_reason: decision.disabledReason, + notice_config_found: decision.noticeConfigFound, + sync_type: "async", + trigger_function: trigger.triggerFunction, + trigger_source: trigger.triggerSource, + trigger_reason: trigger.triggerReason, + }); + + if (decision.displayed && decision.copy) { + process.stderr.write(`${decision.copy}\n`); + } + } catch {} +} + +export async function displayDecayUsageNotice( + instance: TelemetryInstance, + trigger: DecayUsageTrigger, +): Promise { + if (!isTelemetryEnabled()) return; + if (isNoticeCapacityReachedInProcess(DECAY_USAGE_NOTICE_ID)) return; + + try { + const config = loadMem0Config(); + const state = getNoticeState(config, DECAY_USAGE_NOTICE_ID); + if (!hasNoticeCapRoomForNotice(DECAY_USAGE_NOTICE_ID, state)) return; + + const flagEvaluation = await evaluateNoticeFlag(instance.telemetryId); + if (!flagEvaluation) return; + + const decision = getDisplayDecision( + DECAY_USAGE_NOTICE_ID, + LOG_LINE_NOTICE_TYPE, + flagEvaluation.variant, + flagEvaluation.payload, + ); + + const opportunity = { + variant: flagEvaluation.variant, + sync_type: "async", + trigger_function: trigger.triggerFunction, + trigger_source: trigger.triggerSource, + trigger_reason: trigger.triggerReason, + ...(trigger.deleteCount !== undefined && { + delete_count: trigger.deleteCount, + }), + ...(trigger.deletedCount !== undefined && { + deleted_count: trigger.deletedCount, + }), + }; + + if (!recordNoticeOpportunity(DECAY_USAGE_NOTICE_ID, opportunity)) { + return; + } + + await emitNoticeDisplayed(instance, { + notice_id: DECAY_USAGE_NOTICE_ID, + notice_type: LOG_LINE_NOTICE_TYPE, + flag_key: NOTICE_FLAG_KEY, + variant: flagEvaluation.variant, + displayed: decision.displayed, + payload: decision.copy, + bypass_reason: decision.bypassReason, + disabled_reason: decision.disabledReason, + notice_config_found: decision.noticeConfigFound, + sync_type: "async", + trigger_function: trigger.triggerFunction, + trigger_source: trigger.triggerSource, + trigger_reason: trigger.triggerReason, + ...(trigger.deleteCount !== undefined && { + delete_count: trigger.deleteCount, + }), + ...(trigger.deletedCount !== undefined && { + deleted_count: trigger.deletedCount, + }), + }); + + if (decision.displayed && decision.copy) { + process.stderr.write(`${decision.copy}\n`); + } + } catch {} +} + +export async function displayFirstRunNotice( + instance: TelemetryInstance, + triggerFunction: string, +): Promise { + if (!isTelemetryEnabled()) return; + if (firstRunConsumedInProcess || firstRunClaimInProgress) return; + + const config = loadMem0Config(); + if (isFirstRunConsumed(config)) { + firstRunConsumedInProcess = true; + return; + } + + firstRunClaimInProgress = true; + try { + const flagEvaluation = await evaluateNoticeFlag(instance.telemetryId); + if (!flagEvaluation) { + firstRunClaimInProgress = false; + return; + } + + const decision = getDisplayDecision( + FIRST_RUN_NOTICE_ID, + LOG_LINE_NOTICE_TYPE, + flagEvaluation.variant, + flagEvaluation.payload, + ); + + firstRunConsumedInProcess = true; + markFirstRunConsumed(triggerFunction, flagEvaluation.variant); + + await emitNoticeDisplayed(instance, { + notice_id: FIRST_RUN_NOTICE_ID, + notice_type: LOG_LINE_NOTICE_TYPE, + flag_key: NOTICE_FLAG_KEY, + variant: flagEvaluation.variant, + displayed: decision.displayed, + payload: decision.copy, + bypass_reason: decision.bypassReason, + disabled_reason: decision.disabledReason, + notice_config_found: decision.noticeConfigFound, + sync_type: "async", + trigger_function: triggerFunction, + }); + + if (decision.displayed && decision.copy) { + process.stderr.write(`${decision.copy}\n`); + } + } catch { + if (!firstRunConsumedInProcess) firstRunClaimInProgress = false; + } finally { + if (firstRunConsumedInProcess) firstRunClaimInProgress = false; + } +} + +export async function emitNoticeDisplayed( + instance: TelemetryInstance, + properties: Record, +): Promise { + if (!isTelemetryEnabled()) return; + try { + await captureNoticeEvent(instance, properties); + } catch {} +} + +export const __noticeTestHooks = { + appendNoticeCapEvent, + emitNoticeDisplayed, + evaluateNoticeFlag, + displayFirstRunNotice, + displayDecayUsageNotice, + displayTemporalUsageNotice, + displayScaleThresholdNotice, + displayPerformanceSlowQueryNotice, + detectPerformanceSlowQuery, + detectScaleThresholdFromAddResult, + detectScaleThresholdFromTopK, + detectTemporalUsageFromMetadata, + detectTemporalUsageFromSearch, + getDecayFeatureErrorMessage, + getTemporalFeatureErrorMessage, + getDecayUsageDeleteCountAfterSuccess, + getMem0ConfigPath, + getMem0Dir, + getNoticeConfigFromPayload, + getNoticeState, + hasNoticeCapRoom, + isDecayUsageDeleteEligible, + loadMem0Config, + recordNoticeOpportunity, + setNoticeState, + writeMem0ConfigAtomic, +}; diff --git a/mem0-ts/src/oss/src/utils/telemetry.ts b/mem0-ts/src/oss/src/utils/telemetry.ts index 9f75f5d05..0be4869cb 100644 --- a/mem0-ts/src/oss/src/utils/telemetry.ts +++ b/mem0-ts/src/oss/src/utils/telemetry.ts @@ -13,10 +13,12 @@ let version = // Safely check for process.env in different environments let MEM0_TELEMETRY = true; try { - MEM0_TELEMETRY = process?.env?.MEM0_TELEMETRY === "false" ? false : true; + MEM0_TELEMETRY = + process?.env?.MEM0_TELEMETRY?.toLowerCase() === "false" ? false : true; } catch (error) {} const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"; const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"; +const NOTICE_EVENT_NAME = "mem0.notice_displayed"; // Default sampling rate for hot-path OSS events. Lifecycle events always fire at 100%. // Override via MEM0_TELEMETRY_SAMPLE_RATE env var. Mirrors mem0/memory/telemetry.py. @@ -35,7 +37,11 @@ const MEM0_TELEMETRY_SAMPLE_RATE: number = ((): number => { })(); // Events that bypass sampling. Keep in sync with _captureEvent call sites in memory/index.ts. -const LIFECYCLE_EVENTS: ReadonlySet = new Set(["init", "reset"]); +const ALWAYS_SEND_EVENTS: ReadonlySet = new Set([ + "init", + "reset", + "notice_displayed", +]); class UnifiedTelemetry implements TelemetryClient { private apiKey: string; @@ -91,6 +97,10 @@ class UnifiedTelemetry implements TelemetryClient { const telemetry = new UnifiedTelemetry(POSTHOG_API_KEY, POSTHOG_HOST); +function isTelemetryEnabled(): boolean { + return MEM0_TELEMETRY; +} + async function captureClientEvent( eventName: string, instance: TelemetryInstance, @@ -102,8 +112,8 @@ async function captureClientEvent( } // >= so that rate=0 drops everything and rate=1 keeps everything (Math.random() ∈ [0, 1)). - const isLifecycle = LIFECYCLE_EVENTS.has(eventName); - if (!isLifecycle && Math.random() >= MEM0_TELEMETRY_SAMPLE_RATE) { + const alwaysSend = ALWAYS_SEND_EVENTS.has(eventName); + if (!alwaysSend && Math.random() >= MEM0_TELEMETRY_SAMPLE_RATE) { return; } @@ -116,7 +126,7 @@ async function captureClientEvent( client_source: "nodejs", ...additionalData, // sample_rate set AFTER the spread so callers can never override it - sample_rate: isLifecycle ? 1.0 : MEM0_TELEMETRY_SAMPLE_RATE, + sample_rate: alwaysSend ? 1.0 : MEM0_TELEMETRY_SAMPLE_RATE, }; await telemetry.captureEvent( @@ -126,4 +136,36 @@ async function captureClientEvent( ); } -export { telemetry, captureClientEvent }; +async function captureNoticeEvent( + instance: TelemetryInstance, + properties: Record = {}, +) { + if (!instance.telemetryId) return; + + const eventData: TelemetryEventData = { + function: `${instance.constructor.name}`, + method: "notice_displayed", + api_host: instance.host, + timestamp: new Date().toISOString(), + client_version: version, + client_source: "nodejs", + ...properties, + sample_rate: 1.0, + }; + + await telemetry.captureEvent( + instance.telemetryId, + NOTICE_EVENT_NAME, + eventData, + ); +} + +export { + POSTHOG_API_KEY, + POSTHOG_HOST, + NOTICE_EVENT_NAME, + telemetry, + captureClientEvent, + captureNoticeEvent, + isTelemetryEnabled, +}; diff --git a/mem0-ts/src/oss/tests/config-manager.test.ts b/mem0-ts/src/oss/tests/config-manager.test.ts index 035f44733..76bd780ad 100644 --- a/mem0-ts/src/oss/tests/config-manager.test.ts +++ b/mem0-ts/src/oss/tests/config-manager.test.ts @@ -418,6 +418,7 @@ describe("Memory – LM Studio end-to-end flow", () => { })); jest.doMock("../src/utils/telemetry", () => ({ captureClientEvent: jest.fn().mockResolvedValue(undefined), + isTelemetryEnabled: jest.fn(() => false), })); MemoryClass = require("../src/memory").Memory; diff --git a/mem0-ts/src/oss/tests/dimension-autodetect.test.ts b/mem0-ts/src/oss/tests/dimension-autodetect.test.ts index 55398eae3..ad37d1297 100644 --- a/mem0-ts/src/oss/tests/dimension-autodetect.test.ts +++ b/mem0-ts/src/oss/tests/dimension-autodetect.test.ts @@ -290,6 +290,7 @@ describe("Memory – auto-initialization", () => { jest.doMock("../src/utils/telemetry", () => ({ captureClientEvent: jest.fn().mockResolvedValue(undefined), + isTelemetryEnabled: jest.fn(() => false), })); MemoryClass = require("../src/memory").Memory; diff --git a/mem0-ts/src/oss/tests/notices.decay-feature.test.ts b/mem0-ts/src/oss/tests/notices.decay-feature.test.ts new file mode 100644 index 000000000..111f2bad8 --- /dev/null +++ b/mem0-ts/src/oss/tests/notices.decay-feature.test.ts @@ -0,0 +1,387 @@ +/// +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +jest.setTimeout(15000); + +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +const mockEmbedding = new Array(1536).fill(0.1); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), + embeddingDims: 1536, + })), +})); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest.fn(), + })), +})); + +const DECAY_COPY = + 'Memory decay requires Mem0 Platform. Get a free API key at https://app.mem0.ai?utm_source=oss_sdk&utm_medium=in_product&utm_campaign=decay_stub&utm_content=node_error and use `import MemoryClient from "mem0ai"`.'; +const PLAIN_DECAY_ERROR = + "The decay parameter is not supported by the OSS Memory SDK."; +const PROJECT_UPDATE_ERROR = + "Project updates are not supported by the OSS Memory SDK."; + +function makeTempMem0Dir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-decay-feature-")); +} + +function decayPayload(overrides: Record = {}) { + return { + notices: { + decay_stub: { + enabled: true, + notice_type: "error", + copy: DECAY_COPY, + ...overrides, + }, + }, + }; +} + +function createFetchMock(options: { + variant?: string; + payload?: unknown; + failFlags?: boolean; + flagEnabled?: boolean; +}) { + const calls: any[] = []; + const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { + const target = String(url); + + if (target.includes("/flags")) { + if (options.failFlags) { + throw new Error("flag evaluation failed"); + } + return { + ok: true, + json: jest.fn().mockResolvedValue({ + flags: { + "mem0-oss-notices": { + key: "mem0-oss-notices", + enabled: options.flagEnabled ?? true, + variant: options.variant ?? "displayed", + metadata: { + payload: + options.payload === undefined + ? JSON.stringify(decayPayload()) + : options.payload, + }, + }, + }, + }), + }; + } + + if (target.includes("/i/v0/e/")) { + calls.push(JSON.parse(String(init?.body))); + return { + ok: true, + text: jest.fn().mockResolvedValue(""), + }; + } + + return { + ok: true, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }; + }); + + return { fetchMock, calls }; +} + +function noticeEvents(calls: any[]) { + return calls.filter((call) => call.event === "mem0.notice_displayed"); +} + +async function createMemory() { + const { Memory } = await import("../src/memory"); + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-decay-feature-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +describe("Node OSS decay feature error notice", () => { + let originalMem0Dir: string | undefined; + let originalTelemetry: string | undefined; + let originalSampleRate: string | undefined; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalMem0Dir = process.env.MEM0_DIR; + originalTelemetry = process.env.MEM0_TELEMETRY; + originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE; + originalFetch = global.fetch; + + process.env.MEM0_DIR = makeTempMem0Dir(); + process.env.MEM0_TELEMETRY = "true"; + process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1"; + jest.resetModules(); + }); + + afterEach(() => { + if (originalMem0Dir === undefined) delete process.env.MEM0_DIR; + else process.env.MEM0_DIR = originalMem0Dir; + + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + + if (originalSampleRate === undefined) { + delete process.env.MEM0_TELEMETRY_SAMPLE_RATE; + } else { + process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate; + } + + global.fetch = originalFetch; + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("raises CTA copy for displayed and emits displayed=true", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject({ decay: true })).rejects.toThrow( + DECAY_COPY, + ); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "decay_stub", + notice_type: "error", + flag_key: "mem0-oss-notices", + variant: "displayed", + displayed: true, + payload: DECAY_COPY, + notice_config_found: true, + sync_type: "async", + trigger_function: "update_project", + trigger_parameter: "decay", + sample_rate: 1, + }), + ); + }); + + it("raises CTA copy for holdout and emits displayed=true", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "holdout" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject({ decay: true })).rejects.toThrow( + DECAY_COPY, + ); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "decay_stub", + variant: "holdout", + displayed: true, + trigger_function: "update_project", + trigger_parameter: "decay", + }), + ); + expect(notices[0].properties.bypass_reason).toBeUndefined(); + }); + + it("uses plain error for unknown future variants and emits not_displayed", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "silent" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject({ decay: true })).rejects.toThrow( + PLAIN_DECAY_ERROR, + ); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "decay_stub", + variant: "silent", + displayed: false, + bypass_reason: "not_displayed", + payload: DECAY_COPY, + }), + ); + }); + + it("uses plain error for disabled payload and emits payload_disabled", async () => { + const { fetchMock, calls } = createFetchMock({ + variant: "holdout", + payload: JSON.stringify(decayPayload({ enabled: false })), + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject({ decay: true })).rejects.toThrow( + PLAIN_DECAY_ERROR, + ); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "decay_stub", + displayed: false, + bypass_reason: "payload_disabled", + disabled_reason: "payload_disabled", + notice_config_found: true, + }), + ); + }); + + it.each([ + [ + "missing config", + JSON.stringify({ notices: {} }), + "missing_notice_config", + ], + [ + "missing copy", + JSON.stringify(decayPayload({ copy: "" })), + "missing_copy", + ], + ["malformed payload", "{not-json", "missing_notice_config"], + ])("uses plain error for %s", async (_label, payload, bypassReason) => { + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject({ decay: true })).rejects.toThrow( + PLAIN_DECAY_ERROR, + ); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "decay_stub", + displayed: false, + bypass_reason: bypassReason, + }), + ); + }); + + it("uses plain error and emits no event when the blunt flag is disabled", async () => { + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + flagEnabled: false, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject({ decay: true })).rejects.toThrow( + PLAIN_DECAY_ERROR, + ); + + expect(noticeEvents(calls)).toHaveLength(0); + }); + + it("uses plain error and emits no event when PostHog fails", async () => { + const { fetchMock, calls } = createFetchMock({ failFlags: true }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject({ decay: true })).rejects.toThrow( + PLAIN_DECAY_ERROR, + ); + + expect(noticeEvents(calls)).toHaveLength(0); + }); + + it("uses plain error and skips flag evaluation when telemetry is off", async () => { + process.env.MEM0_TELEMETRY = "False"; + jest.resetModules(); + + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject({ decay: true })).rejects.toThrow( + PLAIN_DECAY_ERROR, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(noticeEvents(calls)).toHaveLength(0); + }); + + it.each([ + ["empty options", {}], + ["decay false", { decay: false }], + ["non-decay options", { customInstructions: "Updated" }], + ])("does not emit notice telemetry for %s", async (_label, options) => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject(options)).rejects.toThrow( + PROJECT_UPDATE_ERROR, + ); + + expect( + fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")), + ).toHaveLength(0); + expect(noticeEvents(calls)).toHaveLength(0); + }); + + it("does not trigger first-run notice or update_project telemetry", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.updateProject({ decay: true })).rejects.toThrow( + DECAY_COPY, + ); + + const eventNames = calls.map((call) => call.event); + expect(eventNames).toContain("mem0.notice_displayed"); + expect(eventNames).not.toContain("mem0.update_project"); + expect( + noticeEvents(calls).some( + (call) => call.properties.notice_id === "first_run", + ), + ).toBe(false); + }); +}); diff --git a/mem0-ts/src/oss/tests/notices.decay-usage.test.ts b/mem0-ts/src/oss/tests/notices.decay-usage.test.ts new file mode 100644 index 000000000..a60770135 --- /dev/null +++ b/mem0-ts/src/oss/tests/notices.decay-usage.test.ts @@ -0,0 +1,526 @@ +/// +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +jest.setTimeout(15000); + +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +const mockEmbedding = new Array(1536).fill(0.1); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), + embeddingDims: 1536, + })), +})); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest.fn(), + })), +})); + +const DECAY_USAGE_COPY = + "Tip: Python fallback copy with memory.project.update(decay=True)."; + +function makeTempMem0Dir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-decay-usage-")); +} + +function configPath(): string { + return path.join(process.env.MEM0_DIR as string, "config.json"); +} + +function readConfig(): Record { + const file = configPath(); + if (!fs.existsSync(file)) return {}; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeConfig(config: Record) { + fs.mkdirSync(path.dirname(configPath()), { recursive: true }); + fs.writeFileSync(configPath(), JSON.stringify(config, null, 4)); +} + +function consumeFirstRun() { + writeConfig({ + user_id: "node-decay-usage-test-user", + notice_state: { + first_run: { + consumed: true, + trigger_function: "test_setup", + variant: "test", + }, + }, + }); +} + +function decayUsagePayload(overrides: Record = {}) { + return { + notices: { + decay_usage: { + enabled: true, + notice_type: "log_line", + copy: DECAY_USAGE_COPY, + ...overrides, + }, + }, + }; +} + +function createFetchMock(options: { + variant?: string; + payload?: unknown; + failFlags?: boolean; + flagEnabled?: boolean; +}) { + const calls: any[] = []; + const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { + const target = String(url); + + if (target.includes("/flags")) { + if (options.failFlags) { + throw new Error("flag evaluation failed"); + } + const payload = + options.payload === undefined + ? JSON.stringify(decayUsagePayload()) + : options.payload; + return { + ok: true, + json: jest.fn().mockResolvedValue({ + flags: { + "mem0-oss-notices": { + key: "mem0-oss-notices", + enabled: options.flagEnabled ?? true, + variant: options.variant ?? "displayed", + metadata: { payload }, + }, + }, + }), + }; + } + + if (target.includes("/i/v0/e/")) { + calls.push(JSON.parse(String(init?.body))); + return { + ok: true, + text: jest.fn().mockResolvedValue(""), + }; + } + + return { + ok: true, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }; + }); + + return { fetchMock, calls }; +} + +function noticeEvents(calls: any[]) { + return calls.filter((call) => call.event === "mem0.notice_displayed"); +} + +function flagRequestCount(fetchMock: jest.Mock) { + return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")) + .length; +} + +async function createMemory() { + const { Memory } = await import("../src/memory"); + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-decay-usage-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +async function addMemories(memory: any, userId: string, count: number) { + const ids: string[] = []; + for (let index = 0; index < count; index++) { + const result = await memory.add(`Decay usage memory ${index}`, { + userId, + infer: false, + }); + ids.push(result.results[0].id); + } + return ids; +} + +describe("Node OSS decay usage notice", () => { + let originalMem0Dir: string | undefined; + let originalTelemetry: string | undefined; + let originalSampleRate: string | undefined; + let originalFetch: typeof global.fetch; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + originalMem0Dir = process.env.MEM0_DIR; + originalTelemetry = process.env.MEM0_TELEMETRY; + originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE; + originalFetch = global.fetch; + + process.env.MEM0_DIR = makeTempMem0Dir(); + process.env.MEM0_TELEMETRY = "true"; + process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1"; + stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + jest.resetModules(); + }); + + afterEach(() => { + if (originalMem0Dir === undefined) delete process.env.MEM0_DIR; + else process.env.MEM0_DIR = originalMem0Dir; + + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + + if (originalSampleRate === undefined) { + delete process.env.MEM0_TELEMETRY_SAMPLE_RATE; + } else { + process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate; + } + + global.fetch = originalFetch; + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("does not evaluate or write decay state before the 5th successful delete", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + const ids = await addMemories(memory, "decay-delete-user", 4); + + for (const id of ids) { + await memory.delete(id); + } + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(noticeEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.decay_usage).toBeUndefined(); + expect(stderrSpy).not.toHaveBeenCalledWith( + expect.stringContaining(DECAY_USAGE_COPY), + ); + }); + + it("evaluates on the 5th successful delete and records delete_count", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + const ids = await addMemories(memory, "decay-delete-user", 5); + + for (const id of ids) { + await memory.delete(id); + } + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "decay_usage", + notice_type: "log_line", + flag_key: "mem0-oss-notices", + variant: "displayed", + displayed: true, + payload: DECAY_USAGE_COPY, + notice_config_found: true, + sync_type: "async", + trigger_function: "delete", + trigger_source: "delete_count", + trigger_reason: "repeated_deletes", + delete_count: 5, + sample_rate: 1, + }), + ); + expect(stderrSpy.mock.calls.flat().join("")).toContain(DECAY_USAGE_COPY); + expect(readConfig().notice_state.decay_usage.events).toHaveLength(1); + }); + + it("does not count or emit when delete fails", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + jest + .spyOn(memory as any, "deleteMemory") + .mockRejectedValue(new Error("delete failed")); + + await expect(memory.delete("memory-id")).rejects.toThrow("delete failed"); + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(noticeEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.decay_usage).toBeUndefined(); + }); + + it("evaluates deleteAll after deleting at least one memory", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addMemories(memory, "decay-delete-all-user", 3); + + await memory.deleteAll({ userId: "decay-delete-all-user" }); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "decay_usage", + variant: "displayed", + displayed: true, + payload: DECAY_USAGE_COPY, + trigger_function: "delete_all", + trigger_source: "delete_all", + trigger_reason: "bulk_delete", + deleted_count: 3, + }), + ); + }); + + it("does not evaluate deleteAll when no memories are deleted", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.deleteAll({ userId: "empty-delete-all-user" }); + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(noticeEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.decay_usage).toBeUndefined(); + }); + + it("does not evaluate when deleteAll fails", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.deleteAll({} as any)).rejects.toThrow( + "At least one filter is required", + ); + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(noticeEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.decay_usage).toBeUndefined(); + }); + + it("is silent for holdout and emits displayed=false", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "holdout" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addMemories(memory, "decay-holdout-user", 3); + + await memory.deleteAll({ userId: "decay-holdout-user" }); + + expect(stderrSpy).not.toHaveBeenCalledWith( + expect.stringContaining(DECAY_USAGE_COPY), + ); + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "decay_usage", + variant: "holdout", + displayed: false, + bypass_reason: "holdout", + trigger_function: "delete_all", + }), + ); + }); + + it.each([ + [ + "disabled payload", + JSON.stringify(decayUsagePayload({ enabled: false })), + "payload_disabled", + "payload_disabled", + ], + [ + "missing config", + JSON.stringify({ notices: {} }), + "missing_notice_config", + undefined, + ], + [ + "missing copy", + JSON.stringify({ + notices: { + decay_usage: { enabled: true, notice_type: "log_line" }, + }, + }), + "missing_copy", + undefined, + ], + ["malformed payload", "{not-json", "missing_notice_config", undefined], + ])( + "stays silent and emits safe bypass for %s", + async (_label, payload, bypassReason, disabledReason) => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addMemories(memory, "decay-bypass-user", 3); + + await memory.deleteAll({ userId: "decay-bypass-user" }); + + expect(stderrSpy).not.toHaveBeenCalledWith( + expect.stringContaining(DECAY_USAGE_COPY), + ); + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "decay_usage", + displayed: false, + bypass_reason: bypassReason, + ...(disabledReason && { disabled_reason: disabledReason }), + }), + ); + }, + ); + + it("does not emit or consume cap when the blunt flag is disabled", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + flagEnabled: false, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addMemories(memory, "decay-flag-disabled-user", 3); + + await memory.deleteAll({ userId: "decay-flag-disabled-user" }); + + expect(noticeEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.decay_usage).toBeUndefined(); + }); + + it("does not emit or consume cap when PostHog fails", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ failFlags: true }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addMemories(memory, "decay-posthog-failure-user", 3); + + await memory.deleteAll({ userId: "decay-posthog-failure-user" }); + + expect(noticeEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.decay_usage).toBeUndefined(); + }); + + it("skips flag evaluation, event emission, and state writes when telemetry is off", async () => { + process.env.MEM0_TELEMETRY = "False"; + jest.resetModules(); + + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addMemories(memory, "decay-telemetry-off-user", 3); + + await memory.deleteAll({ userId: "decay-telemetry-off-user" }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(noticeEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.decay_usage).toBeUndefined(); + }); + + it("blocks the 11th evaluated opportunity before flag evaluation", async () => { + consumeFirstRun(); + const now = new Date(); + writeConfig({ + ...readConfig(), + notice_state: { + ...readConfig().notice_state, + first_run: readConfig().notice_state.first_run, + decay_usage: { + events: Array.from({ length: 10 }, (_, index) => ({ + evaluated_at: new Date(now.getTime() - index * 1000).toISOString(), + variant: "displayed", + })), + }, + }, + }); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addMemories(memory, "decay-cap-user", 1); + + await memory.deleteAll({ userId: "decay-cap-user" }); + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(noticeEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state.decay_usage.events).toHaveLength(10); + }); + + it("does not consume first-run on a qualifying decay usage call", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload: JSON.stringify({ + notices: { + first_run: { + enabled: true, + notice_type: "log_line", + copy: "First-run should not appear", + }, + decay_usage: { + enabled: true, + notice_type: "log_line", + copy: DECAY_USAGE_COPY, + }, + }, + }), + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addMemories(memory, "decay-priority-user", 3); + const config = readConfig(); + delete config.notice_state.first_run; + writeConfig(config); + + await memory.deleteAll({ userId: "decay-priority-user" }); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties.notice_id).toBe("decay_usage"); + expect(readConfig().notice_state.first_run).toBeUndefined(); + expect(readConfig().notice_state.decay_usage.events).toHaveLength(1); + }); +}); diff --git a/mem0-ts/src/oss/tests/notices.first-run.test.ts b/mem0-ts/src/oss/tests/notices.first-run.test.ts new file mode 100644 index 000000000..2c849e69d --- /dev/null +++ b/mem0-ts/src/oss/tests/notices.first-run.test.ts @@ -0,0 +1,409 @@ +/// +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +jest.setTimeout(15000); + +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest.fn().mockResolvedValue( + JSON.stringify({ + memory: [{ id: "0", text: "stored fact", attributed_to: "user" }], + }), + ), + })), +})); + +const mockEmbedding = new Array(1536).fill(0.1); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), + embeddingDims: 1536, + })), +})); + +const FIRST_RUN_COPY = "First-run CTA from PostHog"; + +function makeTempMem0Dir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-first-run-")); +} + +function firstRunPayload(overrides: Record = {}) { + return { + notices: { + first_run: { + enabled: true, + notice_type: "log_line", + copy: FIRST_RUN_COPY, + ...overrides, + }, + }, + }; +} + +function createFetchMock(options: { + variant?: string; + payload?: Record; + failFlags?: boolean; +}) { + const calls: any[] = []; + const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { + const target = String(url); + + if (target.includes("/flags")) { + if (options.failFlags) { + throw new Error("flag evaluation failed"); + } + return { + ok: true, + json: jest.fn().mockResolvedValue({ + flags: { + "mem0-oss-notices": { + key: "mem0-oss-notices", + enabled: true, + variant: options.variant ?? "displayed", + metadata: { + payload: JSON.stringify(options.payload ?? firstRunPayload()), + }, + }, + }, + }), + }; + } + + if (target.includes("/i/v0/e/")) { + calls.push(JSON.parse(String(init?.body))); + return { + ok: true, + text: jest.fn().mockResolvedValue(""), + }; + } + + return { + ok: true, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }; + }); + + return { fetchMock, calls }; +} + +function noticeEvents(calls: any[]) { + return calls.filter((call) => call.event === "mem0.notice_displayed"); +} + +async function createMemory() { + const { Memory } = await import("../src/memory"); + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-first-run-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +describe("Node OSS first-run notice", () => { + let originalMem0Dir: string | undefined; + let originalTelemetry: string | undefined; + let originalSampleRate: string | undefined; + let originalFetch: typeof global.fetch; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + originalMem0Dir = process.env.MEM0_DIR; + originalTelemetry = process.env.MEM0_TELEMETRY; + originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE; + originalFetch = global.fetch; + + process.env.MEM0_DIR = makeTempMem0Dir(); + process.env.MEM0_TELEMETRY = "true"; + process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1"; + stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + jest.resetModules(); + }); + + afterEach(() => { + if (originalMem0Dir === undefined) delete process.env.MEM0_DIR; + else process.env.MEM0_DIR = originalMem0Dir; + + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + + if (originalSampleRate === undefined) { + delete process.env.MEM0_TELEMETRY_SAMPLE_RATE; + } else { + process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate; + } + + global.fetch = originalFetch; + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("shows the displayed first-run copy once after a successful public call", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Direct storage content", { + userId: "first-run-user", + infer: false, + }); + await memory.add("Second direct storage content", { + userId: "first-run-user", + infer: false, + }); + + const stderrOutput = stderrSpy.mock.calls.flat().join(""); + expect(stderrOutput.match(new RegExp(FIRST_RUN_COPY, "g"))).toHaveLength(1); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "first_run", + notice_type: "log_line", + flag_key: "mem0-oss-notices", + variant: "displayed", + displayed: true, + payload: FIRST_RUN_COPY, + notice_config_found: true, + sync_type: "async", + trigger_function: "add", + sample_rate: 1, + }), + ); + + const { __noticeTestHooks } = await import("../src/utils/notices"); + const config = __noticeTestHooks.loadMem0Config(); + expect(config.notice_state.first_run).toEqual( + expect.objectContaining({ + consumed: true, + trigger_function: "add", + variant: "displayed", + }), + ); + }); + + it("treats missing enabled as enabled, matching Python payload semantics", async () => { + const payload = firstRunPayload(); + delete (payload.notices.first_run as Record).enabled; + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Missing enabled payload content", { + userId: "first-run-missing-enabled", + infer: false, + }); + + const stderrOutput = stderrSpy.mock.calls.flat().join(""); + expect(stderrOutput).toContain(FIRST_RUN_COPY); + expect(noticeEvents(calls)[0].properties).toEqual( + expect.objectContaining({ + notice_id: "first_run", + displayed: true, + payload: FIRST_RUN_COPY, + }), + ); + }); + + it("is silent for holdout but still emits and consumes the opportunity", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "holdout" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Holdout content", { + userId: "first-run-holdout", + infer: false, + }); + + expect(stderrSpy).not.toHaveBeenCalledWith( + expect.stringContaining(FIRST_RUN_COPY), + ); + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "first_run", + variant: "holdout", + displayed: false, + bypass_reason: "holdout", + notice_config_found: true, + }), + ); + }); + + it("is silent for disabled payloads and records a safe bypass", async () => { + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload: firstRunPayload({ enabled: false }), + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Disabled payload content", { + userId: "first-run-disabled", + infer: false, + }); + + const stderrOutput = stderrSpy.mock.calls.flat().join(""); + expect(stderrOutput).not.toContain(FIRST_RUN_COPY); + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "first_run", + displayed: false, + bypass_reason: "payload_disabled", + disabled_reason: "payload_disabled", + notice_config_found: true, + }), + ); + }); + + it("lets the payload disabled kill switch take precedence over holdout", async () => { + const { fetchMock, calls } = createFetchMock({ + variant: "holdout", + payload: firstRunPayload({ enabled: false }), + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Disabled holdout payload content", { + userId: "first-run-disabled-holdout", + infer: false, + }); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "first_run", + variant: "holdout", + displayed: false, + bypass_reason: "payload_disabled", + disabled_reason: "payload_disabled", + notice_config_found: true, + }), + ); + }); + + it("does not evaluate flags, emit, or write state when telemetry is off", async () => { + process.env.MEM0_TELEMETRY = "false"; + jest.resetModules(); + + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Telemetry off content", { + userId: "first-run-off", + infer: false, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(noticeEvents(calls)).toHaveLength(0); + const { __noticeTestHooks } = await import("../src/utils/notices"); + expect(__noticeTestHooks.loadMem0Config().notice_state).toBeUndefined(); + }); + + it("does not export internal notice test hooks from the public OSS entrypoint", async () => { + const publicOssEntry = await import("../src"); + + expect(publicOssEntry).not.toHaveProperty("__noticeTestHooks"); + }); + + it("treats uppercase False as telemetry off", async () => { + process.env.MEM0_TELEMETRY = "False"; + jest.resetModules(); + + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Uppercase telemetry off content", { + userId: "first-run-off-uppercase", + infer: false, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(noticeEvents(calls)).toHaveLength(0); + const { __noticeTestHooks } = await import("../src/utils/notices"); + expect(__noticeTestHooks.loadMem0Config().notice_state).toBeUndefined(); + }); + + it("does not run after a failed public call", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.add("Missing owner", {} as any)).rejects.toThrow( + "One of the filters: userId, agentId or runId is required!", + ); + + expect( + fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")), + ).toHaveLength(0); + expect(noticeEvents(calls)).toHaveLength(0); + }); + + it("does not break the successful operation when flag evaluation fails", async () => { + const { fetchMock, calls } = createFetchMock({ failFlags: true }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + const result = await memory.add("Flag failure content", { + userId: "first-run-failure", + infer: false, + }); + + expect(result.results).toHaveLength(1); + expect(noticeEvents(calls)).toHaveLength(0); + }); + + it("can trigger from get() when get is the first successful public call", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect(memory.get("missing-memory-id")).resolves.toBeNull(); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties.trigger_function).toBe("get"); + }); +}); diff --git a/mem0-ts/src/oss/tests/notices.foundation.test.ts b/mem0-ts/src/oss/tests/notices.foundation.test.ts new file mode 100644 index 000000000..70b2adff7 --- /dev/null +++ b/mem0-ts/src/oss/tests/notices.foundation.test.ts @@ -0,0 +1,227 @@ +/// +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import type { TelemetryInstance } from "../src/utils/telemetry.types"; + +function makeTempMem0Dir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-notices-")); +} + +function makeInstance( + overrides: Partial = {}, +): TelemetryInstance { + return { + telemetryId: "notice-test-user", + constructor: { name: "Memory" }, + host: "https://test.example.com", + ...overrides, + }; +} + +describe("Node OSS notice foundation", () => { + let originalMem0Dir: string | undefined; + let originalTelemetry: string | undefined; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalMem0Dir = process.env.MEM0_DIR; + originalTelemetry = process.env.MEM0_TELEMETRY; + originalFetch = global.fetch; + process.env.MEM0_DIR = makeTempMem0Dir(); + process.env.MEM0_TELEMETRY = "true"; + jest.resetModules(); + }); + + afterEach(() => { + if (originalMem0Dir === undefined) delete process.env.MEM0_DIR; + else process.env.MEM0_DIR = originalMem0Dir; + + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + + global.fetch = originalFetch; + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("writes notice state into isolated MEM0_DIR config and preserves user_id", async () => { + const notices = await import("../src/utils/notices"); + const configPath = notices.getMem0ConfigPath(); + + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ user_id: "existing-user" })); + + expect( + notices.recordNoticeOpportunity("foundation_notice", { + variant: "displayed", + }), + ).toBe(true); + + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(config.user_id).toBe("existing-user"); + expect(config.notice_state.foundation_notice.events).toHaveLength(1); + }); + + it("writes config through a temp-file path and leaves no temp file behind", async () => { + const notices = await import("../src/utils/notices"); + + expect(notices.writeMem0ConfigAtomic({ user_id: "atomic-user" })).toBe( + true, + ); + + const configDir = path.dirname(notices.getMem0ConfigPath()); + const tempFiles = fs + .readdirSync(configDir) + .filter((name) => name.endsWith(".tmp")); + expect(tempFiles).toHaveLength(0); + expect(notices.loadMem0Config().user_id).toBe("atomic-user"); + }); + + it("allows 10 evaluated opportunities in a rolling window and blocks the 11th", async () => { + const notices = await import("../src/utils/notices"); + const now = new Date("2026-06-11T12:00:00.000Z"); + let state: Record = {}; + + for (let i = 0; i < 10; i++) { + const nextState = notices.appendNoticeCapEvent( + state, + { variant: "displayed", index: i }, + { now: new Date(now.getTime() + i) }, + ); + expect(nextState).not.toBeNull(); + state = nextState!; + } + + expect(notices.hasNoticeCapRoom(state, { now })).toBe(false); + expect( + notices.appendNoticeCapEvent(state, { variant: "displayed" }, { now }), + ).toBeNull(); + }); + + it("drops old cap events outside the rolling window", async () => { + const notices = await import("../src/utils/notices"); + const oldEvent = { + evaluated_at: "2026-06-01T00:00:00.000Z", + variant: "displayed", + }; + const now = new Date("2026-06-11T12:00:00.000Z"); + + expect(notices.hasNoticeCapRoom({ events: [oldEvent] }, { now })).toBe( + true, + ); + const nextState = notices.appendNoticeCapEvent( + { events: [oldEvent] }, + { variant: "displayed" }, + { now }, + ); + expect(nextState?.events).toHaveLength(1); + }); + + it("does not evaluate flags or write notice state when telemetry is off", async () => { + process.env.MEM0_TELEMETRY = "false"; + jest.resetModules(); + + const fetchMock = jest.fn(); + global.fetch = fetchMock as any; + const notices = await import("../src/utils/notices"); + + await expect( + notices.evaluateNoticeFlag("notice-test-user", { fetchImpl: fetchMock }), + ).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect( + notices.recordNoticeOpportunity("foundation_notice", { + variant: "displayed", + }), + ).toBe(false); + expect(fs.existsSync(notices.getMem0ConfigPath())).toBe(false); + }); + + it("returns null when PostHog flag evaluation fails", async () => { + const notices = await import("../src/utils/notices"); + const fetchMock = jest.fn().mockRejectedValue(new Error("network down")); + + await expect( + notices.evaluateNoticeFlag("notice-test-user", { fetchImpl: fetchMock }), + ).resolves.toBeNull(); + }); + + it("returns null when PostHog flag evaluation times out", async () => { + const notices = await import("../src/utils/notices"); + const fetchMock = jest.fn( + (_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }), + ); + + await expect( + notices.evaluateNoticeFlag("notice-test-user", { + fetchImpl: fetchMock as any, + timeoutMs: 1, + }), + ).resolves.toBeNull(); + }); + + it("parses displayed variant and JSON payload from PostHog flags response", async () => { + const notices = await import("../src/utils/notices"); + const payload = { + notices: { + foundation_notice: { + enabled: true, + notice_type: "log_line", + copy: "Foundation notice", + }, + }, + }; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + flags: { + "mem0-oss-notices": { + key: "mem0-oss-notices", + enabled: true, + variant: "displayed", + metadata: { payload: JSON.stringify(payload) }, + }, + }, + }), + }); + + const result = await notices.evaluateNoticeFlag("notice-test-user", { + fetchImpl: fetchMock, + }); + expect(result?.variant).toBe("displayed"); + const parsed = notices.getNoticeConfigFromPayload( + result?.payload, + "foundation_notice", + ); + expect(parsed.found).toBe(true); + expect(parsed.config?.copy).toBe("Foundation notice"); + }); + + it("emits mem0.notice_displayed with sample_rate=1", async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue(""), + }); + global.fetch = fetchMock as any; + const notices = await import("../src/utils/notices"); + + await notices.emitNoticeDisplayed(makeInstance(), { + notice_id: "foundation_notice", + notice_type: "log_line", + flag_key: "mem0-oss-notices", + variant: "displayed", + displayed: true, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.event).toBe("mem0.notice_displayed"); + expect(body.properties.sample_rate).toBe(1); + expect(body.properties.notice_id).toBe("foundation_notice"); + }); +}); diff --git a/mem0-ts/src/oss/tests/notices.performance-slow-query.test.ts b/mem0-ts/src/oss/tests/notices.performance-slow-query.test.ts new file mode 100644 index 000000000..a0a267535 --- /dev/null +++ b/mem0-ts/src/oss/tests/notices.performance-slow-query.test.ts @@ -0,0 +1,492 @@ +/// +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +jest.setTimeout(15000); + +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +const mockEmbedding = new Array(1536).fill(0.1); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), + embeddingDims: 1536, + })), +})); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest.fn(), + })), +})); + +const PERFORMANCE_COPY = + "Mem0 Platform is optimized for this type of workload. Its retrieval benchmarked at ~0.8-1.09s p50 across LoCoMo, LongMemEval, and BEAM 1M/10M workloads; you can use it for free by getting an API key at: https://app.mem0.ai"; + +function makeTempMem0Dir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-performance-")); +} + +function configPath(): string { + return path.join(process.env.MEM0_DIR as string, "config.json"); +} + +function readConfig(): Record { + const file = configPath(); + if (!fs.existsSync(file)) return {}; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeConfig(config: Record) { + fs.mkdirSync(path.dirname(configPath()), { recursive: true }); + fs.writeFileSync(configPath(), JSON.stringify(config, null, 4)); +} + +function consumeFirstRun() { + writeConfig({ + user_id: "node-performance-test-user", + notice_state: { + first_run: { + consumed: true, + trigger_function: "test_setup", + variant: "test", + }, + }, + }); +} + +function performancePayload(overrides: Record = {}) { + return { + notices: { + performance_slow_query: { + enabled: true, + notice_type: "log_line", + copy: PERFORMANCE_COPY, + ...overrides, + }, + }, + }; +} + +function createFetchMock(options: { + variant?: string; + payload?: unknown; + failFlags?: boolean; + flagEnabled?: boolean; +}) { + const calls: any[] = []; + const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { + const target = String(url); + + if (target.includes("/flags")) { + if (options.failFlags) { + throw new Error("flag evaluation failed"); + } + const payload = + options.payload === undefined + ? JSON.stringify(performancePayload()) + : options.payload; + return { + ok: true, + json: jest.fn().mockResolvedValue({ + flags: { + "mem0-oss-notices": { + key: "mem0-oss-notices", + enabled: options.flagEnabled ?? true, + variant: options.variant ?? "displayed", + metadata: { payload }, + }, + }, + }), + }; + } + + if (target.includes("/i/v0/e/")) { + calls.push(JSON.parse(String(init?.body))); + return { + ok: true, + text: jest.fn().mockResolvedValue(""), + }; + } + + return { + ok: true, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }; + }); + + return { fetchMock, calls }; +} + +function noticeEvents(calls: any[]) { + return calls.filter((call) => call.event === "mem0.notice_displayed"); +} + +function performanceEvents(calls: any[]) { + return noticeEvents(calls).filter( + (call) => call.properties.notice_id === "performance_slow_query", + ); +} + +function flagRequestCount(fetchMock: jest.Mock) { + return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")) + .length; +} + +function mockSearchElapsed(elapsedMs: number) { + let now = 1000; + jest.spyOn(Date, "now").mockImplementation(() => { + const current = now; + now += elapsedMs; + return current; + }); +} + +async function createMemory() { + const { Memory } = await import("../src/memory"); + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-performance-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +async function addSeed(memory: any, userId = "performance-user") { + await memory.add("The user's favorite drink is green tea.", { + userId, + infer: false, + }); +} + +describe("Node OSS performance slow query notice", () => { + let originalMem0Dir: string | undefined; + let originalTelemetry: string | undefined; + let originalSampleRate: string | undefined; + let originalFetch: typeof global.fetch; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + originalMem0Dir = process.env.MEM0_DIR; + originalTelemetry = process.env.MEM0_TELEMETRY; + originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE; + originalFetch = global.fetch; + + process.env.MEM0_DIR = makeTempMem0Dir(); + process.env.MEM0_TELEMETRY = "true"; + process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1"; + stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + jest.resetModules(); + }); + + afterEach(() => { + if (originalMem0Dir === undefined) delete process.env.MEM0_DIR; + else process.env.MEM0_DIR = originalMem0Dir; + + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + + if (originalSampleRate === undefined) { + delete process.env.MEM0_TELEMETRY_SAMPLE_RATE; + } else { + process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate; + } + + global.fetch = originalFetch; + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("displays and emits safe fields after a slow successful search", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSeed(memory); + stderrSpy.mockClear(); + mockSearchElapsed(2345); + + const result = await memory.search("favorite drink private text", { + filters: { user_id: "performance-user" }, + topK: 3, + }); + + expect(result.results.length).toBeGreaterThan(0); + expect(stderrSpy).toHaveBeenCalledWith(`${PERFORMANCE_COPY}\n`); + const event = performanceEvents(calls)[0]; + expect(event.properties).toEqual( + expect.objectContaining({ + notice_id: "performance_slow_query", + notice_type: "log_line", + flag_key: "mem0-oss-notices", + variant: "displayed", + displayed: true, + payload: PERFORMANCE_COPY, + notice_config_found: true, + sync_type: "async", + trigger_function: "search", + trigger_reason: "slow_query", + elapsed_ms: 2345, + threshold_ms: 2000, + top_k: 3, + result_count: result.results.length, + sample_rate: 1, + }), + ); + const serialized = JSON.stringify(event.properties); + expect(serialized).not.toContain("private text"); + expect(serialized).not.toContain("performance-user"); + }); + + it("is silent for holdout and emits displayed=false", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "holdout" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSeed(memory); + stderrSpy.mockClear(); + mockSearchElapsed(2345); + + await memory.search("favorite drink", { + filters: { user_id: "performance-user" }, + topK: 3, + }); + + expect(stderrSpy).not.toHaveBeenCalled(); + expect(performanceEvents(calls)[0].properties).toEqual( + expect.objectContaining({ + notice_id: "performance_slow_query", + variant: "holdout", + displayed: false, + bypass_reason: "holdout", + }), + ); + }); + + it.each([ + [ + "disabled payload", + JSON.stringify(performancePayload({ enabled: false })), + "payload_disabled", + ], + [ + "missing config", + JSON.stringify({ notices: {} }), + "missing_notice_config", + ], + [ + "missing copy", + JSON.stringify(performancePayload({ copy: "" })), + "missing_copy", + ], + ["malformed payload", "{not-json", "missing_notice_config"], + ])("is silent and safe for %s", async (_label, payload, bypassReason) => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSeed(memory); + stderrSpy.mockClear(); + mockSearchElapsed(2345); + + await memory.search("favorite drink", { + filters: { user_id: "performance-user" }, + topK: 3, + }); + + expect(stderrSpy).not.toHaveBeenCalled(); + expect(performanceEvents(calls)[0].properties).toEqual( + expect.objectContaining({ + displayed: false, + bypass_reason: bypassReason, + }), + ); + }); + + it("does not evaluate fast searches", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSeed(memory); + mockSearchElapsed(100); + + await memory.search("favorite drink", { + filters: { user_id: "performance-user" }, + topK: 3, + }); + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(performanceEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.performance_slow_query).toBeUndefined(); + }); + + it("does not evaluate failed searches", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + mockSearchElapsed(2345); + + await expect( + memory.search("private slow query", { + filters: {}, + topK: 3, + }), + ).rejects.toThrow("filters must contain"); + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(performanceEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.performance_slow_query).toBeUndefined(); + }); + + it("does not consume cap or emit when the blunt flag is disabled", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + flagEnabled: false, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSeed(memory); + mockSearchElapsed(2345); + + await memory.search("favorite drink", { + filters: { user_id: "performance-user" }, + topK: 3, + }); + + expect(performanceEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.performance_slow_query).toBeUndefined(); + }); + + it("does not consume cap or emit when PostHog fails", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ failFlags: true }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSeed(memory); + mockSearchElapsed(2345); + + await memory.search("favorite drink", { + filters: { user_id: "performance-user" }, + topK: 3, + }); + + expect(performanceEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.performance_slow_query).toBeUndefined(); + }); + + it("does nothing when telemetry is off", async () => { + process.env.MEM0_TELEMETRY = "false"; + jest.resetModules(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSeed(memory); + mockSearchElapsed(2345); + + await memory.search("favorite drink", { + filters: { user_id: "performance-user" }, + topK: 3, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(performanceEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state).toBeUndefined(); + }); + + it("caps evaluated opportunities at 10 per rolling week", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSeed(memory); + stderrSpy.mockClear(); + mockSearchElapsed(2345); + + for (let index = 1; index <= 11; index++) { + await memory.search(`favorite drink ${index}`, { + filters: { user_id: "performance-user" }, + topK: 3, + }); + } + + expect(performanceEvents(calls)).toHaveLength(10); + expect(flagRequestCount(fetchMock)).toBe(10); + expect(stderrSpy).toHaveBeenCalledTimes(10); + expect( + readConfig().notice_state.performance_slow_query.events, + ).toHaveLength(10); + }); + + it("lets temporal usage and scale threshold take priority", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSeed(memory); + mockSearchElapsed(2345); + + await memory.search("what happened last week?", { + filters: { user_id: "performance-user" }, + topK: 3, + }); + await memory.search("favorite drink", { + filters: { user_id: "performance-user" }, + topK: 50, + }); + + expect(performanceEvents(calls)).toHaveLength(0); + const noticeIds = noticeEvents(calls).map( + (call) => call.properties.notice_id, + ); + expect(noticeIds).toEqual(["temporal_usage", "scale_threshold"]); + }); + + it("does not consume first-run on the same qualifying performance call", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + mockSearchElapsed(2345); + + await memory.search("favorite drink", { + filters: { user_id: "performance-user" }, + topK: 3, + }); + + expect(performanceEvents(calls)).toHaveLength(1); + expect(readConfig().notice_state.first_run).toBeUndefined(); + }); +}); diff --git a/mem0-ts/src/oss/tests/notices.scale-threshold.test.ts b/mem0-ts/src/oss/tests/notices.scale-threshold.test.ts new file mode 100644 index 000000000..48f2c0a76 --- /dev/null +++ b/mem0-ts/src/oss/tests/notices.scale-threshold.test.ts @@ -0,0 +1,463 @@ +/// +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +jest.setTimeout(15000); + +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +const mockEmbedding = new Array(1536).fill(0.1); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), + embeddingDims: 1536, + })), +})); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest.fn(), + })), +})); + +const SCALE_TOP_K_COPY = "Scale top {top_k}"; +const SCALE_MEMORY_COUNT_COPY = "Scale count {memory_count}"; + +function makeTempMem0Dir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-scale-")); +} + +function configPath(): string { + return path.join(process.env.MEM0_DIR as string, "config.json"); +} + +function readConfig(): Record { + const file = configPath(); + if (!fs.existsSync(file)) return {}; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeConfig(config: Record) { + fs.mkdirSync(path.dirname(configPath()), { recursive: true }); + fs.writeFileSync(configPath(), JSON.stringify(config, null, 4)); +} + +function consumeFirstRun(scaleState: Record = {}) { + writeConfig({ + user_id: "node-scale-test-user", + notice_state: { + first_run: { + consumed: true, + trigger_function: "test_setup", + variant: "test", + }, + ...(Object.keys(scaleState).length > 0 && { + scale_threshold: scaleState, + }), + }, + }); +} + +function scalePayload(overrides: Record = {}) { + return { + notices: { + scale_threshold: { + enabled: true, + notice_type: "log_line", + copies: { + top_k: SCALE_TOP_K_COPY, + memory_count: SCALE_MEMORY_COUNT_COPY, + }, + ...overrides, + }, + }, + }; +} + +function createFetchMock(options: { + variant?: string; + payload?: unknown; + failFlags?: boolean; + flagEnabled?: boolean; +}) { + const calls: any[] = []; + const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { + const target = String(url); + + if (target.includes("/flags")) { + if (options.failFlags) { + throw new Error("flag evaluation failed"); + } + const payload = + options.payload === undefined + ? JSON.stringify(scalePayload()) + : options.payload; + return { + ok: true, + json: jest.fn().mockResolvedValue({ + flags: { + "mem0-oss-notices": { + key: "mem0-oss-notices", + enabled: options.flagEnabled ?? true, + variant: options.variant ?? "displayed", + metadata: { payload }, + }, + }, + }), + }; + } + + if (target.includes("/i/v0/e/")) { + calls.push(JSON.parse(String(init?.body))); + return { + ok: true, + text: jest.fn().mockResolvedValue(""), + }; + } + + return { + ok: true, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }; + }); + + return { fetchMock, calls }; +} + +function noticeEvents(calls: any[]) { + return calls.filter((call) => call.event === "mem0.notice_displayed"); +} + +function scaleEvents(calls: any[]) { + return noticeEvents(calls).filter( + (call) => call.properties.notice_id === "scale_threshold", + ); +} + +function flagRequestCount(fetchMock: jest.Mock) { + return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")) + .length; +} + +async function createMemory() { + const { Memory } = await import("../src/memory"); + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-scale-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +async function addSeed(memory: any, userId = "scale-user") { + await memory.add("The user's favorite drink is green tea.", { + userId, + infer: false, + }); +} + +describe("Node OSS scale threshold notice", () => { + let originalMem0Dir: string | undefined; + let originalTelemetry: string | undefined; + let originalSampleRate: string | undefined; + let originalFetch: typeof global.fetch; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + originalMem0Dir = process.env.MEM0_DIR; + originalTelemetry = process.env.MEM0_TELEMETRY; + originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE; + originalFetch = global.fetch; + + process.env.MEM0_DIR = makeTempMem0Dir(); + process.env.MEM0_TELEMETRY = "true"; + process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1"; + stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + jest.resetModules(); + }); + + afterEach(() => { + if (originalMem0Dir === undefined) delete process.env.MEM0_DIR; + else process.env.MEM0_DIR = originalMem0Dir; + + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + + if (originalSampleRate === undefined) { + delete process.env.MEM0_TELEMETRY_SAMPLE_RATE; + } else { + process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate; + } + + global.fetch = originalFetch; + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("detects high topK after successful search", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await addSeed(memory); + stderrSpy.mockClear(); + const result = await memory.search("favorite drink private text", { + filters: { user_id: "scale-user" }, + topK: 50, + }); + + expect(result.results.length).toBeGreaterThan(0); + expect(stderrSpy).toHaveBeenCalledWith("Scale top 50\n"); + const event = scaleEvents(calls)[0]; + expect(event.properties).toMatchObject({ + notice_id: "scale_threshold", + displayed: true, + trigger_function: "search", + trigger_source: "top_k", + trigger_reason: "high_top_k", + top_k: 50, + threshold: 50, + sample_rate: 1, + }); + expect(JSON.stringify(event.properties)).not.toContain("private text"); + }); + + it("detects high topK after successful getAll", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await addSeed(memory); + stderrSpy.mockClear(); + const result = await memory.getAll({ + filters: { user_id: "scale-user" }, + topK: 50, + }); + + expect(result.results.length).toBeGreaterThan(0); + expect(stderrSpy).toHaveBeenCalledWith("Scale top 50\n"); + const event = scaleEvents(calls)[0]; + expect(event.properties).toMatchObject({ + notice_id: "scale_threshold", + displayed: true, + trigger_function: "get_all", + trigger_source: "top_k", + trigger_reason: "high_top_k", + top_k: 50, + threshold: 50, + }); + }); + + it("does not evaluate topK below the threshold", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await addSeed(memory); + await memory.search("favorite drink", { + filters: { user_id: "scale-user" }, + topK: 49, + }); + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(scaleEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.scale_threshold).toBeUndefined(); + }); + + it("marks memory-count threshold evaluated before PostHog display", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ failFlags: true }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + (memory as any).vectorStore.count = jest + .fn() + .mockResolvedValue({ count: 2000 }); + + await memory.add("Scale count threshold fixture.", { + userId: "scale-user", + infer: false, + }); + + expect((memory as any).vectorStore.count).toHaveBeenCalledTimes(1); + expect( + readConfig().notice_state.scale_threshold + .memory_count_threshold_evaluated, + ).toBe(true); + expect(scaleEvents(calls)).toHaveLength(0); + }); + + it("does not count provider memories once threshold was evaluated", async () => { + consumeFirstRun({ memory_count_threshold_evaluated: true }); + const { fetchMock } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + (memory as any).vectorStore.count = jest + .fn() + .mockResolvedValue({ count: 2000 }); + + await memory.add("Scale count already evaluated fixture.", { + userId: "scale-user", + infer: false, + }); + + expect((memory as any).vectorStore.count).not.toHaveBeenCalled(); + expect(flagRequestCount(fetchMock)).toBe(0); + }); + + it("throttles under-threshold provider counts", async () => { + consumeFirstRun(); + const { fetchMock } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + (memory as any).vectorStore.count = jest + .fn() + .mockResolvedValue({ count: 1999 }); + + await memory.add("Scale count under threshold one.", { + userId: "scale-user", + infer: false, + }); + await memory.add("Scale count under threshold two.", { + userId: "scale-user", + infer: false, + }); + + expect((memory as any).vectorStore.count).toHaveBeenCalledTimes(1); + expect(flagRequestCount(fetchMock)).toBe(0); + }); + + it("records holdout and disabled variants silently", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ + variant: "holdout", + payload: JSON.stringify(scalePayload({ enabled: false })), + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await addSeed(memory); + stderrSpy.mockClear(); + await memory.search("favorite drink", { + filters: { user_id: "scale-user" }, + topK: 50, + }); + + expect(stderrSpy).not.toHaveBeenCalled(); + const event = scaleEvents(calls)[0]; + expect(event.properties).toMatchObject({ + displayed: false, + bypass_reason: "payload_disabled", + disabled_reason: "payload_disabled", + variant: "holdout", + }); + }); + + it("treats missing enabled as enabled for scale payloads", async () => { + consumeFirstRun(); + const payload = scalePayload(); + delete (payload.notices.scale_threshold as Record).enabled; + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload: JSON.stringify(payload), + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await addSeed(memory); + stderrSpy.mockClear(); + await memory.search("favorite drink", { + filters: { user_id: "scale-user" }, + topK: 50, + }); + + expect(stderrSpy.mock.calls.flat().join("")).toContain("Scale top 50"); + expect(scaleEvents(calls)[0].properties).toMatchObject({ + notice_id: "scale_threshold", + displayed: true, + payload: "Scale top 50", + }); + }); + + it("caps evaluated scale opportunities at 10 per week", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await addSeed(memory); + stderrSpy.mockClear(); + for (let index = 0; index < 11; index++) { + await memory.search(`favorite drink ${index}`, { + filters: { user_id: "scale-user" }, + topK: 50, + }); + } + + expect(scaleEvents(calls)).toHaveLength(10); + expect(flagRequestCount(fetchMock)).toBe(10); + expect(stderrSpy).toHaveBeenCalledTimes(10); + expect(readConfig().notice_state.scale_threshold.events).toHaveLength(10); + }); + + it("does not consume first-run on the same qualifying scale call", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.getAll({ + filters: { user_id: "scale-user" }, + topK: 50, + }); + + expect(scaleEvents(calls)).toHaveLength(1); + expect(readConfig().notice_state?.first_run).toBeUndefined(); + }); + + it("telemetry off skips flag evaluation, event emission, and state writes", async () => { + process.env.MEM0_TELEMETRY = "false"; + jest.resetModules(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.getAll({ + filters: { user_id: "scale-user" }, + topK: 50, + }); + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(scaleEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state).toBeUndefined(); + }); +}); diff --git a/mem0-ts/src/oss/tests/notices.temporal-feature.test.ts b/mem0-ts/src/oss/tests/notices.temporal-feature.test.ts new file mode 100644 index 000000000..66608c0ad --- /dev/null +++ b/mem0-ts/src/oss/tests/notices.temporal-feature.test.ts @@ -0,0 +1,516 @@ +/// +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +jest.setTimeout(15000); + +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +const mockEmbedding = new Array(1536).fill(0.1); +const mockEmbed = jest.fn().mockResolvedValue(mockEmbedding); +const mockEmbedBatch = jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ); + +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: mockEmbed, + embedBatch: mockEmbedBatch, + embeddingDims: 1536, + })), +})); + +const mockGenerateResponse = jest.fn(); +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: mockGenerateResponse, + })), +})); + +const TEMPORAL_COPY = + "Temporal reasoning requires a Mem0 API key. Get one for free at https://app.mem0.ai"; +const PLAIN_TIMESTAMP_ERROR = + "The timestamp parameter is not supported by the OSS Memory SDK."; +const PLAIN_REFERENCE_DATE_ERROR = + "The referenceDate parameter is not supported by the OSS Memory SDK."; + +function makeTempMem0Dir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-temporal-feature-")); +} + +function temporalPayload(overrides: Record = {}) { + return { + notices: { + temporal_stub: { + enabled: true, + notice_type: "error", + copy: TEMPORAL_COPY, + ...overrides, + }, + }, + }; +} + +function createFetchMock(options: { + variant?: string; + payload?: unknown; + failFlags?: boolean; + flagEnabled?: boolean; +}) { + const calls: any[] = []; + const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { + const target = String(url); + + if (target.includes("/flags")) { + if (options.failFlags) { + throw new Error("flag evaluation failed"); + } + return { + ok: true, + json: jest.fn().mockResolvedValue({ + flags: { + "mem0-oss-notices": { + key: "mem0-oss-notices", + enabled: options.flagEnabled ?? true, + variant: options.variant ?? "displayed", + metadata: { + payload: + options.payload === undefined + ? JSON.stringify(temporalPayload()) + : options.payload, + }, + }, + }, + }), + }; + } + + if (target.includes("/i/v0/e/")) { + calls.push(JSON.parse(String(init?.body))); + return { + ok: true, + text: jest.fn().mockResolvedValue(""), + }; + } + + return { + ok: true, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }; + }); + + return { fetchMock, calls }; +} + +function noticeEvents(calls: any[]) { + return calls.filter((call) => call.event === "mem0.notice_displayed"); +} + +async function createMemory() { + const { Memory } = await import("../src/memory"); + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-temporal-feature-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +describe("Node OSS temporal feature error notice", () => { + let originalMem0Dir: string | undefined; + let originalTelemetry: string | undefined; + let originalSampleRate: string | undefined; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalMem0Dir = process.env.MEM0_DIR; + originalTelemetry = process.env.MEM0_TELEMETRY; + originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE; + originalFetch = global.fetch; + + process.env.MEM0_DIR = makeTempMem0Dir(); + process.env.MEM0_TELEMETRY = "true"; + process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1"; + mockEmbed.mockClear(); + mockEmbedBatch.mockClear(); + mockGenerateResponse.mockClear(); + jest.resetModules(); + }); + + afterEach(() => { + if (originalMem0Dir === undefined) delete process.env.MEM0_DIR; + else process.env.MEM0_DIR = originalMem0Dir; + + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + + if (originalSampleRate === undefined) { + delete process.env.MEM0_TELEMETRY_SAMPLE_RATE; + } else { + process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate; + } + + global.fetch = originalFetch; + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it.each([ + ["add", "timestamp"], + ["search", "referenceDate"], + ])( + "raises CTA copy for displayed %s(%s) and emits displayed=true", + async (triggerFunction, triggerParameter) => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + if (triggerFunction === "add") { + await expect( + memory.add("Temporal add", { + userId: "temporal-user", + timestamp: 1778112000, + }), + ).rejects.toThrow(TEMPORAL_COPY); + } else { + await expect( + memory.search("Temporal search", { + filters: { user_id: "temporal-user" }, + referenceDate: "2026-05-06", + }), + ).rejects.toThrow(TEMPORAL_COPY); + } + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_stub", + notice_type: "error", + flag_key: "mem0-oss-notices", + variant: "displayed", + displayed: true, + payload: TEMPORAL_COPY, + notice_config_found: true, + sync_type: "async", + trigger_function: triggerFunction, + trigger_parameter: triggerParameter, + sample_rate: 1, + }), + ); + }, + ); + + it.each([ + ["add", "timestamp"], + ["search", "referenceDate"], + ])( + "raises CTA copy for holdout %s(%s) and emits displayed=true", + async (triggerFunction, triggerParameter) => { + const { fetchMock, calls } = createFetchMock({ variant: "holdout" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + if (triggerFunction === "add") { + await expect( + memory.add("Temporal add", { + userId: "temporal-user", + timestamp: null, + }), + ).rejects.toThrow(TEMPORAL_COPY); + } else { + await expect( + memory.search("Temporal search", { + filters: { user_id: "temporal-user" }, + referenceDate: null, + }), + ).rejects.toThrow(TEMPORAL_COPY); + } + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_stub", + variant: "holdout", + displayed: true, + trigger_function: triggerFunction, + trigger_parameter: triggerParameter, + }), + ); + expect(notices[0].properties.bypass_reason).toBeUndefined(); + }, + ); + + it("uses plain error for unknown future variants and emits not_displayed", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "silent" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect( + memory.add("Temporal add", { + userId: "temporal-user", + timestamp: 1778112000, + }), + ).rejects.toThrow(PLAIN_TIMESTAMP_ERROR); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_stub", + variant: "silent", + displayed: false, + bypass_reason: "not_displayed", + payload: TEMPORAL_COPY, + }), + ); + }); + + it("treats missing enabled as enabled for feature-error payloads", async () => { + const payload = temporalPayload(); + delete (payload.notices.temporal_stub as Record).enabled; + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload: JSON.stringify(payload), + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect( + memory.add("Temporal add", { + userId: "temporal-user", + timestamp: 1778112000, + }), + ).rejects.toThrow(TEMPORAL_COPY); + + expect(noticeEvents(calls)[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_stub", + displayed: true, + payload: TEMPORAL_COPY, + }), + ); + }); + + it("uses timestamp plain error for disabled payload and emits payload_disabled", async () => { + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload: JSON.stringify(temporalPayload({ enabled: false })), + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect( + memory.add("Temporal add", { + userId: "temporal-user", + timestamp: 1778112000, + }), + ).rejects.toThrow(PLAIN_TIMESTAMP_ERROR); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_stub", + displayed: false, + bypass_reason: "payload_disabled", + disabled_reason: "payload_disabled", + notice_config_found: true, + trigger_function: "add", + trigger_parameter: "timestamp", + }), + ); + }); + + it.each([ + [ + "missing config", + JSON.stringify({ notices: {} }), + "missing_notice_config", + ], + [ + "missing copy", + JSON.stringify(temporalPayload({ copy: "" })), + "missing_copy", + ], + ["malformed payload", "{not-json", "missing_notice_config"], + ])( + "uses referenceDate plain error for %s", + async (_label, payload, bypassReason) => { + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect( + memory.search("Temporal search", { + filters: { user_id: "temporal-user" }, + referenceDate: "2026-05-06", + }), + ).rejects.toThrow(PLAIN_REFERENCE_DATE_ERROR); + + const notices = noticeEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_stub", + displayed: false, + bypass_reason: bypassReason, + trigger_function: "search", + trigger_parameter: "referenceDate", + }), + ); + }, + ); + + it("uses plain error and emits no event when the blunt flag is disabled", async () => { + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + flagEnabled: false, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect( + memory.add("Temporal add", { + userId: "temporal-user", + timestamp: 1778112000, + }), + ).rejects.toThrow(PLAIN_TIMESTAMP_ERROR); + + expect(noticeEvents(calls)).toHaveLength(0); + }); + + it("uses plain error and emits no event when PostHog fails", async () => { + const { fetchMock, calls } = createFetchMock({ failFlags: true }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect( + memory.search("Temporal search", { + filters: { user_id: "temporal-user" }, + referenceDate: "2026-05-06", + }), + ).rejects.toThrow(PLAIN_REFERENCE_DATE_ERROR); + + expect(noticeEvents(calls)).toHaveLength(0); + }); + + it("uses plain error and skips flag evaluation when telemetry is off", async () => { + process.env.MEM0_TELEMETRY = "False"; + jest.resetModules(); + + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect( + memory.add("Temporal add", { + userId: "temporal-user", + timestamp: 1778112000, + }), + ).rejects.toThrow(PLAIN_TIMESTAMP_ERROR); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(noticeEvents(calls)).toHaveLength(0); + }); + + it("throws before add validation, normal telemetry, embeddings, and first-run", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + const addToVectorStoreSpy = jest.spyOn(memory as any, "addToVectorStore"); + + await expect( + memory.add( + undefined as any, + { + timestamp: 1778112000, + } as any, + ), + ).rejects.toThrow(TEMPORAL_COPY); + + expect(mockEmbed).not.toHaveBeenCalled(); + expect(addToVectorStoreSpy).not.toHaveBeenCalled(); + expect(calls.map((call) => call.event)).not.toContain("mem0.add"); + expect( + noticeEvents(calls).some( + (call) => call.properties.notice_id === "first_run", + ), + ).toBe(false); + }); + + it("throws before search validation, normal telemetry, embeddings, and first-run", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect( + memory.search("Temporal search", { + userId: "invalid-top-level-user", + referenceDate: "2026-05-06", + } as any), + ).rejects.toThrow(TEMPORAL_COPY); + + expect(mockEmbed).not.toHaveBeenCalled(); + expect(calls.map((call) => call.event)).not.toContain("mem0.search"); + expect( + noticeEvents(calls).some( + (call) => call.properties.notice_id === "first_run", + ), + ).toBe(false); + }); + + it("leaves normal add/search calls unchanged when temporal options are omitted", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "holdout" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + const addResult = await memory.add("Normal add", { + userId: "normal-user", + infer: false, + }); + expect(addResult.results).toHaveLength(1); + + const searchResult = await memory.search("Normal add", { + filters: { user_id: "normal-user" }, + topK: 3, + }); + expect(searchResult.results.length).toBeGreaterThanOrEqual(0); + + const eventNames = calls.map((call) => call.event); + expect(eventNames).toContain("mem0.add"); + expect(eventNames).toContain("mem0.search"); + expect( + noticeEvents(calls).some( + (call) => call.properties.notice_id === "temporal_stub", + ), + ).toBe(false); + }); +}); diff --git a/mem0-ts/src/oss/tests/notices.temporal-usage.test.ts b/mem0-ts/src/oss/tests/notices.temporal-usage.test.ts new file mode 100644 index 000000000..1ca63c7e2 --- /dev/null +++ b/mem0-ts/src/oss/tests/notices.temporal-usage.test.ts @@ -0,0 +1,594 @@ +/// +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +jest.setTimeout(15000); + +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +const mockEmbedding = new Array(1536).fill(0.1); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), + embeddingDims: 1536, + })), +})); + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest.fn(), + })), +})); + +const TEMPORAL_USAGE_COPY = + "This looks like a time-aware memory workflow. Mem0 Platform has temporal reasoning built in. Use `timestamp` when adding memories and `reference_date` when searching."; + +function makeTempMem0Dir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-temporal-usage-")); +} + +function configPath(): string { + return path.join(process.env.MEM0_DIR as string, "config.json"); +} + +function readConfig(): Record { + const file = configPath(); + if (!fs.existsSync(file)) return {}; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeConfig(config: Record) { + fs.mkdirSync(path.dirname(configPath()), { recursive: true }); + fs.writeFileSync(configPath(), JSON.stringify(config, null, 4)); +} + +function consumeFirstRun() { + writeConfig({ + user_id: "node-temporal-usage-test-user", + notice_state: { + first_run: { + consumed: true, + trigger_function: "test_setup", + variant: "test", + }, + }, + }); +} + +function temporalUsagePayload(overrides: Record = {}) { + return { + notices: { + temporal_usage: { + enabled: true, + notice_type: "log_line", + copy: TEMPORAL_USAGE_COPY, + ...overrides, + }, + }, + }; +} + +function createFetchMock(options: { + variant?: string; + payload?: unknown; + failFlags?: boolean; + flagEnabled?: boolean; +}) { + const calls: any[] = []; + const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { + const target = String(url); + + if (target.includes("/flags")) { + if (options.failFlags) { + throw new Error("flag evaluation failed"); + } + const payload = + options.payload === undefined + ? JSON.stringify(temporalUsagePayload()) + : options.payload; + return { + ok: true, + json: jest.fn().mockResolvedValue({ + flags: { + "mem0-oss-notices": { + key: "mem0-oss-notices", + enabled: options.flagEnabled ?? true, + variant: options.variant ?? "displayed", + metadata: { payload }, + }, + }, + }), + }; + } + + if (target.includes("/i/v0/e/")) { + calls.push(JSON.parse(String(init?.body))); + return { + ok: true, + text: jest.fn().mockResolvedValue(""), + }; + } + + return { + ok: true, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }; + }); + + return { fetchMock, calls }; +} + +function noticeEvents(calls: any[]) { + return calls.filter((call) => call.event === "mem0.notice_displayed"); +} + +function temporalUsageEvents(calls: any[]) { + return noticeEvents(calls).filter( + (call) => call.properties.notice_id === "temporal_usage", + ); +} + +function flagRequestCount(fetchMock: jest.Mock) { + return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")) + .length; +} + +async function createMemory() { + const { Memory } = await import("../src/memory"); + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-temporal-usage-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +async function addSearchSeed(memory: any, userId = "temporal-user") { + await memory.add("The user's favorite drink is green tea.", { + userId, + infer: false, + }); +} + +describe("Node OSS temporal usage notice", () => { + let originalMem0Dir: string | undefined; + let originalTelemetry: string | undefined; + let originalSampleRate: string | undefined; + let originalFetch: typeof global.fetch; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + originalMem0Dir = process.env.MEM0_DIR; + originalTelemetry = process.env.MEM0_TELEMETRY; + originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE; + originalFetch = global.fetch; + + process.env.MEM0_DIR = makeTempMem0Dir(); + process.env.MEM0_TELEMETRY = "true"; + process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1"; + stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + jest.resetModules(); + }); + + afterEach(() => { + if (originalMem0Dir === undefined) delete process.env.MEM0_DIR; + else process.env.MEM0_DIR = originalMem0Dir; + + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + + if (originalSampleRate === undefined) { + delete process.env.MEM0_TELEMETRY_SAMPLE_RATE; + } else { + process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate; + } + + global.fetch = originalFetch; + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("detects timestamp-like metadata after successful add", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + const result = await memory.add("Temporal metadata memory", { + userId: "temporal-user", + infer: false, + metadata: { event_date: "2025-04-09" }, + }); + + expect(result.results).toHaveLength(1); + expect(stderrSpy).toHaveBeenCalledWith(`${TEMPORAL_USAGE_COPY}\n`); + const notices = temporalUsageEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_usage", + notice_type: "log_line", + flag_key: "mem0-oss-notices", + variant: "displayed", + displayed: true, + payload: TEMPORAL_USAGE_COPY, + notice_config_found: true, + sync_type: "async", + trigger_function: "add", + trigger_source: "metadata", + trigger_reason: "date_like_metadata", + sample_rate: 1, + }), + ); + expect(readConfig().notice_state.temporal_usage.events).toHaveLength(1); + }); + + it("emits query trigger fields after successful search", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSearchSeed(memory); + + await memory.search("what happened last week?", { + filters: { user_id: "temporal-user" }, + topK: 3, + }); + + const notices = temporalUsageEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_usage", + displayed: true, + trigger_function: "search", + trigger_source: "query", + trigger_reason: "relative_phrase", + }), + ); + }); + + it("detects temporal range filters after successful search", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSearchSeed(memory); + + await memory.search("favorite drink", { + filters: { + user_id: "temporal-user", + AND: [{ created_at: { gte: "2025-04-01" } }], + }, + topK: 3, + }); + + const notices = temporalUsageEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_usage", + displayed: true, + trigger_function: "search", + trigger_source: "filter", + trigger_reason: "date_range_filter", + }), + ); + }); + + it("does not evaluate for normal non-temporal add/search calls", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Normal memory", { + userId: "normal-user", + infer: false, + metadata: { category: "planning" }, + }); + await memory.search("favorite drink", { + filters: { user_id: "normal-user" }, + topK: 3, + }); + + expect(flagRequestCount(fetchMock)).toBe(0); + expect(temporalUsageEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.temporal_usage).toBeUndefined(); + }); + + it("does not evaluate when add/search fail", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await expect( + memory.add("Invalid temporal metadata add", { + infer: false, + metadata: { event_date: "2025-04-09" }, + } as any), + ).rejects.toThrow("One of the filters"); + await expect( + memory.search("what happened last week?", { + filters: {}, + }), + ).rejects.toThrow("filters must contain"); + + expect(temporalUsageEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.temporal_usage).toBeUndefined(); + }); + + it("is silent for holdout and emits displayed=false", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "holdout" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Temporal metadata memory", { + userId: "temporal-user", + infer: false, + metadata: { event_date: "2025-04-09" }, + }); + + expect(stderrSpy).not.toHaveBeenCalled(); + const notices = temporalUsageEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_usage", + variant: "holdout", + displayed: false, + bypass_reason: "holdout", + trigger_source: "metadata", + trigger_reason: "date_like_metadata", + }), + ); + }); + + it.each([ + [ + "disabled payload", + JSON.stringify(temporalUsagePayload({ enabled: false })), + "payload_disabled", + ], + [ + "missing config", + JSON.stringify({ notices: {} }), + "missing_notice_config", + ], + [ + "missing copy", + JSON.stringify(temporalUsagePayload({ copy: "" })), + "missing_copy", + ], + ["malformed payload", "{not-json", "missing_notice_config"], + ])("is silent and safe for %s", async (_label, payload, bypassReason) => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + payload, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Temporal metadata memory", { + userId: "temporal-user", + infer: false, + metadata: { event_date: "2025-04-09" }, + }); + + expect(stderrSpy).not.toHaveBeenCalled(); + const notices = temporalUsageEvents(calls); + expect(notices).toHaveLength(1); + expect(notices[0].properties).toEqual( + expect.objectContaining({ + notice_id: "temporal_usage", + displayed: false, + bypass_reason: bypassReason, + }), + ); + }); + + it("does not consume cap or emit when the blunt flag is disabled", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ + variant: "displayed", + flagEnabled: false, + }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Temporal metadata memory", { + userId: "temporal-user", + infer: false, + metadata: { event_date: "2025-04-09" }, + }); + + expect(temporalUsageEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.temporal_usage).toBeUndefined(); + }); + + it("does not consume cap or emit when PostHog fails", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ failFlags: true }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Temporal metadata memory", { + userId: "temporal-user", + infer: false, + metadata: { event_date: "2025-04-09" }, + }); + + expect(temporalUsageEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state?.temporal_usage).toBeUndefined(); + }); + + it("does nothing when telemetry is off", async () => { + process.env.MEM0_TELEMETRY = "False"; + jest.resetModules(); + + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Temporal metadata memory", { + userId: "temporal-user", + infer: false, + metadata: { event_date: "2025-04-09" }, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(temporalUsageEvents(calls)).toHaveLength(0); + expect(readConfig().notice_state).toBeUndefined(); + }); + + it("caps evaluated opportunities at 10 per rolling week", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSearchSeed(memory); + + for (let index = 1; index <= 11; index++) { + await memory.search(`what happened last week ${index}?`, { + filters: { user_id: "temporal-user" }, + topK: 3, + }); + } + + expect(temporalUsageEvents(calls)).toHaveLength(10); + expect(flagRequestCount(fetchMock)).toBe(10); + expect(readConfig().notice_state.temporal_usage.events).toHaveLength(10); + expect(stderrSpy).toHaveBeenCalledTimes(10); + }); + + it("does not consume first-run on the same qualifying temporal usage call", async () => { + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + + await memory.add("Temporal metadata memory", { + userId: "temporal-user", + infer: false, + metadata: { event_date: "2025-04-09" }, + }); + + expect(readConfig().notice_state.first_run).toBeUndefined(); + await memory.add("Normal follow-up memory", { + userId: "temporal-user", + infer: false, + }); + + const notices = noticeEvents(calls); + expect(notices.map((call) => call.properties.notice_id)).toEqual([ + "temporal_usage", + "first_run", + ]); + expect(readConfig().notice_state.first_run.consumed).toBe(true); + }); + + it("does not include raw query, metadata, or filter values in telemetry props", async () => { + consumeFirstRun(); + const { fetchMock, calls } = createFetchMock({ variant: "displayed" }); + global.fetch = fetchMock as any; + const memory = await createMemory(); + await addSearchSeed(memory); + + await memory.search("private trip since 2025-04-09", { + filters: { + user_id: "temporal-user", + created_at: { gte: "2025-04-09" }, + }, + topK: 3, + }); + + const props = temporalUsageEvents(calls)[0].properties; + const serialized = JSON.stringify(props); + expect(serialized).not.toContain("private trip"); + expect(serialized).not.toContain("2025-04-09"); + expect(serialized).not.toContain("temporal-user"); + }); + + it("detects expected query, metadata, and filter cases conservatively", async () => { + const { __noticeTestHooks } = await import("../src/utils/notices"); + + expect( + __noticeTestHooks.detectTemporalUsageFromSearch("notes from today", null), + ).toEqual({ triggerSource: "query", triggerReason: "relative_phrase" }); + expect( + __noticeTestHooks.detectTemporalUsageFromSearch( + "notes from 2025-04-09", + null, + ), + ).toEqual({ triggerSource: "query", triggerReason: "date_like_query" }); + expect( + __noticeTestHooks.detectTemporalUsageFromMetadata({ + event_date: "2025-04-09", + }), + ).toEqual({ + triggerSource: "metadata", + triggerReason: "date_like_metadata", + }); + expect( + __noticeTestHooks.detectTemporalUsageFromSearch("favorite drink", { + AND: [{ created_at: { gte: "2025-04-01" } }], + }), + ).toEqual({ triggerSource: "filter", triggerReason: "date_range_filter" }); + expect( + __noticeTestHooks.detectTemporalUsageFromSearch("favorite drink", null), + ).toBeNull(); + expect( + __noticeTestHooks.detectTemporalUsageFromMetadata({ + category: "planning", + }), + ).toBeNull(); + }); + + it("detectors never raise for cyclic metadata or filters", async () => { + const { __noticeTestHooks } = await import("../src/utils/notices"); + const metadata: Record = {}; + metadata.self = metadata; + const filters: Record = {}; + filters.AND = [filters]; + + expect( + __noticeTestHooks.detectTemporalUsageFromMetadata(metadata), + ).toBeNull(); + expect( + __noticeTestHooks.detectTemporalUsageFromSearch( + "favorite drink", + filters, + ), + ).toBeNull(); + }); +}); diff --git a/mem0-ts/src/oss/tests/telemetry-sampling.test.ts b/mem0-ts/src/oss/tests/telemetry-sampling.test.ts index 7b5f6c5b3..14e68c2f4 100644 --- a/mem0-ts/src/oss/tests/telemetry-sampling.test.ts +++ b/mem0-ts/src/oss/tests/telemetry-sampling.test.ts @@ -61,6 +61,13 @@ describe("telemetry sampling", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("notice_displayed event fires even at the highest random value", async () => { + randomSpy.mockReturnValue(0.999); + const { captureClientEvent } = await import("../src/utils/telemetry"); + await captureClientEvent("notice_displayed", makeInstance()); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("init event payload has sample_rate: 1.0", async () => { randomSpy.mockReturnValue(0.999); const { captureClientEvent } = await import("../src/utils/telemetry"); diff --git a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts index 8a71049e0..a9ecda1b1 100644 --- a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts +++ b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts @@ -923,6 +923,7 @@ describe("Memory class – backward compat with all providers", () => { jest.doMock("../src/utils/telemetry", () => ({ captureClientEvent: jest.fn().mockResolvedValue(undefined), + isTelemetryEnabled: jest.fn(() => false), })); MemoryClass = require("../src/memory").Memory; diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 80a30cd68..1fe390bd1 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -5,6 +5,7 @@ import hashlib import json import logging import os +import time import uuid import warnings from copy import deepcopy @@ -26,6 +27,29 @@ from mem0.memory.base import MemoryBase from mem0.memory.setup import mem0_dir, setup_config from mem0.memory.storage import SQLiteManager from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event +from mem0.memory.notices import ( + PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS, + detect_scale_threshold_from_add_result, + detect_scale_threshold_from_top_k, + detect_decay_usage_from_delete, + detect_decay_usage_from_delete_all, + detect_temporal_usage_from_metadata, + detect_temporal_usage_from_search, + display_decay_usage_notice, + display_decay_usage_notice_async, + display_first_run_notice, + display_first_run_notice_async, + display_performance_slow_query_notice, + display_performance_slow_query_notice_async, + display_scale_threshold_notice, + display_scale_threshold_notice_async, + display_temporal_usage_notice, + display_temporal_usage_notice_async, + get_decay_feature_error_message, + get_decay_feature_error_message_async, + get_temporal_feature_error_message, + get_temporal_feature_error_message_async, +) from mem0.memory.utils import ( extract_json, parse_messages, @@ -349,6 +373,36 @@ def _entity_collection_name(provider: str, collection_name: str) -> str: setup_config() logger = logging.getLogger(__name__) +_PROJECT_UPDATE_UNSUPPORTED_ERROR = "Project updates are not supported by the OSS Memory SDK." + + +class _OSSProject: + def update( + self, + custom_instructions: Optional[str] = None, + custom_categories: Optional[list] = None, + retrieval_criteria: Optional[list] = None, + multilingual: Optional[bool] = None, + decay: Optional[bool] = None, + ): + if decay is True: + raise ValueError(get_decay_feature_error_message("sync", "project.update", "decay")) + raise ValueError(_PROJECT_UPDATE_UNSUPPORTED_ERROR) + + +class _AsyncOSSProject: + async def update( + self, + custom_instructions: Optional[str] = None, + custom_categories: Optional[list] = None, + retrieval_criteria: Optional[list] = None, + multilingual: Optional[bool] = None, + decay: Optional[bool] = None, + ): + if decay is True: + raise ValueError(await get_decay_feature_error_message_async("async", "project.update", "decay")) + raise ValueError(_PROJECT_UPDATE_UNSUPPORTED_ERROR) + class Memory(MemoryBase): def __init__(self, config: MemoryConfig = MemoryConfig()): @@ -417,6 +471,10 @@ class Memory(MemoryBase): capture_event("mem0.init", self, {"sync_type": "sync"}) + @property + def project(self): + return _OSSProject() + @property def entity_store(self): """Lazily initialize entity store on first use.""" @@ -600,6 +658,7 @@ class Memory(MemoryBase): agent_id: Optional[str] = None, run_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + timestamp: Optional[Any] = None, infer: bool = True, memory_type: Optional[str] = None, prompt: Optional[str] = None, @@ -617,6 +676,7 @@ class Memory(MemoryBase): agent_id (str, optional): ID of the agent creating the memory. Defaults to None. run_id (str, optional): ID of the run creating the memory. Defaults to None. metadata (dict, optional): Metadata to store with the memory. Defaults to None. + timestamp (Any, optional): Platform-only temporal parameter. Not supported in OSS. infer (bool, optional): If True (default), an LLM is used to extract key facts from 'messages' and decide whether to add, update, or delete related memories. If False, 'messages' are added as raw memories directly. @@ -639,7 +699,10 @@ class Memory(MemoryBase): LLMError: If LLM operations fail. DatabaseError: If database operations fail. """ + if timestamp is not None: + raise ValueError(get_temporal_feature_error_message("sync", "add", "timestamp")) + temporal_usage_notice = detect_temporal_usage_from_metadata(metadata) processed_metadata, effective_filters = _build_filters_and_metadata( user_id=user_id, agent_id=agent_id, @@ -671,6 +734,13 @@ class Memory(MemoryBase): if agent_id is not None and memory_type == MemoryType.PROCEDURAL.value: results = self._create_procedural_memory(messages, metadata=processed_metadata, prompt=prompt) + scale_threshold_notice = detect_scale_threshold_from_add_result(self, results) + if temporal_usage_notice: + display_temporal_usage_notice(self, "sync", "add", *temporal_usage_notice) + elif scale_threshold_notice: + display_scale_threshold_notice(self, "sync", "add", *scale_threshold_notice) + else: + display_first_run_notice(self, "sync", "add") return results if self.config.llm.config.get("enable_vision"): @@ -679,6 +749,13 @@ class Memory(MemoryBase): messages = parse_vision_messages(messages) vector_store_result = self._add_to_vector_store(messages, processed_metadata, effective_filters, infer, prompt=prompt) + scale_threshold_notice = detect_scale_threshold_from_add_result(self, vector_store_result) + if temporal_usage_notice: + display_temporal_usage_notice(self, "sync", "add", *temporal_usage_notice) + elif scale_threshold_notice: + display_scale_threshold_notice(self, "sync", "add", *scale_threshold_notice) + else: + display_first_run_notice(self, "sync", "add") return {"results": vector_store_result} def _add_to_vector_store(self, messages, metadata, filters, infer, prompt=None): @@ -1005,6 +1082,7 @@ class Memory(MemoryBase): capture_event("mem0.get", self, {"memory_id": memory_id, "sync_type": "sync"}) memory = self.vector_store.get(vector_id=memory_id) if not memory: + display_first_run_notice(self, "sync", "get") return None promoted_payload_keys = [ @@ -1033,6 +1111,7 @@ class Memory(MemoryBase): if additional_metadata: result_item["metadata"] = additional_metadata + display_first_run_notice(self, "sync", "get") return result_item def get_all( @@ -1088,6 +1167,7 @@ class Memory(MemoryBase): ) limit = top_k + scale_threshold_notice = detect_scale_threshold_from_top_k(top_k) keys, encoded_ids = process_telemetry_filters(effective_filters) capture_event( @@ -1096,6 +1176,10 @@ class Memory(MemoryBase): all_memories_result = self._get_all_from_vector_store(effective_filters, limit) + if scale_threshold_notice: + display_scale_threshold_notice(self, "sync", "get_all", *scale_threshold_notice) + else: + display_first_run_notice(self, "sync", "get_all") return {"results": all_memories_result} def _get_all_from_vector_store(self, filters, limit): @@ -1154,6 +1238,7 @@ class Memory(MemoryBase): threshold: float = 0.1, rerank: bool = False, explain: bool = False, + reference_date: Optional[Any] = None, **kwargs, ): """ @@ -1185,6 +1270,7 @@ class Memory(MemoryBase): threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1. rerank (bool, optional): Whether to rerank results. Defaults to False. explain (bool, optional): Whether to include score_details for each result. Defaults to False. + reference_date (Any, optional): Platform-only temporal parameter. Not supported in OSS. Returns: dict: A dictionary containing the search results under a "results" key. @@ -1194,12 +1280,16 @@ class Memory(MemoryBase): ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id, or if threshold/top_k values are invalid. """ + if reference_date is not None: + raise ValueError(get_temporal_feature_error_message("sync", "search", "reference_date")) + # Reject top-level entity params - must use filters instead _reject_top_level_entity_params(kwargs, "search") # Validate search parameters (before applying defaults) _validate_search_params(threshold=threshold, top_k=top_k) query = _validate_and_trim_search_query(query) + temporal_usage_notice = detect_temporal_usage_from_search(query, filters) # Validate and trim entity IDs in filters effective_filters = filters.copy() if filters else {} @@ -1222,6 +1312,7 @@ class Memory(MemoryBase): ) limit = top_k + scale_threshold_notice = detect_scale_threshold_from_top_k(top_k) # Apply enhanced metadata filtering if advanced operators are detected if self._has_advanced_operators(effective_filters): @@ -1250,7 +1341,9 @@ class Memory(MemoryBase): }, ) + search_start = time.perf_counter() original_memories = self._search_vector_store(query, effective_filters, limit, threshold, explain=explain) + search_elapsed_seconds = time.perf_counter() - search_start # Apply reranking if enabled and reranker is available if rerank and self.reranker and original_memories: @@ -1260,6 +1353,21 @@ class Memory(MemoryBase): except Exception as e: logger.warning(f"Reranking failed, using original results: {e}") + if temporal_usage_notice: + display_temporal_usage_notice(self, "sync", "search", *temporal_usage_notice) + elif scale_threshold_notice: + display_scale_threshold_notice(self, "sync", "search", *scale_threshold_notice) + elif search_elapsed_seconds > PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS: + display_performance_slow_query_notice( + self, + "sync", + "search", + search_elapsed_seconds, + top_k, + len(original_memories), + ) + else: + display_first_run_notice(self, "sync", "search") return {"results": original_memories} def _process_metadata_filters(self, metadata_filters: Dict[str, Any]) -> Dict[str, Any]: @@ -1569,6 +1677,7 @@ class Memory(MemoryBase): existing_embeddings = {data: self.embedding_model.embed(data, "update")} self._update_memory(memory_id, data, existing_embeddings, metadata) + display_first_run_notice(self, "sync", "update") return {"message": "Memory updated successfully!"} def delete(self, memory_id): @@ -1585,6 +1694,11 @@ class Memory(MemoryBase): raise ValueError(f"Memory with id {memory_id} not found") self._delete_memory(memory_id, existing_memory) + decay_usage_notice = detect_decay_usage_from_delete() + if decay_usage_notice: + display_decay_usage_notice(self, "sync", "delete", *decay_usage_notice) + else: + display_first_run_notice(self, "sync", "delete") return {"message": "Memory deleted successfully!"} def delete_all(self, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None): @@ -1618,6 +1732,11 @@ class Memory(MemoryBase): logger.info(f"Deleted {len(memories)} memories") + decay_usage_notice = detect_decay_usage_from_delete_all(len(memories)) + if decay_usage_notice: + display_decay_usage_notice(self, "sync", "delete_all", *decay_usage_notice) + else: + display_first_run_notice(self, "sync", "delete_all") return {"message": "Memories deleted successfully!"} def history(self, memory_id): @@ -1631,7 +1750,9 @@ class Memory(MemoryBase): list: List of changes for the memory. """ capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "sync"}) - return self.db.get_history(memory_id) + history = self.db.get_history(memory_id) + display_first_run_notice(self, "sync", "history") + return history def _create_memory(self, data, existing_embeddings, metadata=None): logger.debug(f"Creating memory with {data=}") @@ -1831,6 +1952,7 @@ class Memory(MemoryBase): self._entity_store = None capture_event("mem0.reset", self, {"sync_type": "sync"}) + display_first_run_notice(self, "sync", "reset") def close(self): """Release resources held by this Memory instance (SQLite connections, etc.).""" @@ -1889,6 +2011,10 @@ class AsyncMemory(MemoryBase): capture_event("mem0.init", self, {"sync_type": "async"}) + @property + def project(self): + return _AsyncOSSProject() + @property def entity_store(self): """Lazily initialize entity store on first use.""" @@ -2058,6 +2184,7 @@ class AsyncMemory(MemoryBase): agent_id: Optional[str] = None, run_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + timestamp: Optional[Any] = None, infer: bool = True, memory_type: Optional[str] = None, prompt: Optional[str] = None, @@ -2072,6 +2199,7 @@ class AsyncMemory(MemoryBase): agent_id (str, optional): ID of the agent creating the memory. Defaults to None. run_id (str, optional): ID of the run creating the memory. Defaults to None. metadata (dict, optional): Metadata to store with the memory. Defaults to None. + timestamp (Any, optional): Platform-only temporal parameter. Not supported in OSS. infer (bool, optional): Whether to infer the memories. Defaults to True. memory_type (str, optional): Type of memory to create. Defaults to None. Pass "procedural_memory" to create procedural memories. @@ -2080,6 +2208,10 @@ class AsyncMemory(MemoryBase): Returns: dict: A dictionary containing the result of the memory addition operation. """ + if timestamp is not None: + raise ValueError(await get_temporal_feature_error_message_async("async", "add", "timestamp")) + + temporal_usage_notice = detect_temporal_usage_from_metadata(metadata) processed_metadata, effective_filters = _build_filters_and_metadata( user_id=user_id, agent_id=agent_id, run_id=run_id, input_metadata=metadata ) @@ -2107,6 +2239,13 @@ class AsyncMemory(MemoryBase): results = await self._create_procedural_memory( messages, metadata=processed_metadata, prompt=prompt, llm=llm ) + scale_threshold_notice = await asyncio.to_thread(detect_scale_threshold_from_add_result, self, results) + if temporal_usage_notice: + await display_temporal_usage_notice_async(self, "async", "add", *temporal_usage_notice) + elif scale_threshold_notice: + await display_scale_threshold_notice_async(self, "async", "add", *scale_threshold_notice) + else: + await display_first_run_notice_async(self, "async", "add") return results if self.config.llm.config.get("enable_vision"): @@ -2115,6 +2254,13 @@ class AsyncMemory(MemoryBase): messages = parse_vision_messages(messages) vector_store_result = await self._add_to_vector_store(messages, processed_metadata, effective_filters, infer, prompt=prompt) + scale_threshold_notice = await asyncio.to_thread(detect_scale_threshold_from_add_result, self, vector_store_result) + if temporal_usage_notice: + await display_temporal_usage_notice_async(self, "async", "add", *temporal_usage_notice) + elif scale_threshold_notice: + await display_scale_threshold_notice_async(self, "async", "add", *scale_threshold_notice) + else: + await display_first_run_notice_async(self, "async", "add") return {"results": vector_store_result} async def _add_to_vector_store( @@ -2448,6 +2594,7 @@ class AsyncMemory(MemoryBase): capture_event("mem0.get", self, {"memory_id": memory_id, "sync_type": "async"}) memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id) if not memory: + await display_first_run_notice_async(self, "async", "get") return None promoted_payload_keys = [ @@ -2476,6 +2623,7 @@ class AsyncMemory(MemoryBase): if additional_metadata: result_item["metadata"] = additional_metadata + await display_first_run_notice_async(self, "async", "get") return result_item async def get_all( @@ -2531,6 +2679,7 @@ class AsyncMemory(MemoryBase): ) limit = top_k + scale_threshold_notice = detect_scale_threshold_from_top_k(top_k) keys, encoded_ids = process_telemetry_filters(effective_filters) capture_event( @@ -2539,6 +2688,10 @@ class AsyncMemory(MemoryBase): all_memories_result = await self._get_all_from_vector_store(effective_filters, limit) + if scale_threshold_notice: + await display_scale_threshold_notice_async(self, "async", "get_all", *scale_threshold_notice) + else: + await display_first_run_notice_async(self, "async", "get_all") return {"results": all_memories_result} async def _get_all_from_vector_store(self, filters, limit): @@ -2597,6 +2750,7 @@ class AsyncMemory(MemoryBase): threshold: float = 0.1, rerank: bool = False, explain: bool = False, + reference_date: Optional[Any] = None, **kwargs, ): """ @@ -2628,6 +2782,7 @@ class AsyncMemory(MemoryBase): threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1. rerank (bool, optional): Whether to rerank results. Defaults to False. explain (bool, optional): Whether to include score_details for each result. Defaults to False. + reference_date (Any, optional): Platform-only temporal parameter. Not supported in OSS. Returns: dict: A dictionary containing the search results under a "results" key. @@ -2637,12 +2792,18 @@ class AsyncMemory(MemoryBase): ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id, or if threshold/top_k values are invalid. """ + if reference_date is not None: + raise ValueError( + await get_temporal_feature_error_message_async("async", "search", "reference_date") + ) + # Reject top-level entity params - must use filters instead _reject_top_level_entity_params(kwargs, "search") # Validate search parameters (before applying defaults) _validate_search_params(threshold=threshold, top_k=top_k) query = _validate_and_trim_search_query(query) + temporal_usage_notice = detect_temporal_usage_from_search(query, filters) # Validate and trim entity IDs in filters effective_filters = filters.copy() if filters else {} @@ -2667,6 +2828,7 @@ class AsyncMemory(MemoryBase): ) limit = top_k + scale_threshold_notice = detect_scale_threshold_from_top_k(top_k) # Apply enhanced metadata filtering if advanced operators are detected if self._has_advanced_operators(effective_filters): @@ -2695,7 +2857,9 @@ class AsyncMemory(MemoryBase): }, ) + search_start = time.perf_counter() original_memories = await self._search_vector_store(query, effective_filters, limit, threshold, explain=explain) + search_elapsed_seconds = time.perf_counter() - search_start # Apply reranking if enabled and reranker is available if rerank and self.reranker and original_memories: @@ -2708,6 +2872,21 @@ class AsyncMemory(MemoryBase): except Exception as e: logger.warning(f"Reranking failed, using original results: {e}") + if temporal_usage_notice: + await display_temporal_usage_notice_async(self, "async", "search", *temporal_usage_notice) + elif scale_threshold_notice: + await display_scale_threshold_notice_async(self, "async", "search", *scale_threshold_notice) + elif search_elapsed_seconds > PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS: + await display_performance_slow_query_notice_async( + self, + "async", + "search", + search_elapsed_seconds, + top_k, + len(original_memories), + ) + else: + await display_first_run_notice_async(self, "async", "search") return {"results": original_memories} def _process_metadata_filters(self, metadata_filters: Dict[str, Any]) -> Dict[str, Any]: @@ -3008,6 +3187,7 @@ class AsyncMemory(MemoryBase): existing_embeddings = {data: embeddings} await self._update_memory(memory_id, data, existing_embeddings, metadata) + await display_first_run_notice_async(self, "async", "update") return {"message": "Memory updated successfully!"} async def delete(self, memory_id): @@ -3024,6 +3204,11 @@ class AsyncMemory(MemoryBase): raise ValueError(f"Memory with id {memory_id} not found") await self._delete_memory(memory_id, existing_memory) + decay_usage_notice = detect_decay_usage_from_delete() + if decay_usage_notice: + await display_decay_usage_notice_async(self, "async", "delete", *decay_usage_notice) + else: + await display_first_run_notice_async(self, "async", "delete") return {"message": "Memory deleted successfully!"} async def delete_all(self, user_id=None, agent_id=None, run_id=None): @@ -3060,6 +3245,11 @@ class AsyncMemory(MemoryBase): logger.info(f"Deleted {len(memories[0])} memories") + decay_usage_notice = detect_decay_usage_from_delete_all(len(memories[0])) + if decay_usage_notice: + await display_decay_usage_notice_async(self, "async", "delete_all", *decay_usage_notice) + else: + await display_first_run_notice_async(self, "async", "delete_all") return {"message": "Memories deleted successfully!"} async def history(self, memory_id): @@ -3073,7 +3263,9 @@ class AsyncMemory(MemoryBase): list: List of changes for the memory. """ capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "async"}) - return await asyncio.to_thread(self.db.get_history, memory_id) + history = await asyncio.to_thread(self.db.get_history, memory_id) + await display_first_run_notice_async(self, "async", "history") + return history async def _create_memory(self, data, existing_embeddings, metadata=None): logger.debug(f"Creating memory with {data=}") @@ -3291,6 +3483,7 @@ class AsyncMemory(MemoryBase): ) capture_event("mem0.reset", self, {"sync_type": "async"}) + await display_first_run_notice_async(self, "async", "reset") def close(self): """Release resources held by this AsyncMemory instance.""" diff --git a/mem0/memory/notices.py b/mem0/memory/notices.py new file mode 100644 index 000000000..f2f769d65 --- /dev/null +++ b/mem0/memory/notices.py @@ -0,0 +1,1559 @@ +import asyncio +import json +import re +import sys +import threading +from datetime import date, datetime, timedelta, timezone +from typing import Any, Dict, Optional, Tuple + +from mem0.memory import telemetry as telemetry_module +from mem0.memory.setup import _load_config, _write_config + + +FLAG_KEY = "mem0-oss-notices" +NOTICE_ID = "first_run" +TEMPORAL_FEATURE_NOTICE_ID = "temporal_stub" +TEMPORAL_USAGE_NOTICE_ID = "temporal_usage" +DECAY_FEATURE_NOTICE_ID = "decay_stub" +DECAY_USAGE_NOTICE_ID = "decay_usage" +SCALE_THRESHOLD_NOTICE_ID = "scale_threshold" +PERFORMANCE_SLOW_QUERY_NOTICE_ID = "performance_slow_query" +NOTICE_EVENT = "mem0.notice_displayed" +DISPLAYED_VARIANT = "displayed" +HOLDOUT_VARIANT = "holdout" +STATE_SECTION = "notice_state" +STATE_KEY = "first_run" +TEMPORAL_USAGE_STATE_KEY = "temporal_usage" +TEMPORAL_USAGE_CAP = 10 +TEMPORAL_USAGE_WINDOW = timedelta(days=7) +DECAY_USAGE_STATE_KEY = "decay_usage" +DECAY_USAGE_CAP = 10 +DECAY_USAGE_WINDOW = timedelta(days=7) +DECAY_USAGE_DELETE_THRESHOLD = 5 +SCALE_THRESHOLD_STATE_KEY = "scale_threshold" +SCALE_THRESHOLD_CAP = 10 +SCALE_THRESHOLD_WINDOW = timedelta(days=7) +SCALE_MEMORY_COUNT_THRESHOLD = 2000 +SCALE_MEMORY_COUNT_CHECK_INTERVAL = 100 +SCALE_TOP_K_THRESHOLD = 50 +PERFORMANCE_SLOW_QUERY_STATE_KEY = "performance_slow_query" +PERFORMANCE_SLOW_QUERY_CAP = 10 +PERFORMANCE_SLOW_QUERY_WINDOW = timedelta(days=7) +PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS = 2.0 +FEATURE_ERROR_CAP = 10 +FEATURE_ERROR_WINDOW = timedelta(days=7) +TEMPORAL_FEATURE_ERROR_MESSAGES = { + "timestamp": "The timestamp parameter is not supported by the OSS Memory SDK.", + "reference_date": "The reference_date parameter is not supported by the OSS Memory SDK.", +} +DECAY_FEATURE_ERROR_MESSAGE = "The decay parameter is not supported by the OSS Memory SDK." + +_ISO_DATE_RE = re.compile( + r"\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b" +) +_RELATIVE_TIME_RE = re.compile( + r"\b(" + r"today|yesterday|tomorrow|" + r"last\s+(?:night|week|month|year)|" + r"this\s+(?:week|month|year)|" + r"next\s+(?:week|month|year)|" + r"(?:past|last)\s+\d+\s+(?:day|days|week|weeks|month|months|year|years)|" + r"(?:since|before|after|until)\s+(?:today|yesterday|tomorrow|\d{4}-\d{2}-\d{2}|last\s+(?:week|month|year))" + r")\b", + re.IGNORECASE, +) +_RANGE_OPERATORS = {"gt", "gte", "lt", "lte"} + +_state_lock = threading.Lock() +_first_run_claimed_in_process = False +_decay_usage_successful_delete_count_in_process = 0 +_temporal_usage_capacity_reached_in_process = False +_decay_usage_capacity_reached_in_process = False +_scale_threshold_capacity_reached_in_process = False +_performance_slow_query_capacity_reached_in_process = False +_feature_error_capacity_reached_in_process = set() +_scale_memory_count_adds_since_check = 0 +_scale_memory_count_checked_in_process = False +_scale_memory_count_threshold_evaluated_in_process = False + + +def display_first_run_notice(memory_instance, sync_type: str, trigger_function: str) -> None: + """Best-effort first-run notice check. Never raises or writes unless displayed.""" + if not telemetry_module.MEM0_TELEMETRY: + return + + if not _claim_first_run_notice(trigger_function): + return + + variant = None + try: + telemetry = telemetry_module._get_oss_telemetry() + if telemetry is None or telemetry.posthog is None or not telemetry.user_id: + return + + flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) + variant = flags.get_flag(FLAG_KEY) + _update_first_run_variant(variant) + + if variant in (None, False): + return + + payload = _coerce_mapping(flags.get_flag_payload(FLAG_KEY)) + notices = payload.get("notices", {}) + notice_config = _coerce_mapping(notices.get(NOTICE_ID) if isinstance(notices, dict) else {}) + notice_config_found = bool(notice_config) + + copy = notice_config.get("copy") + enabled = notice_config.get("enabled", True) if notice_config_found else False + notice_type = notice_config.get("notice_type", "log_line") + + disabled_reason = None + bypass_reason = None + if not notice_config_found: + bypass_reason = "missing_notice_config" + elif not enabled: + disabled_reason = "payload_disabled" + bypass_reason = disabled_reason + elif not copy: + bypass_reason = "missing_copy" + elif variant != DISPLAYED_VARIANT: + bypass_reason = "holdout" if variant == HOLDOUT_VARIANT else "not_displayed" + + displayed = variant == DISPLAYED_VARIANT and enabled and bool(copy) + + telemetry.capture_event( + NOTICE_EVENT, + { + "notice_id": NOTICE_ID, + "notice_type": notice_type, + "flag_key": FLAG_KEY, + "variant": variant, + "displayed": displayed, + "payload": copy, + "bypass_reason": bypass_reason, + "disabled_reason": disabled_reason, + "notice_config_found": notice_config_found, + "sync_type": sync_type, + "trigger_function": trigger_function, + }, + flags=flags, + ) + + if displayed: + print(copy, file=sys.stderr) + except Exception: + if variant is not None: + _update_first_run_variant(variant) + + +async def display_first_run_notice_async(memory_instance, sync_type: str, trigger_function: str) -> None: + if not telemetry_module.MEM0_TELEMETRY or _first_run_claimed_in_process: + return + await asyncio.to_thread(display_first_run_notice, memory_instance, sync_type, trigger_function) + + +def display_temporal_usage_notice( + memory_instance, + sync_type: str, + trigger_function: str, + trigger_source: str, + trigger_reason: str, +) -> None: + """Best-effort temporal usage notice. Never raises or writes unless displayed.""" + if not telemetry_module.MEM0_TELEMETRY: + return + + if _temporal_usage_at_capacity(): + return + + try: + telemetry = telemetry_module._get_oss_telemetry() + if telemetry is None or telemetry.posthog is None or not telemetry.user_id: + return + + flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) + variant = flags.get_flag(FLAG_KEY) + if variant in (None, False): + return + + payload = _coerce_mapping(flags.get_flag_payload(FLAG_KEY)) + notices = payload.get("notices", {}) + notice_config = _coerce_mapping( + notices.get(TEMPORAL_USAGE_NOTICE_ID) if isinstance(notices, dict) else {} + ) + notice_config_found = bool(notice_config) + + copy = notice_config.get("copy") + enabled = notice_config.get("enabled", True) if notice_config_found else False + notice_type = notice_config.get("notice_type", "log_line") + + disabled_reason = None + bypass_reason = None + if not notice_config_found: + bypass_reason = "missing_notice_config" + elif not enabled: + disabled_reason = "payload_disabled" + bypass_reason = disabled_reason + elif not copy: + bypass_reason = "missing_copy" + elif variant != DISPLAYED_VARIANT: + bypass_reason = "holdout" if variant == HOLDOUT_VARIANT else "not_displayed" + + displayed = variant == DISPLAYED_VARIANT and enabled and bool(copy) + + if not _record_temporal_usage_opportunity( + variant=variant, + sync_type=sync_type, + trigger_function=trigger_function, + trigger_source=trigger_source, + trigger_reason=trigger_reason, + ): + return + + telemetry.capture_event( + NOTICE_EVENT, + { + "notice_id": TEMPORAL_USAGE_NOTICE_ID, + "notice_type": notice_type, + "flag_key": FLAG_KEY, + "variant": variant, + "displayed": displayed, + "payload": copy, + "bypass_reason": bypass_reason, + "disabled_reason": disabled_reason, + "notice_config_found": notice_config_found, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_source": trigger_source, + "trigger_reason": trigger_reason, + }, + flags=flags, + ) + + if displayed: + print(copy, file=sys.stderr) + except Exception: + return + + +async def display_temporal_usage_notice_async( + memory_instance, + sync_type: str, + trigger_function: str, + trigger_source: str, + trigger_reason: str, +) -> None: + await asyncio.to_thread( + display_temporal_usage_notice, + memory_instance, + sync_type, + trigger_function, + trigger_source, + trigger_reason, + ) + + +def detect_decay_usage_from_delete() -> Optional[Tuple[str, str, Optional[int], Optional[int]]]: + if not telemetry_module.MEM0_TELEMETRY: + return None + + global _decay_usage_successful_delete_count_in_process + try: + with _state_lock: + if _decay_usage_capacity_reached_in_process: + return None + _decay_usage_successful_delete_count_in_process += 1 + delete_count = _decay_usage_successful_delete_count_in_process + + if delete_count >= DECAY_USAGE_DELETE_THRESHOLD and not _decay_usage_at_capacity(): + return ("delete_count", "repeated_deletes", delete_count, None) + except Exception: + return None + + return None + + +def detect_decay_usage_from_delete_all(deleted_count: Any) -> Optional[Tuple[str, str, Optional[int], Optional[int]]]: + if not telemetry_module.MEM0_TELEMETRY: + return None + + deleted_count_value = _coerce_nonnegative_int(deleted_count, 0) + if deleted_count_value <= 0: + return None + + return ("delete_all", "bulk_delete", None, deleted_count_value) + + +def display_decay_usage_notice( + memory_instance, + sync_type: str, + trigger_function: str, + trigger_source: str, + trigger_reason: str, + delete_count: Optional[int] = None, + deleted_count: Optional[int] = None, +) -> None: + """Best-effort decay usage notice. Never raises or writes unless displayed.""" + if not telemetry_module.MEM0_TELEMETRY: + return + + if _decay_usage_at_capacity(): + return + + try: + telemetry = telemetry_module._get_oss_telemetry() + if telemetry is None or telemetry.posthog is None or not telemetry.user_id: + return + + flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) + variant = flags.get_flag(FLAG_KEY) + if variant in (None, False): + return + + payload = _coerce_mapping(flags.get_flag_payload(FLAG_KEY)) + notices = payload.get("notices", {}) + notice_config = _coerce_mapping( + notices.get(DECAY_USAGE_NOTICE_ID) if isinstance(notices, dict) else {} + ) + notice_config_found = bool(notice_config) + + copy = notice_config.get("copy") + enabled = notice_config.get("enabled", True) if notice_config_found else False + notice_type = notice_config.get("notice_type", "log_line") + + disabled_reason = None + bypass_reason = None + if not notice_config_found: + bypass_reason = "missing_notice_config" + elif not enabled: + disabled_reason = "payload_disabled" + bypass_reason = disabled_reason + elif not copy: + bypass_reason = "missing_copy" + elif variant != DISPLAYED_VARIANT: + bypass_reason = "holdout" if variant == HOLDOUT_VARIANT else "not_displayed" + + displayed = variant == DISPLAYED_VARIANT and enabled and bool(copy) + + if not _record_decay_usage_opportunity( + variant=variant, + sync_type=sync_type, + trigger_function=trigger_function, + trigger_source=trigger_source, + trigger_reason=trigger_reason, + delete_count=delete_count, + deleted_count=deleted_count, + ): + return + + telemetry.capture_event( + NOTICE_EVENT, + { + "notice_id": DECAY_USAGE_NOTICE_ID, + "notice_type": notice_type, + "flag_key": FLAG_KEY, + "variant": variant, + "displayed": displayed, + "payload": copy, + "bypass_reason": bypass_reason, + "disabled_reason": disabled_reason, + "notice_config_found": notice_config_found, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_source": trigger_source, + "trigger_reason": trigger_reason, + "delete_count": delete_count, + "deleted_count": deleted_count, + }, + flags=flags, + ) + + if displayed: + print(copy, file=sys.stderr) + except Exception: + return + + +async def display_decay_usage_notice_async( + memory_instance, + sync_type: str, + trigger_function: str, + trigger_source: str, + trigger_reason: str, + delete_count: Optional[int] = None, + deleted_count: Optional[int] = None, +) -> None: + await asyncio.to_thread( + display_decay_usage_notice, + memory_instance, + sync_type, + trigger_function, + trigger_source, + trigger_reason, + delete_count, + deleted_count, + ) + + +def detect_scale_threshold_from_top_k(top_k: Any) -> Optional[Tuple[str, str, Optional[int], Optional[int], int]]: + try: + top_k_value = int(top_k) + except (TypeError, ValueError): + return None + + if top_k_value < SCALE_TOP_K_THRESHOLD: + return None + + return ("top_k", "high_top_k", top_k_value, None, SCALE_TOP_K_THRESHOLD) + + +def detect_scale_threshold_from_add_result( + memory_instance, + add_result: Any, +) -> Optional[Tuple[str, str, Optional[int], Optional[int], int]]: + if not telemetry_module.MEM0_TELEMETRY: + return None + + added_count = _count_added_memories(add_result) + if added_count == 0: + return None + + global _scale_memory_count_adds_since_check + global _scale_memory_count_checked_in_process + global _scale_memory_count_threshold_evaluated_in_process + try: + with _state_lock: + if _scale_memory_count_threshold_evaluated_in_process: + return None + + _scale_memory_count_adds_since_check += added_count + should_check = ( + not _scale_memory_count_checked_in_process + or _scale_memory_count_adds_since_check >= SCALE_MEMORY_COUNT_CHECK_INTERVAL + ) + if not should_check: + return None + + _scale_memory_count_checked_in_process = True + _scale_memory_count_adds_since_check = 0 + + config = _load_config() + scale_state = _get_notice_state(config, SCALE_THRESHOLD_STATE_KEY) + if scale_state.get("memory_count_threshold_evaluated"): + _scale_memory_count_threshold_evaluated_in_process = True + return None + except Exception: + return None + + provider_count = _get_provider_memory_count(memory_instance) + if provider_count is None or provider_count < SCALE_MEMORY_COUNT_THRESHOLD: + return None + + if not _mark_scale_memory_count_threshold_evaluated(): + return None + + return ( + "memory_count", + "memory_count_threshold", + None, + provider_count, + SCALE_MEMORY_COUNT_THRESHOLD, + ) + + +def display_scale_threshold_notice( + memory_instance, + sync_type: str, + trigger_function: str, + trigger_source: str, + trigger_reason: str, + top_k: Optional[int] = None, + memory_count: Optional[int] = None, + threshold: Optional[int] = None, +) -> None: + """Best-effort scale notice. Never raises or writes unless displayed.""" + if not telemetry_module.MEM0_TELEMETRY: + return + + if _scale_threshold_at_capacity(): + return + + try: + telemetry = telemetry_module._get_oss_telemetry() + if telemetry is None or telemetry.posthog is None or not telemetry.user_id: + return + + flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) + variant = flags.get_flag(FLAG_KEY) + if variant in (None, False): + return + + payload = _coerce_mapping(flags.get_flag_payload(FLAG_KEY)) + notices = payload.get("notices", {}) + notice_config = _coerce_mapping( + notices.get(SCALE_THRESHOLD_NOTICE_ID) if isinstance(notices, dict) else {} + ) + notice_config_found = bool(notice_config) + + copies = _coerce_mapping(notice_config.get("copies")) if notice_config_found else {} + copy_key = "memory_count" if trigger_source == "memory_count" else "top_k" + copy = _render_scale_copy(copies.get(copy_key), top_k=top_k, memory_count=memory_count) + enabled = notice_config.get("enabled", True) if notice_config_found else False + notice_type = notice_config.get("notice_type", "log_line") + + disabled_reason = None + bypass_reason = None + if not notice_config_found: + bypass_reason = "missing_notice_config" + elif not enabled: + disabled_reason = "payload_disabled" + bypass_reason = disabled_reason + elif not copy: + bypass_reason = "missing_copy" + elif variant != DISPLAYED_VARIANT: + bypass_reason = "holdout" if variant == HOLDOUT_VARIANT else "not_displayed" + + displayed = variant == DISPLAYED_VARIANT and enabled and bool(copy) + + if not _record_scale_threshold_opportunity( + variant=variant, + sync_type=sync_type, + trigger_function=trigger_function, + trigger_source=trigger_source, + trigger_reason=trigger_reason, + top_k=top_k, + memory_count=memory_count, + threshold=threshold, + ): + return + + telemetry.capture_event( + NOTICE_EVENT, + { + "notice_id": SCALE_THRESHOLD_NOTICE_ID, + "notice_type": notice_type, + "flag_key": FLAG_KEY, + "variant": variant, + "displayed": displayed, + "payload": copy, + "bypass_reason": bypass_reason, + "disabled_reason": disabled_reason, + "notice_config_found": notice_config_found, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_source": trigger_source, + "trigger_reason": trigger_reason, + "top_k": top_k, + "memory_count": memory_count, + "threshold": threshold, + }, + flags=flags, + ) + + if displayed: + print(copy, file=sys.stderr) + except Exception: + return + + +async def display_scale_threshold_notice_async( + memory_instance, + sync_type: str, + trigger_function: str, + trigger_source: str, + trigger_reason: str, + top_k: Optional[int] = None, + memory_count: Optional[int] = None, + threshold: Optional[int] = None, +) -> None: + await asyncio.to_thread( + display_scale_threshold_notice, + memory_instance, + sync_type, + trigger_function, + trigger_source, + trigger_reason, + top_k, + memory_count, + threshold, + ) + + +def display_performance_slow_query_notice( + memory_instance, + sync_type: str, + trigger_function: str, + elapsed_seconds: float, + top_k: int, + result_count: int, +) -> None: + """Best-effort slow-query notice. Never raises or writes unless displayed.""" + if not telemetry_module.MEM0_TELEMETRY: + return + + if _performance_slow_query_at_capacity(): + return + + try: + telemetry = telemetry_module._get_oss_telemetry() + if telemetry is None or telemetry.posthog is None or not telemetry.user_id: + return + + flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) + variant = flags.get_flag(FLAG_KEY) + if variant in (None, False): + return + + payload = _coerce_mapping(flags.get_flag_payload(FLAG_KEY)) + notices = payload.get("notices", {}) + notice_config = _coerce_mapping( + notices.get(PERFORMANCE_SLOW_QUERY_NOTICE_ID) if isinstance(notices, dict) else {} + ) + notice_config_found = bool(notice_config) + + copy = notice_config.get("copy") + enabled = notice_config.get("enabled", True) if notice_config_found else False + notice_type = notice_config.get("notice_type", "log_line") + + disabled_reason = None + bypass_reason = None + if not notice_config_found: + bypass_reason = "missing_notice_config" + elif not enabled: + disabled_reason = "payload_disabled" + bypass_reason = disabled_reason + elif not copy: + bypass_reason = "missing_copy" + elif variant != DISPLAYED_VARIANT: + bypass_reason = "holdout" if variant == HOLDOUT_VARIANT else "not_displayed" + + displayed = variant == DISPLAYED_VARIANT and enabled and bool(copy) + trigger_reason = "slow_query" + + if not _record_performance_slow_query_opportunity( + variant=variant, + sync_type=sync_type, + trigger_function=trigger_function, + trigger_reason=trigger_reason, + ): + return + + telemetry.capture_event( + NOTICE_EVENT, + { + "notice_id": PERFORMANCE_SLOW_QUERY_NOTICE_ID, + "notice_type": notice_type, + "flag_key": FLAG_KEY, + "variant": variant, + "displayed": displayed, + "payload": copy, + "bypass_reason": bypass_reason, + "disabled_reason": disabled_reason, + "notice_config_found": notice_config_found, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_reason": trigger_reason, + "elapsed_ms": round(elapsed_seconds * 1000), + "threshold_ms": round(PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS * 1000), + "top_k": top_k, + "result_count": result_count, + }, + flags=flags, + ) + + if displayed: + print(copy, file=sys.stderr) + except Exception: + return + + +async def display_performance_slow_query_notice_async( + memory_instance, + sync_type: str, + trigger_function: str, + elapsed_seconds: float, + top_k: int, + result_count: int, +) -> None: + await asyncio.to_thread( + display_performance_slow_query_notice, + memory_instance, + sync_type, + trigger_function, + elapsed_seconds, + top_k, + result_count, + ) + + +def get_temporal_feature_error_message(sync_type: str, trigger_function: str, trigger_parameter: str) -> str: + """Return the temporal feature error copy and capture event when available.""" + return _get_feature_error_message( + TEMPORAL_FEATURE_NOTICE_ID, + TEMPORAL_FEATURE_ERROR_MESSAGES[trigger_parameter], + sync_type, + trigger_function, + trigger_parameter, + ) + + +async def get_temporal_feature_error_message_async( + sync_type: str, + trigger_function: str, + trigger_parameter: str, +) -> str: + return await asyncio.to_thread( + get_temporal_feature_error_message, + sync_type, + trigger_function, + trigger_parameter, + ) + + +def get_decay_feature_error_message(sync_type: str, trigger_function: str, trigger_parameter: str) -> str: + """Return the decay feature error copy and capture event when available.""" + return _get_feature_error_message( + DECAY_FEATURE_NOTICE_ID, + DECAY_FEATURE_ERROR_MESSAGE, + sync_type, + trigger_function, + trigger_parameter, + ) + + +async def get_decay_feature_error_message_async( + sync_type: str, + trigger_function: str, + trigger_parameter: str, +) -> str: + return await asyncio.to_thread( + get_decay_feature_error_message, + sync_type, + trigger_function, + trigger_parameter, + ) + + +def _get_feature_error_message( + notice_id: str, + plain_error: str, + sync_type: str, + trigger_function: str, + trigger_parameter: str, +) -> str: + if not telemetry_module.MEM0_TELEMETRY: + return plain_error + + if _feature_error_at_capacity(notice_id): + return plain_error + + try: + telemetry = telemetry_module._get_oss_telemetry() + if telemetry is None or telemetry.posthog is None or not telemetry.user_id: + return plain_error + + flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) + variant = flags.get_flag(FLAG_KEY) + if variant in (None, False): + return plain_error + + payload = _coerce_mapping(flags.get_flag_payload(FLAG_KEY)) + notices = payload.get("notices", {}) + notice_config = _coerce_mapping( + notices.get(notice_id) if isinstance(notices, dict) else {} + ) + notice_config_found = bool(notice_config) + + copy = notice_config.get("copy") + enabled = notice_config.get("enabled", True) if notice_config_found else False + notice_type = notice_config.get("notice_type", "error") + + disabled_reason = None + bypass_reason = None + if not notice_config_found: + bypass_reason = "missing_notice_config" + elif not enabled: + disabled_reason = "payload_disabled" + bypass_reason = disabled_reason + elif not copy: + bypass_reason = "missing_copy" + elif variant not in (DISPLAYED_VARIANT, HOLDOUT_VARIANT): + bypass_reason = "not_displayed" + + displayed = variant in (DISPLAYED_VARIANT, HOLDOUT_VARIANT) and enabled and bool(copy) + + if not _record_feature_error_opportunity( + notice_id=notice_id, + variant=variant, + sync_type=sync_type, + trigger_function=trigger_function, + trigger_parameter=trigger_parameter, + ): + return plain_error + + telemetry.capture_event( + NOTICE_EVENT, + { + "notice_id": notice_id, + "notice_type": notice_type, + "flag_key": FLAG_KEY, + "variant": variant, + "displayed": displayed, + "payload": copy, + "bypass_reason": bypass_reason, + "disabled_reason": disabled_reason, + "notice_config_found": notice_config_found, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_parameter": trigger_parameter, + }, + flags=flags, + ) + + if displayed: + return copy + except Exception: + return plain_error + + return plain_error + + +def detect_temporal_usage_from_metadata(metadata: Optional[Dict[str, Any]]) -> Optional[Tuple[str, str]]: + try: + if not isinstance(metadata, dict): + return None + + for key, value in _walk_mapping(metadata): + temporal_key = _is_temporal_key(key) + if temporal_key and _looks_temporal_value(value, allow_epoch=True): + return ("metadata", "date_like_metadata") + except Exception: + return None + return None + + +def detect_temporal_usage_from_search( + query: Any, + filters: Optional[Dict[str, Any]], +) -> Optional[Tuple[str, str]]: + try: + if isinstance(query, str): + if _RELATIVE_TIME_RE.search(query): + return ("query", "relative_phrase") + if _ISO_DATE_RE.search(query): + return ("query", "date_like_query") + + if _has_temporal_filter(filters): + return ("filter", "date_range_filter") + except Exception: + return None + return None + + +def _claim_first_run_notice(trigger_function: str) -> bool: + global _first_run_claimed_in_process + + with _state_lock: + if _first_run_claimed_in_process: + return False + + config = _load_config() + state = config.get(STATE_SECTION) + if isinstance(state, dict): + first_run = state.get(STATE_KEY) + if isinstance(first_run, dict) and first_run.get("consumed"): + _first_run_claimed_in_process = True + return False + + if not isinstance(state, dict): + state = {} + + state[STATE_KEY] = { + "consumed": True, + "consumed_at": datetime.now(timezone.utc).isoformat(), + "trigger_function": trigger_function, + "variant": None, + } + config[STATE_SECTION] = state + _write_config(config) + _first_run_claimed_in_process = True + return True + + +def _update_first_run_variant(variant) -> None: + try: + with _state_lock: + config = _load_config() + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + state = {} + first_run = state.get(STATE_KEY) + if not isinstance(first_run, dict): + first_run = {"consumed": True} + first_run["variant"] = variant + state[STATE_KEY] = first_run + config[STATE_SECTION] = state + _write_config(config) + except Exception: + return + + +def _feature_error_at_capacity(notice_id: str) -> bool: + if notice_id in _feature_error_capacity_reached_in_process: + return True + + try: + with _state_lock: + config = _load_config() + entries = _recent_feature_error_entries(config, notice_id, datetime.now(timezone.utc)) + at_capacity = len(entries) >= FEATURE_ERROR_CAP + if at_capacity: + _feature_error_capacity_reached_in_process.add(notice_id) + return at_capacity + except Exception: + return True + + +def _record_feature_error_opportunity( + *, + notice_id: str, + variant: str, + sync_type: str, + trigger_function: str, + trigger_parameter: str, +) -> bool: + try: + with _state_lock: + now = datetime.now(timezone.utc) + config = _load_config() + entries = _recent_feature_error_entries(config, notice_id, now) + if len(entries) >= FEATURE_ERROR_CAP: + _feature_error_capacity_reached_in_process.add(notice_id) + return False + + entries.append( + { + "evaluated_at": now.isoformat(), + "variant": variant, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_parameter": trigger_parameter, + } + ) + + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + state = {} + feature_state = state.get(notice_id) + if not isinstance(feature_state, dict): + feature_state = {} + feature_state["events"] = entries + state[notice_id] = feature_state + config[STATE_SECTION] = state + _write_config(config) + if len(entries) >= FEATURE_ERROR_CAP: + _feature_error_capacity_reached_in_process.add(notice_id) + return True + except Exception: + return False + + +def _recent_feature_error_entries(config: Dict[str, Any], notice_id: str, now: datetime): + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + return [] + + feature_state = state.get(notice_id) + if not isinstance(feature_state, dict): + return [] + + entries = feature_state.get("events") + if not isinstance(entries, list): + return [] + + cutoff = now - FEATURE_ERROR_WINDOW + recent = [] + for entry in entries: + if not isinstance(entry, dict): + continue + evaluated_at = _parse_datetime(entry.get("evaluated_at")) + if evaluated_at is not None and evaluated_at >= cutoff: + recent.append(entry) + return recent + + +def _temporal_usage_at_capacity() -> bool: + global _temporal_usage_capacity_reached_in_process + if _temporal_usage_capacity_reached_in_process: + return True + + try: + with _state_lock: + config = _load_config() + entries = _recent_temporal_usage_entries(config, datetime.now(timezone.utc)) + at_capacity = len(entries) >= TEMPORAL_USAGE_CAP + if at_capacity: + _temporal_usage_capacity_reached_in_process = True + return at_capacity + except Exception: + return True + + +def _record_temporal_usage_opportunity( + *, + variant: str, + sync_type: str, + trigger_function: str, + trigger_source: str, + trigger_reason: str, +) -> bool: + global _temporal_usage_capacity_reached_in_process + try: + with _state_lock: + now = datetime.now(timezone.utc) + config = _load_config() + entries = _recent_temporal_usage_entries(config, now) + if len(entries) >= TEMPORAL_USAGE_CAP: + _temporal_usage_capacity_reached_in_process = True + return False + + entries.append( + { + "evaluated_at": now.isoformat(), + "variant": variant, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_source": trigger_source, + "trigger_reason": trigger_reason, + } + ) + + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + state = {} + temporal_state = state.get(TEMPORAL_USAGE_STATE_KEY) + if not isinstance(temporal_state, dict): + temporal_state = {} + temporal_state["events"] = entries + state[TEMPORAL_USAGE_STATE_KEY] = temporal_state + config[STATE_SECTION] = state + _write_config(config) + if len(entries) >= TEMPORAL_USAGE_CAP: + _temporal_usage_capacity_reached_in_process = True + return True + except Exception: + return False + + +def _recent_temporal_usage_entries(config: Dict[str, Any], now: datetime): + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + return [] + + temporal_state = state.get(TEMPORAL_USAGE_STATE_KEY) + if not isinstance(temporal_state, dict): + return [] + + entries = temporal_state.get("events") + if not isinstance(entries, list): + return [] + + cutoff = now - TEMPORAL_USAGE_WINDOW + recent = [] + for entry in entries: + if not isinstance(entry, dict): + continue + evaluated_at = _parse_datetime(entry.get("evaluated_at")) + if evaluated_at is not None and evaluated_at >= cutoff: + recent.append(entry) + return recent + + +def _decay_usage_at_capacity() -> bool: + global _decay_usage_capacity_reached_in_process + if _decay_usage_capacity_reached_in_process: + return True + + try: + with _state_lock: + config = _load_config() + entries = _recent_decay_usage_entries(config, datetime.now(timezone.utc)) + at_capacity = len(entries) >= DECAY_USAGE_CAP + if at_capacity: + _decay_usage_capacity_reached_in_process = True + return at_capacity + except Exception: + return True + + +def _record_decay_usage_opportunity( + *, + variant: str, + sync_type: str, + trigger_function: str, + trigger_source: str, + trigger_reason: str, + delete_count: Optional[int], + deleted_count: Optional[int], +) -> bool: + global _decay_usage_capacity_reached_in_process + try: + with _state_lock: + now = datetime.now(timezone.utc) + config = _load_config() + entries = _recent_decay_usage_entries(config, now) + if len(entries) >= DECAY_USAGE_CAP: + _decay_usage_capacity_reached_in_process = True + return False + + entry = { + "evaluated_at": now.isoformat(), + "variant": variant, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_source": trigger_source, + "trigger_reason": trigger_reason, + } + if delete_count is not None: + entry["delete_count"] = delete_count + if deleted_count is not None: + entry["deleted_count"] = deleted_count + entries.append(entry) + + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + state = {} + decay_state = state.get(DECAY_USAGE_STATE_KEY) + if not isinstance(decay_state, dict): + decay_state = {} + decay_state["events"] = entries + state[DECAY_USAGE_STATE_KEY] = decay_state + config[STATE_SECTION] = state + _write_config(config) + if len(entries) >= DECAY_USAGE_CAP: + _decay_usage_capacity_reached_in_process = True + return True + except Exception: + return False + + +def _recent_decay_usage_entries(config: Dict[str, Any], now: datetime): + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + return [] + + decay_state = state.get(DECAY_USAGE_STATE_KEY) + if not isinstance(decay_state, dict): + return [] + + entries = decay_state.get("events") + if not isinstance(entries, list): + return [] + + cutoff = now - DECAY_USAGE_WINDOW + recent = [] + for entry in entries: + if not isinstance(entry, dict): + continue + evaluated_at = _parse_datetime(entry.get("evaluated_at")) + if evaluated_at is not None and evaluated_at >= cutoff: + recent.append(entry) + return recent + + +def _scale_threshold_at_capacity() -> bool: + global _scale_threshold_capacity_reached_in_process + if _scale_threshold_capacity_reached_in_process: + return True + + try: + with _state_lock: + config = _load_config() + entries = _recent_scale_threshold_entries(config, datetime.now(timezone.utc)) + at_capacity = len(entries) >= SCALE_THRESHOLD_CAP + if at_capacity: + _scale_threshold_capacity_reached_in_process = True + return at_capacity + except Exception: + return True + + +def _record_scale_threshold_opportunity( + *, + variant: str, + sync_type: str, + trigger_function: str, + trigger_source: str, + trigger_reason: str, + top_k: Optional[int], + memory_count: Optional[int], + threshold: Optional[int], +) -> bool: + global _scale_threshold_capacity_reached_in_process + try: + with _state_lock: + now = datetime.now(timezone.utc) + config = _load_config() + entries = _recent_scale_threshold_entries(config, now) + if len(entries) >= SCALE_THRESHOLD_CAP: + _scale_threshold_capacity_reached_in_process = True + return False + + entry = { + "evaluated_at": now.isoformat(), + "variant": variant, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_source": trigger_source, + "trigger_reason": trigger_reason, + } + if top_k is not None: + entry["top_k"] = top_k + if memory_count is not None: + entry["memory_count"] = memory_count + if threshold is not None: + entry["threshold"] = threshold + entries.append(entry) + + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + state = {} + scale_state = state.get(SCALE_THRESHOLD_STATE_KEY) + if not isinstance(scale_state, dict): + scale_state = {} + scale_state["events"] = entries + if trigger_source == "memory_count": + scale_state["memory_count_threshold_evaluated"] = True + state[SCALE_THRESHOLD_STATE_KEY] = scale_state + config[STATE_SECTION] = state + _write_config(config) + if len(entries) >= SCALE_THRESHOLD_CAP: + _scale_threshold_capacity_reached_in_process = True + return True + except Exception: + return False + + +def _mark_scale_memory_count_threshold_evaluated() -> bool: + global _scale_memory_count_threshold_evaluated_in_process + try: + with _state_lock: + config = _load_config() + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + state = {} + scale_state = state.get(SCALE_THRESHOLD_STATE_KEY) + if not isinstance(scale_state, dict): + scale_state = {} + if scale_state.get("memory_count_threshold_evaluated"): + _scale_memory_count_threshold_evaluated_in_process = True + return False + + scale_state["memory_count_threshold_evaluated"] = True + state[SCALE_THRESHOLD_STATE_KEY] = scale_state + config[STATE_SECTION] = state + _write_config(config) + _scale_memory_count_threshold_evaluated_in_process = True + return True + except Exception: + return False + + +def _recent_scale_threshold_entries(config: Dict[str, Any], now: datetime): + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + return [] + + scale_state = state.get(SCALE_THRESHOLD_STATE_KEY) + if not isinstance(scale_state, dict): + return [] + + entries = scale_state.get("events") + if not isinstance(entries, list): + return [] + + cutoff = now - SCALE_THRESHOLD_WINDOW + recent = [] + for entry in entries: + if not isinstance(entry, dict): + continue + evaluated_at = _parse_datetime(entry.get("evaluated_at")) + if evaluated_at is not None and evaluated_at >= cutoff: + recent.append(entry) + return recent + + +def _performance_slow_query_at_capacity() -> bool: + global _performance_slow_query_capacity_reached_in_process + if _performance_slow_query_capacity_reached_in_process: + return True + + try: + with _state_lock: + config = _load_config() + entries = _recent_performance_slow_query_entries(config, datetime.now(timezone.utc)) + at_capacity = len(entries) >= PERFORMANCE_SLOW_QUERY_CAP + if at_capacity: + _performance_slow_query_capacity_reached_in_process = True + return at_capacity + except Exception: + return True + + +def _record_performance_slow_query_opportunity( + *, + variant: str, + sync_type: str, + trigger_function: str, + trigger_reason: str, +) -> bool: + global _performance_slow_query_capacity_reached_in_process + try: + with _state_lock: + now = datetime.now(timezone.utc) + config = _load_config() + entries = _recent_performance_slow_query_entries(config, now) + if len(entries) >= PERFORMANCE_SLOW_QUERY_CAP: + _performance_slow_query_capacity_reached_in_process = True + return False + + entries.append( + { + "evaluated_at": now.isoformat(), + "variant": variant, + "sync_type": sync_type, + "trigger_function": trigger_function, + "trigger_reason": trigger_reason, + } + ) + + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + state = {} + performance_state = state.get(PERFORMANCE_SLOW_QUERY_STATE_KEY) + if not isinstance(performance_state, dict): + performance_state = {} + performance_state["events"] = entries + state[PERFORMANCE_SLOW_QUERY_STATE_KEY] = performance_state + config[STATE_SECTION] = state + _write_config(config) + if len(entries) >= PERFORMANCE_SLOW_QUERY_CAP: + _performance_slow_query_capacity_reached_in_process = True + return True + except Exception: + return False + + +def _recent_performance_slow_query_entries(config: Dict[str, Any], now: datetime): + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + return [] + + performance_state = state.get(PERFORMANCE_SLOW_QUERY_STATE_KEY) + if not isinstance(performance_state, dict): + return [] + + entries = performance_state.get("events") + if not isinstance(entries, list): + return [] + + cutoff = now - PERFORMANCE_SLOW_QUERY_WINDOW + recent = [] + for entry in entries: + if not isinstance(entry, dict): + continue + evaluated_at = _parse_datetime(entry.get("evaluated_at")) + if evaluated_at is not None and evaluated_at >= cutoff: + recent.append(entry) + return recent + + +def _parse_datetime(value: Any): + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _get_notice_state(config: Dict[str, Any], state_key: str) -> Dict[str, Any]: + state = config.get(STATE_SECTION) + if not isinstance(state, dict): + return {} + + notice_state = state.get(state_key) + return notice_state if isinstance(notice_state, dict) else {} + + +def _count_added_memories(add_result: Any) -> int: + results = add_result.get("results") if isinstance(add_result, dict) else add_result + if not isinstance(results, list): + return 0 + + count = 0 + for item in results: + if isinstance(item, dict) and item.get("event") == "ADD": + count += 1 + return count + + +def _get_provider_memory_count(memory_instance) -> Optional[int]: + vector_store = getattr(memory_instance, "vector_store", None) + if vector_store is None: + return None + + try: + count = getattr(vector_store, "count", None) + if callable(count): + value = _coerce_nonnegative_int(count(), None) + if value is not None: + return value + except Exception: + pass + + try: + col_info = getattr(vector_store, "col_info", None) + if callable(col_info): + return _extract_count(col_info()) + except Exception: + return None + + return None + + +def _extract_count(info: Any) -> Optional[int]: + if info is None: + return None + + if isinstance(info, dict): + for key in ("count", "points_count", "vectors_count", "indexed_vectors_count"): + value = _coerce_nonnegative_int(info.get(key), None) + if value is not None: + return value + return None + + model_dump = getattr(info, "model_dump", None) + if callable(model_dump): + try: + value = _extract_count(model_dump()) + if value is not None: + return value + except Exception: + return None + + for attr in ("count", "points_count", "vectors_count", "indexed_vectors_count"): + value = _coerce_nonnegative_int(getattr(info, attr, None), None) + if value is not None: + return value + + return None + + +def _render_scale_copy(copy_template: Any, *, top_k: Optional[int], memory_count: Optional[int]) -> Optional[str]: + if not isinstance(copy_template, str) or not copy_template.strip(): + return None + try: + return copy_template.format(top_k=top_k, memory_count=memory_count) + except Exception: + return copy_template + + +def _coerce_nonnegative_int(value: Any, default: Optional[int]) -> Optional[int]: + if isinstance(value, bool): + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 0 else default + + +def _coerce_mapping(payload: Any) -> Dict[str, Any]: + if isinstance(payload, dict): + return payload + if isinstance(payload, str): + try: + parsed = json.loads(payload) + except Exception: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _walk_mapping(value: Any, parent_key: str = ""): + if isinstance(value, dict): + for key, child in value.items(): + key_text = str(key) + yield key_text, child + yield from _walk_mapping(child, key_text) + elif isinstance(value, (list, tuple, set)): + for child in value: + yield from _walk_mapping(child, parent_key) + + +def _is_temporal_key(key: Any) -> bool: + key_text = str(key).lower() + exact_keys = { + "date", + "time", + "timestamp", + "datetime", + "event_date", + "reference_date", + "created_at", + "updated_at", + "started_at", + "ended_at", + "expires_at", + } + return ( + key_text in exact_keys + or key_text.endswith("_date") + or key_text.endswith("_time") + or key_text.endswith("_at") + or "timestamp" in key_text + ) + + +def _looks_temporal_value(value: Any, allow_epoch: bool) -> bool: + if isinstance(value, datetime): + return True + if isinstance(value, date): + return True + if isinstance(value, str): + return bool(_ISO_DATE_RE.search(value) or _RELATIVE_TIME_RE.search(value)) + if allow_epoch and isinstance(value, (int, float)) and not isinstance(value, bool): + return 946684800 <= value <= 4102444800 or 946684800000 <= value <= 4102444800000 + return False + + +def _has_temporal_filter(filters: Any) -> bool: + if not isinstance(filters, dict): + return False + + for key, value in filters.items(): + if key in {"AND", "OR", "NOT", "$and", "$or", "$not"}: + if isinstance(value, list) and any(_has_temporal_filter(item) for item in value): + return True + if isinstance(value, dict) and _has_temporal_filter(value): + return True + continue + + temporal_key = _is_temporal_key(key) + if isinstance(value, dict): + range_values = [item for op, item in value.items() if op in _RANGE_OPERATORS] + if range_values and ( + temporal_key + or any(_looks_temporal_value(item, allow_epoch=temporal_key) for item in range_values) + ): + return True + if _has_temporal_filter(value): + return True + elif temporal_key and _looks_temporal_value(value, allow_epoch=True): + return True + + return False diff --git a/mem0/memory/setup.py b/mem0/memory/setup.py index 7d98bcae4..b85b29c99 100644 --- a/mem0/memory/setup.py +++ b/mem0/memory/setup.py @@ -1,6 +1,7 @@ import json import logging import os +import tempfile import uuid from hashlib import sha256 @@ -34,10 +35,21 @@ def _load_config(): def _write_config(config): """Best-effort write of ~/.mem0/config.json. Never raises.""" path = _config_path() + temp_path = None try: - with open(path, "w") as f: + os.makedirs(os.path.dirname(path), exist_ok=True) + with tempfile.NamedTemporaryFile("w", dir=os.path.dirname(path), delete=False) as f: + temp_path = f.name json.dump(config, f, indent=4) + f.flush() + os.fsync(f.fileno()) + os.replace(temp_path, path) except Exception as e: + if temp_path: + try: + os.unlink(temp_path) + except OSError: + pass _logger.debug("Failed to write mem0 config %s: %s", path, e) diff --git a/mem0/memory/telemetry.py b/mem0/memory/telemetry.py index 0c2c544ad..a8b8fbae9 100644 --- a/mem0/memory/telemetry.py +++ b/mem0/memory/telemetry.py @@ -14,6 +14,7 @@ from mem0.memory.setup import get_or_create_user_id MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True") PROJECT_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" HOST = "https://us.i.posthog.com" +FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS = 0.5 if isinstance(MEM0_TELEMETRY, str): MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes") @@ -49,7 +50,9 @@ MEM0_TELEMETRY_SAMPLE_RATE = _parse_sample_rate(os.environ.get("MEM0_TELEMETRY_S # Events that bypass sampling and always fire. Keep this set in sync with the # event names passed to capture_event() in mem0/memory/main.py. # $identify is included so PostHog person-merging is never lost to sampling. -_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "$identify"}) +_LIFECYCLE_EVENTS = frozenset( + {"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "mem0.notice_displayed", "$identify"} +) def _sampling_before_send(msg): @@ -77,15 +80,15 @@ class AnonymousTelemetry: self.user_id = None return - try: - self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST, before_send=before_send) - except TypeError: - # posthog <4.5.0 does not accept before_send; fall back without sampling. - _logger.debug("posthog.Posthog does not accept before_send; upgrade to >=4.5.0 for sampling") - self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST) + self.posthog = Posthog( + project_api_key=PROJECT_API_KEY, + host=HOST, + before_send=before_send, + feature_flags_request_timeout_seconds=FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS, + ) self.user_id = get_or_create_user_id(vector_store) - def capture_event(self, event_name, properties=None, user_email=None): + def capture_event(self, event_name, properties=None, user_email=None, flags=None): if self.posthog is None: return @@ -109,7 +112,10 @@ class AnonymousTelemetry: **properties, } try: - self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties) + capture_kwargs = {"distinct_id": distinct_id, "properties": properties} + if flags is not None: + capture_kwargs["flags"] = flags + self.posthog.capture(event_name, **capture_kwargs) except Exception as e: _logger.debug("Failed to capture telemetry event %r: %s", event_name, e) diff --git a/pyproject.toml b/pyproject.toml index a90ba3fb2..0a5f4c05c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "qdrant-client>=1.12.0", "pydantic>=2.7.3", "openai>=1.90.0", - "posthog>=4.5.0", + "posthog>=7.14.0", "pytz>=2024.1", "sqlalchemy>=2.0.31", "protobuf>=5.29.6,<7.0.0", diff --git a/tests/memory/test_decay_feature_notice.py b/tests/memory/test_decay_feature_notice.py new file mode 100644 index 000000000..75ffa6489 --- /dev/null +++ b/tests/memory/test_decay_feature_notice.py @@ -0,0 +1,60 @@ +import pytest + +from mem0.memory import main as memory_main +from mem0.memory.main import AsyncMemory, Memory + + +def test_sync_project_update_decay_true_raises_with_notice_message(monkeypatch): + calls = [] + + def get_error(sync_type, trigger_function, trigger_parameter): + calls.append((sync_type, trigger_function, trigger_parameter)) + return "blocked decay" + + monkeypatch.setattr(memory_main, "get_decay_feature_error_message", get_error) + + with pytest.raises(ValueError, match="blocked decay"): + Memory.__new__(Memory).project.update(decay=True) + + assert calls == [("sync", "project.update", "decay")] + + +@pytest.mark.asyncio +async def test_async_project_update_decay_true_raises_with_notice_message(monkeypatch): + calls = [] + + async def get_error(sync_type, trigger_function, trigger_parameter): + calls.append((sync_type, trigger_function, trigger_parameter)) + return "blocked async decay" + + monkeypatch.setattr(memory_main, "get_decay_feature_error_message_async", get_error) + + with pytest.raises(ValueError, match="blocked async decay"): + await AsyncMemory.__new__(AsyncMemory).project.update(decay=True) + + assert calls == [("async", "project.update", "decay")] + + +@pytest.mark.parametrize("kwargs", [{}, {"decay": False}]) +def test_sync_project_update_non_trigger_raises_plain_error_without_notice(monkeypatch, kwargs): + monkeypatch.setattr( + memory_main, + "get_decay_feature_error_message", + lambda *args: pytest.fail("decay feature notice should not run"), + ) + + with pytest.raises(ValueError, match="Project updates are not supported by the OSS Memory SDK."): + Memory.__new__(Memory).project.update(**kwargs) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kwargs", [{}, {"decay": False}]) +async def test_async_project_update_non_trigger_raises_plain_error_without_notice(monkeypatch, kwargs): + monkeypatch.setattr( + memory_main, + "get_decay_feature_error_message_async", + lambda *args: pytest.fail("decay feature notice should not run"), + ) + + with pytest.raises(ValueError, match="Project updates are not supported by the OSS Memory SDK."): + await AsyncMemory.__new__(AsyncMemory).project.update(**kwargs) diff --git a/tests/memory/test_decay_usage_notice.py b/tests/memory/test_decay_usage_notice.py new file mode 100644 index 000000000..817f2d738 --- /dev/null +++ b/tests/memory/test_decay_usage_notice.py @@ -0,0 +1,214 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mem0.memory import main as memory_main +from mem0.memory.main import AsyncMemory, Memory + + +def make_sync_memory(): + memory = Memory.__new__(Memory) + memory.vector_store = MagicMock() + memory._delete_memory = MagicMock() + return memory + + +def make_async_memory(): + memory = AsyncMemory.__new__(AsyncMemory) + memory.vector_store = MagicMock() + memory._delete_memory = AsyncMock() + return memory + + +def test_sync_delete_decay_usage_runs_after_success(monkeypatch): + memory = make_sync_memory() + existing_memory = SimpleNamespace(id="memory-1") + memory.vector_store.get.return_value = existing_memory + decay_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr( + memory_main, + "detect_decay_usage_from_delete", + MagicMock(return_value=("delete_count", "repeated_deletes", 5, None)), + ) + monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + result = Memory.delete(memory, "memory-1") + + assert result == {"message": "Memory deleted successfully!"} + memory._delete_memory.assert_called_once_with("memory-1", existing_memory) + decay_notice.assert_called_once_with( + memory, + "sync", + "delete", + "delete_count", + "repeated_deletes", + 5, + None, + ) + first_run_notice.assert_not_called() + + +def test_sync_delete_below_threshold_uses_first_run_notice(monkeypatch): + memory = make_sync_memory() + memory.vector_store.get.return_value = SimpleNamespace(id="memory-1") + decay_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete", MagicMock(return_value=None)) + monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + Memory.delete(memory, "memory-1") + + decay_notice.assert_not_called() + first_run_notice.assert_called_once_with(memory, "sync", "delete") + + +def test_sync_delete_failure_does_not_trigger_decay_usage_notice(monkeypatch): + memory = make_sync_memory() + memory.vector_store.get.return_value = None + detect_decay = MagicMock() + decay_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete", detect_decay) + monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + with pytest.raises(ValueError, match="Memory with id memory-1 not found"): + Memory.delete(memory, "memory-1") + + detect_decay.assert_not_called() + decay_notice.assert_not_called() + first_run_notice.assert_not_called() + + +def test_sync_delete_all_decay_usage_runs_after_success(monkeypatch): + memory = make_sync_memory() + memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")] + memory.vector_store.list.return_value = (memories, None) + decay_notice = MagicMock() + first_run_notice = MagicMock() + detect_decay = MagicMock(return_value=("delete_all", "bulk_delete", None, 2)) + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", detect_decay) + monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + result = Memory.delete_all(memory, user_id="u1") + + assert result == {"message": "Memories deleted successfully!"} + assert memory._delete_memory.call_count == 2 + detect_decay.assert_called_once_with(2) + decay_notice.assert_called_once_with( + memory, + "sync", + "delete_all", + "delete_all", + "bulk_delete", + None, + 2, + ) + first_run_notice.assert_not_called() + + +def test_sync_delete_all_zero_deletes_uses_first_run_notice(monkeypatch): + memory = make_sync_memory() + memory.vector_store.list.return_value = ([], None) + decay_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", MagicMock(return_value=None)) + monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + Memory.delete_all(memory, user_id="u1") + + decay_notice.assert_not_called() + first_run_notice.assert_called_once_with(memory, "sync", "delete_all") + + +@pytest.mark.asyncio +async def test_async_delete_decay_usage_runs_after_success(monkeypatch): + memory = make_async_memory() + existing_memory = SimpleNamespace(id="memory-1") + memory.vector_store.get.return_value = existing_memory + decay_notice = AsyncMock() + first_run_notice = AsyncMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr( + memory_main, + "detect_decay_usage_from_delete", + MagicMock(return_value=("delete_count", "repeated_deletes", 5, None)), + ) + monkeypatch.setattr(memory_main, "display_decay_usage_notice_async", decay_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + result = await AsyncMemory.delete(memory, "memory-1") + + assert result == {"message": "Memory deleted successfully!"} + memory._delete_memory.assert_awaited_once_with("memory-1", existing_memory) + decay_notice.assert_awaited_once_with( + memory, + "async", + "delete", + "delete_count", + "repeated_deletes", + 5, + None, + ) + first_run_notice.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_async_delete_failure_does_not_trigger_decay_usage_notice(monkeypatch): + memory = make_async_memory() + memory.vector_store.get.return_value = None + detect_decay = MagicMock() + decay_notice = AsyncMock() + first_run_notice = AsyncMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete", detect_decay) + monkeypatch.setattr(memory_main, "display_decay_usage_notice_async", decay_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + with pytest.raises(ValueError, match="Memory with id memory-1 not found"): + await AsyncMemory.delete(memory, "memory-1") + + detect_decay.assert_not_called() + decay_notice.assert_not_awaited() + first_run_notice.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_async_delete_all_decay_usage_runs_after_success(monkeypatch): + memory = make_async_memory() + memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")] + memory.vector_store.list.return_value = (memories, None) + decay_notice = AsyncMock() + first_run_notice = AsyncMock() + detect_decay = MagicMock(return_value=("delete_all", "bulk_delete", None, 2)) + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", detect_decay) + monkeypatch.setattr(memory_main, "display_decay_usage_notice_async", decay_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + result = await AsyncMemory.delete_all(memory, user_id="u1") + + assert result == {"message": "Memories deleted successfully!"} + assert memory._delete_memory.await_count == 2 + detect_decay.assert_called_once_with(2) + decay_notice.assert_awaited_once_with( + memory, + "async", + "delete_all", + "delete_all", + "bulk_delete", + None, + 2, + ) + first_run_notice.assert_not_awaited() diff --git a/tests/memory/test_notices.py b/tests/memory/test_notices.py new file mode 100644 index 000000000..f7ac6d1c4 --- /dev/null +++ b/tests/memory/test_notices.py @@ -0,0 +1,1519 @@ +import asyncio +from copy import deepcopy +from datetime import date, datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from mem0.memory import main as memory_main +from mem0.memory import notices +from mem0.memory import telemetry as telemetry_module +from mem0.memory.main import Memory + + +class FakeFlags: + def __init__(self, variant, payload): + self.variant = variant + self.payload = payload + + def get_flag(self, key): + assert key == notices.FLAG_KEY + return self.variant + + def get_flag_payload(self, key): + assert key == notices.FLAG_KEY + return self.payload + + +@pytest.fixture(autouse=True) +def reset_notice_process_state(): + notices._first_run_claimed_in_process = False + notices._decay_usage_successful_delete_count_in_process = 0 + notices._temporal_usage_capacity_reached_in_process = False + notices._decay_usage_capacity_reached_in_process = False + notices._scale_threshold_capacity_reached_in_process = False + notices._performance_slow_query_capacity_reached_in_process = False + notices._feature_error_capacity_reached_in_process.clear() + notices._scale_memory_count_adds_since_check = 0 + notices._scale_memory_count_checked_in_process = False + notices._scale_memory_count_threshold_evaluated_in_process = False + yield + notices._first_run_claimed_in_process = False + notices._decay_usage_successful_delete_count_in_process = 0 + notices._temporal_usage_capacity_reached_in_process = False + notices._decay_usage_capacity_reached_in_process = False + notices._scale_threshold_capacity_reached_in_process = False + notices._performance_slow_query_capacity_reached_in_process = False + notices._feature_error_capacity_reached_in_process.clear() + notices._scale_memory_count_adds_since_check = 0 + notices._scale_memory_count_checked_in_process = False + notices._scale_memory_count_threshold_evaluated_in_process = False + + +@pytest.fixture +def notice_harness(monkeypatch): + config = {} + telemetry = MagicMock() + telemetry.user_id = "oss-user" + + def write_config(updated): + saved = deepcopy(updated) + config.clear() + config.update(saved) + + monkeypatch.setattr(notices, "_load_config", lambda: config) + monkeypatch.setattr(notices, "_write_config", write_config) + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True) + monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry) + + return config, telemetry + + +def configure_flag(telemetry, variant, payload): + flags = FakeFlags(variant, payload) + telemetry.posthog.evaluate_flags.return_value = flags + return flags + + +def display_notice(notice_harness, variant="displayed", payload=None): + config, telemetry = notice_harness + if payload is None: + payload = { + "notices": { + "first_run": { + "enabled": True, + "notice_type": "log_line", + "copy": "Mem0 OSS notice", + } + } + } + flags = configure_flag(telemetry, variant, payload) + notices.display_first_run_notice(MagicMock(), "sync", "add") + return config, telemetry, flags + + +def temporal_payload(copy="Temporal CTA", enabled=True, notice_type="error"): + payload = { + "notices": { + "temporal_stub": { + "enabled": enabled, + "notice_type": notice_type, + } + } + } + if copy is not None: + payload["notices"]["temporal_stub"]["copy"] = copy + return payload + + +def decay_payload(copy="Decay CTA", enabled=True, notice_type="error"): + payload = { + "notices": { + "decay_stub": { + "enabled": enabled, + "notice_type": notice_type, + } + } + } + if copy is not None: + payload["notices"]["decay_stub"]["copy"] = copy + return payload + + +def temporal_usage_payload(copy="Temporal usage CTA", enabled=True, notice_type="log_line"): + payload = { + "notices": { + "temporal_usage": { + "enabled": enabled, + "notice_type": notice_type, + } + } + } + if copy is not None: + payload["notices"]["temporal_usage"]["copy"] = copy + return payload + + +def decay_usage_payload(copy="Decay usage CTA", enabled=True, notice_type="log_line"): + payload = { + "notices": { + "decay_usage": { + "enabled": enabled, + "notice_type": notice_type, + } + } + } + if copy is not None: + payload["notices"]["decay_usage"]["copy"] = copy + return payload + + +def scale_payload( + top_k_copy="Scale top {top_k}", + memory_count_copy="Scale count {memory_count}", + enabled=True, + notice_type="log_line", +): + payload = { + "notices": { + "scale_threshold": { + "enabled": enabled, + "notice_type": notice_type, + "copies": {}, + } + } + } + copies = payload["notices"]["scale_threshold"]["copies"] + if top_k_copy is not None: + copies["top_k"] = top_k_copy + if memory_count_copy is not None: + copies["memory_count"] = memory_count_copy + return payload + + +def test_displayed_notice_logs_once_and_captures_event(notice_harness, capsys): + config, telemetry, flags = display_notice(notice_harness) + + assert capsys.readouterr().err == "Mem0 OSS notice\n" + telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) + telemetry.capture_event.assert_called_once() + event_name, props = telemetry.capture_event.call_args.args + assert event_name == notices.NOTICE_EVENT + assert props["notice_id"] == "first_run" + assert props["notice_type"] == "log_line" + assert props["variant"] == "displayed" + assert props["displayed"] is True + assert props["payload"] == "Mem0 OSS notice" + assert props["bypass_reason"] is None + assert props["notice_config_found"] is True + assert props["sync_type"] == "sync" + assert props["trigger_function"] == "add" + assert telemetry.capture_event.call_args.kwargs["flags"] is flags + assert config["notice_state"]["first_run"]["consumed"] is True + assert config["notice_state"]["first_run"]["variant"] == "displayed" + + notices.display_first_run_notice(MagicMock(), "sync", "search") + assert capsys.readouterr().err == "" + assert telemetry.capture_event.call_count == 1 + + +def test_holdout_notice_is_silent_but_captures_event(notice_harness, capsys): + _, telemetry, _ = display_notice(notice_harness, variant="holdout") + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == "holdout" + assert props["disabled_reason"] is None + + +@pytest.mark.parametrize( + ("payload", "expected_reason", "expected_found"), + [ + ({}, "missing_notice_config", False), + ({"notices": {}}, "missing_notice_config", False), + ({"notices": "not-an-object"}, "missing_notice_config", False), + ({"notices": {"first_run": {"enabled": True, "notice_type": "log_line"}}}, "missing_copy", True), + ( + {"notices": {"first_run": {"enabled": False, "notice_type": "log_line", "copy": "hidden"}}}, + "payload_disabled", + True, + ), + ], +) +def test_bad_or_disabled_payload_is_silent_and_safe( + notice_harness, payload, expected_reason, expected_found, capsys +): + _, telemetry, _ = display_notice(notice_harness, payload=payload) + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == expected_reason + assert props["notice_config_found"] is expected_found + + +def test_telemetry_disabled_does_not_touch_posthog_or_state(monkeypatch, capsys): + load_config = MagicMock(return_value={}) + write_config = MagicMock() + get_telemetry = MagicMock() + + monkeypatch.setattr(notices, "_load_config", load_config) + monkeypatch.setattr(notices, "_write_config", write_config) + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", False) + monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", get_telemetry) + + notices.display_first_run_notice(MagicMock(), "sync", "add") + + load_config.assert_not_called() + write_config.assert_not_called() + get_telemetry.assert_not_called() + assert capsys.readouterr().err == "" + + +def test_posthog_failure_is_silent_and_consumes_first_run(notice_harness, capsys): + config, telemetry = notice_harness + telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") + + notices.display_first_run_notice(MagicMock(), "sync", "add") + + assert capsys.readouterr().err == "" + telemetry.capture_event.assert_not_called() + assert config["notice_state"]["first_run"]["consumed"] is True + + +def test_public_add_succeeds_when_first_run_flag_eval_fails(notice_harness): + _, telemetry = notice_harness + telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") + memory = Memory.__new__(Memory) + memory.config = SimpleNamespace(llm=SimpleNamespace(config={})) + memory._add_to_vector_store = MagicMock(return_value=[{"event": "ADD", "memory": "likes tea"}]) + + result = Memory.add(memory, "The user likes tea.", user_id="u1", infer=False) + + assert result == {"results": [{"event": "ADD", "memory": "likes tea"}]} + + +def test_public_search_succeeds_when_first_run_flag_eval_fails(notice_harness, monkeypatch): + _, telemetry = notice_harness + telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + memory = Memory.__new__(Memory) + memory.api_version = "v1.1" + memory.reranker = None + memory._search_vector_store = MagicMock(return_value=[{"memory": "likes tea"}]) + + result = Memory.search(memory, "favorite drink", filters={"user_id": "u1"}) + + assert result == {"results": [{"memory": "likes tea"}]} + + +def test_notice_event_bypasses_sampling(): + assert notices.NOTICE_EVENT in telemetry_module._LIFECYCLE_EVENTS + + +def test_async_notice_wrapper_uses_shared_helper(monkeypatch): + calls = [] + + def display(memory_instance, sync_type, trigger_function): + calls.append((memory_instance, sync_type, trigger_function)) + + memory = MagicMock() + monkeypatch.setattr(notices, "display_first_run_notice", display) + + asyncio.run(notices.display_first_run_notice_async(memory, "async", "search")) + + assert calls == [(memory, "async", "search")] + + +def test_async_notice_wrapper_skips_thread_when_first_run_claimed(monkeypatch): + to_thread = MagicMock() + monkeypatch.setattr(notices.asyncio, "to_thread", to_thread) + notices._first_run_claimed_in_process = True + + asyncio.run(notices.display_first_run_notice_async(MagicMock(), "async", "search")) + + to_thread.assert_not_called() + + +def test_temporal_feature_displayed_returns_payload_copy_and_captures_event(notice_harness, capsys): + _, telemetry = notice_harness + flags = configure_flag(telemetry, "displayed", temporal_payload()) + + message = notices.get_temporal_feature_error_message("sync", "add", "timestamp") + + assert message == "Temporal CTA" + assert capsys.readouterr().err == "" + telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) + telemetry.capture_event.assert_called_once() + event_name, props = telemetry.capture_event.call_args.args + assert event_name == notices.NOTICE_EVENT + assert props["notice_id"] == "temporal_stub" + assert props["notice_type"] == "error" + assert props["variant"] == "displayed" + assert props["displayed"] is True + assert props["payload"] == "Temporal CTA" + assert props["bypass_reason"] is None + assert props["disabled_reason"] is None + assert props["notice_config_found"] is True + assert props["sync_type"] == "sync" + assert props["trigger_function"] == "add" + assert props["trigger_parameter"] == "timestamp" + assert telemetry.capture_event.call_args.kwargs["flags"] is flags + + +def test_temporal_feature_holdout_returns_payload_copy_and_captures_event(notice_harness, capsys): + _, telemetry = notice_harness + configure_flag(telemetry, "holdout", temporal_payload()) + + message = notices.get_temporal_feature_error_message("sync", "search", "reference_date") + + assert message == "Temporal CTA" + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is True + assert props["bypass_reason"] is None + assert props["trigger_function"] == "search" + assert props["trigger_parameter"] == "reference_date" + + +@pytest.mark.parametrize( + ("payload", "expected_reason", "expected_found"), + [ + ({}, "missing_notice_config", False), + ({"notices": {}}, "missing_notice_config", False), + ({"notices": "not-an-object"}, "missing_notice_config", False), + (temporal_payload(copy=None), "missing_copy", True), + (temporal_payload(enabled=False, copy="hidden"), "payload_disabled", True), + ], +) +def test_temporal_feature_bad_or_disabled_payload_returns_plain_error( + notice_harness, payload, expected_reason, expected_found, capsys +): + _, telemetry = notice_harness + configure_flag(telemetry, "displayed", payload) + + message = notices.get_temporal_feature_error_message("sync", "add", "timestamp") + + assert message == notices.TEMPORAL_FEATURE_ERROR_MESSAGES["timestamp"] + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == expected_reason + assert props["notice_config_found"] is expected_found + + +@pytest.mark.parametrize("variant", [None, False]) +def test_temporal_feature_blunt_flag_disable_returns_plain_error_without_event( + notice_harness, variant, capsys +): + _, telemetry = notice_harness + configure_flag(telemetry, variant, temporal_payload()) + + message = notices.get_temporal_feature_error_message("sync", "add", "timestamp") + + assert message == notices.TEMPORAL_FEATURE_ERROR_MESSAGES["timestamp"] + assert capsys.readouterr().err == "" + telemetry.capture_event.assert_not_called() + + +def test_temporal_feature_telemetry_disabled_does_not_touch_posthog(monkeypatch, capsys): + get_telemetry = MagicMock() + + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", False) + monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", get_telemetry) + + message = notices.get_temporal_feature_error_message("sync", "add", "timestamp") + + assert message == notices.TEMPORAL_FEATURE_ERROR_MESSAGES["timestamp"] + get_telemetry.assert_not_called() + assert capsys.readouterr().err == "" + + +def test_temporal_feature_posthog_failure_returns_plain_error(notice_harness, capsys): + _, telemetry = notice_harness + telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") + + message = notices.get_temporal_feature_error_message("sync", "add", "timestamp") + + assert message == notices.TEMPORAL_FEATURE_ERROR_MESSAGES["timestamp"] + telemetry.capture_event.assert_not_called() + assert capsys.readouterr().err == "" + + +def test_temporal_feature_cap_blocks_repeated_posthog_evaluation(notice_harness): + config, telemetry = notice_harness + configure_flag(telemetry, "displayed", temporal_payload()) + + for _ in range(notices.FEATURE_ERROR_CAP): + assert notices.get_temporal_feature_error_message("sync", "add", "timestamp") == "Temporal CTA" + + assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP + assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP + assert len(config["notice_state"]["temporal_stub"]["events"]) == notices.FEATURE_ERROR_CAP + + message = notices.get_temporal_feature_error_message("sync", "add", "timestamp") + + assert message == notices.TEMPORAL_FEATURE_ERROR_MESSAGES["timestamp"] + assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP + assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP + + +def test_async_temporal_feature_wrapper_uses_shared_helper(monkeypatch): + calls = [] + + def get_error(sync_type, trigger_function, trigger_parameter): + calls.append((sync_type, trigger_function, trigger_parameter)) + return "blocked" + + monkeypatch.setattr(notices, "get_temporal_feature_error_message", get_error) + + message = asyncio.run( + notices.get_temporal_feature_error_message_async("async", "search", "reference_date") + ) + + assert message == "blocked" + assert calls == [("async", "search", "reference_date")] + + +def test_decay_feature_displayed_returns_payload_copy_and_captures_event(notice_harness, capsys): + _, telemetry = notice_harness + flags = configure_flag(telemetry, "displayed", decay_payload()) + + message = notices.get_decay_feature_error_message("sync", "project.update", "decay") + + assert message == "Decay CTA" + assert capsys.readouterr().err == "" + telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) + telemetry.capture_event.assert_called_once() + event_name, props = telemetry.capture_event.call_args.args + assert event_name == notices.NOTICE_EVENT + assert props["notice_id"] == "decay_stub" + assert props["notice_type"] == "error" + assert props["variant"] == "displayed" + assert props["displayed"] is True + assert props["payload"] == "Decay CTA" + assert props["bypass_reason"] is None + assert props["disabled_reason"] is None + assert props["notice_config_found"] is True + assert props["sync_type"] == "sync" + assert props["trigger_function"] == "project.update" + assert props["trigger_parameter"] == "decay" + assert telemetry.capture_event.call_args.kwargs["flags"] is flags + + +def test_decay_feature_holdout_returns_payload_copy_and_captures_event(notice_harness, capsys): + _, telemetry = notice_harness + configure_flag(telemetry, "holdout", decay_payload()) + + message = notices.get_decay_feature_error_message("sync", "project.update", "decay") + + assert message == "Decay CTA" + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is True + assert props["bypass_reason"] is None + assert props["variant"] == "holdout" + + +@pytest.mark.parametrize( + ("payload", "expected_reason", "expected_found"), + [ + ({}, "missing_notice_config", False), + ({"notices": {}}, "missing_notice_config", False), + ({"notices": "not-an-object"}, "missing_notice_config", False), + (decay_payload(copy=None), "missing_copy", True), + (decay_payload(enabled=False, copy="hidden"), "payload_disabled", True), + ], +) +def test_decay_feature_bad_or_disabled_payload_returns_plain_error( + notice_harness, payload, expected_reason, expected_found, capsys +): + _, telemetry = notice_harness + configure_flag(telemetry, "displayed", payload) + + message = notices.get_decay_feature_error_message("sync", "project.update", "decay") + + assert message == notices.DECAY_FEATURE_ERROR_MESSAGE + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == expected_reason + assert props["notice_config_found"] is expected_found + + +@pytest.mark.parametrize("variant", [None, False]) +def test_decay_feature_blunt_flag_disable_returns_plain_error_without_event( + notice_harness, variant, capsys +): + _, telemetry = notice_harness + configure_flag(telemetry, variant, decay_payload()) + + message = notices.get_decay_feature_error_message("sync", "project.update", "decay") + + assert message == notices.DECAY_FEATURE_ERROR_MESSAGE + assert capsys.readouterr().err == "" + telemetry.capture_event.assert_not_called() + + +def test_decay_feature_telemetry_disabled_does_not_touch_posthog(monkeypatch, capsys): + get_telemetry = MagicMock() + + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", False) + monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", get_telemetry) + + message = notices.get_decay_feature_error_message("sync", "project.update", "decay") + + assert message == notices.DECAY_FEATURE_ERROR_MESSAGE + get_telemetry.assert_not_called() + assert capsys.readouterr().err == "" + + +def test_decay_feature_posthog_failure_returns_plain_error(notice_harness, capsys): + _, telemetry = notice_harness + telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") + + message = notices.get_decay_feature_error_message("sync", "project.update", "decay") + + assert message == notices.DECAY_FEATURE_ERROR_MESSAGE + telemetry.capture_event.assert_not_called() + assert capsys.readouterr().err == "" + + +def test_decay_feature_cap_is_independent_from_temporal_feature_cap(notice_harness): + config, telemetry = notice_harness + configure_flag(telemetry, "displayed", temporal_payload()) + + for _ in range(notices.FEATURE_ERROR_CAP): + assert notices.get_temporal_feature_error_message("sync", "add", "timestamp") == "Temporal CTA" + + configure_flag(telemetry, "displayed", decay_payload()) + assert notices.get_decay_feature_error_message("sync", "project.update", "decay") == "Decay CTA" + + assert len(config["notice_state"]["temporal_stub"]["events"]) == notices.FEATURE_ERROR_CAP + assert len(config["notice_state"]["decay_stub"]["events"]) == 1 + + +def test_decay_feature_cap_blocks_repeated_posthog_evaluation(notice_harness): + config, telemetry = notice_harness + configure_flag(telemetry, "displayed", decay_payload()) + + for _ in range(notices.FEATURE_ERROR_CAP): + assert notices.get_decay_feature_error_message("sync", "project.update", "decay") == "Decay CTA" + + assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP + assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP + assert len(config["notice_state"]["decay_stub"]["events"]) == notices.FEATURE_ERROR_CAP + + message = notices.get_decay_feature_error_message("sync", "project.update", "decay") + + assert message == notices.DECAY_FEATURE_ERROR_MESSAGE + assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP + assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP + + +def test_async_decay_feature_wrapper_uses_shared_helper(monkeypatch): + calls = [] + + def get_error(sync_type, trigger_function, trigger_parameter): + calls.append((sync_type, trigger_function, trigger_parameter)) + return "blocked" + + monkeypatch.setattr(notices, "get_decay_feature_error_message", get_error) + + message = asyncio.run( + notices.get_decay_feature_error_message_async("async", "project.update", "decay") + ) + + assert message == "blocked" + assert calls == [("async", "project.update", "decay")] + + +def test_temporal_usage_query_detection_is_conservative(): + assert notices.detect_temporal_usage_from_search("what happened last week?", None) == ( + "query", + "relative_phrase", + ) + assert notices.detect_temporal_usage_from_search("notes from 2025-04-09", None) == ( + "query", + "date_like_query", + ) + assert notices.detect_temporal_usage_from_search("favorite drink", None) is None + + +def test_temporal_usage_metadata_detection(): + assert notices.detect_temporal_usage_from_metadata({"event_date": "2025-04-09"}) == ( + "metadata", + "date_like_metadata", + ) + assert notices.detect_temporal_usage_from_metadata({"timestamp": 1778112000}) == ( + "metadata", + "date_like_metadata", + ) + assert notices.detect_temporal_usage_from_metadata({"nested": {"started_at": datetime.now(timezone.utc)}}) == ( + "metadata", + "date_like_metadata", + ) + assert notices.detect_temporal_usage_from_metadata({"category": "planning"}) is None + assert notices.detect_temporal_usage_from_metadata({"notes": "we met yesterday"}) is None + assert notices.detect_temporal_usage_from_metadata({"category": "2025-04-09"}) is None + + +def test_temporal_usage_metadata_detection_never_raises_for_cyclic_input(): + metadata = {} + metadata["self"] = metadata + + assert notices.detect_temporal_usage_from_metadata(metadata) is None + + +def test_temporal_usage_filter_detection(): + filters = {"AND": [{"user_id": "u1"}, {"created_at": {"gte": "2025-04-01"}}]} + assert notices.detect_temporal_usage_from_search("favorite drink", filters) == ( + "filter", + "date_range_filter", + ) + assert notices.detect_temporal_usage_from_search("favorite drink", {"score": {"gte": 0.5}}) is None + + +def test_temporal_usage_search_detection_never_raises_for_cyclic_filters(): + filters = {} + filters["AND"] = [filters] + + assert notices.detect_temporal_usage_from_search("favorite drink", filters) is None + + +def test_temporal_usage_displayed_logs_and_captures_event(notice_harness, capsys): + config, telemetry = notice_harness + flags = configure_flag(telemetry, "displayed", temporal_usage_payload()) + + notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") + + assert capsys.readouterr().err == "Temporal usage CTA\n" + telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) + telemetry.capture_event.assert_called_once() + event_name, props = telemetry.capture_event.call_args.args + assert event_name == notices.NOTICE_EVENT + assert props["notice_id"] == "temporal_usage" + assert props["notice_type"] == "log_line" + assert props["variant"] == "displayed" + assert props["displayed"] is True + assert props["payload"] == "Temporal usage CTA" + assert props["bypass_reason"] is None + assert props["disabled_reason"] is None + assert props["notice_config_found"] is True + assert props["sync_type"] == "sync" + assert props["trigger_function"] == "search" + assert props["trigger_source"] == "query" + assert props["trigger_reason"] == "relative_phrase" + assert telemetry.capture_event.call_args.kwargs["flags"] is flags + assert len(config["notice_state"]["temporal_usage"]["events"]) == 1 + + +def test_temporal_usage_holdout_is_silent_but_captures_event(notice_harness, capsys): + _, telemetry = notice_harness + configure_flag(telemetry, "holdout", temporal_usage_payload()) + + notices.display_temporal_usage_notice(MagicMock(), "sync", "add", "metadata", "date_like_metadata") + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == "holdout" + assert props["trigger_source"] == "metadata" + assert props["trigger_reason"] == "date_like_metadata" + + +@pytest.mark.parametrize( + ("payload", "expected_reason", "expected_found"), + [ + ({}, "missing_notice_config", False), + ({"notices": {}}, "missing_notice_config", False), + ({"notices": "not-an-object"}, "missing_notice_config", False), + (temporal_usage_payload(copy=None), "missing_copy", True), + (temporal_usage_payload(enabled=False, copy="hidden"), "payload_disabled", True), + ], +) +def test_temporal_usage_bad_or_disabled_payload_is_silent_and_safe( + notice_harness, payload, expected_reason, expected_found, capsys +): + _, telemetry = notice_harness + configure_flag(telemetry, "displayed", payload) + + notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == expected_reason + assert props["notice_config_found"] is expected_found + + +@pytest.mark.parametrize("variant", [None, False]) +def test_temporal_usage_blunt_flag_disable_does_not_capture_or_consume( + notice_harness, variant, capsys +): + config, telemetry = notice_harness + configure_flag(telemetry, variant, temporal_usage_payload()) + + notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") + + assert capsys.readouterr().err == "" + telemetry.capture_event.assert_not_called() + assert config.get("notice_state") is None + + +def test_temporal_usage_telemetry_disabled_does_not_touch_posthog_or_state(monkeypatch, capsys): + load_config = MagicMock(return_value={}) + write_config = MagicMock() + get_telemetry = MagicMock() + + monkeypatch.setattr(notices, "_load_config", load_config) + monkeypatch.setattr(notices, "_write_config", write_config) + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", False) + monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", get_telemetry) + + notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") + + load_config.assert_not_called() + write_config.assert_not_called() + get_telemetry.assert_not_called() + assert capsys.readouterr().err == "" + + +def test_temporal_usage_cap_blocks_before_posthog_eval(notice_harness, capsys): + config, telemetry = notice_harness + configure_flag(telemetry, "displayed", temporal_usage_payload()) + + for _ in range(notices.TEMPORAL_USAGE_CAP): + notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") + + notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") + + assert capsys.readouterr().err == "Temporal usage CTA\n" * notices.TEMPORAL_USAGE_CAP + assert telemetry.posthog.evaluate_flags.call_count == notices.TEMPORAL_USAGE_CAP + assert telemetry.capture_event.call_count == notices.TEMPORAL_USAGE_CAP + assert len(config["notice_state"]["temporal_usage"]["events"]) == notices.TEMPORAL_USAGE_CAP + + +def test_temporal_usage_cap_ignores_old_entries(notice_harness, capsys): + config, telemetry = notice_harness + old_time = datetime.now(timezone.utc) - notices.TEMPORAL_USAGE_WINDOW - timedelta(days=1) + config["notice_state"] = { + "temporal_usage": { + "events": [ + {"evaluated_at": old_time.isoformat(), "variant": "displayed"} + for _ in range(notices.TEMPORAL_USAGE_CAP) + ] + } + } + configure_flag(telemetry, "displayed", temporal_usage_payload()) + + notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") + + assert capsys.readouterr().err == "Temporal usage CTA\n" + assert telemetry.capture_event.call_count == 1 + assert len(config["notice_state"]["temporal_usage"]["events"]) == 1 + + +def test_temporal_usage_props_do_not_include_raw_user_inputs(notice_harness): + _, telemetry = notice_harness + configure_flag(telemetry, "displayed", temporal_usage_payload(copy="safe copy")) + + notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") + + props = telemetry.capture_event.call_args.args[1] + assert "what happened last week" not in str(props) + assert "2025-04-09" not in str(props) + assert date.today().isoformat() not in str(props) + + +def test_decay_usage_delete_detection_reaches_threshold(notice_harness): + config, _ = notice_harness + + for _ in range(notices.DECAY_USAGE_DELETE_THRESHOLD - 1): + assert notices.detect_decay_usage_from_delete() is None + + assert config == {} + assert notices.detect_decay_usage_from_delete() == ( + "delete_count", + "repeated_deletes", + 5, + None, + ) + assert config == {} + + +def test_decay_usage_delete_detection_does_not_write_before_threshold(monkeypatch, notice_harness): + _, _ = notice_harness + write_config = MagicMock() + monkeypatch.setattr(notices, "_write_config", write_config) + + for _ in range(notices.DECAY_USAGE_DELETE_THRESHOLD - 1): + assert notices.detect_decay_usage_from_delete() is None + + write_config.assert_not_called() + + +def test_decay_usage_delete_detection_telemetry_disabled_does_not_write_state(monkeypatch, notice_harness): + config, _ = notice_harness + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", False) + + assert notices.detect_decay_usage_from_delete() is None + + assert config == {} + + +def test_decay_usage_delete_all_detection_requires_deleted_memory(notice_harness): + assert notices.detect_decay_usage_from_delete_all(0) is None + assert notices.detect_decay_usage_from_delete_all(None) is None + assert notices.detect_decay_usage_from_delete_all(3) == ( + "delete_all", + "bulk_delete", + None, + 3, + ) + + +def test_decay_usage_displayed_logs_and_captures_event(notice_harness, capsys): + config, telemetry = notice_harness + flags = configure_flag(telemetry, "displayed", decay_usage_payload()) + + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete", + "delete_count", + "repeated_deletes", + delete_count=5, + ) + + assert capsys.readouterr().err == "Decay usage CTA\n" + telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) + telemetry.capture_event.assert_called_once() + event_name, props = telemetry.capture_event.call_args.args + assert event_name == notices.NOTICE_EVENT + assert props["notice_id"] == "decay_usage" + assert props["notice_type"] == "log_line" + assert props["variant"] == "displayed" + assert props["displayed"] is True + assert props["payload"] == "Decay usage CTA" + assert props["bypass_reason"] is None + assert props["disabled_reason"] is None + assert props["notice_config_found"] is True + assert props["sync_type"] == "sync" + assert props["trigger_function"] == "delete" + assert props["trigger_source"] == "delete_count" + assert props["trigger_reason"] == "repeated_deletes" + assert props["delete_count"] == 5 + assert props["deleted_count"] is None + assert telemetry.capture_event.call_args.kwargs["flags"] is flags + assert len(config["notice_state"]["decay_usage"]["events"]) == 1 + + +def test_decay_usage_holdout_is_silent_but_captures_event(notice_harness, capsys): + _, telemetry = notice_harness + configure_flag(telemetry, "holdout", decay_usage_payload()) + + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete_all", + "delete_all", + "bulk_delete", + deleted_count=3, + ) + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == "holdout" + assert props["trigger_source"] == "delete_all" + assert props["trigger_reason"] == "bulk_delete" + assert props["delete_count"] is None + assert props["deleted_count"] == 3 + + +@pytest.mark.parametrize( + ("payload", "expected_reason", "expected_found"), + [ + ({}, "missing_notice_config", False), + ({"notices": {}}, "missing_notice_config", False), + ({"notices": "not-an-object"}, "missing_notice_config", False), + (decay_usage_payload(copy=None), "missing_copy", True), + (decay_usage_payload(enabled=False, copy="hidden"), "payload_disabled", True), + ], +) +def test_decay_usage_bad_or_disabled_payload_is_silent_and_safe( + notice_harness, payload, expected_reason, expected_found, capsys +): + _, telemetry = notice_harness + configure_flag(telemetry, "displayed", payload) + + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete", + "delete_count", + "repeated_deletes", + delete_count=5, + ) + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == expected_reason + assert props["notice_config_found"] is expected_found + + +@pytest.mark.parametrize("variant", [None, False]) +def test_decay_usage_blunt_flag_disable_does_not_capture_or_consume( + notice_harness, variant, capsys +): + config, telemetry = notice_harness + configure_flag(telemetry, variant, decay_usage_payload()) + + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete", + "delete_count", + "repeated_deletes", + delete_count=5, + ) + + assert capsys.readouterr().err == "" + telemetry.capture_event.assert_not_called() + assert config.get("notice_state") is None + + +def test_decay_usage_telemetry_disabled_does_not_touch_posthog_or_state(monkeypatch, capsys): + load_config = MagicMock(return_value={}) + write_config = MagicMock() + get_telemetry = MagicMock() + + monkeypatch.setattr(notices, "_load_config", load_config) + monkeypatch.setattr(notices, "_write_config", write_config) + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", False) + monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", get_telemetry) + + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete", + "delete_count", + "repeated_deletes", + delete_count=5, + ) + + load_config.assert_not_called() + write_config.assert_not_called() + get_telemetry.assert_not_called() + assert capsys.readouterr().err == "" + + +def test_decay_usage_posthog_failure_does_not_consume_cap(notice_harness, capsys): + config, telemetry = notice_harness + telemetry.posthog.evaluate_flags.side_effect = RuntimeError("posthog down") + + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete", + "delete_count", + "repeated_deletes", + delete_count=5, + ) + + assert capsys.readouterr().err == "" + telemetry.capture_event.assert_not_called() + assert config.get("notice_state") is None + + +def test_decay_usage_cap_blocks_before_posthog_eval(notice_harness, capsys): + config, telemetry = notice_harness + configure_flag(telemetry, "displayed", decay_usage_payload()) + + for _ in range(notices.DECAY_USAGE_CAP): + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete", + "delete_count", + "repeated_deletes", + delete_count=5, + ) + + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete", + "delete_count", + "repeated_deletes", + delete_count=5, + ) + + assert capsys.readouterr().err == "Decay usage CTA\n" * notices.DECAY_USAGE_CAP + assert telemetry.posthog.evaluate_flags.call_count == notices.DECAY_USAGE_CAP + assert telemetry.capture_event.call_count == notices.DECAY_USAGE_CAP + assert len(config["notice_state"]["decay_usage"]["events"]) == notices.DECAY_USAGE_CAP + + +def test_decay_usage_delete_detection_stops_after_cap(notice_harness): + config, telemetry = notice_harness + configure_flag(telemetry, "displayed", decay_usage_payload()) + + for _ in range(notices.DECAY_USAGE_CAP): + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete", + "delete_count", + "repeated_deletes", + delete_count=5, + ) + + notices._decay_usage_successful_delete_count_in_process = notices.DECAY_USAGE_DELETE_THRESHOLD + assert notices.detect_decay_usage_from_delete() is None + assert len(config["notice_state"]["decay_usage"]["events"]) == notices.DECAY_USAGE_CAP + + +def test_decay_usage_cap_ignores_old_entries(notice_harness, capsys): + config, telemetry = notice_harness + old_time = datetime.now(timezone.utc) - notices.DECAY_USAGE_WINDOW - timedelta(days=1) + config["notice_state"] = { + "decay_usage": { + "events": [ + {"evaluated_at": old_time.isoformat(), "variant": "displayed"} + for _ in range(notices.DECAY_USAGE_CAP) + ] + } + } + configure_flag(telemetry, "displayed", decay_usage_payload()) + + notices.display_decay_usage_notice( + MagicMock(), + "sync", + "delete_all", + "delete_all", + "bulk_delete", + deleted_count=2, + ) + + assert capsys.readouterr().err == "Decay usage CTA\n" + assert telemetry.capture_event.call_count == 1 + assert len(config["notice_state"]["decay_usage"]["events"]) == 1 + + +def test_scale_threshold_top_k_detection(): + assert notices.detect_scale_threshold_from_top_k(49) is None + assert notices.detect_scale_threshold_from_top_k(50) == ( + "top_k", + "high_top_k", + 50, + None, + notices.SCALE_TOP_K_THRESHOLD, + ) + + +def test_scale_threshold_displayed_logs_and_captures_event(notice_harness, capsys): + config, telemetry = notice_harness + flags = configure_flag(telemetry, "displayed", scale_payload()) + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + assert capsys.readouterr().err == "Scale top 50\n" + telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) + telemetry.capture_event.assert_called_once() + event_name, props = telemetry.capture_event.call_args.args + assert event_name == notices.NOTICE_EVENT + assert props["notice_id"] == "scale_threshold" + assert props["notice_type"] == "log_line" + assert props["variant"] == "displayed" + assert props["displayed"] is True + assert props["payload"] == "Scale top 50" + assert props["bypass_reason"] is None + assert props["disabled_reason"] is None + assert props["notice_config_found"] is True + assert props["sync_type"] == "sync" + assert props["trigger_function"] == "search" + assert props["trigger_source"] == "top_k" + assert props["trigger_reason"] == "high_top_k" + assert props["top_k"] == 50 + assert props["memory_count"] is None + assert props["threshold"] == notices.SCALE_TOP_K_THRESHOLD + assert telemetry.capture_event.call_args.kwargs["flags"] is flags + assert len(config["notice_state"]["scale_threshold"]["events"]) == 1 + + +def test_scale_threshold_holdout_is_silent_but_captures_event(notice_harness, capsys): + _, telemetry = notice_harness + configure_flag(telemetry, "holdout", scale_payload()) + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "get_all", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == "holdout" + assert props["trigger_function"] == "get_all" + + +@pytest.mark.parametrize( + ("payload", "expected_reason", "expected_found"), + [ + ({}, "missing_notice_config", False), + ({"notices": {}}, "missing_notice_config", False), + ({"notices": "not-an-object"}, "missing_notice_config", False), + (scale_payload(top_k_copy=None), "missing_copy", True), + (scale_payload(enabled=False, top_k_copy="hidden"), "payload_disabled", True), + ], +) +def test_scale_threshold_bad_or_disabled_payload_is_silent_and_safe( + notice_harness, payload, expected_reason, expected_found, capsys +): + _, telemetry = notice_harness + configure_flag(telemetry, "displayed", payload) + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == expected_reason + assert props["notice_config_found"] is expected_found + + +@pytest.mark.parametrize("variant", [None, False]) +def test_scale_threshold_blunt_flag_disable_does_not_capture_or_consume( + notice_harness, variant, capsys +): + config, telemetry = notice_harness + configure_flag(telemetry, variant, scale_payload()) + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + assert capsys.readouterr().err == "" + telemetry.capture_event.assert_not_called() + assert config.get("notice_state") is None + + +def test_scale_threshold_telemetry_disabled_does_not_touch_posthog_or_state(monkeypatch, capsys): + load_config = MagicMock(return_value={}) + write_config = MagicMock() + get_telemetry = MagicMock() + + monkeypatch.setattr(notices, "_load_config", load_config) + monkeypatch.setattr(notices, "_write_config", write_config) + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", False) + monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", get_telemetry) + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + load_config.assert_not_called() + write_config.assert_not_called() + get_telemetry.assert_not_called() + assert capsys.readouterr().err == "" + + +def test_scale_threshold_posthog_failure_does_not_consume_cap(notice_harness, capsys): + config, telemetry = notice_harness + telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + assert capsys.readouterr().err == "" + telemetry.capture_event.assert_not_called() + assert config.get("notice_state") is None + + +def test_scale_threshold_cap_blocks_before_posthog_eval(notice_harness, capsys): + config, telemetry = notice_harness + configure_flag(telemetry, "displayed", scale_payload()) + + for _ in range(notices.SCALE_THRESHOLD_CAP): + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + assert capsys.readouterr().err == "Scale top 50\n" * notices.SCALE_THRESHOLD_CAP + assert telemetry.posthog.evaluate_flags.call_count == notices.SCALE_THRESHOLD_CAP + assert telemetry.capture_event.call_count == notices.SCALE_THRESHOLD_CAP + assert len(config["notice_state"]["scale_threshold"]["events"]) == notices.SCALE_THRESHOLD_CAP + + +def test_scale_threshold_cap_ignores_old_entries(notice_harness, capsys): + config, telemetry = notice_harness + old_time = datetime.now(timezone.utc) - notices.SCALE_THRESHOLD_WINDOW - timedelta(days=1) + config["notice_state"] = { + "scale_threshold": { + "events": [ + {"evaluated_at": old_time.isoformat(), "variant": "displayed"} + for _ in range(notices.SCALE_THRESHOLD_CAP) + ] + } + } + configure_flag(telemetry, "displayed", scale_payload()) + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + assert capsys.readouterr().err == "Scale top 50\n" + assert telemetry.capture_event.call_count == 1 + assert len(config["notice_state"]["scale_threshold"]["events"]) == 1 + + +def test_scale_threshold_memory_count_requires_add_result_and_provider_count(notice_harness): + config, _ = notice_harness + memory = MagicMock() + memory.vector_store.count.return_value = notices.SCALE_MEMORY_COUNT_THRESHOLD + + assert notices.detect_scale_threshold_from_add_result(memory, [{"event": "UPDATE"}]) is None + assert config.get("notice_state") is None + + notice = notices.detect_scale_threshold_from_add_result(memory, [{"event": "ADD"}]) + + assert notice == ( + "memory_count", + "memory_count_threshold", + None, + notices.SCALE_MEMORY_COUNT_THRESHOLD, + notices.SCALE_MEMORY_COUNT_THRESHOLD, + ) + assert config["notice_state"]["scale_threshold"]["memory_count_threshold_evaluated"] is True + + +def test_scale_threshold_memory_count_ignores_under_threshold_provider_count(notice_harness): + config, _ = notice_harness + memory = MagicMock() + memory.vector_store.count.return_value = notices.SCALE_MEMORY_COUNT_THRESHOLD - 1 + + notice = notices.detect_scale_threshold_from_add_result(memory, [{"event": "ADD"}]) + + assert notice is None + assert config.get("notice_state") is None + + +def test_scale_threshold_memory_count_ignores_already_evaluated_threshold(notice_harness): + config, _ = notice_harness + config["notice_state"] = {"scale_threshold": {"memory_count_threshold_evaluated": True}} + memory = MagicMock() + memory.vector_store.count.return_value = notices.SCALE_MEMORY_COUNT_THRESHOLD + + notice = notices.detect_scale_threshold_from_add_result(memory, [{"event": "ADD"}]) + + assert notice is None + memory.vector_store.count.assert_not_called() + + +def test_scale_threshold_memory_count_is_throttled_under_threshold(notice_harness): + memory = MagicMock() + memory.vector_store.count.return_value = notices.SCALE_MEMORY_COUNT_THRESHOLD - 1 + + assert notices.detect_scale_threshold_from_add_result(memory, [{"event": "ADD"}]) is None + assert notices.detect_scale_threshold_from_add_result(memory, [{"event": "ADD"}]) is None + + memory.vector_store.count.assert_called_once() + + +def test_scale_threshold_memory_count_event_marks_threshold_evaluated(notice_harness, capsys): + config, telemetry = notice_harness + configure_flag(telemetry, "displayed", scale_payload()) + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "add", + "memory_count", + "memory_count_threshold", + memory_count=2000, + threshold=notices.SCALE_MEMORY_COUNT_THRESHOLD, + ) + + assert capsys.readouterr().err == "Scale count 2000\n" + assert config["notice_state"]["scale_threshold"]["memory_count_threshold_evaluated"] is True + props = telemetry.capture_event.call_args.args[1] + assert props["memory_count"] == 2000 + assert props["threshold"] == notices.SCALE_MEMORY_COUNT_THRESHOLD + + +def test_scale_threshold_provider_count_helpers_are_safe(): + class Info: + points_count = 2200 + + class CountStore: + def count(self): + return 2100 + + class FallbackStore: + def count(self): + raise RuntimeError("count unavailable") + + def col_info(self): + return {"count": 2300} + + memory = MagicMock() + memory.vector_store = CountStore() + assert notices._get_provider_memory_count(memory) == 2100 + + memory.vector_store = FallbackStore() + assert notices._get_provider_memory_count(memory) == 2300 + + assert notices._extract_count({"points_count": 2400}) == 2400 + assert notices._extract_count(Info()) == 2200 + assert notices._extract_count({"count": -1}) is None + + +def test_scale_threshold_props_do_not_include_raw_user_inputs(notice_harness): + _, telemetry = notice_harness + configure_flag(telemetry, "displayed", scale_payload(top_k_copy="safe copy {top_k}")) + + notices.display_scale_threshold_notice( + MagicMock(), + "sync", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + + props = telemetry.capture_event.call_args.args[1] + assert "favorite drink" not in str(props) + assert "user_id" not in str(props) + assert "green tea" not in str(props) + + +def test_async_scale_threshold_wrapper_uses_shared_helper(monkeypatch): + calls = [] + + def display(memory_instance, sync_type, trigger_function, trigger_source, trigger_reason, top_k, memory_count, threshold): + calls.append( + (memory_instance, sync_type, trigger_function, trigger_source, trigger_reason, top_k, memory_count, threshold) + ) + + memory = MagicMock() + monkeypatch.setattr(notices, "display_scale_threshold_notice", display) + + asyncio.run( + notices.display_scale_threshold_notice_async( + memory, + "async", + "search", + "top_k", + "high_top_k", + top_k=50, + threshold=notices.SCALE_TOP_K_THRESHOLD, + ) + ) + + assert calls == [ + ( + memory, + "async", + "search", + "top_k", + "high_top_k", + 50, + None, + notices.SCALE_TOP_K_THRESHOLD, + ) + ] + + +def test_notice_priority_temporal_usage_beats_scale_and_first_run(monkeypatch): + from mem0.memory import main as memory_main + + memory = memory_main.Memory.__new__(memory_main.Memory) + memory.api_version = "v1.1" + memory.reranker = None + memory._search_vector_store = MagicMock(return_value=[]) + calls = [] + + monkeypatch.setattr(memory_main, "capture_event", lambda *args, **kwargs: None) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", lambda *args: calls.append("temporal")) + monkeypatch.setattr(memory_main, "display_scale_threshold_notice", lambda *args: calls.append("scale")) + monkeypatch.setattr(memory_main, "display_first_run_notice", lambda *args: calls.append("first_run")) + + memory_main.Memory.search( + memory, + "what happened last week?", + top_k=50, + filters={"user_id": "u1"}, + ) + + assert calls == ["temporal"] + + +def test_notice_priority_scale_beats_first_run(monkeypatch): + from mem0.memory import main as memory_main + + memory = memory_main.Memory.__new__(memory_main.Memory) + memory.api_version = "v1.1" + memory.reranker = None + memory._search_vector_store = MagicMock(return_value=[]) + calls = [] + + monkeypatch.setattr(memory_main, "capture_event", lambda *args, **kwargs: None) + monkeypatch.setattr(memory_main, "display_scale_threshold_notice", lambda *args: calls.append("scale")) + monkeypatch.setattr(memory_main, "display_first_run_notice", lambda *args: calls.append("first_run")) + + memory_main.Memory.search( + memory, + "favorite drink", + top_k=50, + filters={"user_id": "u1"}, + ) + + assert calls == ["scale"] diff --git a/tests/memory/test_performance_slow_query_notice.py b/tests/memory/test_performance_slow_query_notice.py new file mode 100644 index 000000000..6e1f49c3b --- /dev/null +++ b/tests/memory/test_performance_slow_query_notice.py @@ -0,0 +1,445 @@ +from copy import deepcopy +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mem0.memory import notices +from mem0.memory import main as memory_main +from mem0.memory.main import AsyncMemory, Memory + + +class FakeFlags: + def __init__(self, variant, payload): + self.variant = variant + self.payload = payload + + def get_flag(self, key): + assert key == notices.FLAG_KEY + return self.variant + + def get_flag_payload(self, key): + assert key == notices.FLAG_KEY + return self.payload + + +@pytest.fixture(autouse=True) +def reset_notice_process_state(): + notices._performance_slow_query_capacity_reached_in_process = False + yield + notices._performance_slow_query_capacity_reached_in_process = False + + +@pytest.fixture +def notice_harness(monkeypatch): + config = {} + telemetry = MagicMock() + telemetry.user_id = "oss-user" + + def write_config(updated): + saved = deepcopy(updated) + config.clear() + config.update(saved) + + monkeypatch.setattr(notices, "_load_config", lambda: config) + monkeypatch.setattr(notices, "_write_config", write_config) + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True) + monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry) + return config, telemetry + + +def configure_flag(telemetry, variant, payload): + flags = FakeFlags(variant, payload) + telemetry.posthog.evaluate_flags.return_value = flags + return flags + + +def performance_payload(copy="Performance CTA", enabled=True, notice_type="log_line"): + payload = { + "notices": { + "performance_slow_query": { + "enabled": enabled, + "notice_type": notice_type, + } + } + } + if copy is not None: + payload["notices"]["performance_slow_query"]["copy"] = copy + return payload + + +def make_sync_memory(search_results=None): + memory = Memory.__new__(Memory) + memory.api_version = "v1.1" + memory.reranker = None + memory._search_vector_store = MagicMock(return_value=search_results or []) + return memory + + +def make_async_memory(search_results=None): + memory = AsyncMemory.__new__(AsyncMemory) + memory.api_version = "v1.1" + memory.reranker = None + memory._search_vector_store = AsyncMock(return_value=search_results or []) + return memory + + +def test_sync_slow_search_triggers_performance_notice_after_success(monkeypatch): + results = [{"id": "m1"}, {"id": "m2"}] + memory = make_sync_memory(search_results=results) + performance_notice = MagicMock() + temporal_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1])) + monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + result = Memory.search(memory, "favorite drink", filters={"user_id": "u1"}, top_k=3) + + assert result == {"results": results} + memory._search_vector_store.assert_called_once() + performance_notice.assert_called_once_with(memory, "sync", "search", pytest.approx(2.1), 3, 2) + temporal_notice.assert_not_called() + first_run_notice.assert_not_called() + + +def test_sync_fast_search_uses_first_run_notice(monkeypatch): + memory = make_sync_memory() + performance_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 101.0])) + monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", MagicMock()) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + Memory.search(memory, "favorite drink", filters={"user_id": "u1"}) + + performance_notice.assert_not_called() + first_run_notice.assert_called_once_with(memory, "sync", "search") + + +def test_sync_failed_search_does_not_trigger_performance_notice(monkeypatch): + memory = make_sync_memory() + memory._search_vector_store.side_effect = RuntimeError("search failure") + performance_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(return_value=100.0)) + monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", MagicMock()) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + with pytest.raises(RuntimeError, match="search failure"): + Memory.search(memory, "favorite drink", filters={"user_id": "u1"}) + + performance_notice.assert_not_called() + first_run_notice.assert_not_called() + + +def test_sync_temporal_usage_takes_precedence_over_slow_search(monkeypatch): + memory = make_sync_memory() + performance_notice = MagicMock() + temporal_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1])) + monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + Memory.search(memory, "what happened last week?", filters={"user_id": "u1"}) + + temporal_notice.assert_called_once_with(memory, "sync", "search", "query", "relative_phrase") + performance_notice.assert_not_called() + first_run_notice.assert_not_called() + + +def test_sync_scale_takes_precedence_over_slow_search(monkeypatch): + memory = make_sync_memory() + performance_notice = MagicMock() + scale_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1])) + monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice) + monkeypatch.setattr(memory_main, "display_scale_threshold_notice", scale_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + Memory.search(memory, "favorite drink", filters={"user_id": "u1"}, top_k=50) + + scale_notice.assert_called_once_with( + memory, + "sync", + "search", + "top_k", + "high_top_k", + 50, + None, + notices.SCALE_TOP_K_THRESHOLD, + ) + performance_notice.assert_not_called() + first_run_notice.assert_not_called() + + +@pytest.mark.asyncio +async def test_async_slow_search_triggers_performance_notice_after_success(monkeypatch): + results = [{"id": "m1"}] + memory = make_async_memory(search_results=results) + performance_notice = AsyncMock() + temporal_notice = AsyncMock() + first_run_notice = AsyncMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1])) + monkeypatch.setattr(memory_main, "display_performance_slow_query_notice_async", performance_notice) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + result = await AsyncMemory.search(memory, "favorite drink", filters={"user_id": "u1"}, top_k=4) + + assert result == {"results": results} + memory._search_vector_store.assert_awaited_once() + performance_notice.assert_awaited_once_with(memory, "async", "search", pytest.approx(2.1), 4, 1) + temporal_notice.assert_not_awaited() + first_run_notice.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_async_fast_search_uses_first_run_notice(monkeypatch): + memory = make_async_memory() + performance_notice = AsyncMock() + first_run_notice = AsyncMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 101.0])) + monkeypatch.setattr(memory_main, "display_performance_slow_query_notice_async", performance_notice) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", AsyncMock()) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + await AsyncMemory.search(memory, "favorite drink", filters={"user_id": "u1"}) + + performance_notice.assert_not_awaited() + first_run_notice.assert_awaited_once_with(memory, "async", "search") + + +@pytest.mark.asyncio +async def test_async_failed_search_does_not_trigger_performance_notice(monkeypatch): + memory = make_async_memory() + memory._search_vector_store.side_effect = RuntimeError("search failure") + performance_notice = AsyncMock() + first_run_notice = AsyncMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(return_value=100.0)) + monkeypatch.setattr(memory_main, "display_performance_slow_query_notice_async", performance_notice) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", AsyncMock()) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + with pytest.raises(RuntimeError, match="search failure"): + await AsyncMemory.search(memory, "favorite drink", filters={"user_id": "u1"}) + + performance_notice.assert_not_awaited() + first_run_notice.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_async_temporal_usage_takes_precedence_over_slow_search(monkeypatch): + memory = make_async_memory() + performance_notice = AsyncMock() + temporal_notice = AsyncMock() + first_run_notice = AsyncMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1])) + monkeypatch.setattr(memory_main, "display_performance_slow_query_notice_async", performance_notice) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + await AsyncMemory.search(memory, "what happened last week?", filters={"user_id": "u1"}) + + temporal_notice.assert_awaited_once_with(memory, "async", "search", "query", "relative_phrase") + performance_notice.assert_not_awaited() + first_run_notice.assert_not_awaited() + + +def test_performance_slow_query_displayed_logs_and_captures_event(notice_harness, capsys): + config, telemetry = notice_harness + flags = configure_flag(telemetry, "displayed", performance_payload()) + + notices.display_performance_slow_query_notice( + MagicMock(), + "sync", + "search", + elapsed_seconds=2.345, + top_k=20, + result_count=7, + ) + + assert capsys.readouterr().err == "Performance CTA\n" + telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) + telemetry.capture_event.assert_called_once() + event_name, props = telemetry.capture_event.call_args.args + assert event_name == notices.NOTICE_EVENT + assert props["notice_id"] == "performance_slow_query" + assert props["notice_type"] == "log_line" + assert props["variant"] == "displayed" + assert props["displayed"] is True + assert props["payload"] == "Performance CTA" + assert props["bypass_reason"] is None + assert props["disabled_reason"] is None + assert props["notice_config_found"] is True + assert props["sync_type"] == "sync" + assert props["trigger_function"] == "search" + assert props["trigger_reason"] == "slow_query" + assert props["elapsed_ms"] == 2345 + assert props["threshold_ms"] == 2000 + assert props["top_k"] == 20 + assert props["result_count"] == 7 + assert telemetry.capture_event.call_args.kwargs["flags"] is flags + assert len(config["notice_state"]["performance_slow_query"]["events"]) == 1 + + +def test_performance_slow_query_holdout_is_silent_but_captures_event(notice_harness, capsys): + _, telemetry = notice_harness + configure_flag(telemetry, "holdout", performance_payload()) + + notices.display_performance_slow_query_notice( + MagicMock(), + "sync", + "search", + elapsed_seconds=2.1, + top_k=10, + result_count=2, + ) + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == "holdout" + assert props["trigger_reason"] == "slow_query" + + +@pytest.mark.parametrize( + ("payload", "expected_reason", "expected_found"), + [ + ({}, "missing_notice_config", False), + ({"notices": {}}, "missing_notice_config", False), + ({"notices": "not-an-object"}, "missing_notice_config", False), + (performance_payload(copy=None), "missing_copy", True), + (performance_payload(enabled=False, copy="hidden"), "payload_disabled", True), + ], +) +def test_performance_slow_query_bad_or_disabled_payload_is_silent_and_safe( + notice_harness, payload, expected_reason, expected_found, capsys +): + _, telemetry = notice_harness + configure_flag(telemetry, "displayed", payload) + + notices.display_performance_slow_query_notice( + MagicMock(), + "sync", + "search", + elapsed_seconds=2.1, + top_k=10, + result_count=2, + ) + + assert capsys.readouterr().err == "" + props = telemetry.capture_event.call_args.args[1] + assert props["displayed"] is False + assert props["bypass_reason"] == expected_reason + assert props["notice_config_found"] is expected_found + + +@pytest.mark.parametrize("variant", [None, False]) +def test_performance_slow_query_blunt_flag_disable_does_not_capture_or_consume( + notice_harness, variant, capsys +): + config, telemetry = notice_harness + configure_flag(telemetry, variant, performance_payload()) + + notices.display_performance_slow_query_notice( + MagicMock(), + "sync", + "search", + elapsed_seconds=2.1, + top_k=10, + result_count=2, + ) + + assert capsys.readouterr().err == "" + telemetry.capture_event.assert_not_called() + assert config.get("notice_state") is None + + +def test_performance_slow_query_telemetry_disabled_does_not_touch_posthog_or_state( + monkeypatch, capsys +): + load_config = MagicMock(return_value={}) + write_config = MagicMock() + get_telemetry = MagicMock() + + monkeypatch.setattr(notices, "_load_config", load_config) + monkeypatch.setattr(notices, "_write_config", write_config) + monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", False) + monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", get_telemetry) + + notices.display_performance_slow_query_notice( + MagicMock(), + "sync", + "search", + elapsed_seconds=2.1, + top_k=10, + result_count=2, + ) + + load_config.assert_not_called() + write_config.assert_not_called() + get_telemetry.assert_not_called() + assert capsys.readouterr().err == "" + + +def test_performance_slow_query_cap_blocks_before_posthog_eval(notice_harness, capsys): + config, telemetry = notice_harness + configure_flag(telemetry, "displayed", performance_payload()) + + for _ in range(notices.PERFORMANCE_SLOW_QUERY_CAP): + notices.display_performance_slow_query_notice( + MagicMock(), + "sync", + "search", + elapsed_seconds=2.1, + top_k=10, + result_count=2, + ) + + notices.display_performance_slow_query_notice( + MagicMock(), + "sync", + "search", + elapsed_seconds=2.1, + top_k=10, + result_count=2, + ) + + assert capsys.readouterr().err == "Performance CTA\n" * notices.PERFORMANCE_SLOW_QUERY_CAP + assert telemetry.posthog.evaluate_flags.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP + assert telemetry.capture_event.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP + assert len(config["notice_state"]["performance_slow_query"]["events"]) == notices.PERFORMANCE_SLOW_QUERY_CAP + + +def test_performance_slow_query_props_do_not_include_raw_user_inputs(notice_harness): + _, telemetry = notice_harness + configure_flag(telemetry, "displayed", performance_payload(copy="safe copy")) + + notices.display_performance_slow_query_notice( + MagicMock(), + "sync", + "search", + elapsed_seconds=2.1, + top_k=10, + result_count=2, + ) + + props = telemetry.capture_event.call_args.args[1] + assert "favorite drink" not in str(props) + assert "user_id" not in str(props) + assert "green tea" not in str(props) diff --git a/tests/memory/test_temporal_feature_notice.py b/tests/memory/test_temporal_feature_notice.py new file mode 100644 index 000000000..bca2c9a50 --- /dev/null +++ b/tests/memory/test_temporal_feature_notice.py @@ -0,0 +1,96 @@ +import pytest + +from mem0.memory import main as memory_main +from mem0.memory.main import AsyncMemory, Memory + + +def test_sync_add_timestamp_raises_before_validation(monkeypatch): + calls = [] + + def get_error(sync_type, trigger_function, trigger_parameter): + calls.append((sync_type, trigger_function, trigger_parameter)) + return "blocked timestamp" + + monkeypatch.setattr(memory_main, "get_temporal_feature_error_message", get_error) + + with pytest.raises(ValueError, match="blocked timestamp"): + Memory.add(Memory.__new__(Memory), "hello", timestamp=123) + + assert calls == [("sync", "add", "timestamp")] + + +def test_sync_search_reference_date_raises_before_validation(monkeypatch): + calls = [] + + def get_error(sync_type, trigger_function, trigger_parameter): + calls.append((sync_type, trigger_function, trigger_parameter)) + return "blocked reference date" + + monkeypatch.setattr(memory_main, "get_temporal_feature_error_message", get_error) + + with pytest.raises(ValueError, match="blocked reference date"): + Memory.search(Memory.__new__(Memory), "what happened last week?", reference_date="2025-03-21") + + assert calls == [("sync", "search", "reference_date")] + + +@pytest.mark.asyncio +async def test_async_add_timestamp_raises_before_validation(monkeypatch): + calls = [] + + async def get_error(sync_type, trigger_function, trigger_parameter): + calls.append((sync_type, trigger_function, trigger_parameter)) + return "blocked async timestamp" + + monkeypatch.setattr(memory_main, "get_temporal_feature_error_message_async", get_error) + + with pytest.raises(ValueError, match="blocked async timestamp"): + await AsyncMemory.add(AsyncMemory.__new__(AsyncMemory), "hello", timestamp=123) + + assert calls == [("async", "add", "timestamp")] + + +@pytest.mark.asyncio +async def test_async_search_reference_date_raises_before_validation(monkeypatch): + calls = [] + + async def get_error(sync_type, trigger_function, trigger_parameter): + calls.append((sync_type, trigger_function, trigger_parameter)) + return "blocked async reference date" + + monkeypatch.setattr(memory_main, "get_temporal_feature_error_message_async", get_error) + + with pytest.raises(ValueError, match="blocked async reference date"): + await AsyncMemory.search( + AsyncMemory.__new__(AsyncMemory), + "what happened last week?", + reference_date="2025-03-21", + ) + + assert calls == [("async", "search", "reference_date")] + + +def test_sync_add_without_timestamp_does_not_call_temporal_feature_notice(monkeypatch): + get_error = monkeypatch.setattr( + memory_main, + "get_temporal_feature_error_message", + lambda *args: pytest.fail("temporal feature notice should not run"), + ) + + with pytest.raises(Exception, match="At least one of 'user_id', 'agent_id', or 'run_id'"): + Memory.add(Memory.__new__(Memory), "hello") + + assert get_error is None + + +def test_sync_search_without_reference_date_does_not_call_temporal_feature_notice(monkeypatch): + get_error = monkeypatch.setattr( + memory_main, + "get_temporal_feature_error_message", + lambda *args: pytest.fail("temporal feature notice should not run"), + ) + + with pytest.raises(ValueError, match="filters must contain"): + Memory.search(Memory.__new__(Memory), "hello") + + assert get_error is None diff --git a/tests/memory/test_temporal_usage_notice.py b/tests/memory/test_temporal_usage_notice.py new file mode 100644 index 000000000..07c18c244 --- /dev/null +++ b/tests/memory/test_temporal_usage_notice.py @@ -0,0 +1,202 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mem0.memory import main as memory_main +from mem0.memory.main import AsyncMemory, Memory + + +def make_sync_memory(): + memory = Memory.__new__(Memory) + memory.config = SimpleNamespace(llm=SimpleNamespace(config={})) + memory.api_version = "v1.1" + memory.reranker = None + memory._add_to_vector_store = MagicMock(return_value=[]) + memory._search_vector_store = MagicMock(return_value=[]) + return memory + + +def make_async_memory(): + memory = AsyncMemory.__new__(AsyncMemory) + memory.config = SimpleNamespace(llm=SimpleNamespace(config={})) + memory.api_version = "v1.1" + memory.reranker = None + memory._add_to_vector_store = AsyncMock(return_value=[]) + memory._search_vector_store = AsyncMock(return_value=[]) + return memory + + +def test_sync_add_temporal_metadata_triggers_notice_after_success(monkeypatch): + memory = make_sync_memory() + temporal_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + result = Memory.add( + memory, + "The user visited Paris.", + user_id="u1", + metadata={"event_date": "2025-04-09"}, + infer=False, + ) + + assert result == {"results": []} + memory._add_to_vector_store.assert_called_once() + temporal_notice.assert_called_once_with(memory, "sync", "add", "metadata", "date_like_metadata") + first_run_notice.assert_not_called() + + +def test_sync_add_non_temporal_metadata_uses_first_run_notice(monkeypatch): + memory = make_sync_memory() + temporal_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + Memory.add(memory, "The user likes tea.", user_id="u1", metadata={"topic": "drink"}, infer=False) + + temporal_notice.assert_not_called() + first_run_notice.assert_called_once_with(memory, "sync", "add") + + +def test_sync_add_failure_does_not_trigger_temporal_usage_notice(monkeypatch): + memory = make_sync_memory() + memory._add_to_vector_store.side_effect = RuntimeError("vector failure") + temporal_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + with pytest.raises(RuntimeError, match="vector failure"): + Memory.add( + memory, + "The user visited Paris.", + user_id="u1", + metadata={"event_date": "2025-04-09"}, + infer=False, + ) + + temporal_notice.assert_not_called() + first_run_notice.assert_not_called() + + +def test_sync_search_temporal_query_triggers_notice_after_success(monkeypatch): + memory = make_sync_memory() + temporal_notice = MagicMock() + first_run_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice) + + result = Memory.search(memory, "what happened last week?", filters={"user_id": "u1"}) + + assert result == {"results": []} + memory._search_vector_store.assert_called_once() + temporal_notice.assert_called_once_with(memory, "sync", "search", "query", "relative_phrase") + first_run_notice.assert_not_called() + + +def test_sync_search_temporal_filter_triggers_notice_after_success(monkeypatch): + memory = make_sync_memory() + temporal_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", MagicMock()) + + Memory.search( + memory, + "favorite drink", + filters={"user_id": "u1", "created_at": {"gte": "2025-04-01"}}, + ) + + temporal_notice.assert_called_once_with(memory, "sync", "search", "filter", "date_range_filter") + + +def test_sync_search_failure_does_not_trigger_temporal_usage_notice(monkeypatch): + memory = make_sync_memory() + memory._search_vector_store.side_effect = RuntimeError("search failure") + temporal_notice = MagicMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice", MagicMock()) + + with pytest.raises(RuntimeError, match="search failure"): + Memory.search(memory, "what happened last week?", filters={"user_id": "u1"}) + + temporal_notice.assert_not_called() + + +@pytest.mark.asyncio +async def test_async_add_temporal_metadata_triggers_notice_after_success(monkeypatch): + memory = make_async_memory() + temporal_notice = AsyncMock() + first_run_notice = AsyncMock() + monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + result = await AsyncMemory.add( + memory, + "The user visited Paris.", + user_id="u1", + metadata={"event_date": "2025-04-09"}, + infer=False, + ) + + assert result == {"results": []} + memory._add_to_vector_store.assert_awaited_once() + temporal_notice.assert_awaited_once_with(memory, "async", "add", "metadata", "date_like_metadata") + first_run_notice.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_async_add_runs_scale_detection_in_thread(monkeypatch): + memory = make_async_memory() + scale_detector = MagicMock(return_value=("memory_count", "memory_count_threshold", None, 2000, 2000)) + scale_notice = AsyncMock() + first_run_notice = AsyncMock() + to_thread_calls = [] + + async def to_thread(fn, *args, **kwargs): + to_thread_calls.append((fn, args, kwargs)) + return fn(*args, **kwargs) + + monkeypatch.setattr(memory_main, "detect_scale_threshold_from_add_result", scale_detector) + monkeypatch.setattr(memory_main.asyncio, "to_thread", to_thread) + monkeypatch.setattr(memory_main, "display_scale_threshold_notice_async", scale_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + result = await AsyncMemory.add(memory, "The user likes tea.", user_id="u1", infer=False) + + assert result == {"results": []} + assert to_thread_calls == [(scale_detector, (memory, []), {})] + scale_detector.assert_called_once_with(memory, []) + scale_notice.assert_awaited_once_with( + memory, + "async", + "add", + "memory_count", + "memory_count_threshold", + None, + 2000, + 2000, + ) + first_run_notice.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_async_search_temporal_query_triggers_notice_after_success(monkeypatch): + memory = make_async_memory() + temporal_notice = AsyncMock() + first_run_notice = AsyncMock() + monkeypatch.setattr(memory_main, "capture_event", MagicMock()) + monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", temporal_notice) + monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice) + + result = await AsyncMemory.search(memory, "what happened last week?", filters={"user_id": "u1"}) + + assert result == {"results": []} + memory._search_vector_store.assert_awaited_once() + temporal_notice.assert_awaited_once_with(memory, "async", "search", "query", "relative_phrase") + first_run_notice.assert_not_awaited() diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 3e9963d7e..fb7001a62 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -70,6 +70,10 @@ class TestTelemetryEnabled: with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"): at = telemetry_module.AnonymousTelemetry() mock_posthog.assert_called_once() + assert ( + mock_posthog.call_args.kwargs["feature_flags_request_timeout_seconds"] + == telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS + ) assert at.posthog is not None assert at.user_id == "test-user" @@ -84,6 +88,22 @@ class TestTelemetryEnabled: telemetry_module.capture_event("test.event", mock_memory) mock_at.capture_event.assert_called_once() + def test_anonymous_capture_event_passes_flags_to_posthog(self): + """capture_event() should use PostHog's event-first API and preserve flag snapshots.""" + flags = MagicMock() + with patch.object(telemetry_module, "MEM0_TELEMETRY", True): + with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls: + with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"): + at = telemetry_module.AnonymousTelemetry() + at.capture_event("test.event", {"key": "value"}, flags=flags) + + mock_posthog_cls.return_value.capture.assert_called_once() + args, kwargs = mock_posthog_cls.return_value.capture.call_args + assert args == ("test.event",) + assert kwargs["distinct_id"] == "test-user" + assert kwargs["flags"] is flags + assert kwargs["properties"]["key"] == "value" + def test_capture_client_event_sends_when_enabled(self): """capture_client_event() should call client_telemetry.capture_event when enabled.""" with patch.object(telemetry_module, "MEM0_TELEMETRY", True): diff --git a/tests/test_telemetry_sampling.py b/tests/test_telemetry_sampling.py index 5c4145b3c..30a670015 100644 --- a/tests/test_telemetry_sampling.py +++ b/tests/test_telemetry_sampling.py @@ -162,16 +162,22 @@ class TestBeforeSendWiring: # before_send is None (default), not _sampling_before_send assert kwargs.get("before_send") is None - def test_anonymous_telemetry_falls_back_when_posthog_rejects_before_send(self): - """If posthog (older version) rejects before_send, construction still succeeds.""" + def test_anonymous_telemetry_constructs_posthog_with_current_kwargs(self): + """AnonymousTelemetry uses the supported PostHog constructor shape.""" with patch.object(telemetry_module, "MEM0_TELEMETRY", True): with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls: with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"): - # First call (with before_send) raises TypeError; second call succeeds. - mock_posthog_cls.side_effect = [TypeError("unexpected kwarg before_send"), object()] at = telemetry_module.AnonymousTelemetry(before_send=telemetry_module._sampling_before_send) - # Constructor was called twice: once with before_send, once without - assert mock_posthog_cls.call_count == 2 + mock_posthog_cls.assert_called_once() + _, kwargs = mock_posthog_cls.call_args + assert kwargs["project_api_key"] == telemetry_module.PROJECT_API_KEY + assert kwargs["host"] == telemetry_module.HOST + assert kwargs["before_send"] is telemetry_module._sampling_before_send + assert ( + kwargs["feature_flags_request_timeout_seconds"] + == telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS + ) + assert at.user_id == "u" assert at.posthog is not None