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 d8e59af2..b9aa3214 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 @@ -46,6 +46,11 @@ public class MemorySummarizationService { private final AgentGraphBuilder agentGraphBuilder; private final MemoryProperties properties; private final ObjectMapper objectMapper; + private final StructuredMemoryService structuredMemoryService; + + /** Typed-memory categories the summarizer may route entries into. */ + private static final java.util.Set STRUCTURED_TYPES = + java.util.Set.of("user", "feedback", "project", "reference"); /** Per-agent 锁,防止并发写入 */ private final ConcurrentHashMap agentLocks = new ConcurrentHashMap<>(); @@ -192,6 +197,40 @@ public class MemorySummarizationService { log.info("[Memory] Updated PROFILE.md for agent={}", agentId); } } + + // Structured entries: route typed facts (especially volatile project / + // 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")); + } + + private void applyStructuredEntries(Long agentId, JsonNode entriesNode) { + if (entriesNode == null || !entriesNode.isArray() || entriesNode.isEmpty()) { + return; + } + int written = 0; + for (JsonNode entry : entriesNode) { + String type = entry.path("type").asText("").trim().toLowerCase(); + String key = entry.path("key").asText("").trim(); + String content = entry.path("content").asText("").trim(); + if (!STRUCTURED_TYPES.contains(type) || key.isEmpty() || content.isEmpty()) { + log.debug("[Memory] Skipping invalid structured entry (type={}, key={}) for agent={}", + type, key, agentId); + continue; + } + try { + structuredMemoryService.remember(agentId, type, key, content, "auto-summary"); + written++; + } catch (Exception e) { + log.warn("[Memory] Failed to write structured entry '{}' (type={}) for agent={}: {}", + key, type, agentId, e.getMessage()); + } + } + if (written > 0) { + log.info("[Memory] Routed {} structured entr{} for agent={}", + written, written == 1 ? "y" : "ies", agentId); + } } private String buildTranscript(List messages) { diff --git a/mateclaw-server/src/main/resources/prompts/memory/summarize-system.txt b/mateclaw-server/src/main/resources/prompts/memory/summarize-system.txt index 3449f5c1..9a2774cf 100644 --- a/mateclaw-server/src/main/resources/prompts/memory/summarize-system.txt +++ b/mateclaw-server/src/main/resources/prompts/memory/summarize-system.txt @@ -36,12 +36,18 @@ MEMORY.md 与 PROFILE.md 会被**无条件注入每一次对话的系统提示** "daily_entry": null, "memory_update": null, "profile_update": null, + "structured_entries": null, "reason": "简要说明判断理由" } 字段说明: - `should_update`: 布尔值,是否有任何需要更新的内容。如果为 false,其余字段应为 null - `daily_entry`: 字符串或 null。要追加到今日 daily note 的内容(markdown 格式,以时间戳开头如 "## HH:mm ...") -- `memory_update`: 字符串或 null。MEMORY.md 的完整新内容(已合并现有内容,不是增量)。仅当有需要新增或修改的稳定信息时才填写 +- `memory_update`: 字符串或 null。MEMORY.md 的完整新内容(已合并现有内容,不是增量)。仅当有需要新增或修改的**跨项目稳定**信息时才填写 - `profile_update`: 字符串或 null。PROFILE.md 的完整新内容(已合并现有内容,不是增量)。仅当用户身份/偏好有显著变化时才填写 +- `structured_entries`: 数组或 null。把适合按条目检索的**具体事实**路由到结构化记忆,每个元素形如 `{"type": "...", "key": "...", "content": "..."}`: + - `type` 取值:`user`(用户偏好/专长/沟通风格/角色)、`feedback`(被纠正的行为或确认的做法,含原因)、`project`(具体项目的代号/名称/技术栈/指标/预算/团队/约束/单项目决策)、`reference`(外部系统指针,如某看板/频道/文档地址) + - `key`: 稳定的英文蛇形命名,便于后续更新同一条目(如 `project_codename`、`project_tech_stack`、`preferred_output_format`) + - `content`: 一两句话陈述该事实 + - **重要**:上面「记忆分层纪律」要求不进 MEMORY.md 的项目易变事实(代号、技术栈、单项目指标/预算/团队等),应放在这里(`type=project`),这样才能在后续对话中按问题被召回;不要让它们只停留在 daily note。 - `reason`: 简要说明判断理由 diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationStructuredRoutingTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationStructuredRoutingTest.java new file mode 100644 index 00000000..92714ce5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationStructuredRoutingTest.java @@ -0,0 +1,97 @@ +package vip.mate.memory.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.memory.MemoryProperties; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.document.WorkspaceFileService; + +import java.lang.reflect.Method; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * The conversation summarizer routes typed facts it extracts into structured + * memory (the query-conditioned recall channel), so project/reference facts kept + * out of the always-on MEMORY.md still become recallable instead of being + * stranded in daily notes. Valid entries are written; malformed ones are skipped. + */ +class MemorySummarizationStructuredRoutingTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private MemorySummarizationService newService(StructuredMemoryService structured) { + return new MemorySummarizationService( + mock(ConversationService.class), + mock(WorkspaceFileService.class), + mock(ModelConfigService.class), + mock(AgentGraphBuilder.class), + mock(MemoryProperties.class), + mapper, + structured); + } + + private void invokeApply(MemorySummarizationService svc, long agentId, String entriesJson) throws Exception { + JsonNode node = mapper.readTree(entriesJson); + Method m = MemorySummarizationService.class + .getDeclaredMethod("applyStructuredEntries", Long.class, JsonNode.class); + m.setAccessible(true); + m.invoke(svc, agentId, node); + } + + @Test + @DisplayName("valid typed entries are routed to structured memory") + void routesValidEntries() throws Exception { + StructuredMemoryService structured = mock(StructuredMemoryService.class); + MemorySummarizationService svc = newService(structured); + + invokeApply(svc, 1000000001L, """ + [ + {"type": "project", "key": "project_codename", "content": "项目代号:云梯计划"}, + {"type": "user", "key": "preferred_output_format", "content": "偏好表格输出"} + ] + """); + + verify(structured).remember(1000000001L, "project", "project_codename", "项目代号:云梯计划", "auto-summary"); + verify(structured).remember(1000000001L, "user", "preferred_output_format", "偏好表格输出", "auto-summary"); + verifyNoMoreInteractions(structured); + } + + @Test + @DisplayName("malformed or unknown-type entries are skipped") + void skipsInvalidEntries() throws Exception { + StructuredMemoryService structured = mock(StructuredMemoryService.class); + MemorySummarizationService svc = newService(structured); + + invokeApply(svc, 1000000001L, """ + [ + {"type": "secret", "key": "k", "content": "bad type"}, + {"type": "project", "key": "", "content": "missing key"}, + {"type": "project", "key": "ok_key", "content": ""}, + {"type": "project", "key": "good", "content": "kept"} + ] + """); + + // Only the last, fully-valid entry is written. + verify(structured).remember(1000000001L, "project", "good", "kept", "auto-summary"); + verifyNoMoreInteractions(structured); + } + + @Test + @DisplayName("null / non-array structured_entries is a no-op") + void noopForNullOrNonArray() throws Exception { + StructuredMemoryService structured = mock(StructuredMemoryService.class); + MemorySummarizationService svc = newService(structured); + + invokeApply(svc, 1000000001L, "null"); + invokeApply(svc, 1000000001L, "\"not-an-array\""); + invokeApply(svc, 1000000001L, "[]"); + + verifyNoInteractions(structured); + } +}