diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-title-read-model.integration.test.ts b/App/frontend/desktop/src/pages/memory/tests/memory-title-read-model.integration.test.ts index d1fc2f4d3..9a1d64edf 100644 --- a/App/frontend/desktop/src/pages/memory/tests/memory-title-read-model.integration.test.ts +++ b/App/frontend/desktop/src/pages/memory/tests/memory-title-read-model.integration.test.ts @@ -210,11 +210,11 @@ describe("stored memory -> panel/detail -> rendered trace title", () => { expect(detailFromMemory(stored).body).toContain(input); }); - it("refreshes existing log summaries to empty without replacing missing records or span goals", () => { + it("refreshes existing log summaries to empty without replacing missing records or span goals", async () => { const { db, service } = createTestService(); const repos = new Repositories(db.db); const session = service.openSession({ namespace: { source: "codex", profileId: "default", userId: "review-log" } }); - const completed = service.completeTurn("preview-log-turn", { sessionId: session.sessionId, query: request, answer: "完成。" }); + const completed = await service.completeTurn("preview-log-turn", { sessionId: session.sessionId, query: request, answer: "完成。" }); const storedLog = db.db.prepare("SELECT output_json FROM api_logs WHERE tool_name = 'memory_add'").get() as { output_json: string }; expect(JSON.parse(storedLog.output_json).details[0].summary).toBe(""); const row = repos.memories.get(completed.l1MemoryId)!; diff --git a/Memory/src/config/index.ts b/Memory/src/config/index.ts index 4a6e722eb..75e002c7f 100644 --- a/Memory/src/config/index.ts +++ b/Memory/src/config/index.ts @@ -118,6 +118,8 @@ export interface AgentAccessConfig { export interface AlgorithmConfig { enableMemoryAdd: boolean; enableMemorySearch: boolean; + /** Controls agent-facing User Memory recall; capture and management remain available. */ + enableUserMemoryRecall: boolean; enableQueryRewrite: boolean; capture: { maxTextChars: number; @@ -384,6 +386,7 @@ export const DEFAULT_MEMMY_CONFIG: MemmyConfig = { algorithm: { enableMemoryAdd: true, enableMemorySearch: true, + enableUserMemoryRecall: false, enableQueryRewrite: false, capture: { maxTextChars: 4_000, @@ -645,6 +648,7 @@ function configFromEnv(): Record { algorithm: compactRecord({ enableMemoryAdd: booleanEnv("MEMMY_ENABLE_MEMORY_ADD"), enableMemorySearch: booleanEnv("MEMMY_ENABLE_MEMORY_SEARCH"), + enableUserMemoryRecall: booleanEnv("MEMMY_ENABLE_USER_MEMORY_RECALL"), enableQueryRewrite: booleanEnv("MEMMY_ENABLE_QUERY_REWRITE"), retrieval: compactRecord({ readOnlyInjectionProfile: @@ -1161,6 +1165,7 @@ function normalizeAlgorithm(input: Record): AlgorithmConfig { return { enableMemoryAdd: booleanValue(input.enableMemoryAdd, DEFAULT_MEMMY_CONFIG.algorithm.enableMemoryAdd), enableMemorySearch: booleanValue(input.enableMemorySearch, DEFAULT_MEMMY_CONFIG.algorithm.enableMemorySearch), + enableUserMemoryRecall: booleanValue(input.enableUserMemoryRecall, DEFAULT_MEMMY_CONFIG.algorithm.enableUserMemoryRecall), enableQueryRewrite: booleanValue(input.enableQueryRewrite, DEFAULT_MEMMY_CONFIG.algorithm.enableQueryRewrite), capture: { maxTextChars: numberValue(capture.maxTextChars, DEFAULT_MEMMY_CONFIG.algorithm.capture.maxTextChars), diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts index 2255cef17..fb91f6aeb 100644 --- a/Memory/src/service/memory-service.ts +++ b/Memory/src/service/memory-service.ts @@ -1297,8 +1297,13 @@ export class MemoryService { if (!this.memoryAddEnabled()) { return scopedRun(); } + // Recall responses depend on this gate. Do not replay a pre-gate response + // or a response produced while the opposite setting was active. + const recallPolicyKey = operation === "turn.start" || operation === "memory.search" + ? `:user-memory-recall:${this.config.algorithm.enableUserMemoryRecall === true}` + : ""; const idempotencyKey = request.adapterId && request.requestId - ? `${operation}:${request.adapterId}:${request.requestId}` + ? `${operation}:${request.adapterId}:${request.requestId}${recallPolicyKey}` : undefined; if (!idempotencyKey) { return scopedRun(); @@ -4152,6 +4157,7 @@ function memoryConfigLogFields(config: MemmyConfig): Record { embeddingMode: config.embedding.mode, memoryAddEnabled: config.algorithm.enableMemoryAdd, memorySearchEnabled: config.algorithm.enableMemorySearch, + userMemoryRecallEnabled: config.algorithm.enableUserMemoryRecall, summaryModel: { provider: config.summary.provider, vendor: config.summary.vendor, diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts index 2a0e9624b..2aabe0946 100644 --- a/Memory/src/service/retrieval/retrieval-service.ts +++ b/Memory/src/service/retrieval/retrieval-service.ts @@ -1932,6 +1932,9 @@ export class RetrievalService { ? requestedSemanticLayers.filter((layer) => layer !== "L1") : requestedSemanticLayers; const searchAt = Date.now(); + const userMemoryRecallEnabled = this.deps.config.algorithm.enableUserMemoryRecall === true; + // Capture uses these candidates to confirm or correct existing User Memory, + // even when it must not be returned to an agent. const includeUserMemory = !onboardingFirstReportHit && semanticLayers.includes("L1"); const userMemoryCount = includeUserMemory ? this.deps.repos.userMemories.listActive(context.userId).length @@ -2022,7 +2025,9 @@ export class RetrievalService { const agentHits = onboardingFirstReportHit || timeFilter ? retrieval.hits : filterL1TraceSpanRecallHits(retrieval.hits, memories); - const merged = mergeSameTurnRecallHits(agentHits, memories, userMemoryOutput.hits); + const merged = mergeSameTurnRecallHits( + agentHits, memories, userMemoryRecallEnabled ? userMemoryOutput.hits : [] + ); const rerankAt = Date.now(); const filteredHits = onboardingFirstReportHit ? { hits: retrieval.hits, status: ["first_report_handoff:latest_only"] } @@ -2060,7 +2065,7 @@ export class RetrievalService { .map((memory) => memory.id); const candidateMemoryIds = uniq([ ...memories.map((memory) => memory.id), - ...userMemoryCandidateIds + ...(userMemoryRecallEnabled ? userMemoryCandidateIds : []) ]); const sourceMemoryIds = contextPacket.sourceMemoryIds; const hitIds = new Set(hits.flatMap((hit) => hit.memberMemoryIds ?? [hit.id])); @@ -2075,7 +2080,7 @@ export class RetrievalService { memoryLayer: memory.memoryLayer, reason: "rank_threshold" as const })), - ...userMemoryOutput.memories + ...(userMemoryRecallEnabled ? userMemoryOutput.memories : []) .filter((memory) => !hitIds.has(memory.id)) .slice(0, 50) .map((memory) => ({ @@ -2145,6 +2150,7 @@ export class RetrievalService { }, status: uniq([ ...filteredHits.status, + ...(!userMemoryRecallEnabled ? ["user_memory_recall:disabled"] : []), ...(dynamicCurrentQuery ? ["dynamic_current:refresh_required"] : []), ...(stalePolicyIds.size > 0 ? ["policy:revalidation_required"] : []), ...(!this.deps.memoryAddEnabled() ? ["memory_add:disabled:no_recall_log"] : []) diff --git a/Memory/src/storage/queue-scheduling-store.ts b/Memory/src/storage/queue-scheduling-store.ts index a38b24a1f..e43d4aeea 100644 --- a/Memory/src/storage/queue-scheduling-store.ts +++ b/Memory/src/storage/queue-scheduling-store.ts @@ -47,6 +47,9 @@ export interface PolicyRefreshResult { /** A metadata projection and read-only candidate adapter; never claims or completes jobs. */ export class QueueSchedulingStore { + // Cache statements, never eligibility results; each store owns one connection. + private readonly eligibilityStatements = new Map(); + constructor(private readonly db: Database.Database) {} getDeadlineRevision(): { revision: number; rescanRevision: number } { @@ -68,8 +71,20 @@ export class QueueSchedulingStore { const condition = candidate.queue === "main" ? mainQueueEligibility(at, options) : retryQueueEligibility(Date.parse(at), options.targetMemoryIds); const table = candidate.queue === "main" ? "evolution_jobs" : "embedding_retry_queue q"; - return !!this.db.prepare(`SELECT 1 FROM ${table} WHERE ${condition.sql} AND id = ? AND enqueue_seq = ?`) - .get(...condition.params, candidate.id, candidate.enqueueSeq); + const sql = `SELECT 1 FROM ${table} WHERE ${condition.sql} AND id = ? AND enqueue_seq = ?`; + let statement = this.eligibilityStatements.get(sql); + if (statement) { + this.eligibilityStatements.delete(sql); + } else { + statement = this.db.prepare(sql); + // Filters can produce different placeholder counts. Keep the cache bounded. + if (this.eligibilityStatements.size >= 32) { + const oldest = this.eligibilityStatements.keys().next().value; + if (oldest !== undefined) this.eligibilityStatements.delete(oldest); + } + } + this.eligibilityStatements.set(sql, statement); + return !!statement.get(...condition.params, candidate.id, candidate.enqueueSeq); } readSnapshot(read: () => T): T { diff --git a/Memory/tests/config.test.ts b/Memory/tests/config.test.ts index 22f64bda0..0991b5454 100644 --- a/Memory/tests/config.test.ts +++ b/Memory/tests/config.test.ts @@ -78,6 +78,7 @@ describe("memmy memory config", () => { expect(loadMemmyConfig(configPath).config.algorithm.enableMemoryAdd).toBe(true); expect(loadMemmyConfig(configPath).config.algorithm.enableMemorySearch).toBe(true); + expect(loadMemmyConfig(configPath).config.algorithm.enableUserMemoryRecall).toBe(false); expect(loadMemmyConfig(configPath).config.algorithm.enableQueryRewrite).toBe(false); expect(loadMemmyConfig(configPath).config.algorithm).not.toHaveProperty("lightweightMemory"); expect(loadMemmyConfig(configPath).config).not.toHaveProperty("logging"); @@ -105,6 +106,28 @@ describe("memmy memory config", () => { }); }); + it.each([undefined, null, "true", 1, {}, false, true])( + "only enables User Memory recall for an explicit boolean true (%j)", (enableUserMemoryRecall) => { + const configPath = join(tempRoot(), "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + memmyMemory: { algorithm: { enableUserMemoryRecall } } + })); + expect(loadMemmyConfig(configPath).config.algorithm.enableUserMemoryRecall) + .toBe(enableUserMemoryRecall === true); + } + ); + + it("allows the environment to disable and re-enable User Memory recall", () => { + const configPath = join(tempRoot(), "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + memmyMemory: { algorithm: { enableUserMemoryRecall: true } } + })); + setEnv("MEMMY_ENABLE_USER_MEMORY_RECALL", "false"); + expect(loadMemmyConfig(configPath).config.algorithm.enableUserMemoryRecall).toBe(false); + setEnv("MEMMY_ENABLE_USER_MEMORY_RECALL", "true"); + expect(loadMemmyConfig(configPath).config.algorithm.enableUserMemoryRecall).toBe(true); + }); + it("reads query extract history limits from retrieval config and defaults them", () => { const root = tempRoot(); const configPath = join(root, "config.yaml"); diff --git a/Memory/tests/fixtures/memory-service-fixture.ts b/Memory/tests/fixtures/memory-service-fixture.ts index 0d14f42f6..6e25e36d7 100644 --- a/Memory/tests/fixtures/memory-service-fixture.ts +++ b/Memory/tests/fixtures/memory-service-fixture.ts @@ -183,6 +183,7 @@ export function addAgentSourceImport( export function configWithMemoryGates(gates: { enableMemoryAdd?: boolean; enableMemorySearch?: boolean; + enableUserMemoryRecall?: boolean; enableQueryRewrite?: boolean; }): typeof DEFAULT_MEMMY_CONFIG { return { diff --git a/Memory/tests/service/user-memory/user-memory-recall.test.ts b/Memory/tests/service/user-memory/user-memory-recall.test.ts new file mode 100644 index 000000000..9aee124ee --- /dev/null +++ b/Memory/tests/service/user-memory/user-memory-recall.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULT_MEMMY_CONFIG, createMemoryHttpServer } from "../../../src/index.js"; +import { buildUserMemory } from "../../../src/service/user-memory/user-memory.js"; +import { Repositories } from "../../../src/storage/repositories.js"; +import { stableHash } from "../../../src/utils/id.js"; +import { insertActivePolicyMemory } from "../../fixtures/evolution-fixture.js"; +import { + configWithMemoryGates, + createFailingLlm, + createMemoryServiceFixture +} from "../../fixtures/memory-service-fixture.js"; + +const { cleanup, createTestService } = createMemoryServiceFixture(); +afterEach(cleanup); + +const userId = "user-memory-recall-user"; +const userMemoryId = "user_memory_recall_gate"; +const query = "我最喜欢的水果 苹果"; +const sources = ["memmy-agent", "codex", "claude_code", "deepseek_harness", "workbuddy", "cursor", "openclaw", "opencode", "hermes"]; + +function seedUserMemory(db: ReturnType["db"]) { + const repos = new Repositories(db.db); + repos.userMemories.upsertExact(buildUserMemory({ + id: userMemoryId, + userId, + sourceTurnId: "raw_preference", + memoryTypes: ["User Preference"], + content: "我最喜欢的水果是苹果", + createdAt: "2026-10-01T00:00:00.000Z" + })); + return repos; +} + +describe("User Memory recall gate", () => { + it.each([ + { mode: "search", poolSize: 12, includeL1: true, expectedCandidates: 8 }, + { mode: "turn_start", poolSize: 12, includeL1: true, expectedCandidates: 8 }, + { mode: "search", poolSize: 2, includeL1: true, expectedCandidates: 2 }, + { mode: "turn_start", poolSize: 2, includeL1: true, expectedCandidates: 2 }, + { mode: "search", poolSize: 12, includeL1: false, expectedCandidates: 5 }, + { mode: "turn_start", poolSize: 12, includeL1: false, expectedCandidates: 5 } + ] as const)("preserves the Agent candidate budget across gate changes: $mode, pool=$poolSize, L1=$includeL1", async ({ mode, poolSize, includeL1, expectedCandidates }) => { + let config = configWithMemoryGates({ enableUserMemoryRecall: false }); + config.algorithm.retrieval = { + ...config.algorithm.retrieval, + tier1TopK: 0, + tier2TopK: 5, + tier3TopK: 0, + relativeThresholdFloor: 0, + minRecallScore: 0, + smartSeed: false, + llmFilterEnabled: false + }; + const { db, service } = createTestService({ config, configLoader: () => ({ config }) }); + const namespace = { source: "codex", profileId: "default", userId }; + const { sessionId } = service.openSession({ namespace }); + seedUserMemory(db); + for (let index = 0; index < poolSize; index += 1) { + insertActivePolicyMemory(db, { + id: `policy_recall_quota_${index}`, + userId, + sessionId, + agentId: namespace.source, + appId: "memmy", + profileId: namespace.profileId, + sourceTraceId: `trace_quota_${index}`, + sourceEpisodeId: `episode_quota_${index}` + }); + } + for (const enabled of [false, true, false]) { + config = { ...config, algorithm: { ...config.algorithm, enableUserMemoryRecall: enabled } }; + expect(service.reloadConfig({ restartFailedProcessing: false }).requiresRestart).toBe(false); + const request = { + sessionId, + query: "python pytest failure inspection retry", + layers: includeL1 ? ["L1", "L2"] as const : ["L2"] as const + }; + const result = mode === "turn_start" + ? await service.startTurn({ ...request, layers: [...request.layers] }) + : await service.search({ ...request, layers: [...request.layers] }); + const log = service.apiLogs({ tools: ["memory_search"], limit: 1 }).logs[0]!; + const output = JSON.parse(log.outputJson) as { candidates: Array<{ tier: string }> }; + expect(output.candidates.filter((candidate) => candidate.tier === "L2")).toHaveLength(expectedCandidates); + expect(result.hits.length).toBeLessThanOrEqual(5); + if (!enabled) { + expect(result.hits).toHaveLength(Math.min(poolSize, 5)); + expect(JSON.stringify(result)).not.toContain(userMemoryId); + expect(output.candidates).toHaveLength(expectedCandidates); + } + } + await service.stop(); + }); + + it.each(sources)("applies to turn start and both search formats for %s", async (source) => { + let config = configWithMemoryGates({}); + const { db, service } = createTestService({ config, configLoader: () => ({ config }) }); + seedUserMemory(db); + const namespace = { source, profileId: "default", userId }; + const { sessionId } = service.openSession({ namespace }); + const server = createMemoryHttpServer({ service }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected TCP address"); + const post = async (path: string, extra: Record) => { + const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ namespace, sessionId, query, ...extra }) + }); + expect(response.status).toBe(200); + return await response.json(); + }; + for (const enabled of [false, true, false]) { + config = configWithMemoryGates({ enableUserMemoryRecall: enabled }); + expect(service.reloadConfig({ restartFailedProcessing: false }).requiresRestart).toBe(false); + const results = [ + await post("turns/start", { turnId: `turn-${enabled}` }), + await post("memory/search", {}), + await post("memory/search", { verbose: true }) + ]; + for (const result of results) { + if (enabled) { + expect(JSON.stringify(result)).toContain(userMemoryId); + expect(JSON.stringify(result)).toContain("## User Memories"); + } else { + expect(JSON.stringify(result)).not.toContain(userMemoryId); + expect(JSON.stringify(result)).not.toContain("## User Memories"); + expect(JSON.stringify(result)).not.toContain("我最喜欢的水果是苹果"); + } + } + } + expect(service.panelItems({ namespace, layer: "UserMemory" }).items) + .toEqual([expect.objectContaining({ id: userMemoryId })]); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + await service.stop(); + } + }); + + it("keeps same-turn L1 recall and internal maintenance candidates when disabled", async () => { + const { db, service } = createTestService(); + const namespace = { source: "codex", profileId: "default", userId }; + const writer = service.openSession({ namespace }); + const completed = await service.completeTurn("code-feedback", { + sessionId: writer.sessionId, + query: "你刚才写了很多兜底代码,我更喜欢简洁的代码,以后不要写不必要的兜底代码", + answer: "已精简实现并通过测试。" + }); + await service.runWorkerOnce(50); + const reader = service.openSession({ namespace }); + const userIdForTurn = completed.userMemoryIds[0]!; + const l1Id = completed.l1MemoryIds[0]!; + expect(userIdForTurn).toBeTruthy(); + expect(l1Id).toBeTruthy(); + const request = { sessionId: reader.sessionId, query: "简洁代码 不必要兜底代码" }; + const results = [await service.search(request), await service.startTurn({ ...request, turnId: "read-code-feedback" })]; + for (const result of results) { + expect(JSON.stringify(result)).not.toContain(userIdForTurn); + expect(result.sourceMemoryIds).toContain(l1Id); + expect(result.injectedContext.markdown).toContain("## L1 Trace Memories"); + const event = new Repositories(db.db).runtime.getRecallEvent(result.searchEventId)!; + expect(event.userMemoryCandidateIds).toContain(userIdForTurn); + expect(event.candidateMemoryIds).not.toContain(userIdForTurn); + expect(event.hitMemoryIds).not.toContain(userIdForTurn); + } + await service.stop(); + }); + + it("returns no agent memories for an empty database or a User-Memory-only match", async () => { + const { db, service } = createTestService(); + const namespace = { source: "memmy-agent", profileId: "default", userId }; + const { sessionId } = service.openSession({ namespace }); + for (const seed of [false, true]) { + if (seed) seedUserMemory(db); + const result = await service.search({ sessionId, query }); + expect(result.hits).toEqual([]); + expect(result.sourceMemoryIds).toEqual([]); + expect(result.candidateMemoryIds).toEqual([]); + expect(result.injectedContext.sections).toEqual([]); + } + await service.stop(); + }); + + it("does not expose User Memory when model calls fall back", async () => { + const { db, service } = createTestService({ llm: createFailingLlm() }); + seedUserMemory(db); + const result = await service.search({ namespace: { source: "codex", profileId: "default", userId }, query }); + expect(result.hits).toEqual([]); + expect(JSON.stringify(result)).not.toContain(userMemoryId); + await service.stop(); + }); + + it.each(["turn.start", "memory.search"])("isolates legacy and opposite-gate cached %s responses", async (operation) => { + let config = configWithMemoryGates({ enableUserMemoryRecall: true }); + const { db, service } = createTestService({ config, configLoader: () => ({ config }) }); + const repos = seedUserMemory(db); + const namespace = { source: "codex", profileId: "default", userId }; + const { sessionId } = service.openSession({ namespace }); + const request = { namespace, sessionId, query, turnId: "cached-turn", adapterId: "gate-test", requestId: "same-request" }; + const run = async () => operation === "turn.start" ? service.startTurn(request) : service.search(request); + const legacyResponse = await run(); + expect(JSON.stringify(legacyResponse)).toContain(userMemoryId); + repos.runtime.saveIdempotency( + `${operation}:${request.adapterId}:${request.requestId}`, + stableHash({ operation, fingerprint: request }), + legacyResponse + ); + const eventsByGate = new Map(); + for (const enabled of [false, false, true, true, false]) { + config = configWithMemoryGates({ enableUserMemoryRecall: enabled }); + service.reloadConfig({ restartFailedProcessing: false }); + const result = await service.idempotent(operation, request, request, run); + if (enabled) expect(JSON.stringify(result)).toContain(userMemoryId); + else expect(JSON.stringify(result)).not.toContain(userMemoryId); + expect(result.searchEventId).not.toBe(legacyResponse.searchEventId); + if (eventsByGate.has(enabled)) expect(result.searchEventId).toBe(eventsByGate.get(enabled)); + eventsByGate.set(enabled, result.searchEventId); + } + expect(eventsByGate.get(true)).not.toBe(eventsByGate.get(false)); + await expect(service.idempotent(operation, request, { ...request, query: "different" }, run)) + .rejects.toThrow("idempotency key reused with different request body"); + expect(DEFAULT_MEMMY_CONFIG.algorithm.enableUserMemoryRecall).toBe(false); + await service.stop(); + }); +}); diff --git a/Memory/tests/service/user-memory/user-memory.test.ts b/Memory/tests/service/user-memory/user-memory.test.ts index e2501806b..0cf60c7e9 100644 --- a/Memory/tests/service/user-memory/user-memory.test.ts +++ b/Memory/tests/service/user-memory/user-memory.test.ts @@ -5,7 +5,7 @@ import { type LlmCompletionOptions, type LlmMessage } from "../../../src/index.js"; -import { createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; +import { configWithMemoryGates, createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; const { cleanup, createTestService } = createMemoryServiceFixture(); @@ -790,10 +790,11 @@ describe("User Memory", () => { db.close(); }); - it("uses this turn's recalled User Memory candidates to confirm a semantic repeat", async () => { + it.each([false, true])("uses this turn's recalled User Memory candidates to confirm a semantic repeat (recall enabled: %s)", async (enableUserMemoryRecall) => { let existingMemoryId = ""; let sawCandidate = false; const { db, service } = createTestService({ + config: configWithMemoryGates({ enableUserMemoryRecall }), llm: captureDecisionRouterLlm((payload) => { const second = payload.includes("苹果是我最喜欢的水果"); if (second) { @@ -847,13 +848,14 @@ describe("User Memory", () => { db.close(); }); - it("[BC-02 correction] uses the summary model to archive an explicitly corrected recalled User Memory", async () => { + it.each([false, true])("[BC-02 correction] uses the summary model to archive an explicitly corrected recalled User Memory (recall enabled: %s)", async (enableUserMemoryRecall) => { let appleMemoryId = ""; let sawCorrectionCandidate = false; const original = "我在大学的时候最喜欢吃苹果,我现在爱看的书是《百年孤独》"; const correction = "前面说错了,我在大学的时候最喜欢吃的是西瓜"; const revised = "我在大学的时候最喜欢吃西瓜,我现在爱看的书是《百年孤独》"; const { db, service } = createTestService({ + config: configWithMemoryGates({ enableUserMemoryRecall }), llm: captureDecisionRouterLlm((payload) => { const isCorrection = payload.includes(correction); if (isCorrection) { @@ -920,7 +922,7 @@ describe("User Memory", () => { ])); const evidence = service.recallEvidence(started.turnId); expect(evidence.diagnostics).toMatchObject({ - candidateMemoryIds: expect.arrayContaining([appleMemoryId]), + candidateMemoryIds: enableUserMemoryRecall ? expect.arrayContaining([appleMemoryId]) : [], injectedMemoryIds: expect.not.arrayContaining([appleMemoryId]), capture: { status: "completed", @@ -973,7 +975,9 @@ describe("User Memory", () => { }); it("includes User Memory in search log candidates and statistics", async () => { - const { db, service } = createTestService(); + const { db, service } = createTestService({ + config: configWithMemoryGates({ enableUserMemoryRecall: true }) + }); const session = open(service, "user-memory-log-user"); const completed = (await service.completeTurn("turn-user-memory-log", { sessionId: session.sessionId, @@ -1019,7 +1023,9 @@ describe("User Memory", () => { }); it("limits the User Memory recall lane to the requested TopK", async () => { - const { db, service } = createTestService(); + const { db, service } = createTestService({ + config: configWithMemoryGates({ enableUserMemoryRecall: true }) + }); const session = open(service, "user-memory-top-k-user"); for (let index = 0; index < 12; index += 1) { (await service.completeTurn(`turn-user-memory-top-k-${index}`, { @@ -1045,7 +1051,9 @@ describe("User Memory", () => { }); it("excludes User Memory from automatic injection for the current session's latest 8 turns", async () => { - const { db, service } = createTestService(); + const { db, service } = createTestService({ + config: configWithMemoryGates({ enableUserMemoryRecall: true }) + }); const session = open(service, "recent-user-memory-user"); const completed = (await service.completeTurn("turn-recent-user-memory", { sessionId: session.sessionId, @@ -1149,6 +1157,7 @@ describe("User Memory", () => { let appleMemoryId = ""; let sawHistoricalCandidate = false; const { db, service } = createTestService({ + config: configWithMemoryGates({ enableUserMemoryRecall: true }), llm: captureDecisionRouterLlm((payload) => { const current = payload.includes("我现在最喜欢的水果是西瓜"); if (current) { @@ -1222,7 +1231,9 @@ describe("User Memory", () => { }); it("[BC-28] stores a compound user statement as one record and no L1", async () => { - const { db, service } = createTestService(); + const { db, service } = createTestService({ + config: configWithMemoryGates({ enableUserMemoryRecall: true }) + }); const session = open(service, "compound-user"); const content = "我在大学的时候最喜欢吃苹果,我现在爱看的书是《百年孤独》"; const completed = (await service.completeTurn("turn-compound", { @@ -1250,7 +1261,9 @@ describe("User Memory", () => { }); it("[BC-25] independently creates User Memory and L1, then merges them by source turn", async () => { - const { db, service } = createTestService(); + const { db, service } = createTestService({ + config: configWithMemoryGates({ enableUserMemoryRecall: true }) + }); const session = open(service, "same-turn-user"); const completed = (await service.completeTurn("turn-code-feedback", { sessionId: session.sessionId, diff --git a/tests/smoke/local-agent-memory-smoke-plan.test.ts b/tests/smoke/local-agent-memory-smoke-plan.test.ts index 5aa17aaea..6ad2fefd7 100644 --- a/tests/smoke/local-agent-memory-smoke-plan.test.ts +++ b/tests/smoke/local-agent-memory-smoke-plan.test.ts @@ -8,9 +8,22 @@ import { ToolRegistry } from "../../App/memmy-agent/src/core/agent-runtime/tools import { MemmyMemoryClient } from "../../App/memmy-agent/src/memmy-memory/client.js"; import { registerMemmyMemoryTools } from "../../App/memmy-agent/src/memmy-memory/tools.js"; import type { MemmyMemoryToolRuntime } from "../../App/memmy-agent/src/memmy-memory/types.js"; -import { readExecutorState } from "./local-agent-memory-smoke.js"; +import { readExecutorState, runLocalAgentMemorySmoke } from "./local-agent-memory-smoke.js"; describe("local agent memory smoke plan", () => { + it.each([ + { source: "codex", messageCount: 3 }, + { source: "cursor", messageCount: 2 }, + { source: "claude_code", messageCount: 2 } + ])("imports, deduplicates, redacts, and recalls a native $source turn", async ({ source, messageCount }) => { + const result = await runLocalAgentMemorySmoke([source]); + expect(result.sources).toEqual([source]); + expect(result.scannedMessages).toBe(messageCount); + expect(result.memoryIds).toEqual([result.memoryId]); + expect(result.recallHitCount).toBe(1); + expect(result.panelItemCount).toBe(1); + }); + it("keeps the executable local-Agent and Cursor smoke entrypoints wired", () => { const repoRoot = resolve(import.meta.dirname, "../.."); const manifest = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { diff --git a/tests/smoke/local-agent-memory-smoke.ts b/tests/smoke/local-agent-memory-smoke.ts index 7f12a7a91..e15af7a02 100644 --- a/tests/smoke/local-agent-memory-smoke.ts +++ b/tests/smoke/local-agent-memory-smoke.ts @@ -2,15 +2,15 @@ import Database from "better-sqlite3"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { DEFAULT_MEMMY_CONFIG, MemoryDb, MemoryService, - createLlmClient, listenMemoryHttpServer, type Embedder } from "../../Memory/src/index.js"; +import { createTurnJudgeLlm } from "../../Memory/tests/service/session/turn-judge-llm.js"; import { createAgentSourceExecutor, type AgentSourceExecutor @@ -40,6 +40,7 @@ export type LocalAgentSourceId = typeof DEFAULT_SOURCE_IDS[number]; interface SyntheticSourceFixture { sourceId: LocalAgentSourceId; adapter: SourceAdapter; + expectedMessageCount: number; rawSecret: string; redactionMarker: string; expectedAssistantText: string; @@ -100,11 +101,12 @@ export async function runLocalAgentMemorySmoke( ); db = new MemoryDb({ path: memoryDbPath }); - const llm = createLlmClient(config.summary); + const { llm } = createTurnJudgeLlm(); const service = new MemoryService({ db, mode: "dev", config, + configLoader: () => ({ config, path: configPath }), llm, skillLlm: llm, embedder: createSmokeEmbedder(), @@ -120,17 +122,20 @@ export async function runLocalAgentMemorySmoke( resolveAgentSkillRoot: () => null }); - await assertSourcesAvailable(executor, sourceIds, 0); + await assertSourcesAvailable(executor, fixtures, false); await runScanAndWait(executor, scanSourceId(sourceIds)); - const scannedMessages = await assertSourcesAvailable(executor, sourceIds, 2); + const scannedMessages = await assertSourcesAvailable(executor, fixtures, true); const memoryIdsBySource = readImportedMemoryIds(service, fixtures); const memoryIds = sourceIds.map((sourceId) => requireMapValue(memoryIdsBySource, sourceId)); assertImportedDetails(service, fixtures, memoryIdsBySource); await drainImportedMemoryWorkers(service, memoryIds); + for (const memoryId of memoryIds) { + assert(service.getMemory(memoryId).status === "activated", "Processed source memory must be activated"); + } const firstState = readExecutorState(statePath, sourceIds); await runScanAndWait(executor, scanSourceId(sourceIds)); - await assertSourcesAvailable(executor, sourceIds, 2); + await assertSourcesAvailable(executor, fixtures, true); const dedupedMemoryIds = readImportedMemoryIds(service, fixtures); for (const sourceId of sourceIds) { assert( @@ -344,45 +349,50 @@ function createSourceFixture(root: string, sourceId: LocalAgentSourceId): Synthe } function createCursorFixture(root: string): SyntheticSourceFixture { - const workspaceRoot = join(root, "fixtures", "cursor", "workspace"); const storageRoot = join(root, "fixtures", "cursor", "User", "workspaceStorage"); - const storagePath = join(storageRoot, "smoke-workspace"); - mkdirSync(join(workspaceRoot, ".git"), { recursive: true }); + const storagePath = join(root, "fixtures", "cursor", "User", "globalStorage"); + const globalStateDbPath = join(storagePath, "state.vscdb"); + mkdirSync(storageRoot, { recursive: true }); mkdirSync(storagePath, { recursive: true }); - writeFileSync( - join(storagePath, "workspace.json"), - `${JSON.stringify({ folder: pathToFileURL(workspaceRoot).href })}\n`, - "utf8" - ); - const stateDb = new Database(join(storagePath, "state.vscdb")); + const composerId = "cursor-smoke-conversation"; + const bubbles = [ + { + bubbleId: "cursor-smoke-user", + type: 1, + text: `${SHARED_RECALL_QUERY} for Cursor. Authorization: Bearer ${CURSOR_SECRET}`, + createdAt: "2026-09-01T00:00:00.000Z" + }, + { + bubbleId: "cursor-smoke-assistant", + type: 2, + text: "Cursor smoke assistant confirms the isolated release checklist.", + createdAt: "2026-09-01T00:00:01.000Z" + } + ]; + const stateDb = new Database(globalStateDbPath); try { - stateDb.exec("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL)"); - stateDb.prepare("INSERT INTO ItemTable(key, value) VALUES (?, ?)").run( - "composerData:cursor-smoke-conversation", + stateDb.exec("CREATE TABLE composerHeaders (composerId TEXT PRIMARY KEY, isSubagent INTEGER)"); + stateDb.exec("CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT NOT NULL)"); + stateDb.prepare("INSERT INTO composerHeaders (composerId, isSubagent) VALUES (?, 0)").run(composerId); + const insert = stateDb.prepare("INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)"); + insert.run( + `composerData:${composerId}`, JSON.stringify({ - conversationId: "cursor-smoke-conversation", - messages: [ - { - id: "cursor-smoke-user", - role: "user", - content: `${SHARED_RECALL_QUERY} for Cursor. Authorization: Bearer ${CURSOR_SECRET}`, - createdAt: "2026-09-01T00:00:00.000Z" - }, - { - id: "cursor-smoke-assistant", - role: "assistant", - content: "Cursor smoke assistant confirms the isolated release checklist.", - createdAt: "2026-09-01T00:00:01.000Z" - } - ] + composerId, + status: "completed", + fullConversationHeadersOnly: bubbles.map(({ bubbleId, type, createdAt }) => ({ bubbleId, type, createdAt })) }) ); + for (const bubble of bubbles) { + insert.run(`bubbleId:${composerId}:${bubble.bubbleId}`, JSON.stringify(bubble)); + } } finally { stateDb.close(); } return { sourceId: "cursor", - adapter: createCursorSourceAdapter({ storageRoot }), + adapter: createCursorSourceAdapter({ storageRoot, globalStateDbPath }), + expectedMessageCount: 2, rawSecret: CURSOR_SECRET, redactionMarker: "[REDACTED:authorization_bearer]", expectedAssistantText: "Cursor smoke assistant confirms the isolated release checklist." @@ -406,7 +416,12 @@ function createCodexFixture(root: string): SyntheticSourceFixture { JSON.stringify({ timestamp: "2026-09-01T00:01:00.000Z", type: "session_meta", - payload: { cwd: workspaceRoot } + payload: { id: "019e72be-500b-7f02-9400-112c5a194e5c", cwd: workspaceRoot } + }), + JSON.stringify({ + timestamp: "2026-09-01T00:01:00.000Z", + type: "event_msg", + payload: { type: "task_started", turn_id: "codex-smoke-turn" } }), JSON.stringify({ timestamp: "2026-09-01T00:01:01.000Z", @@ -425,6 +440,11 @@ function createCodexFixture(root: string): SyntheticSourceFixture { role: "assistant", content: [{ type: "output_text", text: "Codex smoke assistant confirms the isolated release checklist." }] } + }), + JSON.stringify({ + timestamp: "2026-09-01T00:01:03.000Z", + type: "event_msg", + payload: { type: "task_complete", turn_id: "codex-smoke-turn" } }) ].join("\n"), "utf8" @@ -432,6 +452,8 @@ function createCodexFixture(root: string): SyntheticSourceFixture { return { sourceId: "codex", adapter: createCodexSourceAdapter({ sessionsRoot }), + // The native reader also stages the task_complete system message. + expectedMessageCount: 3, rawSecret: CODEX_SECRET, redactionMarker: "[REDACTED:openai_api_key]", expectedAssistantText: "Codex smoke assistant confirms the isolated release checklist." @@ -443,7 +465,8 @@ function createClaudeCodeFixture(root: string): SyntheticSourceFixture { const projectsRoot = join(fixtureRoot, "projects"); const workspaceRoot = join(fixtureRoot, "workspace"); const projectDirectory = join(projectsRoot, "-smoke-workspace"); - const transcriptPath = join(projectDirectory, "claude-smoke-session.jsonl"); + const sessionId = "19e74616-8b83-4f16-936f-63caf804a008"; + const transcriptPath = join(projectDirectory, `${sessionId}.jsonl`); mkdirSync(join(workspaceRoot, ".git"), { recursive: true }); mkdirSync(projectDirectory, { recursive: true }); writeFileSync( @@ -451,13 +474,15 @@ function createClaudeCodeFixture(root: string): SyntheticSourceFixture { [ JSON.stringify({ type: "user", + origin: { kind: "human" }, + promptId: "claude-smoke-prompt", message: { role: "user", content: `${SHARED_RECALL_QUERY} for Claude Code. ANTHROPIC_API_KEY=${CLAUDE_SECRET}` }, uuid: "claude-smoke-user", timestamp: "2026-09-01T00:02:00.000Z", - sessionId: "claude-smoke-session", + sessionId, cwd: workspaceRoot }), JSON.stringify({ @@ -468,8 +493,15 @@ function createClaudeCodeFixture(root: string): SyntheticSourceFixture { }, uuid: "claude-smoke-assistant", timestamp: "2026-09-01T00:02:01.000Z", - sessionId: "claude-smoke-session", + sessionId, cwd: workspaceRoot + }), + JSON.stringify({ + type: "system", + subtype: "turn_duration", + uuid: "claude-smoke-duration", + timestamp: "2026-09-01T00:02:02.000Z", + sessionId }) ].join("\n"), "utf8" @@ -477,6 +509,7 @@ function createClaudeCodeFixture(root: string): SyntheticSourceFixture { return { sourceId: "claude_code", adapter: createClaudeCodeSourceAdapter({ projectsRoot }), + expectedMessageCount: 2, rawSecret: CLAUDE_SECRET, redactionMarker: "[REDACTED:anthropic_api_key]", expectedAssistantText: "Claude Code smoke assistant confirms the isolated release checklist." @@ -485,13 +518,15 @@ function createClaudeCodeFixture(root: string): SyntheticSourceFixture { async function assertSourcesAvailable( executor: AgentSourceExecutor, - sourceIds: readonly LocalAgentSourceId[], - expectedMessageCount: number + fixtures: readonly SyntheticSourceFixture[], + imported: boolean ): Promise { const listed = await executor.list(); - assert(listed.sources.length === sourceIds.length, "Executor must expose only the selected synthetic sources"); + assert(listed.sources.length === fixtures.length, "Executor must expose only the selected synthetic sources"); let total = 0; - for (const sourceId of sourceIds) { + for (const fixture of fixtures) { + const { sourceId } = fixture; + const expectedMessageCount = imported ? fixture.expectedMessageCount : 0; const source = listed.sources.find((item) => item.sourceId === sourceId); assert(source, `Executor must expose synthetic source ${sourceId}`); assert(source.available, `Synthetic source ${sourceId} must be detected`); @@ -533,8 +568,6 @@ function readImportedMemoryIds( for (const fixture of fixtures) { const panel = service.panelItems({ layer: "L1", - status: "activated", - tags: ["agent-source", fixture.sourceId], sourceAgent: fixture.sourceId, limit: 10 }); @@ -579,7 +612,8 @@ async function drainImportedMemoryWorkers(service: MemoryService, memoryIds: rea ) { return; } - const worker = await service.runWorkerOnce(20, { targetMemoryIds: [...memoryIds] }); + // Native captures also queue episode assignment jobs without a target memory id. + const worker = await service.runWorkerOnce(20); assert(worker.failed === 0, "Imported memory worker jobs must not fail"); assert(worker.embeddingRetries.failed === 0, "Imported memory embedding retries must not fail"); }