mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(memory): route long-term memory edits to the file being edited (#148)
This commit is contained in:
parent
c92b579924
commit
3157dde1f4
@ -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<Void> editEntry(@PathVariable Long agentId,
|
||||
@ -136,8 +136,16 @@ public class DreamController {
|
||||
@RequestBody Map<String, String> 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<DreamReportEntity>()
|
||||
.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
|
||||
|
||||
@ -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.
|
||||
* <p>
|
||||
* When a user edits a memory entry, this service writes it back to MEMORY.md
|
||||
* with a hidden metadata marker (<!-- user-edited: YYYY-MM-DD -->) 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 <!-- user-edited: YYYY-MM-DD -->}) 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]*<!-- user-edited:.*-->[ \\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 = "<!-- user-edited: " + LocalDate.now() + " -->";
|
||||
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 = "<!-- user-edited: " + LocalDate.now() + " -->";
|
||||
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 = "<!-- user-edited: " + LocalDate.now() + " -->";
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String> 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("<!-- user-edited:"), "user-edited marker appended");
|
||||
assertTrue(saved.contains("## Goals\nlearn"), "other sections untouched");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Editing SOUL.md does not publish a MemoryWriteEvent (avoids SOUL self-overwrite)")
|
||||
void editSoul_noEvent() {
|
||||
when(workspaceFileService.getFile(1L, "SOUL.md"))
|
||||
.thenReturn(file("SOUL.md", "## Tone\ndry\n"));
|
||||
|
||||
service.editMemoryEntry(1L, "SOUL.md", "Tone", "warm");
|
||||
|
||||
verify(workspaceFileService).saveFile(eq(1L), eq("SOUL.md"), any());
|
||||
verify(eventPublisher, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Editing MEMORY.md publishes a MemoryWriteEvent targeting MEMORY.md")
|
||||
void editMemory_publishesEvent() {
|
||||
when(workspaceFileService.getFile(1L, "MEMORY.md"))
|
||||
.thenReturn(file("MEMORY.md", "## Facts\nold\n"));
|
||||
|
||||
service.editMemoryEntry(1L, "MEMORY.md", "Facts", "fresh");
|
||||
|
||||
ArgumentCaptor<MemoryWriteEvent> event = ArgumentCaptor.forClass(MemoryWriteEvent.class);
|
||||
verify(eventPublisher).publishEvent(event.capture());
|
||||
assertEquals("MEMORY.md", event.getValue().target());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Repeated edits do not accumulate user-edited markers")
|
||||
void repeatedEdit_singleMarker() {
|
||||
// Section body already carries a marker from a previous edit
|
||||
String memory = "## Facts\nv1\n<!-- user-edited: 2026-05-01 -->\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<!-- user-edited: 2026-05-01 -->");
|
||||
|
||||
ArgumentCaptor<String> content = ArgumentCaptor.forClass(String.class);
|
||||
verify(workspaceFileService).saveFile(eq(1L), eq("MEMORY.md"), content.capture());
|
||||
|
||||
String saved = content.getValue();
|
||||
int markers = saved.split("<!-- user-edited:", -1).length - 1;
|
||||
assertEquals(1, markers, "exactly one marker after a re-edit");
|
||||
assertTrue(saved.contains("v2"));
|
||||
assertFalse(saved.contains("v1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Editing a section absent from the file appends it as a new section")
|
||||
void editMissingSection_appends() {
|
||||
when(workspaceFileService.getFile(1L, "PROFILE.md"))
|
||||
.thenReturn(file("PROFILE.md", "## Identity\nMate\n"));
|
||||
|
||||
service.editMemoryEntry(1L, "PROFILE.md", "Goals", "ship the fix");
|
||||
|
||||
ArgumentCaptor<String> content = ArgumentCaptor.forClass(String.class);
|
||||
verify(workspaceFileService).saveFile(eq(1L), eq("PROFILE.md"), content.capture());
|
||||
|
||||
String saved = content.getValue();
|
||||
assertTrue(saved.contains("## Identity\nMate"), "existing section kept");
|
||||
assertTrue(saved.contains("## Goals\nship the fix"), "new section appended");
|
||||
}
|
||||
}
|
||||
@ -3370,7 +3370,7 @@ export default {
|
||||
save: 'Save',
|
||||
editPlaceholder: 'Edit memory content...',
|
||||
confirmed: 'Confirmed',
|
||||
saved: 'Saved to MEMORY.md',
|
||||
saved: 'Saved',
|
||||
},
|
||||
facts: {
|
||||
searchPlaceholder: 'Search facts…',
|
||||
|
||||
@ -3462,7 +3462,7 @@ export default {
|
||||
save: '保存',
|
||||
editPlaceholder: '修改记忆内容...',
|
||||
confirmed: '已确认',
|
||||
saved: '已保存到 MEMORY.md',
|
||||
saved: '已保存',
|
||||
},
|
||||
facts: {
|
||||
searchPlaceholder: '搜索事实…',
|
||||
|
||||
@ -70,7 +70,9 @@ const props = defineProps<{ agentId: string | number }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface FileInfo { filename: string; fileSize: number; enabled: boolean }
|
||||
interface Section { heading: string; body: string; userEdited: boolean; raw: string }
|
||||
// `synthetic` marks the pseudo-section built from content before the first
|
||||
// `## ` heading — it has no heading key the HiL endpoint can address.
|
||||
interface Section { heading: string; body: string; userEdited: boolean; raw: string; synthetic?: boolean }
|
||||
|
||||
const files = ref<FileInfo[]>([])
|
||||
const currentFile = ref('')
|
||||
@ -121,31 +123,54 @@ function parseSections(content: string): Section[] {
|
||||
const match = part.match(/^## (.+)\n([\s\S]*)/)
|
||||
if (match) {
|
||||
const heading = match[1].trim()
|
||||
const body = match[2].trim()
|
||||
const userEdited = body.includes('<!-- user-edited')
|
||||
result.push({ heading, body, userEdited, raw: part })
|
||||
const rawBody = match[2].trim()
|
||||
const userEdited = rawBody.includes('<!-- user-edited')
|
||||
// Strip the hidden marker from the display body — it is metadata, and
|
||||
// since renderMd now escapes HTML it would otherwise show as raw text.
|
||||
result.push({ heading, body: stripMarker(rawBody), userEdited, raw: part })
|
||||
} else if (part.trim() && result.length === 0) {
|
||||
// Content before first ## heading
|
||||
result.push({ heading: t('memory.memoryBrowser.header'), body: part.trim(), userEdited: false, raw: part })
|
||||
// Content before the first ## heading — a synthetic "preamble" section.
|
||||
result.push({ heading: t('memory.memoryBrowser.header'), body: part.trim(), userEdited: false, raw: part, synthetic: true })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Strip the hidden user-edited marker so it never shows up as raw text in the
|
||||
// editor (and never accumulates when a section is edited repeatedly).
|
||||
function stripMarker(body: string): string {
|
||||
return body.replace(/^[ \t]*<!-- user-edited:.*-->[ \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, '<').replace(/>/g, '>')
|
||||
const inline = (s: string) =>
|
||||
esc(s)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+?)`/g, '<code>$1</code>')
|
||||
// Italic — require a boundary before the marker so snake_case is left alone.
|
||||
.replace(/(^|[\s(])([*_])(?=\S)([^*_]+?)\2(?=[\s).,;:!?,。;:!?]|$)/g, '$1<em>$3</em>')
|
||||
return body
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
let html = line
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
if (html.startsWith('- ')) return `<li>${html.slice(2)}</li>`
|
||||
if (html.trim() === '') return ''
|
||||
return `<p>${html}</p>`
|
||||
if (line.trim() === '') return ''
|
||||
if (line.startsWith('- ')) return `<li>${inline(line.slice(2))}</li>`
|
||||
if (line.startsWith('> ')) return `<blockquote>${inline(line.slice(2))}</blockquote>`
|
||||
return `<p>${inline(line)}</p>`
|
||||
})
|
||||
.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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user