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 b7a95cbd..e7b9c159 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -49,6 +49,7 @@ public class AgentService { private final MemoryRecallTracker memoryRecallTracker; private final MemoryLifecycleMediator lifecycleMediator; private final MemoryProperties memoryProperties; + private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; /** Read-only lookup of a conversation's pinned model. Mapper (not service) * to keep this a leaf dependency with no risk of a bean cycle. */ private final ConversationMapper conversationMapper; @@ -518,7 +519,8 @@ public class AgentService { if (!memoryProperties.isLifecycleMediatorEnabled()) { return invoke.apply(message, conversationId); } - TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message); + String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get()); + TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey); String memoryContext = lifecycleMediator.beforeLlmCall(ctx); // Inject memory context into the user message (RFC-037 §3.3) String enrichedMessage = injectMemoryContext(message, memoryContext); @@ -540,7 +542,8 @@ public class AgentService { if (!memoryProperties.isLifecycleMediatorEnabled()) { return invoke.apply(message, conversationId); } - TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message); + String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get()); + TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey); String memoryContext = lifecycleMediator.beforeLlmCall(ctx); String enrichedMessage = injectMemoryContext(message, memoryContext); StringBuilder reply = new StringBuilder(); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 975be499..d0ecc33c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -836,7 +836,7 @@ public class ChannelMessageRouter { usage[0], usage[1], modelInfo[0], modelInfo[1]); savedAssistantId = saved != null ? saved.getId() : null; if (!isError) { - publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply); + publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin); } adapter.renderAndSend(replyTarget, reply); log.info("[{}] Reply sent to {}: {}chars", @@ -984,7 +984,7 @@ public class ChannelMessageRouter { conversationId, "assistant", finalContent, null, status, usage[0], usage[1], modelInfo[0], modelInfo[1]); if (!isError) { - publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent); + publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin); } log.info("[{}] Streaming completed: contentLen={}, isError={}", channelType, finalContent.length(), isError); @@ -1160,8 +1160,13 @@ public class ChannelMessageRouter { * messageCount lookup no longer live here. */ private void publishConversationCompletedEvent(Long agentId, String conversationId, - String userMessage, String assistantReply) { - completionPublisher.publish(agentId, conversationId, userMessage, assistantReply, "channel"); + String userMessage, String assistantReply, + ChatOrigin origin) { + // Attribute the memory write to the same external sender the read path + // recalled for, so per-sender IM memory is both written and recalled + // under the same owner key. + completionPublisher.publishForOrigin(agentId, conversationId, userMessage, assistantReply, + "channel", origin); } // ==================== 流式处理(Web 渠道专用,不走队列) ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 270bbf00..37ee1637 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -61,6 +61,7 @@ public class ChatController { private final ChatStreamTracker streamTracker; private final ObjectMapper objectMapper; private final ConversationCompletionPublisher completionPublisher; + private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; private final Path uploadRoot = Paths.get("data", "chat-uploads"); // 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor()) @@ -543,7 +544,7 @@ public class ChatController { // tools that need a workspace path read it from the agent (origin // is enriched with workspaceBasePath in StateGraph buildInitialState). vip.mate.agent.context.ChatOrigin webOrigin = - vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null); + memoryOrigin(conversationId, username, workspaceId, request.getEndUserId()); Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin) .doOnNext(delta -> { if (emitterDone.get()) return; @@ -630,7 +631,12 @@ public class ChatController { // garbage like "[错误] Bad request..." as the assistant reply, // which would pollute the memory extraction pipeline if propagated. if (!wasStopped && !isError) { - completionPublisher.publish(agentId, conversationId, message, assistantText, "web"); + // Attribute the memory write to the same owner the read + // path recalled this turn — the publish runs in a reactive + // completion callback after the origin holder is cleared, + // so resolve from the captured webOrigin explicitly. + completionPublisher.publish(agentId, conversationId, message, assistantText, "web", + memoryOwnerResolver.resolve(webOrigin)); } if (isInterruptFollowup) { @@ -1035,12 +1041,17 @@ public class ChatController { conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts()); String promptText = buildPromptText(request.getMessage(), request.getContentParts()); - AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId()); + // Carry the web origin so per-owner memory recall (read) and the + // post-conversation memory write below agree on the same owner key. + vip.mate.agent.context.ChatOrigin webOrigin = + memoryOrigin(request.getConversationId(), username, workspaceId, request.getEndUserId()); + AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin); String response = result.content(); conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed", result.promptTokens(), result.completionTokens(), result.runtimeModel(), result.runtimeProvider()); - completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web"); + completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web", + memoryOwnerResolver.resolve(webOrigin)); return R.ok(response); } @@ -1123,11 +1134,36 @@ public class ChatController { .body(resource); } + /** + * Build the {@link vip.mate.agent.context.ChatOrigin} that drives per-owner + * memory isolation for a web request. When {@code endUserId} is supplied + * (third-party single-account integration) the origin is attributed to that + * external end-user ({@code api:}); otherwise to the logged-in + * MateClaw user ({@code user:}). + */ + private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username, + Long workspaceId, String endUserId) { + if (endUserId != null && !endUserId.isBlank()) { + return vip.mate.agent.context.ChatOrigin + .web(conversationId, endUserId.trim(), workspaceId, null) + .withSender(null, "api", null); + } + return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null); + } + @lombok.Data public static class ChatRequest { private String message; private String conversationId = "default"; private List contentParts; + /** + * Optional third-party end-user identifier. When a single MateClaw + * account (e.g. one PAT) fronts many of an external system's users, + * pass that system's user id here so memory and recall are isolated + * per end-user. Kept as a string (never coerced to a number) to + * preserve precision of large external ids. + */ + private String endUserId; } @lombok.Data @@ -1167,6 +1203,12 @@ public class ChatController { private String modelProvider; /** Model id the user picked for this conversation. See {@link #modelProvider}. */ private String modelName; + /** + * Optional third-party end-user identifier — see + * {@link ChatRequest#getEndUserId()}. Isolates memory per external + * end-user when one MateClaw account fronts many of them. + */ + private String endUserId; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java index 89cea694..b487663d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java @@ -144,9 +144,13 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler { talkSession.conversationId, talkSession.agentId, talkSession.username, talkWsId); conversationService.saveMessage(talkSession.conversationId, "user", transcript, List.of()); - // 5. Agent 对话(同步) + // 5. Agent 对话(同步)。Carry the voice user's identity so per-owner + // memory recall (read) and the post-turn memory write (below) agree + // on the same owner key. + vip.mate.agent.context.ChatOrigin talkOrigin = vip.mate.agent.context.ChatOrigin.web( + talkSession.conversationId, talkSession.username, talkWsId, null); AgentService.ChatResult chatResult = agentService.chatWithUsage( - talkSession.agentId, transcript, talkSession.conversationId); + talkSession.agentId, transcript, talkSession.conversationId, talkOrigin); String reply = chatResult.content(); if (reply == null || reply.isBlank()) { reply = "Sorry, I couldn't generate a response."; @@ -158,8 +162,8 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler { chatResult.runtimeModel(), chatResult.runtimeProvider()); // Publish conversation-completed event so memory extraction runs for voice turns too. - completionPublisher.publish(talkSession.agentId, talkSession.conversationId, - transcript, reply, "talk"); + completionPublisher.publishForOrigin(talkSession.agentId, talkSession.conversationId, + transcript, reply, "talk", talkOrigin); // 7. 推送文字回复 sendJson(session, Map.of("type", "reply", "text", reply)); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 779fd1cc..0b16ddb3 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -50,6 +50,7 @@ public class WebChatController { private final ChatStreamTracker streamTracker; private final ObjectMapper objectMapper; private final ConversationCompletionPublisher completionPublisher; + private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); @@ -122,7 +123,16 @@ public class WebChatController { final int[] usage = {0, 0}; // [promptTokens, completionTokens] final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider] - agentService.chatStructuredStream(agentId, message, conversationId, visitorId) + // Attribute memory to this external visitor so each end-user + // behind the shared webchat account is isolated. The same origin + // resolves the owner key for both the read (recall) and write + // (publish) paths below. + vip.mate.agent.context.ChatOrigin webchatOrigin = + vip.mate.agent.context.ChatOrigin.web(conversationId, visitorId, webWsId, null) + .withSender(null, "api", null); + String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin); + + agentService.chatStructuredStream(agentId, message, conversationId, visitorId, null, webchatOrigin) .doOnNext(delta -> { if (delta.isEvent() && "_usage_final".equals(delta.eventType())) { Map data = delta.eventData(); @@ -155,7 +165,7 @@ public class WebChatController { "completed", usage[0], usage[1], modelInfo[0], modelInfo[1]); } completionPublisher.publish( - agentId, conversationId, message, reply, "webchat"); + agentId, conversationId, message, reply, "webchat", webchatOwnerKey); } catch (Exception persistErr) { log.warn("[WebChat] Failed to persist assistant reply / publish event: {}", persistErr.getMessage()); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/event/ConversationCompletedEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/event/ConversationCompletedEvent.java index 2c63584f..91558f55 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/event/ConversationCompletedEvent.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/event/ConversationCompletedEvent.java @@ -11,6 +11,9 @@ package vip.mate.memory.event; * @param assistantReply Agent 最终回答 * @param messageCount 当前会话消息总数 * @param triggerSource 触发来源:"web" / "channel" / "cron" + * @param ownerKey memory owner this turn is attributed to (e.g. + * "user:42"); null / "system" means not owner-scoped, + * in which case extracted memory is written as shared. * @author MateClaw Team */ public record ConversationCompletedEvent( @@ -19,5 +22,12 @@ public record ConversationCompletedEvent( String userMessage, String assistantReply, int messageCount, - String triggerSource -) {} + String triggerSource, + String ownerKey +) { + /** Backwards-compatible constructor without an owner key (resolves to null). */ + public ConversationCompletedEvent(Long agentId, String conversationId, String userMessage, + String assistantReply, int messageCount, String triggerSource) { + this(agentId, conversationId, userMessage, assistantReply, messageCount, triggerSource, null); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/event/ConversationCompletionPublisher.java b/mateclaw-server/src/main/java/vip/mate/memory/event/ConversationCompletionPublisher.java index f3258cbd..86a9a38f 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/event/ConversationCompletionPublisher.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/event/ConversationCompletionPublisher.java @@ -4,6 +4,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.workspace.conversation.ConversationService; /** @@ -32,6 +35,7 @@ public class ConversationCompletionPublisher { private final ApplicationEventPublisher eventPublisher; private final ConversationService conversationService; + private final MemoryOwnerResolver memoryOwnerResolver; /** * Publish a {@link ConversationCompletedEvent} for the given turn. @@ -51,6 +55,43 @@ public class ConversationCompletionPublisher { String userMessage, String assistantReply, String source) { + // Best-effort owner resolution from the request-scoped origin holder. + // Reliable for callers that publish on the same thread the origin was + // captured on (IM router, talk mode, cron). Web entry points publish + // from a reactive completion callback after the holder is cleared, so + // they MUST use the explicit overload below to stay consistent with the + // read path's owner key. + publish(agentId, conversationId, userMessage, assistantReply, source, + memoryOwnerResolver.resolve(ChatOriginHolder.get())); + } + + /** + * Publish, attributing the memory write to the owner resolved from an + * explicit {@link ChatOrigin}. Use from entry points (IM channels, talk + * mode) that publish after the request-scoped origin holder is cleared, so + * the write owner matches the read path's owner for the same turn. + */ + public void publishForOrigin(Long agentId, + String conversationId, + String userMessage, + String assistantReply, + String source, + ChatOrigin origin) { + publish(agentId, conversationId, userMessage, assistantReply, source, + memoryOwnerResolver.resolve(origin)); + } + + /** + * Publish with an explicit {@code ownerKey}. Use this from entry points + * where the request-scoped origin is no longer on the current thread, so + * the memory write is attributed to the same owner the read path recalls. + */ + public void publish(Long agentId, + String conversationId, + String userMessage, + String assistantReply, + String source, + String ownerKey) { if (agentId == null || conversationId == null || conversationId.isBlank()) { return; } @@ -62,7 +103,8 @@ public class ConversationCompletionPublisher { userMessage != null ? userMessage : "", assistantReply != null ? assistantReply : "", messageCount, - source != null ? source : "unknown")); + source != null ? source : "unknown", + ownerKey)); } catch (Exception e) { log.debug("[Memory] Failed to publish ConversationCompletedEvent (source={}, conv={}): {}", source, conversationId, e.getMessage()); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntity.java index e0bcbee0..0fb38a1e 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntity.java @@ -50,6 +50,12 @@ public class FactEntity { /** pattern | llm */ private String extractedBy; + /** Memory subject this fact belongs to (e.g. "user:42"); null for shared/legacy rows. */ + private String ownerKey; + + /** Visibility scope: PERSONAL / TEAM / GLOBAL. Defaults to TEAM at the DB level. */ + private String scope; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/provider/FactMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/provider/FactMemoryProvider.java index 764fc352..f16e21fb 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/provider/FactMemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/provider/FactMemoryProvider.java @@ -47,8 +47,13 @@ public class FactMemoryProvider implements MemoryProvider { @Override public String prefetch(Long agentId, String userQuery) { + return prefetch(agentId, userQuery, null); + } + + @Override + public String prefetch(Long agentId, String userQuery, String ownerKey) { if (!properties.getFact().isProjectionEnabled()) return ""; - List facts = queryService.recallRelevant(agentId, userQuery); + List facts = queryService.recallRelevant(agentId, userQuery, ownerKey); if (facts.isEmpty()) return ""; // Bump usage diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java index d5ec9f0f..29b3f7c8 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java @@ -56,6 +56,16 @@ public class FactQueryService { * Recall relevant facts for a query (used by FactMemoryProvider.prefetch). */ public List recallRelevant(Long agentId, String query) { + return recallRelevant(agentId, query, null); + } + + /** + * Owner-scoped recall: returns facts visible to {@code ownerKey} — shared + * (TEAM / GLOBAL) facts plus this owner's PERSONAL facts. A null ownerKey + * means shared-only. Keeps one user's recalled facts out of another user's + * prompt when a single agent is shared across end-users. + */ + public List recallRelevant(Long agentId, String query, String ownerKey) { return factMapper.selectList( new LambdaQueryWrapper() .eq(FactEntity::getAgentId, agentId) @@ -63,6 +73,18 @@ public class FactQueryService { .and(w -> w.like(FactEntity::getSubject, query) .or().like(FactEntity::getObjectValue, query) .or().like(FactEntity::getPredicate, query)) + .and(s -> { + if (ownerKey == null || ownerKey.isBlank()) { + s.in(FactEntity::getScope, vip.mate.memory.identity.MemoryScope.TEAM, + vip.mate.memory.identity.MemoryScope.GLOBAL); + } else { + s.in(FactEntity::getScope, vip.mate.memory.identity.MemoryScope.TEAM, + vip.mate.memory.identity.MemoryScope.GLOBAL) + .or(p -> p.eq(FactEntity::getScope, + vip.mate.memory.identity.MemoryScope.PERSONAL) + .eq(FactEntity::getOwnerKey, ownerKey)); + } + }) .orderByDesc(FactEntity::getTrust) .last("LIMIT 10")); } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/identity/MemoryOwnerResolver.java b/mateclaw-server/src/main/java/vip/mate/memory/identity/MemoryOwnerResolver.java new file mode 100644 index 00000000..39542938 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/identity/MemoryOwnerResolver.java @@ -0,0 +1,53 @@ +package vip.mate.memory.identity; + +import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; + +/** + * Resolves the {@code owner_key} that conversation-derived memory should be + * attributed to, from the request's {@link ChatOrigin}. + * + *

