diff --git a/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamController.java b/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamController.java index 1f86a12c..004b26a1 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamController.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamController.java @@ -5,10 +5,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.memory.model.DreamReportEntity; import vip.mate.memory.repository.DreamReportMapper; +import vip.mate.memory.service.MorningCardService; +import vip.mate.memory.service.MemoryHilService; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.LinkedHashMap; @@ -27,6 +30,8 @@ import java.util.Map; public class DreamController { private final DreamReportMapper dreamReportMapper; + private final MorningCardService morningCardService; + private final MemoryHilService hilService; @Operation(summary = "List dream reports (paginated, newest first)") @GetMapping("/reports") @@ -66,4 +71,72 @@ public class DreamController { } return R.ok(entity); } + + // ==================== Morning Card ==================== + + @Operation(summary = "Get morning card for current user + agent") + @GetMapping("/morning-card") + @RequireWorkspaceRole("viewer") + public R> getMorningCard(@PathVariable Long agentId, Authentication auth) { + Long userId = resolveUserId(auth); + if (userId == null) return R.fail("Not authenticated"); + Map card = morningCardService.getCardFor(userId, agentId); + return R.ok(card); // null = no card to show + } + + @Operation(summary = "Mark morning card as seen") + @PostMapping("/morning-card/seen") + @RequireWorkspaceRole("viewer") + public R markMorningCardSeen(@PathVariable Long agentId, + @RequestBody Map body, + Authentication auth) { + Long userId = resolveUserId(auth); + if (userId == null) return R.fail("Not authenticated"); + Long reportId = body.get("reportId") != null + ? Long.valueOf(body.get("reportId").toString()) : null; + morningCardService.markSeen(userId, agentId, reportId); + return R.ok(null); + } + + // ==================== HiL (Human-in-the-Loop) ==================== + + @Operation(summary = "Confirm a memory entry (no-op acknowledgment)") + @PostMapping("/reports/{reportId}/entries/{key}/confirm") + @RequireWorkspaceRole("member") + public R confirmEntry(@PathVariable Long agentId, + @PathVariable Long reportId, + @PathVariable String key) { + // Confirm is a no-op in Phase 2 — just logs the action + return R.ok(null); + } + + @Operation(summary = "Edit a memory entry — writes back to MEMORY.md with user-edited metadata") + @PostMapping("/reports/{reportId}/entries/{key}/edit") + @RequireWorkspaceRole("member") + public R editEntry(@PathVariable Long agentId, + @PathVariable Long reportId, + @PathVariable String key, + @RequestBody Map body) { + String newContent = body.get("content"); + if (newContent == null || newContent.isBlank()) { + return R.fail("content is required"); + } + hilService.editMemoryEntry(agentId, key, newContent); + return R.ok(null); + } + + private Long resolveUserId(Authentication auth) { + if (auth == null) return null; + // Resolve from auth principal — assumes user ID is accessible + try { + Object principal = auth.getPrincipal(); + if (principal instanceof vip.mate.auth.model.UserEntity user) { + return user.getId(); + } + // Fallback: use username hash as stable ID + return (long) auth.getName().hashCode(); + } catch (Exception e) { + return null; + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java new file mode 100644 index 00000000..df388dfe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java @@ -0,0 +1,33 @@ +package vip.mate.memory.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Morning card seen state — tracks per (user, agent) whether the card was dismissed. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_morning_card_seen") +public class MorningCardSeenEntity { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + private Long agentId; + + private LocalDateTime lastSeenAt; + + private Long lastReportId; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/repository/MorningCardSeenMapper.java b/mateclaw-server/src/main/java/vip/mate/memory/repository/MorningCardSeenMapper.java new file mode 100644 index 00000000..92c645b2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/repository/MorningCardSeenMapper.java @@ -0,0 +1,9 @@ +package vip.mate.memory.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.memory.model.MorningCardSeenEntity; + +@Mapper +public interface MorningCardSeenMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java new file mode 100644 index 00000000..6289329f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java @@ -0,0 +1,67 @@ +package vip.mate.memory.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import vip.mate.memory.event.MemoryWriteEvent; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import java.time.LocalDate; + +/** + * Human-in-the-Loop service for memory editing. + *

