fix(memory): route long-term memory edits to the file being edited (#148)

This commit is contained in:
matevip 2026-05-18 11:28:01 +08:00
parent c92b579924
commit 3157dde1f4
7 changed files with 342 additions and 79 deletions

View File

@ -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

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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");
}
}

View File

@ -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…',

View File

@ -3462,7 +3462,7 @@ export default {
save: '保存',
editPlaceholder: '修改记忆内容...',
confirmed: '已确认',
saved: '已保存到 MEMORY.md',
saved: '已保存',
},
facts: {
searchPlaceholder: '搜索事实…',

View File

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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; }