The key is a prefixed string so a single agent shared across surfaces + * keeps every subject's memory separate: + *

    + *
  • Web console → {@code user:} (the MateClaw username)
  • + *
  • IM channels → {@code :} (feishu/dingtalk/…)
  • + *
  • WebChat / 3rd-party API → {@code api:}
  • + *
  • Cron / system / unknown → {@link #SYSTEM_OWNER}
  • + *
+ * + * The cron/system fallback is deliberate: it keeps unattributed writes out of + * any real user's PERSONAL bucket (which would otherwise be a black hole that + * nobody can read) — such writes are expected to be TEAM-scoped instead. + * + * @author MateClaw Team + */ +@Component +public class MemoryOwnerResolver { + + /** Owner key used for cron-triggered and identity-less invocations. */ + public static final String SYSTEM_OWNER = "system"; + + /** + * Resolve the owner key for the given origin. Never returns null; falls + * back to {@link #SYSTEM_OWNER} when no usable identity is present. + */ + public String resolve(ChatOrigin origin) { + if (origin == null || origin.cronOrigin()) { + return SYSTEM_OWNER; + } + String requester = origin.requesterId(); + if (requester == null || requester.isBlank() || SYSTEM_OWNER.equals(requester)) { + return SYSTEM_OWNER; + } + String channel = origin.channelType(); + if (channel == null || channel.isBlank() || "web".equals(channel)) { + // Web console (or a degraded origin with no channel): the requester + // id is already the MateClaw username. + return "user:" + requester; + } + // IM / api origins: the requester id is the external sender id; prefix + // with the channel type so two platforms can't collide on the same id. + return channel + ":" + requester; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/identity/MemoryScope.java b/mateclaw-server/src/main/java/vip/mate/memory/identity/MemoryScope.java new file mode 100644 index 00000000..7c2973dc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/identity/MemoryScope.java @@ -0,0 +1,34 @@ +package vip.mate.memory.identity; + +/** + * Visibility scope for a memory row (workspace file, fact, recall). + * + *
    + *
  • {@link #PERSONAL} — only the matching {@code owner_key} can read it. + * Conversation-derived memory defaults here.
  • + *
  • {@link #TEAM} — everyone using the agent can read it. Agent config / + * persona files (AGENTS.md, SOUL.md, PROFILE.md) and legacy rows live + * here.
  • + *
  • {@link #GLOBAL} — always visible. Reserved for agent-creator preset + * facts.
  • + *
+ * + * Stored as a plain string column ({@code scope}) rather than a DB enum so the + * H2 / MySQL migrations stay dialect-neutral. + * + * @author MateClaw Team + */ +public final class MemoryScope { + + public static final String PERSONAL = "PERSONAL"; + public static final String TEAM = "TEAM"; + public static final String GLOBAL = "GLOBAL"; + + private MemoryScope() { + } + + /** A scope is "shared" (visible to every owner) when it is TEAM or GLOBAL. */ + public static boolean isShared(String scope) { + return TEAM.equals(scope) || GLOBAL.equals(scope); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java index eb5e1e30..f52f28a6 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java @@ -42,7 +42,7 @@ public class MemoryLifecycleMediator { */ public String beforeLlmCall(TurnContext ctx) { try { - String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery()); + String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery(), ctx.ownerKey()); events.publishEvent(new TurnStartedEvent(ctx)); log.debug("[Memory] beforeLlmCall: agent={}, contextLen={}", ctx.agentId(), context != null ? context.length() : 0); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java index d7b6f4c2..ca398d8a 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java @@ -8,6 +8,9 @@ package vip.mate.memory.lifecycle; * @param sessionId session ID (may equal conversationId in Phase 1) * @param turnNumber turn sequence number within the conversation * @param userQuery the current user message + * @param ownerKey resolved memory owner key for this turn (e.g. + * "user:42"); drives per-owner memory recall. May be + * null when memory-isolation context is unavailable. * @author MateClaw Team */ public record TurnContext( @@ -15,5 +18,12 @@ public record TurnContext( String conversationId, String sessionId, int turnNumber, - String userQuery -) {} + String userQuery, + String ownerKey +) { + /** Backwards-compatible constructor without an owner key (resolves to null). */ + public TurnContext(Long agentId, String conversationId, String sessionId, + int turnNumber, String userQuery) { + this(agentId, conversationId, sessionId, turnNumber, userQuery, null); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/listener/PostConversationMemoryListener.java b/mateclaw-server/src/main/java/vip/mate/memory/listener/PostConversationMemoryListener.java index fd4f76c7..2834d2fc 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/listener/PostConversationMemoryListener.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/listener/PostConversationMemoryListener.java @@ -52,7 +52,7 @@ public class PostConversationMemoryListener { try { log.debug("[Memory] Triggering post-conversation memory analysis: agent={}, conv={}", event.agentId(), event.conversationId()); - summarizationService.analyzeAndUpdateMemory(event.agentId(), event.conversationId()); + summarizationService.analyzeAndUpdateMemory(event.agentId(), event.conversationId(), event.ownerKey()); } catch (Exception e) { log.warn("[Memory] Post-conversation summarization failed: agent={}, conv={}, error={}", event.agentId(), event.conversationId(), e.getMessage()); @@ -60,7 +60,7 @@ public class PostConversationMemoryListener { // Memory Nudge: extract structured entries every N turns try { - nudgeService.maybeNudge(event.agentId(), event.conversationId(), event.messageCount()); + nudgeService.maybeNudge(event.agentId(), event.conversationId(), event.messageCount(), event.ownerKey()); } catch (Exception e) { log.debug("[Memory] Nudge trigger failed (non-fatal): {}", e.getMessage()); } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java index 3902e95e..b956c133 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java @@ -56,6 +56,12 @@ public class MemoryRecallEntity { /** Last time this candidate was reviewed during a dream run */ private LocalDateTime lastReviewedAt; + /** Memory subject this recall belongs to (e.g. "user:42"); null for shared/legacy rows. */ + private String ownerKey; + + /** Visibility scope: PERSONAL / TEAM / GLOBAL. Defaults to TEAM at the DB level. */ + private String scope; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/nudge/MemoryNudgeService.java b/mateclaw-server/src/main/java/vip/mate/memory/nudge/MemoryNudgeService.java index 65f19eb0..e723500e 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/nudge/MemoryNudgeService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/nudge/MemoryNudgeService.java @@ -46,17 +46,29 @@ public class MemoryNudgeService { private final ObjectMapper objectMapper; /** Per-agent cooldown tracking */ - private final ConcurrentHashMap lastNudgeTimes = new ConcurrentHashMap<>(); + private final ConcurrentHashMap lastNudgeTimes = new ConcurrentHashMap<>(); /** * Check if a nudge should be triggered and execute if so. * Called from PostConversationMemoryListener or directly. */ + /** Backwards-compatible entry without an owner key (writes shared memory). */ @Async public void maybeNudge(Long agentId, String conversationId, int messageCount) { + maybeNudge(agentId, conversationId, messageCount, null); + } + + @Async + public void maybeNudge(Long agentId, String conversationId, int messageCount, String ownerKey) { if (!properties.isNudgeEnabled()) { return; } + // Gate per-owner isolation on the lifecycle prefetch path (the only + // auto-injector of PERSONAL structured memory); otherwise write shared + // so nudged entries are not stranded in an unread PERSONAL bucket. + if (!properties.isLifecycleMediatorEnabled()) { + ownerKey = null; + } // Check turn interval if (properties.getNudgeTurnInterval() <= 0 @@ -64,22 +76,23 @@ public class MemoryNudgeService { return; } - // Cooldown check - if (isInCooldown(agentId)) { - log.debug("[Nudge] Agent {} is in cooldown, skipping", agentId); + // Cooldown keyed per (agent, owner) so one owner can't starve another. + String cooldownKey = agentId + ":" + (ownerKey == null ? "" : ownerKey); + if (isInCooldown(cooldownKey)) { + log.debug("[Nudge] Agent {} (owner {}) is in cooldown, skipping", agentId, ownerKey); return; } try { - doNudge(agentId, conversationId); - lastNudgeTimes.put(agentId, Instant.now()); + doNudge(agentId, conversationId, ownerKey); + lastNudgeTimes.put(cooldownKey, Instant.now()); } catch (Exception e) { log.warn("[Nudge] Failed for agent={}, conv={}: {}", agentId, conversationId, e.getMessage()); } } - private void doNudge(Long agentId, String conversationId) { + private void doNudge(Long agentId, String conversationId, String ownerKey) { // 1. Load recent messages List messages = conversationService.listMessages(conversationId); int maxReview = properties.getNudgeMaxMessages(); @@ -96,8 +109,8 @@ public class MemoryNudgeService { String transcript = buildTranscript(recent); if (transcript.isBlank()) return; - // 3. Load existing structured memories for dedup - String existingMemories = structuredMemoryService.buildMemoryBlock(agentId); + // 3. Load existing structured memories for dedup (owner-scoped) + String existingMemories = structuredMemoryService.buildMemoryBlock(agentId, ownerKey); // 4. Build prompt String systemPrompt = PromptLoader.loadPrompt("memory/nudge-system"); @@ -140,7 +153,7 @@ public class MemoryNudgeService { if (type.isBlank() || key.isBlank() || content.isBlank()) continue; try { - structuredMemoryService.remember(agentId, type, key, content, "nudge"); + structuredMemoryService.remember(agentId, type, key, content, "nudge", ownerKey); saved++; } catch (Exception e) { log.debug("[Nudge] Failed to save entry {}/{}: {}", type, key, e.getMessage()); @@ -226,8 +239,8 @@ public class MemoryNudgeService { || msg.contains("速率限制") || msg.contains("Too Many Requests")); } - private boolean isInCooldown(Long agentId) { - Instant lastRun = lastNudgeTimes.get(agentId); + private boolean isInCooldown(String cooldownKey) { + Instant lastRun = lastNudgeTimes.get(cooldownKey); if (lastRun == null) return false; long cooldownSeconds = properties.getNudgeCooldownMinutes() * 60L; return Instant.now().isBefore(lastRun.plusSeconds(cooldownSeconds)); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/provider/BuiltinMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/provider/BuiltinMemoryProvider.java index 7cf62bae..70efc3b2 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/provider/BuiltinMemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/provider/BuiltinMemoryProvider.java @@ -59,12 +59,24 @@ public class BuiltinMemoryProvider implements MemoryProvider { } /** - * Builtin memory is already injected via system prompt. - * No additional per-turn prefetch needed. + * Shared (TEAM / GLOBAL) memory is baked into the system prompt at build + * time. Per-owner PERSONAL memory cannot be — the agent instance is cached + * and reused across users — so it is injected here, per turn, for the + * current requester only. */ @Override - public String prefetch(Long agentId, String userQuery) { - return ""; + public String prefetch(Long agentId, String userQuery, String ownerKey) { + if (ownerKey == null || ownerKey.isBlank()) { + return ""; + } + try { + String block = workspaceFileService.buildOwnerMemoryBlock(agentId, ownerKey); + return block != null ? block : ""; + } catch (Exception e) { + log.warn("[BuiltinMemory] Failed to build owner memory block for agent={}, owner={}: {}", + agentId, ownerKey, e.getMessage()); + return ""; + } } /** diff --git a/mateclaw-server/src/main/java/vip/mate/memory/provider/StructuredMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/provider/StructuredMemoryProvider.java index 09f75cf6..9d6b2543 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/provider/StructuredMemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/provider/StructuredMemoryProvider.java @@ -38,31 +38,61 @@ public class StructuredMemoryProvider implements MemoryProvider { * Returns the stable, low-volume typed entries (user profile, feedback) * for unconditional system prompt injection. */ + /** + * Build-time injection is limited to SHARED (TEAM / GLOBAL) structured + * memory — agent-creator presets and team-wide facts. Conversation-derived + * PERSONAL structured memory is owner-specific and the agent instance is + * cached across users, so it is injected per-turn in + * {@link #prefetch(Long, String, String)} for the current owner only. + */ @Override public String systemPromptBlock(Long agentId) { try { - return structuredMemoryService.buildMemoryBlock(agentId); + // ownerKey=null → buildMemoryBlock reads shared (TEAM/GLOBAL) rows only. + return structuredMemoryService.buildMemoryBlock(agentId, null); } catch (Exception e) { - log.warn("[StructuredMemory] Failed to build memory block for agent={}: {}", + log.warn("[StructuredMemory] Failed to build shared memory block for agent={}: {}", agentId, e.getMessage()); return ""; } } - /** - * Returns growing/specific typed entries (project facts, reference notes) - * relevant to the current question. Surfacing these per-turn rather than - * always-on keeps them salient when asked about and avoids the model - * confusing a stored fact with similarly-shaped background knowledge. - * The returned block is fenced centrally by the memory manager. - */ @Override public String prefetch(Long agentId, String userQuery) { + return prefetch(agentId, userQuery, null); + } + + /** + * Owner-scoped per-turn injection: the stable user/feedback entries plus the + * query-relevant project/reference entries — all restricted to the current + * owner's structured memory. Returns empty when there is no isolatable owner + * so a shared agent never injects another user's structured memory. The + * returned block is fenced centrally by the memory manager. + */ + @Override + public String prefetch(Long agentId, String userQuery, String ownerKey) { try { - return structuredMemoryService.buildPrefetchBlock(agentId, userQuery); + String stable = structuredMemoryService.buildMemoryBlock(agentId, ownerKey); + String relevant = structuredMemoryService.buildPrefetchBlock(agentId, userQuery, ownerKey); + boolean hasStable = stable != null && !stable.isBlank(); + boolean hasRelevant = relevant != null && !relevant.isBlank(); + if (!hasStable && !hasRelevant) { + return ""; + } + StringBuilder sb = new StringBuilder(); + if (hasStable) { + sb.append(stable); + } + if (hasRelevant) { + if (sb.length() > 0) { + sb.append("\n\n"); + } + sb.append(relevant); + } + return sb.toString(); } catch (Exception e) { - log.warn("[StructuredMemory] Failed to build prefetch block for agent={}: {}", - agentId, e.getMessage()); + log.warn("[StructuredMemory] Failed to build prefetch block for agent={}, owner={}: {}", + agentId, ownerKey, e.getMessage()); return ""; } } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java index b9aa3214..962737d3 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java @@ -52,40 +52,60 @@ public class MemorySummarizationService { private static final java.util.Set STRUCTURED_TYPES = java.util.Set.of("user", "feedback", "project", "reference"); - /** Per-agent 锁,防止并发写入 */ - private final ConcurrentHashMap agentLocks = new ConcurrentHashMap<>(); + /** Per-(agent, owner) 锁,防止并发写入 */ + private final ConcurrentHashMap agentLocks = new ConcurrentHashMap<>(); - /** Per-agent 冷却时间记录 */ - private final ConcurrentHashMap lastRunTimes = new ConcurrentHashMap<>(); + /** Per-(agent, owner) 冷却时间记录 */ + private final ConcurrentHashMap lastRunTimes = new ConcurrentHashMap<>(); + + /** Backwards-compatible entry without an owner key (writes shared memory). */ + public void analyzeAndUpdateMemory(Long agentId, String conversationId) { + analyzeAndUpdateMemory(agentId, conversationId, null); + } /** * 分析对话并更新记忆文件 * * @param agentId Agent ID * @param conversationId 会话 ID + * @param ownerKey memory owner this conversation is attributed to; null + * or "system" writes shared (TEAM) memory, otherwise + * memory is written PERSONAL to this owner */ - public void analyzeAndUpdateMemory(Long agentId, String conversationId) { + public void analyzeAndUpdateMemory(Long agentId, String conversationId, String ownerKey) { + // Per-owner isolation is gated on the lifecycle prefetch path, which is + // the only auto-injector of PERSONAL memory. When that path is off, + // writing PERSONAL would strand memory in a bucket nothing auto-reads, + // so fall back to shared (legacy) writes — isolation activates together + // with lifecycleMediatorEnabled. + if (!properties.isLifecycleMediatorEnabled()) { + ownerKey = null; + } + // Lock / cooldown are keyed per (agent, owner) so one owner's busy + // extraction never starves another owner sharing the same agent. + String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey); + // 冷却检查 - if (isInCooldown(agentId)) { - log.debug("[Memory] Agent {} is in cooldown, skipping summarization", agentId); + if (isInCooldown(lockKey)) { + log.debug("[Memory] Agent {} (owner {}) is in cooldown, skipping summarization", agentId, ownerKey); return; } - ReentrantLock lock = agentLocks.computeIfAbsent(agentId, k -> new ReentrantLock()); + ReentrantLock lock = agentLocks.computeIfAbsent(lockKey, k -> new ReentrantLock()); if (!lock.tryLock()) { - log.debug("[Memory] Agent {} is already being summarized, skipping", agentId); + log.debug("[Memory] Agent {} (owner {}) is already being summarized, skipping", agentId, ownerKey); return; } try { - doAnalyzeAndUpdate(agentId, conversationId); - lastRunTimes.put(agentId, Instant.now()); + doAnalyzeAndUpdate(agentId, conversationId, ownerKey); + lastRunTimes.put(lockKey, Instant.now()); } finally { lock.unlock(); } } - private void doAnalyzeAndUpdate(Long agentId, String conversationId) { + private void doAnalyzeAndUpdate(Long agentId, String conversationId, String ownerKey) { // 1. 加载对话消息 List messages = conversationService.listMessages(conversationId); if (messages.size() < properties.getMinMessagesForSummarize()) { @@ -100,11 +120,11 @@ public class MemorySummarizationService { return; } - // 2. 加载现有记忆文件内容 - String profileContent = readFileContentSafe(agentId, "PROFILE.md"); - String memoryContent = readFileContentSafe(agentId, "MEMORY.md"); + // 2. 加载现有记忆文件内容(按 owner 隔离) + String profileContent = readFileContentSafe(agentId, "PROFILE.md", ownerKey); + String memoryContent = readFileContentSafe(agentId, "MEMORY.md", ownerKey); String dailyFilename = "memory/" + LocalDate.now() + ".md"; - String dailyContent = readFileContentSafe(agentId, dailyFilename); + String dailyContent = readFileContentSafe(agentId, dailyFilename, ownerKey); // 3. 构建对话 transcript String transcript = buildTranscript(messages); @@ -153,7 +173,7 @@ public class MemorySummarizationService { } // 6. 应用更新 - applyUpdates(agentId, root, dailyFilename, dailyContent); + applyUpdates(agentId, root, dailyFilename, dailyContent, ownerKey); String reason = root.path("reason").asText(""); log.info("[Memory] Memory updated for agent={}, conv={}: {}", agentId, conversationId, reason); @@ -164,7 +184,8 @@ public class MemorySummarizationService { } } - private void applyUpdates(Long agentId, JsonNode root, String dailyFilename, String existingDailyContent) { + private void applyUpdates(Long agentId, JsonNode root, String dailyFilename, + String existingDailyContent, String ownerKey) { // Daily entry: 追加模式 JsonNode dailyNode = root.path("daily_entry"); if (!dailyNode.isNull() && dailyNode.isTextual()) { @@ -173,8 +194,8 @@ public class MemorySummarizationService { String newContent = existingDailyContent.isEmpty() ? "# " + LocalDate.now() + "\n\n" + entry : existingDailyContent + "\n\n" + entry; - workspaceFileService.saveFile(agentId, dailyFilename, newContent); - log.info("[Memory] Appended daily entry to {} for agent={}", dailyFilename, agentId); + saveMemory(agentId, dailyFilename, newContent, ownerKey); + log.info("[Memory] Appended daily entry to {} for agent={}, owner={}", dailyFilename, agentId, ownerKey); } } @@ -183,8 +204,8 @@ public class MemorySummarizationService { if (!memoryNode.isNull() && memoryNode.isTextual()) { String content = memoryNode.asText().trim(); if (!content.isEmpty()) { - workspaceFileService.saveFile(agentId, "MEMORY.md", content); - log.info("[Memory] Updated MEMORY.md for agent={}", agentId); + saveMemory(agentId, "MEMORY.md", content, ownerKey); + log.info("[Memory] Updated MEMORY.md for agent={}, owner={}", agentId, ownerKey); } } @@ -193,8 +214,8 @@ public class MemorySummarizationService { if (!profileNode.isNull() && profileNode.isTextual()) { String content = profileNode.asText().trim(); if (!content.isEmpty()) { - workspaceFileService.saveFile(agentId, "PROFILE.md", content); - log.info("[Memory] Updated PROFILE.md for agent={}", agentId); + saveMemory(agentId, "PROFILE.md", content, ownerKey); + log.info("[Memory] Updated PROFILE.md for agent={}, owner={}", agentId, ownerKey); } } @@ -202,10 +223,10 @@ public class MemorySummarizationService { // reference facts kept out of the always-on MEMORY.md) into structured // memory so they become query-conditioned recallable, instead of being // stranded in daily notes that only the agent's tools can reach. - applyStructuredEntries(agentId, root.path("structured_entries")); + applyStructuredEntries(agentId, root.path("structured_entries"), ownerKey); } - private void applyStructuredEntries(Long agentId, JsonNode entriesNode) { + private void applyStructuredEntries(Long agentId, JsonNode entriesNode, String ownerKey) { if (entriesNode == null || !entriesNode.isArray() || entriesNode.isEmpty()) { return; } @@ -220,7 +241,7 @@ public class MemorySummarizationService { continue; } try { - structuredMemoryService.remember(agentId, type, key, content, "auto-summary"); + structuredMemoryService.remember(agentId, type, key, content, "auto-summary", ownerKey); written++; } catch (Exception e) { log.warn("[Memory] Failed to write structured entry '{}' (type={}) for agent={}: {}", @@ -286,15 +307,40 @@ public class MemorySummarizationService { } } - private String readFileContentSafe(Long agentId, String filename) { + /** + * Read an owner-scoped memory file. When {@code ownerKey} denotes a real + * owner the row is looked up by (agent, filename, owner); otherwise it falls + * back to the shared file so cron / system extraction keeps working. + */ + private String readFileContentSafe(Long agentId, String filename, String ownerKey) { try { - WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); + WorkspaceFileEntity file = isPersonal(ownerKey) + ? workspaceFileService.getMemoryFile(agentId, filename, ownerKey) + : workspaceFileService.getFile(agentId, filename); return file != null && file.getContent() != null ? file.getContent() : ""; } catch (Exception e) { return ""; } } + /** + * Persist extracted memory to the owner's PERSONAL bucket, or to the shared + * (TEAM) file when there is no real owner (cron / system). + */ + private void saveMemory(Long agentId, String filename, String content, String ownerKey) { + if (isPersonal(ownerKey)) { + workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey); + } else { + workspaceFileService.saveFile(agentId, filename, content); + } + } + + /** A real, isolatable owner — i.e. not null/blank and not the system bucket. */ + private boolean isPersonal(String ownerKey) { + return ownerKey != null && !ownerKey.isBlank() + && !vip.mate.memory.identity.MemoryOwnerResolver.SYSTEM_OWNER.equals(ownerKey); + } + /** * 带轻量重试的 LLM 调用:遇到 429 时等待后重试,避免后台任务因限流直接放弃。 * Spring AI RetryTemplate 已处理第一层重试,此方法作为二次保护。 @@ -331,8 +377,8 @@ public class MemorySummarizationService { || msg.contains("速率限制") || msg.contains("Too Many Requests")); } - private boolean isInCooldown(Long agentId) { - Instant lastRun = lastRunTimes.get(agentId); + private boolean isInCooldown(String lockKey) { + Instant lastRun = lastRunTimes.get(lockKey); if (lastRun == null) return false; long cooldownSeconds = properties.getCooldownMinutes() * 60L; return Instant.now().isBefore(lastRun.plusSeconds(cooldownSeconds)); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java index 9b57659f..0edfc717 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java @@ -114,13 +114,18 @@ public class StructuredMemoryService { * Uses per-file locking to handle concurrent tool calls writing to the same file. */ public void remember(Long agentId, String type, String key, String content, String source) { + remember(agentId, type, key, content, source, null); + } + + /** Owner-scoped variant of {@link #remember}. */ + public void remember(Long agentId, String type, String key, String content, String source, String ownerKey) { validateType(type); String filename = toFilename(type); - String lockKey = agentId + ":" + filename; + String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename; ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock()); lock.lock(); try { - String fileContent = readFileSafe(agentId, filename); + String fileContent = readFileSafe(agentId, filename, ownerKey); String metadata = "> Source: " + (source != null ? source : "agent") + " | Updated: " + LocalDate.now(); @@ -136,7 +141,7 @@ public class StructuredMemoryService { updated = fileContent.isBlank() ? newSection : fileContent.trim() + "\n\n" + newSection; } - workspaceFileService.saveFile(agentId, filename, updated); + saveStructured(agentId, filename, updated, ownerKey); log.info("[StructuredMemory] {} entry '{}' for agent={} (source={})", existingSection != null ? "Updated" : "Added", key, agentId, source); // Publish event for SOUL auto-evolution (Phase 2) @@ -150,6 +155,11 @@ public class StructuredMemoryService { * Search entries by type and optional keyword. */ public List> recall(Long agentId, String type, String keyword) { + return recall(agentId, type, keyword, null); + } + + /** Owner-scoped variant of {@link #recall(Long, String, String)}. */ + public List> recall(Long agentId, String type, String keyword, String ownerKey) { if (type != null) { validateType(type); } @@ -158,7 +168,7 @@ public class StructuredMemoryService { List> results = new ArrayList<>(); for (String t : types) { - String fileContent = readFileSafe(agentId, toFilename(t)); + String fileContent = readFileSafe(agentId, toFilename(t), ownerKey); if (fileContent.isBlank()) continue; Map sections = parseSections(fileContent); @@ -181,13 +191,18 @@ public class StructuredMemoryService { * Remove a memory entry by type and key. */ public boolean forget(Long agentId, String type, String key) { + return forget(agentId, type, key, null); + } + + /** Owner-scoped variant of {@link #forget(Long, String, String)}. */ + public boolean forget(Long agentId, String type, String key, String ownerKey) { validateType(type); String filename = toFilename(type); - String lockKey = agentId + ":" + filename; + String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename; ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock()); lock.lock(); try { - String fileContent = readFileSafe(agentId, filename); + String fileContent = readFileSafe(agentId, filename, ownerKey); if (fileContent.isBlank()) return false; String section = findSection(fileContent, key); @@ -196,7 +211,7 @@ public class StructuredMemoryService { String updated = fileContent.replace(section, "").trim(); // Clean up double blank lines updated = updated.replaceAll("\n{3,}", "\n\n"); - workspaceFileService.saveFile(agentId, filename, updated); + saveStructured(agentId, filename, updated, ownerKey); log.info("[StructuredMemory] Removed entry '{}' (type={}) for agent={}", key, type, agentId); return true; } finally { @@ -211,17 +226,27 @@ public class StructuredMemoryService { return recall(agentId, type, null); } + /** Owner-scoped variant of {@link #listEntries(Long, String)}. */ + public List> listEntries(Long agentId, String type, String ownerKey) { + return recall(agentId, type, null, ownerKey); + } + /** * Build a formatted memory block for system prompt injection. * Includes only the stable, low-volume entry types ({@link #SYSTEM_PROMPT_TYPES}); * growing/specific types are surfaced per-turn via {@link #buildPrefetchBlock}. */ public String buildMemoryBlock(Long agentId) { + return buildMemoryBlock(agentId, null); + } + + /** Owner-scoped variant of {@link #buildMemoryBlock(Long)}. */ + public String buildMemoryBlock(Long agentId, String ownerKey) { StringBuilder sb = new StringBuilder(); boolean hasContent = false; for (String type : SYSTEM_PROMPT_TYPES) { - String fileContent = readFileSafe(agentId, toFilename(type)); + String fileContent = readFileSafe(agentId, toFilename(type), ownerKey); if (fileContent.isBlank()) continue; Map sections = parseSections(fileContent); @@ -253,9 +278,14 @@ public class StructuredMemoryService { * instead of the specific stored fact. */ public String buildPrefetchBlock(Long agentId, String userQuery) { + return buildPrefetchBlock(agentId, userQuery, null); + } + + /** Owner-scoped variant of {@link #buildPrefetchBlock(Long, String)}. */ + public String buildPrefetchBlock(Long agentId, String userQuery, String ownerKey) { if (userQuery == null || userQuery.isBlank()) return ""; - List scored = recallRelevant(agentId, userQuery, PREFETCH_TYPES, MAX_PREFETCH_ENTRIES); + List scored = recallRelevant(agentId, userQuery, PREFETCH_TYPES, MAX_PREFETCH_ENTRIES, ownerKey); if (scored.isEmpty()) return ""; boolean hasProject = scored.stream().anyMatch(e -> "project".equals(e.type())); @@ -282,12 +312,16 @@ public class StructuredMemoryService { * highest-scoring matches (score > 0), best first, capped at {@code limit}. */ private List recallRelevant(Long agentId, String userQuery, List types, int limit) { + return recallRelevant(agentId, userQuery, types, limit, null); + } + + private List recallRelevant(Long agentId, String userQuery, List types, int limit, String ownerKey) { String q = userQuery.toLowerCase(); Set queryShingles = shingles(q); List matches = new ArrayList<>(); for (String t : types) { - String fileContent = readFileSafe(agentId, toFilename(t)); + String fileContent = readFileSafe(agentId, toFilename(t), ownerKey); if (fileContent.isBlank()) continue; for (Map.Entry entry : parseSections(fileContent).entrySet()) { @@ -374,14 +408,35 @@ public class StructuredMemoryService { } private String readFileSafe(Long agentId, String filename) { + return readFileSafe(agentId, filename, null); + } + + private String readFileSafe(Long agentId, String filename, String ownerKey) { try { - WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); + WorkspaceFileEntity file = isPersonal(ownerKey) + ? workspaceFileService.getMemoryFile(agentId, filename, ownerKey) + : workspaceFileService.getFile(agentId, filename); return file != null && file.getContent() != null ? file.getContent() : ""; } catch (Exception e) { return ""; } } + /** Persist structured memory to the owner's PERSONAL bucket, or shared when no real owner. */ + private void saveStructured(Long agentId, String filename, String content, String ownerKey) { + if (isPersonal(ownerKey)) { + workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey); + } else { + workspaceFileService.saveFile(agentId, filename, content); + } + } + + /** A real, isolatable owner — not null/blank and not the system bucket. */ + private boolean isPersonal(String ownerKey) { + return ownerKey != null && !ownerKey.isBlank() + && !vip.mate.memory.identity.MemoryOwnerResolver.SYSTEM_OWNER.equals(ownerKey); + } + /** * Parse all sections from a Markdown file. * Returns map of key → full section content (including metadata line). diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java index 5ba4fb72..e0ba36ff 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java @@ -104,10 +104,19 @@ public class MemoryManager { * context as new user discourse. */ public String prefetchAll(Long agentId, String userQuery) { + return prefetchAll(agentId, userQuery, null); + } + + /** + * Owner-scoped prefetch. Passes the resolved memory {@code ownerKey} so + * providers recall only the current requester's personal memory plus + * shared (TEAM / GLOBAL) memory (per-owner isolation). + */ + public String prefetchAll(Long agentId, String userQuery, String ownerKey) { List parts = new ArrayList<>(); for (MemoryProvider provider : providers) { try { - String result = provider.prefetch(agentId, userQuery); + String result = provider.prefetch(agentId, userQuery, ownerKey); if (result != null && !result.isBlank()) { parts.add(sanitizeContext(result)); } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java index 4af0007b..00595074 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java @@ -64,6 +64,21 @@ public interface MemoryProvider { return ""; } + /** + * Owner-scoped pre-turn recall. Providers that isolate memory per end-user + * override this to recall only the given {@code ownerKey}'s personal memory + * plus shared memory. Default delegates to {@link #prefetch(Long, String)} + * for providers that are not owner-aware. + * + * @param agentId the agent ID + * @param userQuery the current user message + * @param ownerKey resolved memory owner key (e.g. "user:42"); may be null + * @return context text to inject, wrapped in a memory-context fence by MemoryManager + */ + default String prefetch(Long agentId, String userQuery, String ownerKey) { + return prefetch(agentId, userQuery); + } + /** * Post-turn sync. Called after LLM response is available. * Should be non-blocking (async). diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java index 850a7370..1617c0be 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java @@ -24,6 +24,7 @@ public abstract class MemoryProviderDecorator implements MemoryProvider { @Override public boolean isAvailable() { return delegate.isAvailable(); } @Override public String systemPromptBlock(Long agentId) { return delegate.systemPromptBlock(agentId); } @Override public String prefetch(Long agentId, String userQuery) { return delegate.prefetch(agentId, userQuery); } + @Override public String prefetch(Long agentId, String userQuery, String ownerKey) { return delegate.prefetch(agentId, userQuery, ownerKey); } @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java index 90b1f792..04d76382 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java @@ -40,9 +40,14 @@ public class MetricsMemoryProvider extends MemoryProviderDecorator { @Override public String prefetch(Long agentId, String userQuery) { + return prefetch(agentId, userQuery, null); + } + + @Override + public String prefetch(Long agentId, String userQuery, String ownerKey) { return prefetchTimer.record(() -> { try { - return delegate.prefetch(agentId, userQuery); + return delegate.prefetch(agentId, userQuery, ownerKey); } catch (Exception e) { meterRegistry.counter("memory.prefetch.failures", "provider", delegate.id()).increment(); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java index 6d88583c..fd99dd1c 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java @@ -20,10 +20,15 @@ public class RetryableMemoryProvider extends MemoryProviderDecorator { @Override public String prefetch(Long agentId, String userQuery) { + return prefetch(agentId, userQuery, null); + } + + @Override + public String prefetch(Long agentId, String userQuery, String ownerKey) { Exception lastException = null; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { - return delegate.prefetch(agentId, userQuery); + return delegate.prefetch(agentId, userQuery, ownerKey); } catch (Exception e) { lastException = e; if (attempt < maxAttempts) { diff --git a/mateclaw-server/src/main/java/vip/mate/memory/tool/StructuredMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/memory/tool/StructuredMemoryTool.java index e26f8f09..be30b600 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/tool/StructuredMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/tool/StructuredMemoryTool.java @@ -4,9 +4,13 @@ import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.memory.service.StructuredMemoryService; import java.util.List; @@ -28,6 +32,22 @@ import java.util.Map; public class StructuredMemoryTool { private final StructuredMemoryService structuredMemoryService; + private final MemoryOwnerResolver memoryOwnerResolver; + private final MemoryProperties memoryProperties; + + /** Owner key for reads: the resolved requester (visibility = shared + own personal). */ + private String readOwner(ToolContext ctx) { + return memoryOwnerResolver.resolve(ChatOrigin.from(ctx)); + } + + /** + * Owner key for writes/deletes: the resolved requester only when per-owner + * isolation is active; otherwise null so the entry lands in the shared + * bucket rather than an un-read PERSONAL row. + */ + private String writeOwner(ToolContext ctx) { + return memoryProperties.isLifecycleMediatorEnabled() ? readOwner(ctx) : null; + } @Tool(description = """ 记住一条结构化信息到 Agent 的长期记忆。 @@ -43,7 +63,8 @@ public class StructuredMemoryTool { @ToolParam(description = "当前 Agent 的 ID") Long agentId, @ToolParam(description = "记忆类型:user / feedback / project / reference") String type, @ToolParam(description = "条目标识符(snake_case),例如 preferred_language") String key, - @ToolParam(description = "条目内容") String content) { + @ToolParam(description = "条目内容") String content, + ToolContext toolContext) { if (agentId == null || type == null || key == null || content == null) { return error("agentId, type, key, content 均不能为空"); @@ -51,7 +72,7 @@ public class StructuredMemoryTool { try { structuredMemoryService.remember(agentId, type.trim().toLowerCase(), - key.trim(), content.trim(), "agent"); + key.trim(), content.trim(), "agent", writeOwner(toolContext)); JSONObject result = new JSONObject(); result.set("success", true); @@ -75,7 +96,8 @@ public class StructuredMemoryTool { public String recall_structured( @ToolParam(description = "当前 Agent 的 ID") Long agentId, @ToolParam(description = "记忆类型过滤(可选):user / feedback / project / reference", required = false) String type, - @ToolParam(description = "搜索关键词(可选),匹配 key 和内容", required = false) String keyword) { + @ToolParam(description = "搜索关键词(可选),匹配 key 和内容", required = false) String keyword, + ToolContext toolContext) { if (agentId == null) { return error("agentId 不能为空"); @@ -85,7 +107,8 @@ public class StructuredMemoryTool { List> results = structuredMemoryService.recall( agentId, type != null && !type.isBlank() ? type.trim().toLowerCase() : null, - keyword); + keyword, + readOwner(toolContext)); JSONObject result = new JSONObject(); result.set("agentId", agentId); @@ -107,7 +130,8 @@ public class StructuredMemoryTool { public String forget_structured( @ToolParam(description = "当前 Agent 的 ID") Long agentId, @ToolParam(description = "记忆类型:user / feedback / project / reference") String type, - @ToolParam(description = "要删除的条目标识符") String key) { + @ToolParam(description = "要删除的条目标识符") String key, + ToolContext toolContext) { if (agentId == null || type == null || key == null) { return error("agentId, type, key 均不能为空"); @@ -115,7 +139,7 @@ public class StructuredMemoryTool { try { boolean removed = structuredMemoryService.forget(agentId, - type.trim().toLowerCase(), key.trim()); + type.trim().toLowerCase(), key.trim(), writeOwner(toolContext)); JSONObject result = new JSONObject(); result.set("success", removed); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java index a9894c3a..4351735c 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java @@ -4,11 +4,15 @@ import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.memory.MemoryProperties; import vip.mate.memory.event.MemoryWriteEvent; +import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.workspace.document.model.WorkspaceFileEntity; import vip.mate.workspace.document.WorkspaceFileService; @@ -41,6 +45,8 @@ public class UniversalMemoryTool { private final WorkspaceFileService workspaceFileService; private final ApplicationEventPublisher eventPublisher; + private final MemoryOwnerResolver memoryOwnerResolver; + private final MemoryProperties memoryProperties; @Tool(description = """ 将一条自由形式的经验或洞察追加到 Agent 的长期记忆 (MEMORY.md)。 @@ -51,17 +57,24 @@ public class UniversalMemoryTool { public String remember( @ToolParam(description = "当前 Agent 的 ID") Long agentId, @ToolParam(description = "要记住的内容(自由形式)") String content, - @ToolParam(description = "可选:来源上下文(skill 名 / conversation id)", required = false) String source) { + @ToolParam(description = "可选:来源上下文(skill 名 / conversation id)", required = false) String source, + ToolContext toolContext) { if (agentId == null) return error("agentId 不能为空"); if (content == null || content.isBlank()) return error("content 不能为空"); try { - WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, MEMORY_FILENAME); + // Write to the requester's PERSONAL MEMORY.md when per-owner isolation + // is active; otherwise the shared file (so the note is not stranded + // in an un-read PERSONAL row). + String ownerKey = memoryProperties.isLifecycleMediatorEnabled() + ? memoryOwnerResolver.resolve(ChatOrigin.from(toolContext)) + : null; + WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(agentId, MEMORY_FILENAME, ownerKey); String existingContent = existing != null && existing.getContent() != null ? existing.getContent() : ""; String updated = appendLesson(existingContent, content, source); - workspaceFileService.saveFile(agentId, MEMORY_FILENAME, updated); + workspaceFileService.saveVisibleFile(agentId, MEMORY_FILENAME, updated, ownerKey); // RFC-090 §14.3 — universal remember() targets MEMORY.md (the // canonical file), so this IS a MemoryWriteEvent. Skill-local 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 cfe453c4..3c2e28c1 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 @@ -5,9 +5,13 @@ import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.memory.service.MemoryRecallTracker; import vip.mate.workspace.document.MemorySearchHit; import vip.mate.workspace.document.WorkspaceFileService; @@ -32,6 +36,22 @@ public class WorkspaceMemoryTool { private final WorkspaceFileService workspaceFileService; private final MemoryRecallTracker memoryRecallTracker; + private final MemoryOwnerResolver memoryOwnerResolver; + private final MemoryProperties memoryProperties; + + /** Owner key for reads: always the resolved requester (visibility = shared + own personal). */ + private String readOwner(ToolContext ctx) { + return memoryOwnerResolver.resolve(ChatOrigin.from(ctx)); + } + + /** + * Owner key for writes: the resolved requester only when per-owner isolation + * is active (lifecycle prefetch on); otherwise null so the write lands in + * the shared bucket and is not stranded in an un-read PERSONAL row. + */ + private String writeOwner(ToolContext ctx) { + return memoryProperties.isLifecycleMediatorEnabled() ? readOwner(ctx) : null; + } @Tool(description = """ 列出指定 Agent 的数据库工作区记忆文件。 @@ -40,13 +60,14 @@ public class WorkspaceMemoryTool { """) public String list_workspace_memory_files( @ToolParam(description = "当前 Agent 的 ID") Long agentId, - @ToolParam(description = "可选:按文件名前缀过滤,例如 memory/ 或 MEM", required = false) String filenamePrefix) { + @ToolParam(description = "可选:按文件名前缀过滤,例如 memory/ 或 MEM", required = false) String filenamePrefix, + ToolContext toolContext) { if (agentId == null) { return error("agentId 不能为空"); } - List files = workspaceFileService.listFiles(agentId).stream() + List files = workspaceFileService.listVisibleFiles(agentId, readOwner(toolContext)).stream() .filter(file -> filenamePrefix == null || filenamePrefix.isBlank() || (file.getFilename() != null && file.getFilename().startsWith(filenamePrefix))) .sorted(Comparator @@ -78,14 +99,15 @@ public class WorkspaceMemoryTool { """) public String read_workspace_memory_file( @ToolParam(description = "当前 Agent 的 ID") Long agentId, - @ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename) { + @ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename, + ToolContext toolContext) { String validation = validate(agentId, filename); if (validation != null) { return error(validation); } - WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); + WorkspaceFileEntity file = workspaceFileService.getVisibleFile(agentId, filename, readOwner(toolContext)); if (file == null) { return error("工作区文件不存在: " + filename); } @@ -116,15 +138,17 @@ public class WorkspaceMemoryTool { public String write_workspace_memory_file( @ToolParam(description = "当前 Agent 的 ID") Long agentId, @ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename, - @ToolParam(description = "要写入的完整 Markdown 内容") String content) { + @ToolParam(description = "要写入的完整 Markdown 内容") String content, + ToolContext toolContext) { String validation = validate(agentId, filename); if (validation != null) { return error(validation); } - WorkspaceFileEntity before = workspaceFileService.getFile(agentId, filename); - WorkspaceFileEntity saved = workspaceFileService.saveFile(agentId, filename, content != null ? content : ""); + String ownerKey = writeOwner(toolContext); + WorkspaceFileEntity before = workspaceFileService.getVisibleFile(agentId, filename, ownerKey); + WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(agentId, filename, content != null ? content : "", ownerKey); JSONObject result = new JSONObject(); result.set("agentId", agentId); @@ -149,7 +173,8 @@ public class WorkspaceMemoryTool { @ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename, @ToolParam(description = "要查找的原始文本,要求精确匹配") String oldText, @ToolParam(description = "替换后的新文本") String newText, - @ToolParam(description = "是否替换全部匹配项,默认 false", required = false) Boolean replaceAll) { + @ToolParam(description = "是否替换全部匹配项,默认 false", required = false) Boolean replaceAll, + ToolContext toolContext) { String validation = validate(agentId, filename); if (validation != null) { @@ -165,7 +190,8 @@ public class WorkspaceMemoryTool { return error("oldText 和 newText 相同,无需替换"); } - WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, filename); + String ownerKey = writeOwner(toolContext); + WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(agentId, filename, ownerKey); if (existing == null) { return error("工作区文件不存在: " + filename); } @@ -187,7 +213,7 @@ public class WorkspaceMemoryTool { replacements = 1; } - workspaceFileService.saveFile(agentId, filename, updated); + workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey); JSONObject result = new JSONObject(); result.set("agentId", agentId); @@ -211,7 +237,8 @@ public class WorkspaceMemoryTool { @ToolParam(description = "关键词或短语,2-64 字符") String query, @ToolParam(description = "搜索范围:all(全部)/ memory(MEMORY.md 与 memory/)/ profile / persona,默认 all", required = false) String scope, - @ToolParam(description = "返回的最大命中数,默认 10,上限 30", required = false) Integer limit) { + @ToolParam(description = "返回的最大命中数,默认 10,上限 30", required = false) Integer limit, + ToolContext toolContext) { if (agentId == null) { return error("agentId 不能为空"); @@ -230,8 +257,11 @@ public class WorkspaceMemoryTool { int effectiveLimit = limit == null ? 10 : Math.min(Math.max(limit, 1), 30); Set prefixes = resolveScope(scope); + // Restrict hits to memory the current requester may see: shared memory + // plus this owner's PERSONAL memory only. + String ownerKey = readOwner(toolContext); List hits = workspaceFileService.searchSnippets( - agentId, trimmed, prefixes, effectiveLimit); + agentId, trimmed, prefixes, effectiveLimit, ownerKey); // Treat each unique file in the results as an active retrieval signal — // boosts that file's weight in the dream-consolidation ranker the same @@ -239,7 +269,9 @@ public class WorkspaceMemoryTool { Set retrieved = new HashSet<>(); for (MemorySearchHit hit : hits) { if (retrieved.add(hit.filename())) { - WorkspaceFileEntity file = workspaceFileService.getFile(agentId, hit.filename()); + // Read the same visible row the hit came from (the owner's + // PERSONAL row when present) so PERSONAL hits track correctly. + WorkspaceFileEntity file = workspaceFileService.getVisibleFile(agentId, hit.filename(), ownerKey); if (file != null && file.getContent() != null) { memoryRecallTracker.trackActiveRetrieval(agentId, hit.filename(), file.getContent()); } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java index 5f900f9d..b9fa712f 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vip.mate.memory.identity.MemoryScope; import vip.mate.workspace.document.event.WorkspaceFileChangedEvent; import vip.mate.workspace.document.model.WorkspaceFileEntity; import vip.mate.workspace.document.repository.WorkspaceFileMapper; @@ -50,19 +51,39 @@ public class WorkspaceFileService { } /** - * 读取单个文件(含内容) + * Read a single shared (config / persona) file by name. + *

+ * Restricted to TEAM / GLOBAL scope so it never matches — or accidentally + * mutates — an owner's PERSONAL row that happens to share the same filename + * (e.g. MEMORY.md). Owner-scoped reads must use + * {@link #getMemoryFile(Long, String, String)}. Uses the non-throwing + * {@code selectOne(wrapper, false)} so duplicate rows can never surface a + * {@code TooManyResultsException}. */ public WorkspaceFileEntity getFile(Long agentId, String filename) { return fileMapper.selectOne( new LambdaQueryWrapper() .eq(WorkspaceFileEntity::getAgentId, agentId) - .eq(WorkspaceFileEntity::getFilename, filename)); + .eq(WorkspaceFileEntity::getFilename, filename) + .in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL) + .orderByAsc(WorkspaceFileEntity::getId), + false); } /** - * 创建或更新文件 + * 创建或更新文件(共享 / 配置文件路径) + *

+ * Used by the agent-config surface (AGENTS.md, SOUL.md, PROFILE.md …). + * Rows written here are TEAM-scoped and shared by everyone using the agent. + * Conversation-derived memory must go through + * {@link #saveMemoryFile(Long, String, String, String)} instead so it is + * attributed to a single owner. */ - @Transactional + // NOTE: intentionally NOT @Transactional. These are single-row upserts and + // the dup-key fallback below reselects+updates after a failed insert — under + // a transaction the failed insert would mark it rollback-only and poison the + // recovery update. WorkspaceFileChangedEvent uses a plain @EventListener + // (not @TransactionalEventListener), so event timing is unaffected. public WorkspaceFileEntity saveFile(Long agentId, String filename, String content) { WorkspaceFileEntity existing = getFile(agentId, filename); long size = content != null ? content.getBytes(StandardCharsets.UTF_8).length : 0; @@ -73,29 +94,200 @@ public class WorkspaceFileService { fileMapper.updateById(existing); eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); return existing; - } else { - WorkspaceFileEntity entity = new WorkspaceFileEntity(); - entity.setAgentId(agentId); - entity.setFilename(filename); - entity.setContent(content); - entity.setFileSize(size); - entity.setEnabled(false); - entity.setSortOrder(0); - fileMapper.insert(entity); - eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); - return entity; } + WorkspaceFileEntity entity = new WorkspaceFileEntity(); + entity.setAgentId(agentId); + entity.setFilename(filename); + entity.setContent(content); + entity.setFileSize(size); + entity.setEnabled(false); + entity.setSortOrder(0); + entity.setScope(MemoryScope.TEAM); + // Shared rows use the empty-string sentinel (not NULL) so the + // (agent_id, filename, owner_key) unique index treats one shared + // row per filename as a single slot — NULLs are considered distinct + // by both H2 and MySQL unique indexes and would not be deduped. + entity.setOwnerKey(SHARED_OWNER_KEY); + WorkspaceFileEntity saved = insertOrUpdateOnConflict( + entity, () -> getFile(agentId, filename), content, size); + eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); + return saved; } /** - * 删除文件 + * Insert {@code entity}; if a concurrent writer already created the row + * (unique-index violation), reselect via {@code reselect} and update its + * content instead of throwing. Makes the check-then-insert in + * saveFile / saveMemoryFile safe under concurrent / multi-node first writes. + */ + private WorkspaceFileEntity insertOrUpdateOnConflict(WorkspaceFileEntity entity, + java.util.function.Supplier reselect, + String content, long size) { + try { + fileMapper.insert(entity); + return entity; + } catch (org.springframework.dao.DuplicateKeyException dup) { + // Only recover the specific concurrent first-write race on the + // owner-scope unique index. Any other duplicate (e.g. a primary-key + // collision, or a future unique constraint) must surface — silently + // reselecting+updating would mask a real bug. The driver message + // names the violated index on both MySQL and H2. + String msg = dup.getMessage(); + if (msg == null || !msg.toLowerCase().contains(UK_OWNER_INDEX)) { + throw dup; + } + WorkspaceFileEntity raced = reselect.get(); + if (raced != null) { + raced.setContent(content); + raced.setFileSize(size); + fileMapper.updateById(raced); + return raced; + } + throw dup; + } + } + + /** Name of the (agent_id, filename, owner_key) unique index — see V137 migration. */ + private static final String UK_OWNER_INDEX = "uk_workspace_file_owner"; + + /** Sentinel owner key for shared (TEAM / GLOBAL) rows — keeps the unique index effective. */ + static final String SHARED_OWNER_KEY = ""; + + /** A real, isolatable owner — not null/blank and not the system bucket. */ + private boolean isPersonalOwner(String ownerKey) { + return ownerKey != null && !ownerKey.isBlank() + && !vip.mate.memory.identity.MemoryOwnerResolver.SYSTEM_OWNER.equals(ownerKey); + } + + /** + * List files visible to {@code ownerKey}: shared (TEAM / GLOBAL) rows plus + * this owner's PERSONAL rows. A null/blank/system ownerKey lists shared + * only. Content is stripped (metadata listing). + */ + public List listVisibleFiles(Long agentId, String ownerKey) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(WorkspaceFileEntity::getAgentId, agentId); + applyScopeVisibility(wrapper, isPersonalOwner(ownerKey) ? ownerKey : null); + wrapper.orderByAsc(WorkspaceFileEntity::getSortOrder) + .orderByAsc(WorkspaceFileEntity::getFilename); + List files = fileMapper.selectList(wrapper); + files.forEach(f -> f.setContent(null)); + return files; + } + + /** + * Read a file visible to {@code ownerKey}: the owner's PERSONAL row when it + * exists, otherwise the shared row. Null when neither exists. + */ + public WorkspaceFileEntity getVisibleFile(Long agentId, String filename, String ownerKey) { + if (isPersonalOwner(ownerKey)) { + WorkspaceFileEntity personal = getMemoryFile(agentId, filename, ownerKey); + if (personal != null) { + return personal; + } + } + return getFile(agentId, filename); + } + + /** + * Save a file to the owner's PERSONAL bucket when {@code ownerKey} denotes a + * real owner, otherwise to the shared (TEAM) file. The single entry point + * tools should use so per-owner isolation and the shared fallback stay + * consistent. + */ + public WorkspaceFileEntity saveVisibleFile(Long agentId, String filename, String content, String ownerKey) { + return isPersonalOwner(ownerKey) + ? saveMemoryFile(agentId, filename, content, ownerKey) + : saveFile(agentId, filename, content); + } + + /** + * Read a memory file scoped to a single owner. + *

+ * Daily ledgers and consolidated memory share a filename across owners + * (e.g. {@code memory/2026-06-02.md}), so the lookup key is + * {@code (agentId, filename, ownerKey)} — otherwise two end-users sharing + * one agent would clobber each other's row. + */ + public WorkspaceFileEntity getMemoryFile(Long agentId, String filename, String ownerKey) { + return fileMapper.selectOne( + new LambdaQueryWrapper() + .eq(WorkspaceFileEntity::getAgentId, agentId) + .eq(WorkspaceFileEntity::getFilename, filename) + .eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL) + .eq(WorkspaceFileEntity::getOwnerKey, ownerKey) + .orderByAsc(WorkspaceFileEntity::getId), + false); + } + + /** + * Create or update a PERSONAL, owner-scoped memory file. + *

+ * Rows written here carry {@code scope=PERSONAL} + {@code ownerKey} and are + * enabled so the per-turn memory injection ({@code prefetch}) picks them up + * for that owner only. + */ + // NOTE: intentionally NOT @Transactional — see saveFile for the dup-key + // recovery rationale. + public WorkspaceFileEntity saveMemoryFile(Long agentId, String filename, String content, String ownerKey) { + WorkspaceFileEntity existing = getMemoryFile(agentId, filename, ownerKey); + long size = content != null ? content.getBytes(StandardCharsets.UTF_8).length : 0; + + if (existing != null) { + existing.setContent(content); + existing.setFileSize(size); + fileMapper.updateById(existing); + eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); + return existing; + } + WorkspaceFileEntity entity = new WorkspaceFileEntity(); + entity.setAgentId(agentId); + entity.setFilename(filename); + entity.setContent(content); + entity.setFileSize(size); + entity.setEnabled(true); + entity.setSortOrder(0); + entity.setOwnerKey(ownerKey); + entity.setScope(MemoryScope.PERSONAL); + WorkspaceFileEntity saved = insertOrUpdateOnConflict( + entity, () -> getMemoryFile(agentId, filename, ownerKey), content, size); + eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); + return saved; + } + + /** + * Delete a shared (config / persona) file by name. + *

+ * Scoped to TEAM / GLOBAL so the config-editor surface can never wipe every + * owner's same-named PERSONAL row (e.g. all users' {@code MEMORY.md}). + * Owner-scoped deletion goes through + * {@link #deleteMemoryFile(Long, String, String)}. */ @Transactional public void deleteFile(Long agentId, String filename) { fileMapper.delete( new LambdaQueryWrapper() .eq(WorkspaceFileEntity::getAgentId, agentId) - .eq(WorkspaceFileEntity::getFilename, filename)); + .eq(WorkspaceFileEntity::getFilename, filename) + .in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)); + eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); + } + + /** + * Delete a single owner's PERSONAL file by name. Only ever removes the row + * belonging to {@code ownerKey}, never another owner's or the shared row. + */ + @Transactional + public void deleteMemoryFile(Long agentId, String filename, String ownerKey) { + if (!isPersonalOwner(ownerKey)) { + return; + } + fileMapper.delete( + new LambdaQueryWrapper() + .eq(WorkspaceFileEntity::getAgentId, agentId) + .eq(WorkspaceFileEntity::getFilename, filename) + .eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL) + .eq(WorkspaceFileEntity::getOwnerKey, ownerKey)); eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); } @@ -107,6 +299,10 @@ public class WorkspaceFileService { new LambdaQueryWrapper() .eq(WorkspaceFileEntity::getAgentId, agentId) .eq(WorkspaceFileEntity::getEnabled, true) + // System-prompt file management operates on shared config + // files only; PERSONAL memory rows (enabled by default) + // must never appear in or be toggled by this surface. + .in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL) .orderByAsc(WorkspaceFileEntity::getSortOrder)) .stream() .map(WorkspaceFileEntity::getFilename) @@ -121,9 +317,14 @@ public class WorkspaceFileService { */ @Transactional public void setPromptFiles(Long agentId, List filenames) { + // Only shared config files participate in system-prompt enable/disable. + // PERSONAL memory rows are enabled per-owner by saveMemoryFile and must + // not be batch-toggled by filename here (that would flip every owner's + // row sharing that filename). List allFiles = fileMapper.selectList( new LambdaQueryWrapper() - .eq(WorkspaceFileEntity::getAgentId, agentId)); + .eq(WorkspaceFileEntity::getAgentId, agentId) + .in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)); for (WorkspaceFileEntity file : allFiles) { int index = filenames.indexOf(file.getFilename()); @@ -208,6 +409,20 @@ public class WorkspaceFileService { */ public List searchSnippets(Long agentId, String query, Set filenamePrefixes, int limit) { + return searchSnippets(agentId, query, filenamePrefixes, limit, null); + } + + /** + * Owner-scoped overload of {@link #searchSnippets(Long, String, Set, int)}. + *

+ * Restricts candidates to memory the given {@code ownerKey} may see: + * shared rows (TEAM / GLOBAL) plus this owner's own PERSONAL rows. A null + * {@code ownerKey} means "shared only" — used by legacy call sites that + * have no requester identity in scope. + */ + public List searchSnippets(Long agentId, String query, + Set filenamePrefixes, int limit, + String ownerKey) { if (agentId == null || limit <= 0) { return List.of(); } @@ -221,6 +436,7 @@ public class WorkspaceFileService { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(WorkspaceFileEntity::getAgentId, agentId); + applyScopeVisibility(wrapper, ownerKey); if (filenamePrefixes != null && !filenamePrefixes.isEmpty()) { // Group prefix conditions inside a single AND-bracketed OR chain @@ -433,24 +649,71 @@ public class WorkspaceFileService { } /** - * 将启用的工作区文件拼接为系统提示词 + * Restrict a query to memory visible to {@code ownerKey}: shared rows + * (TEAM / GLOBAL) always, plus this owner's own PERSONAL rows. When + * {@code ownerKey} is null/blank only shared rows are returned. + */ + private void applyScopeVisibility(LambdaQueryWrapper wrapper, String ownerKey) { + if (ownerKey == null || ownerKey.isBlank()) { + wrapper.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL); + return; + } + wrapper.and(w -> w + .in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL) + .or(p -> p.eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL) + .eq(WorkspaceFileEntity::getOwnerKey, ownerKey))); + } + + /** + * 将启用的、共享(TEAM / GLOBAL)工作区文件拼接为系统提示词 *

* 每个文件以 "--- {filename} ---\n{content}\n" 的格式拼接。 * 如果没有启用的文件,返回 null。 + *

+ * Baked once at agent build time and shared by every requester, so this + * deliberately excludes PERSONAL rows — per-owner memory is injected + * per-turn via {@link #buildOwnerMemoryBlock(Long, String)} instead. */ public String buildSystemPrompt(Long agentId) { List enabledFiles = fileMapper.selectList( new LambdaQueryWrapper() .eq(WorkspaceFileEntity::getAgentId, agentId) .eq(WorkspaceFileEntity::getEnabled, true) + .in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL) .orderByAsc(WorkspaceFileEntity::getSortOrder)); - if (enabledFiles.isEmpty()) { + return concatFiles(enabledFiles); + } + + /** + * Assemble the per-owner memory block injected before each LLM call. + *

+ * Returns the enabled PERSONAL files belonging to {@code ownerKey} (the + * owner's consolidated MEMORY.md / PROFILE.md and any enabled daily notes), + * concatenated in the same format as {@link #buildSystemPrompt(Long)}. + * Null when the owner has no personal memory yet. + */ + public String buildOwnerMemoryBlock(Long agentId, String ownerKey) { + if (agentId == null || ownerKey == null || ownerKey.isBlank()) { return null; } + List files = fileMapper.selectList( + new LambdaQueryWrapper() + .eq(WorkspaceFileEntity::getAgentId, agentId) + .eq(WorkspaceFileEntity::getEnabled, true) + .eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL) + .eq(WorkspaceFileEntity::getOwnerKey, ownerKey) + .orderByAsc(WorkspaceFileEntity::getSortOrder)); + return concatFiles(files); + } + /** Concatenate file bodies in the "--- {filename} ---\n{content}" format. */ + private String concatFiles(List files) { + if (files == null || files.isEmpty()) { + return null; + } StringBuilder sb = new StringBuilder(); - for (WorkspaceFileEntity file : enabledFiles) { + for (WorkspaceFileEntity file : files) { if (file.getContent() != null && !file.getContent().isBlank()) { if (!sb.isEmpty()) { sb.append("\n\n"); diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java index 0f049fb7..70bf6d57 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java @@ -42,7 +42,9 @@ public class WorkspaceFileController { @RequireWorkspaceRole("viewer") @GetMapping("/files") public R> listFiles(@PathVariable Long agentId) { - return R.ok(workspaceFileService.listFiles(agentId)); + // Config-editor surface: shared (TEAM/GLOBAL) files only. Per-owner + // PERSONAL memory rows are never exposed or managed through this REST API. + return R.ok(workspaceFileService.listVisibleFiles(agentId, null)); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/model/WorkspaceFileEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/model/WorkspaceFileEntity.java index 373dbc76..5fc2af37 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/model/WorkspaceFileEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/model/WorkspaceFileEntity.java @@ -36,6 +36,20 @@ public class WorkspaceFileEntity { /** 排序顺序(越小越靠前) */ private Integer sortOrder; + /** + * Memory subject this row belongs to, as a prefixed string + * ("user:42", "feishu:ou_xxx", "api:<endUserId>"). Null for shared + * config rows (AGENTS.md / SOUL.md / PROFILE.md) and legacy data. + */ + private String ownerKey; + + /** + * Visibility scope: PERSONAL (only the matching {@link #ownerKey} sees it), + * TEAM (everyone using the agent), or GLOBAL (always visible). Defaults to + * TEAM at the DB level so config files and legacy rows stay shared. + */ + private String scope; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V137__memory_owner_scope.sql b/mateclaw-server/src/main/resources/db/migration/h2/V137__memory_owner_scope.sql new file mode 100644 index 00000000..5d616a4e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V137__memory_owner_scope.sql @@ -0,0 +1,55 @@ +-- V137: Per-owner memory isolation with a three-state visibility scope. +-- +-- Adds owner_key + scope to the three memory-bearing tables so a single agent +-- shared across multiple end-users (web users, IM senders, third-party API +-- end-users) keeps each owner's memory separate. +-- +-- owner_key - the memory subject this row belongs to, as a prefixed string +-- ("user:42", "feishu:ou_xxx", "api:"). NULL means +-- "not owner-scoped" (legacy / shared config rows). +-- scope - PERSONAL (only the matching owner_key sees it), +-- TEAM (everyone using the agent sees it), +-- GLOBAL (always visible). +-- +-- Existing rows are backfilled to scope='TEAM' by the NOT NULL DEFAULT so that +-- upgrading does NOT hide previously-shared memory. New memory writes set +-- scope='PERSONAL' with the resolved owner_key; agent config files (AGENTS.md, +-- SOUL.md, PROFILE.md) keep the TEAM default and stay shared. + +ALTER TABLE mate_workspace_file ADD COLUMN IF NOT EXISTS owner_key VARCHAR(128) NULL; +ALTER TABLE mate_workspace_file ADD COLUMN IF NOT EXISTS scope VARCHAR(16) NOT NULL DEFAULT 'TEAM'; +CREATE INDEX IF NOT EXISTS idx_workspace_file_scope_owner ON mate_workspace_file(agent_id, scope, owner_key); +-- Shared rows use the '' sentinel (not NULL) so the unique index below treats +-- one shared row per filename as a single slot. +UPDATE mate_workspace_file SET owner_key = '' WHERE owner_key IS NULL; +-- De-duplicate before adding the unique index: the table never had a unique +-- constraint and the service layer was check-then-insert, so historical +-- duplicates may exist. Keep the most recently inserted row per +-- (agent_id, filename, owner_key); drop the rest so the index can be created. +-- +-- IRREVERSIBLE: this keeps MAX(id) (newest row) and PERMANENTLY deletes the +-- other rows in a duplicate group — their content / enabled / sort_order are +-- not preserved or merged. Duplicates are NOT expected (every write path is +-- check-then-insert), so this is a safety net to guarantee the index builds, +-- not a routine merge. If a deployment knowingly relies on duplicate rows, +-- reconcile them manually before upgrading. +DELETE FROM mate_workspace_file +WHERE id NOT IN ( + SELECT keep_id FROM ( + SELECT MAX(id) AS keep_id + FROM mate_workspace_file + GROUP BY agent_id, filename, owner_key + ) t +); +-- One row per (agent, filename, owner): one shared row + one row per PERSONAL +-- owner. Hardens the check-then-insert in saveFile/saveMemoryFile against +-- concurrent / multi-node duplicates. +CREATE UNIQUE INDEX IF NOT EXISTS uk_workspace_file_owner ON mate_workspace_file(agent_id, filename, owner_key); + +ALTER TABLE mate_memory_recall ADD COLUMN IF NOT EXISTS owner_key VARCHAR(128) NULL; +ALTER TABLE mate_memory_recall ADD COLUMN IF NOT EXISTS scope VARCHAR(16) NOT NULL DEFAULT 'TEAM'; +CREATE INDEX IF NOT EXISTS idx_memory_recall_scope_owner ON mate_memory_recall(agent_id, scope, owner_key); + +ALTER TABLE mate_fact ADD COLUMN IF NOT EXISTS owner_key VARCHAR(128) NULL; +ALTER TABLE mate_fact ADD COLUMN IF NOT EXISTS scope VARCHAR(16) NOT NULL DEFAULT 'TEAM'; +CREATE INDEX IF NOT EXISTS idx_fact_scope_owner ON mate_fact(agent_id, scope, owner_key); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V137__memory_owner_scope.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V137__memory_owner_scope.sql new file mode 100644 index 00000000..92ecf77e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V137__memory_owner_scope.sql @@ -0,0 +1,109 @@ +-- V137: Per-owner memory isolation with a three-state visibility scope (MySQL). +-- +-- See the H2 counterpart for the full rationale. MySQL has no +-- "ADD COLUMN IF NOT EXISTS", so each column/index is guarded with an +-- INFORMATION_SCHEMA existence check + prepared statement for idempotency. +-- +-- Existing rows are backfilled to scope='TEAM' by the NOT NULL DEFAULT so that +-- upgrading does NOT hide previously-shared memory. + +-- ---------- mate_workspace_file ---------- +SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_workspace_file' AND COLUMN_NAME = 'owner_key'); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_workspace_file ADD COLUMN owner_key VARCHAR(128) NULL', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_workspace_file' AND COLUMN_NAME = 'scope'); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_workspace_file ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT ''TEAM''', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_workspace_file' AND INDEX_NAME = 'idx_workspace_file_scope_owner'); +SET @stmt := IF(@idx_exists = 0, + 'CREATE INDEX idx_workspace_file_scope_owner ON mate_workspace_file(agent_id, scope, owner_key)', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +-- Shared rows use the '' sentinel (not NULL) so the unique index below treats +-- one shared row per filename as a single slot (NULLs are distinct in unique indexes). +UPDATE mate_workspace_file SET owner_key = '' WHERE owner_key IS NULL; + +-- De-duplicate before adding the unique index: the table never had a unique +-- constraint and the service layer was check-then-insert, so historical +-- duplicates may exist. Keep the most recently inserted row per +-- (agent_id, filename, owner_key); drop the rest. The extra derived-table wrap +-- is required so MySQL doesn't reject selecting from the table being deleted. +-- +-- IRREVERSIBLE: this keeps MAX(id) (newest row) and PERMANENTLY deletes the +-- other rows in a duplicate group — their content / enabled / sort_order are +-- not preserved or merged. Duplicates are NOT expected (every write path is +-- check-then-insert), so this is a safety net to guarantee the index builds, +-- not a routine merge. If a deployment knowingly relies on duplicate rows, +-- reconcile them manually before upgrading. +DELETE FROM mate_workspace_file +WHERE id NOT IN ( + SELECT keep_id FROM ( + SELECT MAX(id) AS keep_id + FROM mate_workspace_file + GROUP BY agent_id, filename, owner_key + ) t +); + +-- One row per (agent, filename, owner): one shared row + one row per PERSONAL +-- owner. Hardens the check-then-insert in saveFile/saveMemoryFile against +-- concurrent / multi-node duplicates. +SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_workspace_file' AND INDEX_NAME = 'uk_workspace_file_owner'); +SET @stmt := IF(@idx_exists = 0, + 'CREATE UNIQUE INDEX uk_workspace_file_owner ON mate_workspace_file(agent_id, filename, owner_key)', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +-- ---------- mate_memory_recall ---------- +SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_memory_recall' AND COLUMN_NAME = 'owner_key'); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_memory_recall ADD COLUMN owner_key VARCHAR(128) NULL', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_memory_recall' AND COLUMN_NAME = 'scope'); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_memory_recall ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT ''TEAM''', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_memory_recall' AND INDEX_NAME = 'idx_memory_recall_scope_owner'); +SET @stmt := IF(@idx_exists = 0, + 'CREATE INDEX idx_memory_recall_scope_owner ON mate_memory_recall(agent_id, scope, owner_key)', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +-- ---------- mate_fact ---------- +SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_fact' AND COLUMN_NAME = 'owner_key'); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_fact ADD COLUMN owner_key VARCHAR(128) NULL', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_fact' AND COLUMN_NAME = 'scope'); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_fact ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT ''TEAM''', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_fact' AND INDEX_NAME = 'idx_fact_scope_owner'); +SET @stmt := IF(@idx_exists = 0, + 'CREATE INDEX idx_fact_scope_owner ON mate_fact(agent_id, scope, owner_key)', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleFlagGuardTest.java b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleFlagGuardTest.java index df3a354d..b35e115f 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleFlagGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleFlagGuardTest.java @@ -56,7 +56,7 @@ class LifecycleFlagGuardTest { new ConversationCompletedEvent(1L, "conv-" + i, "hello", "reply", 5, "web")); } - verify(memoryManager, never()).prefetchAll(any(), any()); + verify(memoryManager, never()).prefetchAll(any(), any(), any()); verify(memoryManager, never()).syncAll(any(), any(), any(), any()); verify(memoryManager, never()).onSessionEnd(any(), any()); } @@ -68,11 +68,11 @@ class LifecycleFlagGuardTest { // But MemoryLifecycleEventListener guards onSessionEnd. props.setLifecycleMediatorEnabled(false); - when(memoryManager.prefetchAll(eq(1L), eq("q"))).thenReturn(""); + when(memoryManager.prefetchAll(eq(1L), eq("q"), any())).thenReturn(""); // Direct mediator call works (AgentService would not call this when flag is off) mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q")); - verify(memoryManager, times(1)).prefetchAll(1L, "q"); + verify(memoryManager, times(1)).prefetchAll(eq(1L), eq("q"), any()); } // ==================== Flag ON ==================== @@ -81,13 +81,13 @@ class LifecycleFlagGuardTest { @DisplayName("Flag ON: beforeLlmCall invokes prefetchAll") void flagOn_prefetchAll() { props.setLifecycleMediatorEnabled(true); - when(memoryManager.prefetchAll(eq(1L), eq("hello"))).thenReturn(""); + when(memoryManager.prefetchAll(eq(1L), eq("hello"), any())).thenReturn(""); for (int i = 0; i < 10; i++) { mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", i, "hello")); } - verify(memoryManager, times(10)).prefetchAll(1L, "hello"); + verify(memoryManager, times(10)).prefetchAll(eq(1L), eq("hello"), any()); } @Test @@ -131,7 +131,7 @@ class LifecycleFlagGuardTest { @Test @DisplayName("Provider exception in prefetchAll degrades gracefully (returns empty)") void prefetchException_graceful() { - when(memoryManager.prefetchAll(any(), any())).thenThrow(new RuntimeException("boom")); + when(memoryManager.prefetchAll(any(), any(), any())).thenThrow(new RuntimeException("boom")); String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q")); 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 bb411525..62995d9d 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 @@ -52,7 +52,8 @@ class LifecycleRecallCountIT { props = new MemoryProperties(); MemoryLifecycleMediator mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher); agentService = new AgentService(agentMapper, agentGraphBuilder, - memoryRecallTracker, mediator, props, conversationMapper); + memoryRecallTracker, mediator, props, + new vip.mate.memory.identity.MemoryOwnerResolver(), conversationMapper); // Stub agent resolution (lenient for structural-only tests) AgentEntity entity = new AgentEntity(); @@ -76,7 +77,7 @@ class LifecycleRecallCountIT { verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any()); // Mediator is not invoked when flag is off - verify(memoryManager, never()).prefetchAll(any(), any()); + verify(memoryManager, never()).prefetchAll(any(), any(), any()); verify(memoryManager, never()).syncAll(any(), any(), any(), any()); } @@ -84,7 +85,7 @@ class LifecycleRecallCountIT { @DisplayName("F4 regression: flag ON — trackRecalls still called exactly once per chat (not doubled)") void flagOn_trackRecallsStillOncePerChat() { props.setLifecycleMediatorEnabled(true); - when(memoryManager.prefetchAll(any(), any())).thenReturn(""); + when(memoryManager.prefetchAll(any(), any(), any())).thenReturn(""); for (int i = 0; i < 10; i++) { agentService.chat(1L, "msg-" + i, "conv-1"); @@ -94,7 +95,7 @@ class LifecycleRecallCountIT { verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any()); // Mediator IS invoked - verify(memoryManager, times(10)).prefetchAll(eq(1L), any()); + verify(memoryManager, times(10)).prefetchAll(eq(1L), any(), any()); verify(memoryManager, times(10)).syncAll(eq(1L), eq("conv-1"), any(), any()); } @@ -109,7 +110,7 @@ class LifecycleRecallCountIT { // 5 rounds with flag ON props.setLifecycleMediatorEnabled(true); - when(memoryManager.prefetchAll(any(), any())).thenReturn(""); + when(memoryManager.prefetchAll(any(), any(), any())).thenReturn(""); for (int i = 0; i < 5; i++) { agentService.chat(1L, "on-" + i, "conv-1"); } @@ -118,7 +119,7 @@ class LifecycleRecallCountIT { verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any()); // Mediator only called for the ON rounds - verify(memoryManager, times(5)).prefetchAll(eq(1L), any()); + verify(memoryManager, times(5)).prefetchAll(eq(1L), any(), any()); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/MemoryLifecycleMediatorTest.java b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/MemoryLifecycleMediatorTest.java index db7a07c7..f74049f8 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/MemoryLifecycleMediatorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/MemoryLifecycleMediatorTest.java @@ -41,14 +41,14 @@ class MemoryLifecycleMediatorTest { @Test @DisplayName("beforeLlmCall returns prefetchAll result and publishes TurnStartedEvent") void beforeLlmCall_normalPath() { - when(memoryManager.prefetchAll(eq(1L), eq("hello"))) + when(memoryManager.prefetchAll(eq(1L), eq("hello"), any())) .thenReturn("some context"); TurnContext ctx = new TurnContext(1L, "c1", "s1", 1, "hello"); String result = mediator.beforeLlmCall(ctx); assertEquals("some context", result); - verify(memoryManager).prefetchAll(1L, "hello"); + verify(memoryManager).prefetchAll(eq(1L), eq("hello"), any()); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); verify(eventPublisher).publishEvent(eventCaptor.capture()); @@ -59,7 +59,7 @@ class MemoryLifecycleMediatorTest { @Test @DisplayName("beforeLlmCall returns empty string when prefetchAll returns empty") void beforeLlmCall_emptyPrefetch() { - when(memoryManager.prefetchAll(any(), any())).thenReturn(""); + when(memoryManager.prefetchAll(any(), any(), any())).thenReturn(""); String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q")); @@ -95,7 +95,7 @@ class MemoryLifecycleMediatorTest { @Test @DisplayName("beforeLlmCall degrades to empty string when prefetchAll throws") void beforeLlmCall_exceptionDegrades() { - when(memoryManager.prefetchAll(any(), any())) + when(memoryManager.prefetchAll(any(), any(), any())) .thenThrow(new RuntimeException("provider down")); String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q")); @@ -141,7 +141,7 @@ class MemoryLifecycleMediatorTest { @Test @DisplayName("Multiple sequential turns do not interfere (Mediator is stateless)") void multipleTurns_noInterference() { - when(memoryManager.prefetchAll(any(), any())).thenReturn(""); + when(memoryManager.prefetchAll(any(), any(), any())).thenReturn(""); for (int i = 0; i < 5; i++) { TurnContext ctx = new TurnContext(1L, "c1", "s1", i, "msg-" + i); @@ -149,7 +149,7 @@ class MemoryLifecycleMediatorTest { mediator.afterLlmCall(ctx, "reply-" + i); } - verify(memoryManager, times(5)).prefetchAll(eq(1L), any()); + verify(memoryManager, times(5)).prefetchAll(eq(1L), any(), any()); verify(memoryManager, times(5)).syncAll(eq(1L), eq("c1"), any(), any()); } } diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java index bc49eb4c..6b772ff9 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java @@ -58,7 +58,8 @@ class WorkspaceMemorySearchTest { @Test @DisplayName("saveFile publishes a change event so the cached agent instance is invalidated") void saveFilePublishesChangeEvent() { - when(fileMapper.selectOne(any())).thenReturn(null); // new file path + // getFile() now uses the non-throwing selectOne(wrapper, false) overload. + when(fileMapper.selectOne(any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(null); // new file path service.saveFile(1000000001L, "MEMORY.md", "## 稳定事实\n- 用户语言:简体中文"); @@ -271,18 +272,77 @@ class WorkspaceMemorySearchTest { assertThat(sql).contains("LIKE ? OR") .contains("AND content") .contains("LIMIT 50"); + // With a null ownerKey the scope-visibility clause restricts to shared + // rows via an IN (?, ?) on (TEAM, GLOBAL), adding two bind params. assertThat(sql.chars().filter(ch -> ch == '?').count()) - .as("one agentId + two prefix LIKEs + three content LIKEs = 6 bind params") - .isEqualTo(6); + .as("agentId + two scope params + two prefix LIKEs + three content LIKEs = 8 bind params") + .isEqualTo(8); List values = new ArrayList<>(wrapper.getParamNameValuePairs().values()); assertThat(values).contains(42L); + // Shared-scope visibility filter binds the TEAM / GLOBAL literals. + assertThat(values).contains("TEAM", "GLOBAL"); // Each content-LIKE term gets %term% by MyBatis-Plus's like(). assertThat(values).contains("%running%", "%shoes%", "%跑步%"); // likeRight produces "prefix%" — confirms both prefixes were bound. assertThat(values).contains("memory/%", "MEMORY.md%"); } + @Test + @DisplayName("Owner-scoped search binds shared (TEAM/GLOBAL) + this owner's PERSONAL rows only") + void ownerScopedSearchBindsPersonalBranch() { + when(fileMapper.selectList(any())).thenReturn(List.of()); + service.searchSnippets(42L, "running", null, 10, "user:7"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + org.mockito.Mockito.verify(fileMapper).selectList(captor.capture()); + LambdaQueryWrapper wrapper = captor.getValue(); + wrapper.getTargetSql(); + + List values = new ArrayList<>(wrapper.getParamNameValuePairs().values()); + // Visibility clause: (scope IN (TEAM, GLOBAL)) OR (scope = PERSONAL AND owner_key = ?) + assertThat(values).contains("TEAM", "GLOBAL", "PERSONAL", "user:7"); + // A different owner's key must NOT be bound — that is the isolation guarantee. + assertThat(values).doesNotContain("user:99"); + } + + @Test + @DisplayName("saveFile recovers from a concurrent first-write unique conflict by reselect+update") + void saveFileRecoversFromConcurrentInsert() { + WorkspaceFileEntity raced = file("MEMORY.md", "racer content"); + // First selectOne (existence check) → null (we think it's new); after the + // insert loses the unique-index race, the reselect → the racer's row. + when(fileMapper.selectOne(any(), org.mockito.ArgumentMatchers.anyBoolean())) + .thenReturn(null, raced); + when(fileMapper.insert(any(WorkspaceFileEntity.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("uk_workspace_file_owner")); + + service.saveFile(1000000001L, "MEMORY.md", "## new"); + + // Must fall back to updating the existing row, not propagate the exception. + org.mockito.Mockito.verify(fileMapper).updateById(raced); + assertThat(raced.getContent()).isEqualTo("## new"); + } + + @Test + @DisplayName("saveFile rethrows a duplicate-key error unrelated to the owner-scope index (no false recovery)") + void saveFileRethrowsUnrelatedDuplicateKey() { + when(fileMapper.selectOne(any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(null); + // A PRIMARY-key collision (not the uk_workspace_file_owner index) must + // NOT be swallowed as a concurrent first-write. + when(fileMapper.insert(any(WorkspaceFileEntity.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException( + "Duplicate entry '42' for key 'PRIMARY'")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> service.saveFile(1000000001L, "MEMORY.md", "## new")) + .isInstanceOf(org.springframework.dao.DuplicateKeyException.class); + org.mockito.Mockito.verify(fileMapper, org.mockito.Mockito.never()) + .updateById(any(WorkspaceFileEntity.class)); + } + // ---------- helpers ---------- private static WorkspaceFileEntity file(String filename, String content) {