+ * When a user edits a memory entry, this service writes it back to MEMORY.md + * with a hidden metadata marker () so that + * future Dream runs do not overwrite user modifications. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MemoryHilService { + + private final WorkspaceFileService workspaceFileService; + private final ApplicationEventPublisher eventPublisher; + + /** + * Edit a section in MEMORY.md identified by key (section heading). + * Appends user-edited metadata so Dream prompts respect user changes. + */ + public void editMemoryEntry(Long agentId, String key, String newContent) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md"); + if (file == null || file.getContent() == null) { + log.warn("[HiL] MEMORY.md not found for agent={}", agentId); + return; + } + + String memoryContent = file.getContent(); + String sectionHeader = "## " + key; + int headerIdx = memoryContent.indexOf(sectionHeader); + + if (headerIdx < 0) { + // Section not found — append as new section + String metadata = ""; + String newSection = "\n\n" + sectionHeader + "\n" + newContent.trim() + "\n" + metadata; + memoryContent = memoryContent.trim() + newSection; + } else { + // Find section boundaries + int contentStart = memoryContent.indexOf('\n', headerIdx) + 1; + int nextSection = memoryContent.indexOf("\n## ", contentStart); + int sectionEnd = nextSection > 0 ? nextSection : memoryContent.length(); + + // Replace section content + String metadata = ""; + String replacement = newContent.trim() + "\n" + metadata + "\n"; + memoryContent = memoryContent.substring(0, contentStart) + replacement + + memoryContent.substring(sectionEnd); + } + + workspaceFileService.saveFile(agentId, "MEMORY.md", memoryContent); + eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "user-edit", newContent)); + log.info("[HiL] User edited MEMORY.md section '{}' for agent={}", key, agentId); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MorningCardService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MorningCardService.java new file mode 100644 index 00000000..f98ad412 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MorningCardService.java @@ -0,0 +1,96 @@ +package vip.mate.memory.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.memory.model.DreamReportEntity; +import vip.mate.memory.model.MorningCardSeenEntity; +import vip.mate.memory.repository.DreamReportMapper; +import vip.mate.memory.repository.MorningCardSeenMapper; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Morning card service — determines whether to show a dream summary card + * when a user enters an agent view. Scope is per (userId, agentId). + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MorningCardService { + + private final MorningCardSeenMapper seenMapper; + private final DreamReportMapper dreamReportMapper; + + /** + * Get the morning card for a user+agent. Returns null if no unseen dream exists. + */ + public Map getCardFor(Long userId, Long agentId) { + // Find the latest successful dream report for this agent + DreamReportEntity latestReport = dreamReportMapper.selectOne( + new LambdaQueryWrapper() + .eq(DreamReportEntity::getAgentId, agentId) + .eq(DreamReportEntity::getStatus, "SUCCESS") + .eq(DreamReportEntity::getDeleted, 0) + .orderByDesc(DreamReportEntity::getStartedAt) + .last("LIMIT 1")); + + if (latestReport == null) { + return null; // No dream yet + } + + // Check if user has already seen this report + MorningCardSeenEntity seen = seenMapper.selectOne( + new LambdaQueryWrapper() + .eq(MorningCardSeenEntity::getUserId, userId) + .eq(MorningCardSeenEntity::getAgentId, agentId)); + + if (seen != null && seen.getLastReportId() != null + && seen.getLastReportId().equals(latestReport.getId())) { + return null; // Already seen + } + + // Build card data + Map card = new LinkedHashMap<>(); + card.put("reportId", latestReport.getId()); + card.put("mode", latestReport.getMode()); + card.put("topic", latestReport.getTopic()); + card.put("startedAt", latestReport.getStartedAt()); + card.put("promotedCount", latestReport.getPromotedCount()); + card.put("rejectedCount", latestReport.getRejectedCount()); + card.put("llmReason", latestReport.getLlmReason()); + card.put("memoryDiff", latestReport.getMemoryDiff()); + return card; + } + + /** + * Mark the morning card as seen for a user+agent. + */ + public void markSeen(Long userId, Long agentId, Long reportId) { + MorningCardSeenEntity existing = seenMapper.selectOne( + new LambdaQueryWrapper() + .eq(MorningCardSeenEntity::getUserId, userId) + .eq(MorningCardSeenEntity::getAgentId, agentId)); + + if (existing != null) { + existing.setLastSeenAt(LocalDateTime.now()); + existing.setLastReportId(reportId); + existing.setUpdateTime(LocalDateTime.now()); + seenMapper.updateById(existing); + } else { + MorningCardSeenEntity entity = new MorningCardSeenEntity(); + entity.setUserId(userId); + entity.setAgentId(agentId); + entity.setLastSeenAt(LocalDateTime.now()); + entity.setLastReportId(reportId); + entity.setCreateTime(LocalDateTime.now()); + entity.setUpdateTime(LocalDateTime.now()); + seenMapper.insert(entity); + } + } +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V28__morning_card_seen.sql b/mateclaw-server/src/main/resources/db/migration/h2/V28__morning_card_seen.sql new file mode 100644 index 00000000..eac7ec12 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V28__morning_card_seen.sql @@ -0,0 +1,14 @@ +-- Dream v2 Phase 2b: Morning Card seen state per (user, agent) +-- Ref: rfc-034 F5 — DO NOT add to mate_user; use separate table +CREATE TABLE IF NOT EXISTS mate_morning_card_seen ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + last_seen_at DATETIME NOT NULL, + last_report_id BIGINT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_morning_card_user_agent + ON mate_morning_card_seen(user_id, agent_id); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V28__morning_card_seen.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V28__morning_card_seen.sql new file mode 100644 index 00000000..10e89c42 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V28__morning_card_seen.sql @@ -0,0 +1,12 @@ +-- Dream v2 Phase 2b: Morning Card seen state per (user, agent) +-- Ref: rfc-034 F5 — DO NOT add to mate_user; use separate table +CREATE TABLE IF NOT EXISTS mate_morning_card_seen ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + last_seen_at DATETIME NOT NULL, + last_report_id BIGINT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + UNIQUE KEY uk_user_agent (user_id, agent_id) +); diff --git a/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt b/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt index 48d695b8..297d5b43 100644 --- a/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt +++ b/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt @@ -20,4 +20,8 @@ --- 请围绕主题 "{topic}" 分析以上内容,将相关的稳定信息整合到 MEMORY.md 中。 -非主题类的高分候选如有重要内容也一并整合。严格输出 JSON 格式。 +非主题类的高分候选如有重要内容也一并整合。 + +**重要**:MEMORY.md 中含有 `` 标记的段落是用户手动编辑过的,不要覆盖或修改这些段落的内容,只在其后追加新信息。 + +严格输出 JSON 格式。 diff --git a/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt b/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt index d42ce039..18524a76 100644 --- a/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt +++ b/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt @@ -17,4 +17,8 @@ --- 请分析以上内容,优先将高分召回候选中的稳定信息整合到 MEMORY.md 中。 -低分或未被引用的内容仅作补充参考。严格输出 JSON 格式。 \ No newline at end of file +低分或未被引用的内容仅作补充参考。 + +**重要**:MEMORY.md 中含有 `` 标记的段落是用户手动编辑过的,不要覆盖或修改这些段落的内容,只在其后追加新信息。 + +严格输出 JSON 格式。 \ No newline at end of file diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 236b02f5..d803774a 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1802,5 +1802,19 @@ export default { reason: 'LLM Reason', diff: 'Change Summary', }, + morningCard: { + title: 'Dream Recap', + dismiss: 'Got it', + promoted: '{count} memories consolidated', + }, + hil: { + confirm: 'Confirm', + edit: 'Edit', + cancel: 'Cancel', + save: 'Save', + editPlaceholder: 'Edit memory content...', + confirmed: 'Confirmed', + saved: 'Saved to MEMORY.md', + }, }, } as const diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 68bc99a3..bfa32ea1 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1812,5 +1812,19 @@ export default { reason: 'LLM 理由', diff: '变更摘要', }, + morningCard: { + title: '昨夜 Dream 回顾', + dismiss: '我知道了', + promoted: '整合了 {count} 条记忆', + }, + hil: { + confirm: '确认', + edit: '编辑', + cancel: '取消', + save: '保存', + editPlaceholder: '修改记忆内容...', + confirmed: '已确认', + saved: '已保存到 MEMORY.md', + }, }, } as const diff --git a/mateclaw-ui/src/views/Memory/components/MemoryMdSection.vue b/mateclaw-ui/src/views/Memory/components/MemoryMdSection.vue new file mode 100644 index 00000000..1396f7bc --- /dev/null +++ b/mateclaw-ui/src/views/Memory/components/MemoryMdSection.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/mateclaw-ui/src/views/Memory/components/MorningCard.vue b/mateclaw-ui/src/views/Memory/components/MorningCard.vue new file mode 100644 index 00000000..dc84ed1c --- /dev/null +++ b/mateclaw-ui/src/views/Memory/components/MorningCard.vue @@ -0,0 +1,90 @@ + + + + +