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 e1d5cc8b..b2760e6f 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 @@ -127,7 +127,7 @@ public class DreamController { return R.ok(null); } - @Operation(summary = "Edit a memory entry — writes back to MEMORY.md with user-edited metadata") + @Operation(summary = "Edit a memory entry — writes back to the target memory file with user-edited metadata") @PostMapping("/reports/{reportId}/entries/{key}/edit") @RequireWorkspaceRole("member") public R editEntry(@PathVariable Long agentId, @@ -136,8 +136,16 @@ public class DreamController { @RequestBody Map body) { String decodedKey = java.net.URLDecoder.decode(key, java.nio.charset.StandardCharsets.UTF_8); + String newContent = body.get("content"); + if (newContent == null || newContent.isBlank()) { + return R.fail("content is required"); + } + + String filename; if (reportId != 0L) { - // Report-scoped edit: validate report belongs to agent AND key belongs to report + // Report-scoped edit: dream report entries are always MEMORY.md sections. + filename = "MEMORY.md"; + // Validate report belongs to agent AND key belongs to report DreamReportEntity report = dreamReportMapper.selectOne( new LambdaQueryWrapper() .eq(DreamReportEntity::getId, reportId) @@ -166,20 +174,35 @@ public class DreamController { return R.fail("Entry '" + decodedKey + "' does not belong to report " + reportId); } } else { - // Direct edit (reportId=0, from MemoryBrowser): only require section exists - if (!hilService.sectionExists(agentId, decodedKey)) { - return R.fail("Section '" + decodedKey + "' not found in MEMORY.md"); + // Direct edit (reportId=0, from MemoryBrowser): the target file comes + // from the request body and must be an editable memory file. + filename = body.getOrDefault("filename", "MEMORY.md"); + if (!isMemoryFile(filename)) { + return R.fail("Unsupported memory file: " + filename); + } + if (!hilService.sectionExists(agentId, filename, decodedKey)) { + return R.fail("Section '" + decodedKey + "' not found in " + filename); } } - String newContent = body.get("content"); - if (newContent == null || newContent.isBlank()) { - return R.fail("content is required"); - } - hilService.editMemoryEntry(agentId, decodedKey, newContent); + hilService.editMemoryEntry(agentId, filename, decodedKey, newContent); return R.ok(null); } + /** + * Whitelist of workspace files the memory browser may edit. Keeps this + * memory-scoped endpoint from becoming a general-purpose file-write vector. + */ + private boolean isMemoryFile(String filename) { + if (filename == null || filename.isBlank() || filename.contains("..")) { + return false; + } + return "MEMORY.md".equals(filename) + || "PROFILE.md".equals(filename) + || "SOUL.md".equals(filename) + || (filename.startsWith("structured/") && filename.endsWith(".md")); + } + /** * Resolve the authenticated user's id from the JWT principal. The auth * filter exposes the username via {@link Authentication#getName()}, so the 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 index 1d38f130..11cb7bad 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java @@ -9,13 +9,15 @@ import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; import java.time.LocalDate; +import java.util.regex.Pattern; /** * 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. + * When a user edits a memory entry, this service writes it back to the target + * memory file (MEMORY.md, PROFILE.md, SOUL.md, ...) with a hidden metadata + * marker ({@code }) so that future Dream runs + * do not overwrite user modifications. * * @author MateClaw Team */ @@ -24,53 +26,64 @@ import java.time.LocalDate; @RequiredArgsConstructor public class MemoryHilService { + /** Matches a whole-line user-edited marker so repeated edits do not accumulate markers. */ + private static final Pattern USER_EDITED_MARKER = + Pattern.compile("(?m)^[ \\t]*[ \\t]*\\r?\\n?"); + private final WorkspaceFileService workspaceFileService; private final ApplicationEventPublisher eventPublisher; /** - * Edit a section in MEMORY.md identified by key (section heading). + * Edit a section identified by key (section heading) inside {@code filename}. * Appends user-edited metadata so Dream prompts respect user changes. + * + * @param agentId the agent whose workspace file is edited + * @param filename the target memory file (e.g. MEMORY.md / PROFILE.md / SOUL.md) + * @param key the section heading (text after {@code ## }) + * @param newContent the new section body */ - 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; - } + public void editMemoryEntry(Long agentId, String filename, String key, String newContent) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); + String fileContent = (file != null && file.getContent() != null) ? file.getContent() : ""; - String memoryContent = file.getContent(); + // Strip any pre-existing user-edited markers from the incoming body so a + // section edited multiple times does not pick up a stack of markers. + String cleanContent = USER_EDITED_MARKER.matcher(newContent).replaceAll("").trim(); + String metadata = ""; String sectionHeader = "## " + key; - int headerIdx = memoryContent.indexOf(sectionHeader); + int headerIdx = fileContent.indexOf(sectionHeader); + String updated; 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; + // Section not found — append as a new section. + String newSection = sectionHeader + "\n" + cleanContent + "\n" + metadata; + updated = fileContent.isBlank() ? newSection : fileContent.trim() + "\n\n" + 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); + // Replace the existing section body, keeping the heading in place. + int contentStart = fileContent.indexOf('\n', headerIdx) + 1; + int nextSection = fileContent.indexOf("\n## ", contentStart); + int sectionEnd = nextSection > 0 ? nextSection : fileContent.length(); + String replacement = cleanContent + "\n" + metadata + "\n"; + updated = fileContent.substring(0, contentStart) + replacement + + fileContent.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); + workspaceFileService.saveFile(agentId, filename, updated); + // SOUL.md auto-evolution counts canonical memory writes. A manual SOUL.md + // edit must not bump that counter, or a later auto-regeneration would + // discard the user's edit; PROFILE.md likewise is not a write trigger. + if ("MEMORY.md".equals(filename)) { + eventPublisher.publishEvent(new MemoryWriteEvent(agentId, filename, "user-edit", cleanContent)); + } + log.info("[HiL] User edited {} section '{}' for agent={}", filename, key, agentId); } /** - * Check if a section heading exists in MEMORY.md. - * Used by DreamController to validate edit key before allowing write. + * Check if a section heading exists in {@code filename}. + * Used by DreamController to validate the edit key before allowing a write. */ - public boolean sectionExists(Long agentId, String key) { - WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md"); + public boolean sectionExists(Long agentId, String filename, String key) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); if (file == null || file.getContent() == null) return false; return file.getContent().contains("## " + key); } diff --git a/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java b/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java index abb77359..05f4f0cb 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java @@ -1,6 +1,5 @@ package vip.mate.memory.controller; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,8 +25,9 @@ import static org.mockito.Mockito.*; /** * Tests for HiL edit API contract: - * - Report-scoped edit: key must belong to that report's entry set - * - Direct edit (reportId=0): key must be an existing MEMORY.md section + * - Report-scoped edit: key must belong to that report's entry set, target is MEMORY.md + * - Direct edit (reportId=0): key must be an existing section in the request's target file, + * which must be a whitelisted memory file (MEMORY.md / PROFILE.md / SOUL.md / structured/*.md) */ @ExtendWith(MockitoExtension.class) class HilEditValidationTest { @@ -71,11 +71,11 @@ class HilEditValidationTest { // Should fail — key doesn't belong to this report assertNotEquals(200, result.getCode()); - verify(hilService, never()).editMemoryEntry(any(), any(), any()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); } @Test - @DisplayName("Report-scoped edit: key matches report candidate → allowed") + @DisplayName("Report-scoped edit: key matches report candidate → allowed, writes MEMORY.md") void reportScopedEdit_keyInReport_allowed() { DreamReportEntity report = new DreamReportEntity(); report.setId(100L); @@ -95,9 +95,9 @@ class HilEditValidationTest { var result = controller.editEntry(1L, 100L, "deployment_info", Map.of("content", "updated content")); - // Should succeed + // Should succeed — report-scoped edits always target MEMORY.md assertEquals(200, result.getCode()); - verify(hilService).editMemoryEntry(eq(1L), eq("deployment_info"), eq("updated content")); + verify(hilService).editMemoryEntry(eq(1L), eq("MEMORY.md"), eq("deployment_info"), eq("updated content")); } @Test @@ -122,30 +122,97 @@ class HilEditValidationTest { Map.of("content", "content")); assertNotEquals(200, result.getCode()); - verify(hilService, never()).editMemoryEntry(any(), any(), any()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); } @Test - @DisplayName("Direct edit (reportId=0): existing section → allowed") - void directEdit_existingSection_allowed() { - when(hilService.sectionExists(1L, "stable_facts")).thenReturn(true); + @DisplayName("Direct edit (reportId=0): existing section, no filename → defaults to MEMORY.md") + void directEdit_existingSection_defaultsToMemoryMd() { + when(hilService.sectionExists(1L, "MEMORY.md", "stable_facts")).thenReturn(true); var result = controller.editEntry(1L, 0L, "stable_facts", Map.of("content", "new content")); assertEquals(200, result.getCode()); - verify(hilService).editMemoryEntry(eq(1L), eq("stable_facts"), eq("new content")); + verify(hilService).editMemoryEntry(eq(1L), eq("MEMORY.md"), eq("stable_facts"), eq("new content")); } @Test @DisplayName("Direct edit (reportId=0): non-existing section → rejected") void directEdit_nonExistingSection_rejected() { - when(hilService.sectionExists(1L, "ghost_section")).thenReturn(false); + when(hilService.sectionExists(1L, "MEMORY.md", "ghost_section")).thenReturn(false); var result = controller.editEntry(1L, 0L, "ghost_section", Map.of("content", "content")); assertNotEquals(200, result.getCode()); - verify(hilService, never()).editMemoryEntry(any(), any(), any()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); + } + + @Test + @DisplayName("Direct edit (reportId=0): PROFILE.md section → writes PROFILE.md, not MEMORY.md") + void directEdit_profileFile_writesProfile() { + when(hilService.sectionExists(1L, "PROFILE.md", "Identity")).thenReturn(true); + + var result = controller.editEntry(1L, 0L, "Identity", + Map.of("content", "name: Mate", "filename", "PROFILE.md")); + + assertEquals(200, result.getCode()); + verify(hilService).editMemoryEntry(eq(1L), eq("PROFILE.md"), eq("Identity"), eq("name: Mate")); + } + + @Test + @DisplayName("Direct edit (reportId=0): SOUL.md section → writes SOUL.md") + void directEdit_soulFile_writesSoul() { + when(hilService.sectionExists(1L, "SOUL.md", "Tone")).thenReturn(true); + + var result = controller.editEntry(1L, 0L, "Tone", + Map.of("content", "warm and direct", "filename", "SOUL.md")); + + assertEquals(200, result.getCode()); + verify(hilService).editMemoryEntry(eq(1L), eq("SOUL.md"), eq("Tone"), eq("warm and direct")); + } + + @Test + @DisplayName("Direct edit (reportId=0): structured/*.md section → allowed") + void directEdit_structuredFile_allowed() { + when(hilService.sectionExists(1L, "structured/user.md", "Preferences")).thenReturn(true); + + var result = controller.editEntry(1L, 0L, "Preferences", + Map.of("content", "likes dark mode", "filename", "structured/user.md")); + + assertEquals(200, result.getCode()); + verify(hilService).editMemoryEntry(eq(1L), eq("structured/user.md"), eq("Preferences"), + eq("likes dark mode")); + } + + @Test + @DisplayName("Direct edit (reportId=0): non-whitelisted filename → rejected") + void directEdit_unsupportedFile_rejected() { + var result = controller.editEntry(1L, 0L, "anything", + Map.of("content", "content", "filename", "application.yml")); + + assertNotEquals(200, result.getCode()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); + } + + @Test + @DisplayName("Direct edit (reportId=0): path-traversal filename → rejected") + void directEdit_pathTraversal_rejected() { + var result = controller.editEntry(1L, 0L, "anything", + Map.of("content", "content", "filename", "../../etc/passwd")); + + assertNotEquals(200, result.getCode()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); + } + + @Test + @DisplayName("Direct edit (reportId=0): blank content → rejected") + void directEdit_blankContent_rejected() { + var result = controller.editEntry(1L, 0L, "stable_facts", + Map.of("content", " ")); + + assertNotEquals(200, result.getCode()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); } } diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryHilServiceTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryHilServiceTest.java new file mode 100644 index 00000000..aa9b976d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryHilServiceTest.java @@ -0,0 +1,123 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.memory.event.MemoryWriteEvent; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Tests for MemoryHilService — a user edit must land in the file the user is + * editing (MEMORY.md / PROFILE.md / SOUL.md), not unconditionally in MEMORY.md. + */ +@ExtendWith(MockitoExtension.class) +class MemoryHilServiceTest { + + @Mock private WorkspaceFileService workspaceFileService; + @Mock private ApplicationEventPublisher eventPublisher; + + private MemoryHilService service; + + @BeforeEach + void setUp() { + service = new MemoryHilService(workspaceFileService, eventPublisher); + } + + private WorkspaceFileEntity file(String filename, String content) { + WorkspaceFileEntity e = new WorkspaceFileEntity(); + e.setAgentId(1L); + e.setFilename(filename); + e.setContent(content); + return e; + } + + @Test + @DisplayName("Editing a PROFILE.md section writes back to PROFILE.md, not MEMORY.md") + void editProfile_writesProfile() { + String profile = "## Identity\nold name\n\n## Goals\nlearn\n"; + when(workspaceFileService.getFile(1L, "PROFILE.md")).thenReturn(file("PROFILE.md", profile)); + + service.editMemoryEntry(1L, "PROFILE.md", "Identity", "new name"); + + ArgumentCaptor content = ArgumentCaptor.forClass(String.class); + verify(workspaceFileService).saveFile(eq(1L), eq("PROFILE.md"), content.capture()); + verify(workspaceFileService, never()).saveFile(eq(1L), eq("MEMORY.md"), any()); + + String saved = content.getValue(); + assertTrue(saved.contains("## Identity\nnew name"), "section body replaced"); + assertTrue(saved.contains("\n"; + when(workspaceFileService.getFile(1L, "MEMORY.md")).thenReturn(file("MEMORY.md", memory)); + + // Simulate an editor that echoes the old marker back inside the new body + service.editMemoryEntry(1L, "MEMORY.md", "Facts", "v2\n"); + + ArgumentCaptor content = ArgumentCaptor.forClass(String.class); + verify(workspaceFileService).saveFile(eq(1L), eq("MEMORY.md"), content.capture()); + + String saved = content.getValue(); + int markers = saved.split("[ \t]*$/gm, '').trim() +} + function startEdit(idx: number) { editingIdx.value = idx - editText.value = sections.value[idx].body + editText.value = stripMarker(sections.value[idx].body) } async function saveSection(idx: number) { saving.value = true try { - // Use HiL edit endpoint to write back with user-edited metadata - const heading = sections.value[idx].heading - await http.post( - `/memory/${props.agentId}/dream/reports/0/entries/${encodeURIComponent(heading)}/edit`, - { content: editText.value } - ) + const sec = sections.value[idx] + if (sec.synthetic) { + // The preamble (content before the first ## heading) has no section key + // the HiL endpoint can address — rewrite it through the workspace file + // API, preserving every real `## ` section that follows. + const res: any = await agentContextApi.getFile(props.agentId, currentFile.value) + const content: string = res.data?.content || '' + const headingIdx = content.search(/^## /m) + const rest = headingIdx >= 0 ? content.slice(headingIdx) : '' + const preamble = editText.value.trim() + const merged = preamble && rest ? `${preamble}\n\n${rest}` : preamble + rest + await agentContextApi.saveFile(props.agentId, currentFile.value, merged) + } else { + // Use HiL edit endpoint to write back with user-edited metadata. + // `filename` tells the backend which memory file to edit — without it the + // backend defaults to MEMORY.md and PROFILE.md / SOUL.md edits fail. + await http.post( + `/memory/${props.agentId}/dream/reports/0/entries/${encodeURIComponent(sec.heading)}/edit`, + { content: editText.value, filename: currentFile.value } + ) + } mcToast.success(t('memory.hil.saved')) editingIdx.value = -1 // Reload file to see changes @@ -156,16 +181,23 @@ async function saveSection(idx: number) { } function renderMd(body: string): string { - // Simple markdown rendering (lists, bold, line breaks) + // Minimal Markdown — bold, inline code, italic, lists, blockquotes. + // HTML is escaped first so a section body can never inject markup via v-html. + const esc = (s: string) => + s.replace(/&/g, '&').replace(//g, '>') + const inline = (s: string) => + esc(s) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`([^`]+?)`/g, '$1') + // Italic — require a boundary before the marker so snake_case is left alone. + .replace(/(^|[\s(])([*_])(?=\S)([^*_]+?)\2(?=[\s).,;:!?,。;:!?]|$)/g, '$1$3') return body .split('\n') .map(line => { - let html = line - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/`(.+?)`/g, '$1') - if (html.startsWith('- ')) return `

  • ${html.slice(2)}
  • ` - if (html.trim() === '') return '' - return `

    ${html}

    ` + if (line.trim() === '') return '' + if (line.startsWith('- ')) return `
  • ${inline(line.slice(2))}
  • ` + if (line.startsWith('> ')) return `
    ${inline(line.slice(2))}
    ` + return `

    ${inline(line)}

    ` }) .filter(Boolean) .join('') @@ -256,7 +288,12 @@ function fileLabel(filename: string): string { font-size: 12px; font-family: 'SF Mono', Menlo, monospace; } .section-body :deep(strong) { color: var(--mc-text-primary); } +.section-body :deep(em) { font-style: italic; } .section-body :deep(p) { margin: 2px 0; } +.section-body :deep(blockquote) { + margin: 4px 0; padding: 1px 0 1px 10px; + border-left: 2px solid var(--mc-border); color: var(--mc-text-tertiary); +} /* Edit mode */ .section-edit { margin-top: 8px; }