diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/DefaultMemoryWriter.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/DefaultMemoryWriter.java
new file mode 100644
index 00000000..18c7def1
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/DefaultMemoryWriter.java
@@ -0,0 +1,57 @@
+package vip.mate.workflow.runtime;
+
+import org.springframework.stereotype.Component;
+import vip.mate.workspace.document.WorkspaceFileService;
+import vip.mate.workspace.document.model.WorkspaceFileEntity;
+
+/**
+ * Production binding for {@link MemoryWriter}. Resolves {@code employeeId}
+ * (a string in the wire format) to the agent id keying
+ * {@code mate_workspace_file}, applies the chosen merge strategy via
+ * {@link MergeStrategies}, then persists the result through
+ * {@link WorkspaceFileService#saveFile}.
+ *
+ *
v0 treats {@code employeeId} as the numeric agent id rendered as a
+ * string. Looking the agent up by name was considered but pushes name
+ * uniqueness into the runtime — the schema validator already accepts only
+ * a string so the wire format does not change. When we add a "human
+ * employee" surface this binding will grow a separate code path.
+ */
+@Component
+public class DefaultMemoryWriter implements MemoryWriter {
+
+ private final WorkspaceFileService fileService;
+
+ public DefaultMemoryWriter(WorkspaceFileService fileService) {
+ this.fileService = fileService;
+ }
+
+ @Override
+ public Result write(long workspaceId, String employeeId, String file,
+ String mergeStrategy, String content) {
+ Long agentId;
+ try {
+ agentId = Long.parseLong(employeeId);
+ } catch (NumberFormatException e) {
+ return Result.fail("employeeId '" + employeeId
+ + "' is not a valid agent id (numeric string expected)");
+ }
+ WorkspaceFileEntity existing = fileService.getFile(agentId, file);
+ String existingBody = existing == null ? "" : (existing.getContent() == null ? "" : existing.getContent());
+
+ String merged;
+ try {
+ merged = MergeStrategies.apply(existingBody, content, mergeStrategy);
+ } catch (IllegalArgumentException e) {
+ return Result.fail(e.getMessage());
+ }
+
+ try {
+ fileService.saveFile(agentId, file, merged);
+ } catch (Exception e) {
+ return Result.fail("failed to persist memory file '" + file + "': " + e.getMessage());
+ }
+ return Result.ok(mergeStrategy + " merged " + content.length() + " chars into "
+ + file + " (agent " + agentId + ")");
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MemoryWriter.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MemoryWriter.java
new file mode 100644
index 00000000..b1266ee9
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MemoryWriter.java
@@ -0,0 +1,24 @@
+package vip.mate.workflow.runtime;
+
+/**
+ * SPI for the {@code write_memory} step. Hides the workspace-file storage
+ * implementation behind a small surface so tests can stub the file side
+ * effect without booting WorkspaceFileService. Production binding lives in
+ * {@link DefaultMemoryWriter}.
+ */
+public interface MemoryWriter {
+
+ /**
+ * Apply {@code mergeStrategy} to {@code content} against the existing
+ * file body for {@code (workspaceId, employeeId, file)} and persist the
+ * result. Returns a {@link Result} carrying a short summary so the step
+ * row's {@code output_summary} captures what changed.
+ */
+ Result write(long workspaceId, String employeeId, String file,
+ String mergeStrategy, String content);
+
+ record Result(boolean success, String summary, String errorMessage) {
+ public static Result ok(String summary) { return new Result(true, summary, null); }
+ public static Result fail(String error) { return new Result(false, null, error); }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MergeStrategies.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MergeStrategies.java
new file mode 100644
index 00000000..cc8d136b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MergeStrategies.java
@@ -0,0 +1,139 @@
+package vip.mate.workflow.runtime;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Pure helpers implementing the four v0 merge strategies the {@code write_memory}
+ * step supports. Stateless so the same logic backs the production
+ * {@link MemoryWriter} binding and any test fake.
+ *
+ *
+ * - {@code append} — incoming content is concatenated to the existing
+ * body with a separating blank line. The simplest no-magic merge.
+ * - {@code replace_section} — the incoming body's first non-blank line is
+ * expected to be a Markdown {@code ## } heading; if a section with that
+ * heading already exists in the file, it is replaced (heading inclusive
+ * through the line before the next {@code ## } heading or EOF);
+ * otherwise the incoming body is appended with a blank-line separator.
+ * - {@code upsert_kv} — every line of the incoming body that matches
+ * {@code key: value} is treated as a key/value pair. Existing matching
+ * keys are updated in place; new keys are appended. Non-kv lines in the
+ * incoming body are dropped (they would otherwise re-introduce
+ * freeform text on every run).
+ * - {@code overwrite} — replace the file with the incoming body
+ * verbatim. The escape hatch when no other strategy fits.
+ *
+ */
+public final class MergeStrategies {
+
+ /** Heading line for {@code replace_section}. */
+ private static final Pattern SECTION_HEADING = Pattern.compile(
+ "^##\\s+.+$", Pattern.MULTILINE);
+
+ /** {@code key: value} line for {@code upsert_kv} parsing. */
+ private static final Pattern KV_LINE = Pattern.compile(
+ "^([A-Za-z0-9_.\\-]+)\\s*:\\s*(.*)$");
+
+ private MergeStrategies() {}
+
+ public static String apply(String existing, String incoming, String strategy) {
+ String existingSafe = existing == null ? "" : existing;
+ String incomingSafe = incoming == null ? "" : incoming;
+ return switch (strategy) {
+ case "append" -> append(existingSafe, incomingSafe);
+ case "replace_section" -> replaceSection(existingSafe, incomingSafe);
+ case "upsert_kv" -> upsertKv(existingSafe, incomingSafe);
+ case "overwrite" -> incomingSafe;
+ default -> throw new IllegalArgumentException(
+ "unknown merge strategy '" + strategy
+ + "' — must be append / replace_section / upsert_kv / overwrite");
+ };
+ }
+
+ private static String append(String existing, String incoming) {
+ if (existing.isEmpty()) return incoming;
+ if (incoming.isEmpty()) return existing;
+ String trimmed = existing.endsWith("\n") ? existing : existing + "\n";
+ return trimmed + "\n" + incoming;
+ }
+
+ private static String replaceSection(String existing, String incoming) {
+ String heading = firstHeading(incoming);
+ if (heading == null) {
+ // No heading on the incoming side — fall back to append so the
+ // step never silently drops content.
+ return append(existing, incoming);
+ }
+ int existingStart = indexOfHeading(existing, heading);
+ if (existingStart < 0) {
+ return append(existing, incoming);
+ }
+ int existingEnd = indexOfNextHeading(existing, existingStart + heading.length());
+ if (existingEnd < 0) existingEnd = existing.length();
+ StringBuilder out = new StringBuilder();
+ out.append(existing, 0, existingStart);
+ out.append(incoming);
+ if (!incoming.endsWith("\n")) out.append('\n');
+ if (existingEnd < existing.length()) {
+ out.append(existing, existingEnd, existing.length());
+ }
+ return out.toString();
+ }
+
+ private static String firstHeading(String body) {
+ Matcher m = SECTION_HEADING.matcher(body);
+ return m.find() ? m.group().stripTrailing() : null;
+ }
+
+ private static int indexOfHeading(String body, String heading) {
+ // Match the heading at start of line (after any line break or at
+ // position 0) so we don't false-match an inline "## " inside a code
+ // block by accident.
+ Pattern p = Pattern.compile("(?m)^" + Pattern.quote(heading) + "\\s*$");
+ Matcher m = p.matcher(body);
+ return m.find() ? m.start() : -1;
+ }
+
+ private static int indexOfNextHeading(String body, int from) {
+ Matcher m = SECTION_HEADING.matcher(body);
+ if (m.find(from)) return m.start();
+ return -1;
+ }
+
+ private static String upsertKv(String existing, String incoming) {
+ Map updates = new LinkedHashMap<>();
+ for (String line : incoming.split("\\R", -1)) {
+ Matcher m = KV_LINE.matcher(line.trim());
+ if (m.matches()) {
+ updates.put(m.group(1), m.group(2));
+ }
+ }
+ if (updates.isEmpty()) return existing;
+
+ StringBuilder out = new StringBuilder();
+ for (String line : existing.split("\\R", -1)) {
+ Matcher m = KV_LINE.matcher(line.trim());
+ if (m.matches() && updates.containsKey(m.group(1))) {
+ out.append(m.group(1)).append(": ").append(updates.remove(m.group(1)));
+ } else {
+ out.append(line);
+ }
+ out.append('\n');
+ }
+ // Trim trailing empty line we always added so a clean file stays clean.
+ if (out.length() > 0 && out.charAt(out.length() - 1) == '\n') {
+ out.setLength(out.length() - 1);
+ }
+ // Append any incoming keys that did not exist in the original file.
+ for (var e : updates.entrySet()) {
+ if (out.length() > 0 && out.charAt(out.length() - 1) != '\n') {
+ out.append('\n');
+ }
+ out.append(e.getKey()).append(": ").append(e.getValue());
+ }
+ return out.toString();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/WriteMemoryStepAdapter.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/WriteMemoryStepAdapter.java
new file mode 100644
index 00000000..30a6860c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/WriteMemoryStepAdapter.java
@@ -0,0 +1,75 @@
+package vip.mate.workflow.runtime.mode;
+
+import org.springframework.stereotype.Component;
+import vip.mate.workflow.compiler.PebbleSubsetEvaluator;
+import vip.mate.workflow.compiler.ir.StepMode;
+import vip.mate.workflow.compiler.ir.WorkflowStep;
+import vip.mate.workflow.runtime.MemoryWriter;
+import vip.mate.workflow.runtime.PayloadStore;
+import vip.mate.workflow.runtime.StepAdapter;
+import vip.mate.workflow.runtime.StepResult;
+import vip.mate.workflow.runtime.WorkflowRunContext;
+
+/**
+ * {@code write_memory} — render the content template, then delegate to
+ * {@link MemoryWriter} to apply the configured merge strategy against the
+ * target memory file. The rendered content is also written through to
+ * {@code mate_workflow_payload} so the step row's {@code output_ref} points
+ * at the exact text that was merged in (independent of the file's final
+ * post-merge state, which downstream tooling may want to diff).
+ */
+@Component
+public class WriteMemoryStepAdapter implements StepAdapter {
+
+ private final PebbleSubsetEvaluator pebble;
+ private final PayloadStore payloadStore;
+ private final MemoryWriter memoryWriter;
+
+ public WriteMemoryStepAdapter(PebbleSubsetEvaluator pebble,
+ PayloadStore payloadStore,
+ MemoryWriter memoryWriter) {
+ this.pebble = pebble;
+ this.payloadStore = payloadStore;
+ this.memoryWriter = memoryWriter;
+ }
+
+ @Override
+ public String typeName() { return "write_memory"; }
+
+ @Override
+ public StepResult execute(WorkflowStep step, WorkflowRunContext context) {
+ if (!(step.mode() instanceof StepMode.WriteMemory cfg)) {
+ return StepResult.failed("write_memory adapter received non-write_memory mode: "
+ + step.mode().typeName());
+ }
+
+ String rendered;
+ try {
+ var compiled = pebble.parseTemplate(cfg.content());
+ rendered = pebble.evaluateAsString(compiled, context.templateContext());
+ } catch (Exception e) {
+ return StepResult.failed("write_memory content render failed for step '"
+ + step.name() + "': " + e.getMessage());
+ }
+
+ // Resolve template-form employeeId now that the run context exists —
+ // the publish-time ACL phase deliberately skipped checking templates.
+ String employeeId;
+ try {
+ var compiled = pebble.parseTemplate(cfg.employeeId());
+ employeeId = pebble.evaluateAsString(compiled, context.templateContext());
+ } catch (Exception e) {
+ return StepResult.failed("write_memory employeeId template failed for step '"
+ + step.name() + "': " + e.getMessage());
+ }
+
+ MemoryWriter.Result result = memoryWriter.write(
+ context.workspaceId(), employeeId, cfg.file(), cfg.mergeStrategy(), rendered);
+ if (!result.success()) {
+ return StepResult.failed(result.errorMessage());
+ }
+
+ String payloadUri = payloadStore.storeString(context.workspaceId(), rendered, "text/markdown");
+ return StepResult.succeeded(payloadUri, "text", rendered, result.summary());
+ }
+}