diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 54044fae..7833774b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -324,11 +324,11 @@ public class AgentService { */ public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); - memoryRecallTracker.trackRecalls(agentId, message); if (isDshAgent(agentId)) { return collectChatResult(chatStructuredStream(agentId, message, conversationId, "", null, origin != null ? origin : ChatOrigin.EMPTY)).content(); } + trackMemoryRecalls(agentId, message, origin); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { @@ -365,13 +365,13 @@ public class AgentService { public Flux chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); - memoryRecallTracker.trackRecalls(agentId, message); if (isDshAgent(agentId)) { return chatStructuredStream(agentId, message, conversationId, "", null, origin != null ? origin : ChatOrigin.EMPTY) .filter(delta -> delta.content() != null) .map(StreamDelta::content); } + trackMemoryRecalls(agentId, message, origin); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); // Capture the origin into a request-scoped holder; cleared on Flux // termination so the next reactive subscriber doesn't inherit stale state. @@ -408,7 +408,7 @@ public class AgentService { String requesterId, String thinkingLevel, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); - memoryRecallTracker.trackRecalls(agentId, message); + trackMemoryRecalls(agentId, message, origin); if (isDshAgent(agentId)) { AgentEntity dshAgent = getAgent(agentId); return withLifecycleFlux(agentId, message, conversationId, @@ -468,7 +468,7 @@ public class AgentService { public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); - memoryRecallTracker.trackRecalls(agentId, goal); + trackMemoryRecalls(agentId, goal, origin); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { @@ -495,7 +495,7 @@ public class AgentService { public String chatWithReplay(Long agentId, String userMessage, String conversationId, String toolCallPayload, ChatOrigin origin) { - memoryRecallTracker.trackRecalls(agentId, userMessage); + trackMemoryRecalls(agentId, userMessage, origin); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { @@ -543,7 +543,7 @@ public class AgentService { public Flux chatWithReplayStream(Long agentId, String userMessage, String conversationId, String toolCallPayload, String requesterId, ChatOrigin origin) { - memoryRecallTracker.trackRecalls(agentId, userMessage); + trackMemoryRecalls(agentId, userMessage, origin); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY; return Flux.defer(() -> { @@ -755,6 +755,13 @@ public class AgentService { return "dsh".equalsIgnoreCase(entity.getRuntimeType()); } + private void trackMemoryRecalls(Long agentId, String message, ChatOrigin origin) { + String ownerKey = memoryProperties.isLifecycleMediatorEnabled() + ? memoryOwnerResolver.resolve(origin != null ? origin : ChatOrigin.EMPTY) + : null; + memoryRecallTracker.trackRecalls(agentId, message, ownerKey); + } + private void validateDshConfiguration(AgentEntity agent) { if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return; if (dshRuntimeService == null) { diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java index df10190d..9253c7be 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.memory.MemoryProperties; +import vip.mate.memory.identity.MemoryScope; import vip.mate.memory.model.MemoryRecallEntity; import vip.mate.memory.repository.MemoryRecallMapper; @@ -55,9 +56,21 @@ public class MemoryRecallService { * 记录一次文件召回 */ public void recordRecall(Long agentId, String filename, String snippetText, String userQueryHash) { + recordRecall(agentId, filename, snippetText, userQueryHash, null, MemoryScope.TEAM); + } + + /** Owner-aware recall ledger write. Shared legacy rows keep a null owner key. */ + public void recordRecall(Long agentId, String filename, String snippetText, String userQueryHash, + String ownerKey, String scope) { if (agentId == null || filename == null || filename.isBlank()) { return; } + String effectiveScope = normalizeScope(scope); + String effectiveOwner = MemoryScope.PERSONAL.equals(effectiveScope) ? ownerKey : null; + if (MemoryScope.PERSONAL.equals(effectiveScope) + && (effectiveOwner == null || effectiveOwner.isBlank())) { + return; + } // 写库前硬截断:覆盖所有调用路径(含 trackActiveRetrieval 透传的外部 filename), // 防 filename 突破 VARCHAR(256) 导致写入失败(#461) filename = truncateFilename(filename); @@ -67,12 +80,13 @@ public class MemoryRecallService { ? snippetText.substring(0, 200) : snippetText; - MemoryRecallEntity existing = recallMapper.selectOne( - new LambdaQueryWrapper() + LambdaQueryWrapper existingQuery = new LambdaQueryWrapper() .eq(MemoryRecallEntity::getAgentId, agentId) .eq(MemoryRecallEntity::getFilename, filename) - .eq(MemoryRecallEntity::getDeleted, 0) - .last("LIMIT 1")); + .eq(MemoryRecallEntity::getScope, effectiveScope) + .eq(MemoryRecallEntity::getDeleted, 0); + applyOwnerIdentity(existingQuery, effectiveOwner, effectiveScope); + MemoryRecallEntity existing = recallMapper.selectOne(existingQuery.last("LIMIT 1")); LocalDateTime now = LocalDateTime.now(); @@ -103,6 +117,8 @@ public class MemoryRecallService { entity.setLastRecalledAt(now); entity.setPromoted(false); entity.setScore(0.0); + entity.setOwnerKey(effectiveOwner); + entity.setScope(effectiveScope); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleted(0); @@ -115,12 +131,13 @@ public class MemoryRecallService { } catch (org.springframework.dao.DuplicateKeyException e) { // 并发插入冲突,重新查询后更新(不递归,避免 StackOverflow) log.debug("[MemoryRecall] Concurrent insert for {}, falling back to update", filename); - MemoryRecallEntity retry = recallMapper.selectOne( - new LambdaQueryWrapper() + LambdaQueryWrapper retryQuery = new LambdaQueryWrapper() .eq(MemoryRecallEntity::getAgentId, agentId) .eq(MemoryRecallEntity::getFilename, filename) - .eq(MemoryRecallEntity::getDeleted, 0) - .last("LIMIT 1")); + .eq(MemoryRecallEntity::getScope, effectiveScope) + .eq(MemoryRecallEntity::getDeleted, 0); + applyOwnerIdentity(retryQuery, effectiveOwner, effectiveScope); + MemoryRecallEntity retry = recallMapper.selectOne(retryQuery.last("LIMIT 1")); if (retry != null) { retry.setRecallCount(retry.getRecallCount() + 1); retry.setDailyCount(retry.getDailyCount() + 1); @@ -157,6 +174,9 @@ public class MemoryRecallService { return recallMapper.selectList( new LambdaQueryWrapper() .eq(MemoryRecallEntity::getAgentId, agentId) + // Current Dream writes shared MEMORY.md. Keep PERSONAL + // candidates out until consolidation itself is owner-aware. + .in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL) .eq(MemoryRecallEntity::getPromoted, false) .eq(MemoryRecallEntity::getDeleted, 0) .orderByDesc(MemoryRecallEntity::getScore)); @@ -280,10 +300,12 @@ public class MemoryRecallService { long total = recallMapper.selectCount( new LambdaQueryWrapper() .eq(MemoryRecallEntity::getAgentId, agentId) + .in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL) .eq(MemoryRecallEntity::getDeleted, 0)); long promoted = recallMapper.selectCount( new LambdaQueryWrapper() .eq(MemoryRecallEntity::getAgentId, agentId) + .in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL) .eq(MemoryRecallEntity::getPromoted, true) .eq(MemoryRecallEntity::getDeleted, 0)); long pending = total - promoted; @@ -307,6 +329,7 @@ public class MemoryRecallService { List candidates = recallMapper.selectList( new LambdaQueryWrapper() .eq(MemoryRecallEntity::getAgentId, agentId) + .in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL) .eq(MemoryRecallEntity::getDeleted, 0) .orderByDesc(MemoryRecallEntity::getScore)); @@ -374,4 +397,23 @@ public class MemoryRecallService { } } + private static String normalizeScope(String scope) { + if (MemoryScope.PERSONAL.equals(scope) || MemoryScope.GLOBAL.equals(scope)) { + return scope; + } + return MemoryScope.TEAM; + } + + private static void applyOwnerIdentity(LambdaQueryWrapper query, + String ownerKey, String scope) { + if (MemoryScope.PERSONAL.equals(scope)) { + query.eq(MemoryRecallEntity::getOwnerKey, ownerKey); + } else { + // V137 left legacy shared recall rows with NULL while newer rows may + // use the workspace-file empty-string sentinel. Treat both as shared. + query.and(w -> w.isNull(MemoryRecallEntity::getOwnerKey) + .or().eq(MemoryRecallEntity::getOwnerKey, "")); + } + } + } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java index 49449f20..2fac0b75 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import vip.mate.memory.identity.MemoryScope; import vip.mate.workspace.document.model.WorkspaceFileEntity; import vip.mate.workspace.document.repository.WorkspaceFileMapper; @@ -44,15 +45,32 @@ public class MemoryRecallTracker { */ @Async public void trackRecalls(Long agentId, String userQuery) { + trackRecalls(agentId, userQuery, null); + } + + /** + * Track only the shared files plus PERSONAL files visible to {@code ownerKey}. + * The owner and scope are copied into the recall ledger so downstream Dream + * processing cannot collapse two owners' same-named files into one candidate. + */ + @Async + public void trackRecalls(Long agentId, String userQuery, String ownerKey) { try { - // 精确复现 buildSystemPrompt 的注入条件 + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(WorkspaceFileEntity::getAgentId, agentId) + .eq(WorkspaceFileEntity::getEnabled, true) + .isNotNull(WorkspaceFileEntity::getContent) + .ne(WorkspaceFileEntity::getContent, ""); + if (ownerKey == null || ownerKey.isBlank()) { + query.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL); + } else { + query.and(w -> w + .in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL) + .or(p -> p.eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL) + .eq(WorkspaceFileEntity::getOwnerKey, ownerKey))); + } List injectedFiles = workspaceFileMapper.selectList( - new LambdaQueryWrapper() - .eq(WorkspaceFileEntity::getAgentId, agentId) - .eq(WorkspaceFileEntity::getEnabled, true) - .isNotNull(WorkspaceFileEntity::getContent) - .ne(WorkspaceFileEntity::getContent, "") - .orderByAsc(WorkspaceFileEntity::getSortOrder)); + query.orderByAsc(WorkspaceFileEntity::getSortOrder)); if (injectedFiles.isEmpty()) { return; @@ -62,6 +80,10 @@ public class MemoryRecallTracker { int trackedCount = 0; for (WorkspaceFileEntity file : injectedFiles) { + // Defence in depth for custom mappers/tests and future query refactors. + if (!isVisibleToOwner(file, ownerKey)) { + continue; + } String content = file.getContent(); if (content == null || content.isBlank()) { continue; @@ -71,10 +93,12 @@ public class MemoryRecallTracker { if (filename.startsWith("memory/") && filename.endsWith(".md")) { // daily note: 按 ## 标题拆分为独立片段 - trackedCount += trackDailyNoteSnippets(agentId, filename, content, queryHash); + trackedCount += trackDailyNoteSnippets(agentId, filename, content, queryHash, + file.getOwnerKey(), file.getScope()); } else { // 非 daily note (PROFILE.md, MEMORY.md 等): 文件级追踪 - recallService.recordRecall(agentId, filename, content, queryHash); + recallService.recordRecall(agentId, filename, content, queryHash, + file.getOwnerKey(), file.getScope()); trackedCount++; } } @@ -88,7 +112,8 @@ public class MemoryRecallTracker { /** * 将 daily note 按 ## 标题拆分为独立片段,分别追踪 */ - private int trackDailyNoteSnippets(Long agentId, String filename, String content, String queryHash) { + private int trackDailyNoteSnippets(Long agentId, String filename, String content, String queryHash, + String ownerKey, String scope) { Matcher matcher = SECTION_PATTERN.matcher(content); List sectionStarts = new java.util.ArrayList<>(); while (matcher.find()) { @@ -97,7 +122,7 @@ public class MemoryRecallTracker { if (sectionStarts.isEmpty()) { // 没有 ## 标题,整个文件作为一个片段 - recallService.recordRecall(agentId, filename, content.trim(), queryHash); + recallService.recordRecall(agentId, filename, content.trim(), queryHash, ownerKey, scope); return 1; } @@ -106,7 +131,7 @@ public class MemoryRecallTracker { if (sectionStarts.get(0) > 0) { String preamble = content.substring(0, sectionStarts.get(0)).trim(); if (!preamble.isEmpty()) { - recallService.recordRecall(agentId, filename + "#preamble", preamble, queryHash); + recallService.recordRecall(agentId, filename + "#preamble", preamble, queryHash, ownerKey, scope); count++; } } @@ -119,7 +144,7 @@ public class MemoryRecallTracker { // 从 ## 标题行提取 section 标识 String firstLine = snippet.contains("\n") ? snippet.substring(0, snippet.indexOf('\n')).trim() : snippet; String sectionKey = filename + "#" + sanitizeSectionKey(firstLine); - recallService.recordRecall(agentId, sectionKey, snippet, queryHash); + recallService.recordRecall(agentId, sectionKey, snippet, queryHash, ownerKey, scope); count++; } } @@ -149,17 +174,33 @@ public class MemoryRecallTracker { */ @Async public void trackActiveRetrieval(Long agentId, String filename, String content) { + trackActiveRetrieval(agentId, filename, content, null, MemoryScope.TEAM); + } + + @Async + public void trackActiveRetrieval(Long agentId, String filename, String content, + String ownerKey, String scope) { try { if (agentId == null || filename == null || content == null || content.isBlank()) { return; } - recallService.recordRecall(agentId, filename, content, "__active_read__"); + recallService.recordRecall(agentId, filename, content, "__active_read__", ownerKey, scope); log.debug("[MemoryRecall] Tracked active retrieval: agent={}, file={}", agentId, filename); } catch (Exception e) { log.warn("[MemoryRecall] Failed to track active retrieval for agent={}: {}", agentId, e.getMessage()); } } + static boolean isVisibleToOwner(WorkspaceFileEntity file, String ownerKey) { + String scope = file.getScope(); + if (scope == null || scope.isBlank() || MemoryScope.TEAM.equals(scope) || MemoryScope.GLOBAL.equals(scope)) { + return true; + } + return MemoryScope.PERSONAL.equals(scope) + && ownerKey != null && !ownerKey.isBlank() + && ownerKey.equals(file.getOwnerKey()); + } + private String sha256Short(String text) { if (text == null || text.isBlank()) return null; try { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java index bf3d88fc..d4a9c040 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java @@ -115,7 +115,8 @@ public class WorkspaceMemoryTool { // 追踪主动检索信号(比被动注入更强的"真实需要"指标) String content = file.getContent() != null ? file.getContent() : ""; - memoryRecallTracker.trackActiveRetrieval(parsedAgentId, filename, content); + memoryRecallTracker.trackActiveRetrieval(parsedAgentId, filename, content, + file.getOwnerKey(), file.getScope()); JSONObject result = new JSONObject(); result.set("agentId", String.valueOf(agentId)); @@ -282,7 +283,8 @@ public class WorkspaceMemoryTool { // PERSONAL row when present) so PERSONAL hits track correctly. WorkspaceFileEntity file = workspaceFileService.getVisibleFile(parsedAgentId, hit.filename(), ownerKey); if (file != null && file.getContent() != null) { - memoryRecallTracker.trackActiveRetrieval(parsedAgentId, hit.filename(), file.getContent()); + memoryRecallTracker.trackActiveRetrieval(parsedAgentId, hit.filename(), file.getContent(), + file.getOwnerKey(), file.getScope()); } } } diff --git a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java index 62995d9d..021edf9a 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java @@ -19,6 +19,7 @@ import vip.mate.workspace.conversation.repository.ConversationMapper; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -74,7 +75,7 @@ class LifecycleRecallCountIT { } // trackRecalls: exactly 10 times (once per chat call) - verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any()); + verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any(), isNull()); // Mediator is not invoked when flag is off verify(memoryManager, never()).prefetchAll(any(), any(), any()); @@ -92,11 +93,12 @@ class LifecycleRecallCountIT { } // trackRecalls: still exactly 10 times — NOT 20 (D4: mediator does not call trackRecalls) - verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any()); + verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any(), eq("system")); // Mediator IS invoked verify(memoryManager, times(10)).prefetchAll(eq(1L), any(), any()); - verify(memoryManager, times(10)).syncAll(eq(1L), eq("conv-1"), any(), any()); + verify(memoryManager, times(10)).syncAll( + eq(1L), eq("conv-1"), any(), any(), eq("system")); } @Test @@ -116,7 +118,8 @@ class LifecycleRecallCountIT { } // Total: 10 trackRecalls calls regardless of flag state - verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any()); + verify(memoryRecallTracker, times(5)).trackRecalls(eq(1L), any(), isNull()); + verify(memoryRecallTracker, times(5)).trackRecalls(eq(1L), any(), eq("system")); // Mediator only called for the ON rounds verify(memoryManager, times(5)).prefetchAll(eq(1L), any(), any()); diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryRecallOwnerIsolationTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryRecallOwnerIsolationTest.java new file mode 100644 index 00000000..3dad1752 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryRecallOwnerIsolationTest.java @@ -0,0 +1,123 @@ +package vip.mate.memory.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.identity.MemoryScope; +import vip.mate.memory.model.MemoryRecallEntity; +import vip.mate.memory.repository.MemoryRecallMapper; +import vip.mate.workspace.document.model.WorkspaceFileEntity; +import vip.mate.workspace.document.repository.WorkspaceFileMapper; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MemoryRecallOwnerIsolationTest { + + @BeforeAll + static void initLambdaCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + MemoryRecallEntity.class); + } + + @Test + @DisplayName("tracker records shared and current-owner files but rejects another owner's row") + void trackerPropagatesOnlyVisibleOwnerIdentity() { + MemoryRecallService recallService = mock(MemoryRecallService.class); + WorkspaceFileMapper fileMapper = mock(WorkspaceFileMapper.class); + WorkspaceFileEntity shared = file("MEMORY.md", "shared", "", MemoryScope.TEAM); + WorkspaceFileEntity ownerA = file("PROFILE.md", "owner-a", "user:a", MemoryScope.PERSONAL); + WorkspaceFileEntity ownerB = file("PROFILE.md", "owner-b", "user:b", MemoryScope.PERSONAL); + when(fileMapper.selectList(any())).thenReturn(List.of(shared, ownerA, ownerB)); + + new MemoryRecallTracker(recallService, fileMapper) + .trackRecalls(7L, "what do you remember?", "user:a"); + + verify(recallService).recordRecall(eq(7L), eq("MEMORY.md"), eq("shared"), + anyString(), eq(""), eq(MemoryScope.TEAM)); + verify(recallService).recordRecall(eq(7L), eq("PROFILE.md"), eq("owner-a"), + anyString(), eq("user:a"), eq(MemoryScope.PERSONAL)); + verify(recallService, never()).recordRecall(eq(7L), eq("PROFILE.md"), eq("owner-b"), + anyString(), eq("user:b"), eq(MemoryScope.PERSONAL)); + } + + @Test + @DisplayName("owner-aware ledger insert persists PERSONAL scope and owner") + void recordRecallPersistsPersonalIdentity() { + MemoryRecallMapper mapper = mock(MemoryRecallMapper.class); + when(mapper.selectOne(any())).thenReturn(null); + MemoryRecallService service = new MemoryRecallService(mapper, new MemoryProperties(), new ObjectMapper()); + + service.recordRecall(7L, "PROFILE.md", "private preference", "query-hash", + "user:a", MemoryScope.PERSONAL); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(MemoryRecallEntity.class); + verify(mapper).insert(inserted.capture()); + assertEquals("user:a", inserted.getValue().getOwnerKey()); + assertEquals(MemoryScope.PERSONAL, inserted.getValue().getScope()); + } + + @Test + @DisplayName("legacy recordRecall overload remains shared and ownerless") + void legacyRecordRecallRemainsShared() { + MemoryRecallMapper mapper = mock(MemoryRecallMapper.class); + when(mapper.selectOne(any())).thenReturn(null); + MemoryRecallService service = new MemoryRecallService(mapper, new MemoryProperties(), new ObjectMapper()); + + service.recordRecall(7L, "MEMORY.md", "shared memory", "query-hash"); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(MemoryRecallEntity.class); + verify(mapper).insert(inserted.capture()); + assertNull(inserted.getValue().getOwnerKey()); + assertEquals(MemoryScope.TEAM, inserted.getValue().getScope()); + } + + @Test + @DisplayName("shared Dream candidate query excludes PERSONAL scope") + @SuppressWarnings({"rawtypes", "unchecked"}) + void dreamCandidatesStaySharedOnly() { + MemoryRecallMapper mapper = mock(MemoryRecallMapper.class); + when(mapper.selectList(any())).thenReturn(List.of()); + MemoryRecallService service = new MemoryRecallService(mapper, new MemoryProperties(), new ObjectMapper()); + + service.listCandidates(7L); + + ArgumentCaptor> query = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectList(query.capture()); + query.getValue().getSqlSegment(); + String parameters = query.getValue().getParamNameValuePairs().values().toString(); + assertTrue(parameters.contains(MemoryScope.TEAM)); + assertTrue(parameters.contains(MemoryScope.GLOBAL)); + assertFalse(parameters.contains(MemoryScope.PERSONAL)); + } + + private static WorkspaceFileEntity file(String filename, String content, String ownerKey, String scope) { + WorkspaceFileEntity file = new WorkspaceFileEntity(); + file.setFilename(filename); + file.setContent(content); + file.setOwnerKey(ownerKey); + file.setScope(scope); + file.setEnabled(true); + return file; + } +}