From fe68f22aa8be2bf0e7d163834a62d75d32088ea1 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 17 Jun 2026 11:04:19 +0800 Subject: [PATCH] feat(memory): bound always-on memory growth with injection budget, consolidation, and file ceilings Always-on memory (structured user/feedback blocks, PROFILE.md, MEMORY.md) is injected into every system prompt but only ever grew, inflating per-turn context over time. This adds deterministic size control across all always-on sources: - Injection budget: cap the always-on structured block by total chars and per-type entry count, keeping the most-recently-updated entries (LRU by Updated date) and disclosing how many were omitted - Nightly consolidation: a dedicated scheduled pass merges duplicate/stale user & feedback entries via the LLM, preserving each entry's original Updated date; runs per owner bucket (shared + personal) with a per-run cap and a never-grow safety guard - File ceilings: deterministic backstop truncates PROFILE.md / MEMORY.md at a section boundary when a rewrite overruns its budget - Manual trigger endpoint for the consolidation maintenance task All knobs under mate.memory.*; covered by unit tests. --- .../vip/mate/memory/MemoryProperties.java | 60 +++++ .../memory/controller/MemoryController.java | 24 ++ .../StructuredMemoryMaintenanceScheduler.java | 72 ++++++ .../memory/service/AlwaysOnFileBudget.java | 42 ++++ .../service/MemoryEmergenceService.java | 3 + .../service/MemorySummarizationService.java | 16 ++ .../StructuredMemoryConsolidationService.java | 184 +++++++++++++++ .../service/StructuredMemoryService.java | 210 +++++++++++++++++- .../src/main/resources/application.yml | 11 + .../memory/consolidate-structured-system.txt | 28 +++ .../memory/consolidate-structured-user.txt | 6 + .../service/AlwaysOnFileBudgetTest.java | 54 +++++ ...ucturedMemoryConsolidationServiceTest.java | 151 +++++++++++++ .../service/StructuredMemoryPrefetchTest.java | 93 +++++++- .../src/components/chat/MessageBubble.vue | 6 +- mateclaw-ui/src/composables/chat/useChat.ts | 14 +- 16 files changed, 957 insertions(+), 17 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/scheduler/StructuredMemoryMaintenanceScheduler.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/service/AlwaysOnFileBudget.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryConsolidationService.java create mode 100644 mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-system.txt create mode 100644 mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-user.txt create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/service/AlwaysOnFileBudgetTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryConsolidationServiceTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java index dec2aeef..b77f3fb3 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java @@ -81,6 +81,66 @@ public class MemoryProperties { /** 禁用的 MemoryProvider ID 集合(例如 "structured", "session_search") */ private Set disabledProviders = new HashSet<>(); + // ==================== Always-on injection budget ==================== + + /** + * Character budget for the always-on structured memory block injected into + * every system prompt (user + feedback entries). When exceeded, the + * most-recently-updated entries are kept and older ones are omitted, so + * accumulated memory cannot grow the per-turn context without bound. + * 0 = unlimited (legacy behavior). + */ + private int systemBlockMaxChars = 4000; + + /** + * Hard cap on the number of entries injected per structured type in the + * always-on block. Keeps the newest entries per type even when the global + * character budget would otherwise admit more from a single type. + * 0 = unlimited. + */ + private int systemBlockMaxEntriesPerType = 40; + + /** + * Enable the nightly consolidation pass over always-on structured memory + * (user/feedback): an LLM merges near-duplicate and stale entries so the + * files shrink on disk rather than only being trimmed at injection time. + */ + private boolean structuredConsolidationEnabled = true; + + /** + * Minimum entry count before a structured type is consolidated. Below this + * the file is already small, so the LLM call is skipped. + */ + private int structuredConsolidationMinEntries = 8; + + /** + * Cron expression for the structured-memory consolidation maintenance task. + * Independent of {@code dreamingCron} so the two passes can be scheduled + * (and gated) separately. Default: 3:30 AM daily, after nightly emergence. + */ + private String structuredConsolidationCron = "0 30 3 * * ?"; + + /** + * Maximum number of owner buckets (shared + personal) consolidated per agent + * per run. Bounds LLM cost on agents with many per-owner memory buckets; + * remaining owners are picked up on subsequent runs. 0 = unlimited. + */ + private int structuredConsolidationMaxOwnersPerRun = 50; + + /** + * Deterministic character ceiling for PROFILE.md, the always-on user-profile + * file. The summarization pass rewrites it and is asked to stay concise; this + * is the hard backstop that truncates at a section boundary if it overruns. + * 0 = unlimited. + */ + private int profileMaxChars = 4000; + + /** + * Deterministic character ceiling for MEMORY.md, the always-on long-term + * memory file rewritten by summarization and emergence. 0 = unlimited. + */ + private int memoryMdMaxChars = 8000; + // ==================== Dream v2 Feature Flags ==================== // --- Phase 1: Lifecycle mediator wiring --- diff --git a/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java b/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java index 6f63553c..14add3c8 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java @@ -37,6 +37,30 @@ public class MemoryController { private final MemoryProperties memoryProperties; private final DreamingScheduler dreamingScheduler; private final WorkspaceFileService workspaceFileService; + private final StructuredMemoryConsolidationService structuredConsolidationService; + + @Operation(summary = "手动触发 always-on 结构化记忆整合(user/feedback,合并去重过时条目)") + @PostMapping("/{agentId}/structured-consolidation") + @RequireWorkspaceRole("member") + public R> triggerStructuredConsolidation(@PathVariable Long agentId) { + try { + StructuredMemoryConsolidationService.ConsolidationStats s = + structuredConsolidationService.consolidateAgent(agentId); + Map out = new LinkedHashMap<>(); + out.put("ownersConsolidated", s.ownersConsolidated); + out.put("updated", s.updated); + out.put("skippedSmall", s.skippedSmall); + out.put("skippedOverCap", s.skippedOverCap); + out.put("failed", s.failed); + out.put("entriesBefore", s.entriesBefore); + out.put("entriesAfter", s.entriesAfter); + return R.ok(out); + } catch (Exception e) { + log.error("[Memory] Manual structured consolidation failed for agent={}: {}", + agentId, e.getMessage(), e); + return R.fail("结构化记忆整合失败: " + e.getMessage()); + } + } @Operation(summary = "手动触发记忆整合(daily notes → MEMORY.md,NIGHTLY 模式)") @PostMapping("/{agentId}/emergence") diff --git a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/StructuredMemoryMaintenanceScheduler.java b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/StructuredMemoryMaintenanceScheduler.java new file mode 100644 index 00000000..59a7e962 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/StructuredMemoryMaintenanceScheduler.java @@ -0,0 +1,72 @@ +package vip.mate.memory.scheduler; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.service.StructuredMemoryConsolidationService; +import vip.mate.memory.service.StructuredMemoryConsolidationService.ConsolidationStats; + +import java.time.LocalDateTime; + +/** + * Scheduled maintenance for always-on structured memory. + *

+ * Runs the consolidation pass that merges duplicate / stale user & feedback + * entries so the always-on block shrinks at the storage level. Kept separate from + * {@link DreamingScheduler} so it has its own cron and enable flag — disabling + * nightly dreaming must not silently disable structured consolidation, and vice + * versa. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class StructuredMemoryMaintenanceScheduler { + + private final AgentService agentService; + private final StructuredMemoryConsolidationService consolidationService; + private final MemoryProperties properties; + + /** Last run time, for the status API. */ + @Getter + private volatile LocalDateTime lastRunTime; + + @Scheduled(cron = "${mate.memory.structured-consolidation-cron:0 30 3 * * ?}") + public void runConsolidation() { + if (!properties.isStructuredConsolidationEnabled()) { + log.debug("[StructuredConsolidation] Disabled, skipping"); + return; + } + + log.info("[StructuredConsolidation] Starting maintenance cycle"); + ConsolidationStats total = new ConsolidationStats(); + int agents = 0; + + for (AgentEntity agent : agentService.listAgents()) { + if (!Boolean.TRUE.equals(agent.getEnabled())) { + continue; + } + agents++; + try { + ConsolidationStats agentStats = consolidationService.consolidateAgent(agent.getId()); + total.add(agentStats); + } catch (Exception e) { + log.warn("[StructuredConsolidation] Failed for agent={} ({}): {}", + agent.getId(), agent.getName(), e.getMessage()); + } + } + + lastRunTime = LocalDateTime.now(); + log.info("[StructuredConsolidation] Cycle done: agents={}, buckets={}, updated={}, " + + "skipped(small)={}, skipped(cap)={}, failed={}, entries {}->{}", + agents, total.ownersConsolidated, total.updated, + total.skippedSmall, total.skippedOverCap, total.failed, + total.entriesBefore, total.entriesAfter); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/AlwaysOnFileBudget.java b/mateclaw-server/src/main/java/vip/mate/memory/service/AlwaysOnFileBudget.java new file mode 100644 index 00000000..d2b4cebe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/AlwaysOnFileBudget.java @@ -0,0 +1,42 @@ +package vip.mate.memory.service; + +/** + * Deterministic character-budget backstop for always-on memory files + * (PROFILE.md / MEMORY.md) that are injected into every system prompt. + *

+ * These files are rewritten wholesale by the summarization and emergence passes, + * which are instructed to stay concise but have no hard ceiling — so on their own + * they can grow the per-turn context without bound. This enforces a deterministic + * cap: when content exceeds the budget it is truncated at a Markdown section + * boundary (keeping the head, where the core/principle sections live) and a marker + * is appended. The LLM rewrite remains the primary, content-aware compressor; this + * is the last-resort guarantee that the file stays bounded. + * + * @author MateClaw Team + */ +final class AlwaysOnFileBudget { + + /** Appended when content is truncated; user-facing note kept in the file. */ + static final String MARKER = "\n\n> ⚠️ 后续内容已截断以控制注入体积。"; + + private AlwaysOnFileBudget() { + } + + /** + * Truncate {@code content} to at most {@code maxChars} characters, cutting at + * the last {@code "## "} section boundary that fits so a section is never split + * mid-way. Returns the input unchanged when it already fits or when budgeting + * is disabled ({@code maxChars <= 0}). + */ + static String enforce(String content, int maxChars) { + if (content == null || maxChars <= 0 || content.length() <= maxChars) { + return content; + } + int limit = Math.max(0, maxChars - MARKER.length()); + // Prefer cutting at a section boundary within the budget; "\n## " keeps the + // leading section header intact. Fall back to a hard cut when none fits. + int boundary = content.lastIndexOf("\n## ", limit); + String head = boundary > 0 ? content.substring(0, boundary) : content.substring(0, limit); + return head.stripTrailing() + MARKER; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java index bfa545e8..5fc66b65 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java @@ -159,6 +159,9 @@ public class MemoryEmergenceService { return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "empty memory_content"); } + // Deterministic backstop so the always-on MEMORY.md stays bounded even + // if the LLM rewrite ignores the "keep concise" instruction. + newContent = AlwaysOnFileBudget.enforce(newContent, properties.getMemoryMdMaxChars()); workspaceFileService.saveFile(agentId, "MEMORY.md", newContent); eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent)); String llmReason = root.path("reason").asText(""); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java index 962737d3..0f37ca04 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java @@ -328,6 +328,7 @@ public class MemorySummarizationService { * (TEAM) file when there is no real owner (cron / system). */ private void saveMemory(Long agentId, String filename, String content, String ownerKey) { + content = capAlwaysOnFile(filename, content); if (isPersonal(ownerKey)) { workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey); } else { @@ -335,6 +336,21 @@ public class MemorySummarizationService { } } + /** + * Enforce the deterministic size ceiling on the always-on profile/memory files + * so they cannot grow the per-turn system prompt without bound. Daily notes and + * other files (only recalled on demand) are left untouched. + */ + private String capAlwaysOnFile(String filename, String content) { + if ("PROFILE.md".equals(filename)) { + return AlwaysOnFileBudget.enforce(content, properties.getProfileMaxChars()); + } + if ("MEMORY.md".equals(filename)) { + return AlwaysOnFileBudget.enforce(content, properties.getMemoryMdMaxChars()); + } + return content; + } + /** A real, isolatable owner — i.e. not null/blank and not the system bucket. */ private boolean isPersonal(String ownerKey) { return ownerKey != null && !ownerKey.isBlank() diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryConsolidationService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryConsolidationService.java new file mode 100644 index 00000000..fdcf775d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryConsolidationService.java @@ -0,0 +1,184 @@ +package vip.mate.memory.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.prompt.PromptLoader; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.memory.MemoryProperties; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; + +/** + * Nightly consolidation of always-on structured memory (user / feedback). + *

+ * These typed files are injected into every system prompt and only ever grow: + * nudge and post-conversation summarization append entries, key-exact dedup lets + * paraphrased keys through, and no pass ever merges them. Over time the always-on + * block inflates per-turn context. This service periodically rewrites each + * always-on type file into a smaller, deduplicated, non-stale set via the LLM, so + * accumulated memory shrinks at the storage level rather than only being trimmed + * at injection time. + *

+ * Consolidation runs per bucket: the shared (TEAM/GLOBAL) file plus every personal + * owner's file, because most growth accumulates in per-owner buckets that the + * always-on prefetch injects each turn. The number of buckets processed per agent + * per run is capped to bound LLM cost. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class StructuredMemoryConsolidationService { + + private final StructuredMemoryService structuredMemoryService; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final MemoryProperties properties; + + /** Typed LLM output for a single consolidation call. */ + public record ConsolidationResult(boolean shouldUpdate, List entries, String reason) { + public record Entry(String key, String content) {} + } + + /** Aggregate counters for one consolidation run, for observability. */ + public static class ConsolidationStats { + public int ownersConsolidated; // buckets that passed the gate and called the LLM + public int updated; // buckets actually rewritten + public int skippedSmall; // buckets below the min-entries gate + public int skippedOverCap; // buckets deferred to a later run by the per-run cap + public int failed; // buckets whose LLM/parse/write raised + public int entriesBefore; + public int entriesAfter; + + public void add(ConsolidationStats o) { + ownersConsolidated += o.ownersConsolidated; + updated += o.updated; + skippedSmall += o.skippedSmall; + skippedOverCap += o.skippedOverCap; + failed += o.failed; + entriesBefore += o.entriesBefore; + entriesAfter += o.entriesAfter; + } + } + + /** Consolidate every always-on structured bucket (shared + personal) for an agent. */ + public ConsolidationStats consolidateAgent(Long agentId) { + ConsolidationStats stats = new ConsolidationStats(); + if (!properties.isStructuredConsolidationEnabled()) { + return stats; + } + int cap = properties.getStructuredConsolidationMaxOwnersPerRun(); + int remaining = cap > 0 ? cap : Integer.MAX_VALUE; + + for (String type : structuredMemoryService.alwaysOnTypes()) { + for (String ownerKey : structuredMemoryService.consolidatableOwnerKeys(agentId, type)) { + String content = structuredMemoryService.readTypeRaw(agentId, type, ownerKey); + int count = structuredMemoryService.countEntries(content); + if (count < properties.getStructuredConsolidationMinEntries()) { + stats.skippedSmall++; + continue; + } + if (remaining <= 0) { + stats.skippedOverCap++; + continue; + } + remaining--; + stats.ownersConsolidated++; + stats.entriesBefore += count; + try { + int after = consolidateBucket(agentId, type, ownerKey, content, count); + if (after >= 0) { + stats.updated++; + stats.entriesAfter += after; + } else { + stats.entriesAfter += count; // no write — bucket unchanged + } + } catch (Exception e) { + stats.failed++; + stats.entriesAfter += count; + log.warn("[StructuredConsolidation] agent={} type={} owner={} failed: {}", + agentId, type, ownerKey, e.getMessage()); + } + } + } + return stats; + } + + /** + * Consolidate one bucket. Returns the new entry count when the file was + * rewritten, or {@code -1} when nothing was written (LLM declined, produced + * unparseable output, or would not have reduced the entry count). + */ + private int consolidateBucket(Long agentId, String type, String ownerKey, String content, int count) { + BeanOutputConverter converter = new BeanOutputConverter<>(ConsolidationResult.class); + String systemPrompt = PromptLoader.loadPrompt("memory/consolidate-structured-system"); + String userPrompt = PromptLoader.loadPrompt("memory/consolidate-structured-user") + .replace("{type}", type) + .replace("{today}", LocalDate.now().toString()) + .replace("{count}", String.valueOf(count)) + .replace("{content}", content); + + ChatModel chatModel = buildChatModel(); + ChatResponse resp = chatModel.call(new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt), + new UserMessage(converter.getFormat())))); + String text = resp.getResult().getOutput().getText(); + + ConsolidationResult result; + try { + result = converter.convert(text); + } catch (Exception e) { + log.warn("[StructuredConsolidation] Unparseable LLM output agent={} type={} owner={}: {}", + agentId, type, ownerKey, e.getMessage()); + return -1; + } + if (result == null || !result.shouldUpdate() + || result.entries() == null || result.entries().isEmpty()) { + return -1; + } + + LinkedHashMap consolidated = new LinkedHashMap<>(); + for (ConsolidationResult.Entry e : result.entries()) { + if (e == null) continue; + String key = e.key() == null ? "" : e.key().trim(); + String value = e.content() == null ? "" : e.content().trim(); + if (!key.isEmpty() && !value.isEmpty()) { + consolidated.put(key, value); + } + } + if (consolidated.isEmpty()) { + return -1; + } + + // Safety invariant: consolidation must never grow the entry count. A model + // that hallucinates extra entries would otherwise make the bloat worse. + if (consolidated.size() > count) { + log.debug("[StructuredConsolidation] agent={} type={} owner={} produced {} > {} entries; skipping write", + agentId, type, ownerKey, consolidated.size(), count); + return -1; + } + + structuredMemoryService.replaceTypeEntries(agentId, type, ownerKey, consolidated, "consolidation"); + log.info("[StructuredConsolidation] agent={} type={} owner={} consolidated {} -> {} entries", + agentId, type, ownerKey, count, consolidated.size()); + return consolidated.size(); + } + + private ChatModel buildChatModel() { + ModelConfigEntity defaultModel = modelConfigService.getDefaultModel(); + return agentGraphBuilder.buildRuntimeChatModel(defaultModel); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java index 0edfc717..a5f10954 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java @@ -105,6 +105,7 @@ public class StructuredMemoryService { private final WorkspaceFileService workspaceFileService; private final ApplicationEventPublisher eventPublisher; + private final vip.mate.memory.MemoryProperties properties; /** Per-file lock to prevent concurrent read-modify-write on the same file */ private final ConcurrentHashMap fileLocks = new ConcurrentHashMap<>(); @@ -240,35 +241,133 @@ public class StructuredMemoryService { return buildMemoryBlock(agentId, null); } + /** Reserve left for structural overhead (headers, blank lines) when truncating a single oversized entry. */ + private static final int BLOCK_STRUCTURE_RESERVE = 120; + /** Owner-scoped variant of {@link #buildMemoryBlock(Long)}. */ public String buildMemoryBlock(Long agentId, String ownerKey) { - StringBuilder sb = new StringBuilder(); - boolean hasContent = false; + int maxChars = Math.max(0, properties.getSystemBlockMaxChars()); + int maxPerType = Math.max(0, properties.getSystemBlockMaxEntriesPerType()); + // 1. Collect every always-on entry with its update date and a global + // insertion index (file order, types in SYSTEM_PROMPT_TYPES order). + // Truncate any single entry larger than the whole budget so it can + // never blow the cap on its own. + int contentCap = maxChars > 0 ? Math.max(1, maxChars - BLOCK_STRUCTURE_RESERVE) : -1; + List all = new ArrayList<>(); + int globalIndex = 0; for (String type : SYSTEM_PROMPT_TYPES) { String fileContent = readFileSafe(agentId, toFilename(type), ownerKey); if (fileContent.isBlank()) continue; + for (Map.Entry entry : parseSections(fileContent).entrySet()) { + String content = extractContentOnly(entry.getValue()); + if (content.isBlank()) continue; + if (contentCap >= 0 && content.length() > contentCap) { + content = content.substring(0, contentCap) + "…"; + } + all.add(new BlockEntry(type, entry.getKey(), content, + extractUpdated(entry.getValue()), globalIndex++)); + } + } + if (all.isEmpty()) return ""; - Map sections = parseSections(fileContent); - if (sections.isEmpty()) continue; + // 2. Enforce the always-on budget against the TRUE rendered length + // (headers, blank lines, and the omission note all counted), keeping + // the most-recently-updated entries so accumulated memory cannot grow + // the per-turn context without bound. + Set kept = selectWithinBudget(all, maxChars, maxPerType); + int omitted = all.size() - kept.size(); + return renderBlock(all, kept, omitted); + } + + /** A candidate entry for the always-on block, with budget metadata. */ + private record BlockEntry(String type, String key, String content, + String updated, int index) {} + + /** + * Render the always-on block: survivors grouped by type, in original file + * order (stable ordering keeps the system prefix cacheable), followed by an + * omission note when entries were dropped. + */ + private String renderBlock(List all, Set kept, int omitted) { + StringBuilder sb = new StringBuilder(); + boolean hasContent = false; + for (String type : SYSTEM_PROMPT_TYPES) { + List typeEntries = all.stream() + .filter(e -> e.type().equals(type) && kept.contains(e)) + .sorted(Comparator.comparingInt(BlockEntry::index)) + .toList(); + if (typeEntries.isEmpty()) continue; if (!hasContent) { sb.append("## Structured Memory\n\n"); hasContent = true; } - sb.append("### ").append(typeDisplayName(type)).append("\n"); - for (Map.Entry entry : sections.entrySet()) { - // Extract just the content line (skip metadata) - String content = extractContentOnly(entry.getValue()); - sb.append("- **").append(entry.getKey()).append("**: ").append(content).append("\n"); + for (BlockEntry e : typeEntries) { + sb.append("- **").append(e.key()).append("**: ").append(e.content()).append("\n"); } sb.append("\n"); } - + if (omitted > 0) { + sb.append("> ").append(omitted) + .append(" older memory entries omitted to bound context size.\n"); + } return sb.toString().trim(); } + /** + * Select the entries that fit the always-on injection budget, preferring the + * most-recently-updated ones. Applies a per-type entry cap first, then a + * global character budget measured against the actual rendered block (not + * just bullet lengths). Newer entries (later update date, then later + * insertion order) win; ties and missing dates fall back to insertion order. + */ + private Set selectWithinBudget(List all, int maxChars, int maxPerType) { + // Keep-priority: most recent update first, then most recently inserted. + Comparator newestFirst = Comparator + .comparing(BlockEntry::updated, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparingInt(BlockEntry::index) + .reversed(); + + // Per-type cap: drop the oldest entries beyond the cap. + List survivors = new ArrayList<>(all); + if (maxPerType > 0) { + Set overflow = new HashSet<>(); + for (String type : SYSTEM_PROMPT_TYPES) { + List ofType = survivors.stream() + .filter(e -> e.type().equals(type)) + .sorted(newestFirst) + .toList(); + if (ofType.size() > maxPerType) { + overflow.addAll(ofType.subList(maxPerType, ofType.size())); + } + } + survivors.removeAll(overflow); + } + + if (maxChars <= 0) { + return new HashSet<>(survivors); + } + + // Global character budget: admit newest entries while the fully rendered + // block stays within budget. Measuring the real render (including the + // omission note) makes the cap exact; once an entry no longer fits, every + // remaining entry is older and is dropped too. + List ordered = survivors.stream().sorted(newestFirst).toList(); + Set picked = new HashSet<>(); + for (BlockEntry e : ordered) { + Set trial = new HashSet<>(picked); + trial.add(e); + int omittedIfStop = all.size() - trial.size(); + if (renderBlock(all, trial, Math.max(0, omittedIfStop)).length() > maxChars) { + break; + } + picked.add(e); + } + return picked; + } + /** * Build a query-conditioned memory block for per-turn prefetch injection. * Scores {@link #PREFETCH_TYPES} entries against the user's question and returns @@ -400,6 +499,97 @@ public class StructuredMemoryService { return "structured/" + type + ".md"; } + // ==================== Consolidation support ==================== + + /** The always-on structured types injected into every system prompt. */ + public List alwaysOnTypes() { + return SYSTEM_PROMPT_TYPES; + } + + /** Read the raw Markdown of a structured type file (owner-scoped when personal). */ + public String readTypeRaw(Long agentId, String type, String ownerKey) { + validateType(type); + return readFileSafe(agentId, toFilename(type), ownerKey); + } + + /** Count the {@code ## key} entries in a structured file's raw Markdown. */ + public int countEntries(String rawContent) { + return (rawContent == null || rawContent.isBlank()) ? 0 : parseSections(rawContent).size(); + } + + /** + * Distinct buckets that hold entries for a structured type and are eligible + * for consolidation: the shared bucket (returned as {@code null}) plus each + * personal owner that has its own row. Lets the nightly maintenance pass + * consolidate per-owner memory, where most growth actually accumulates. + */ + public List consolidatableOwnerKeys(Long agentId, String type) { + validateType(type); + String filename = toFilename(type); + List owners = new ArrayList<>(); + owners.add(null); // shared (TEAM/GLOBAL) bucket + for (WorkspaceFileEntity f : workspaceFileService.listFiles(agentId)) { + if (filename.equals(f.getFilename()) && isPersonal(f.getOwnerKey()) + && !owners.contains(f.getOwnerKey())) { + owners.add(f.getOwnerKey()); + } + } + return owners; + } + + /** + * Atomically replace all entries of a structured type with a consolidated set, + * re-serialized in the canonical {@code ## key / content / > Source | Updated} + * format. Used by the nightly consolidation pass to shrink always-on memory. + * Insertion order of {@code entries} is preserved. + *

+ * Update dates are preserved per key: an entry whose key already existed keeps + * its original {@code Updated} date, and a newly-merged key inherits the newest + * date among the existing entries. This keeps recency/LRU semantics intact — + * consolidation must not make a batch of old facts look freshly written. + */ + public void replaceTypeEntries(Long agentId, String type, String ownerKey, + LinkedHashMap entries, String source) { + validateType(type); + String filename = toFilename(type); + String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename; + ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock()); + lock.lock(); + try { + // Derive prior update dates so consolidation preserves provenance. + Map keyToDate = new HashMap<>(); + String newestDate = ""; + for (Map.Entry s : parseSections(readFileSafe(agentId, filename, ownerKey)).entrySet()) { + String d = extractUpdated(s.getValue()); + if (!d.isEmpty()) { + keyToDate.put(s.getKey(), d); + if (d.compareTo(newestDate) > 0) newestDate = d; + } + } + String fallbackDate = newestDate.isEmpty() ? LocalDate.now().toString() : newestDate; + String src = source != null ? source : "consolidation"; + + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : entries.entrySet()) { + if (e.getKey() == null || e.getKey().isBlank() + || e.getValue() == null || e.getValue().isBlank()) { + continue; + } + String key = e.getKey().trim(); + String date = keyToDate.getOrDefault(key, fallbackDate); + if (sb.length() > 0) sb.append("\n\n"); + sb.append("## ").append(key).append("\n") + .append(e.getValue().trim()) + .append("\n> Source: ").append(src).append(" | Updated: ").append(date); + } + saveStructured(agentId, filename, sb.toString(), ownerKey); + log.info("[StructuredMemory] Replaced {} entries in '{}' for agent={} owner={} (source={})", + entries.size(), filename, agentId, ownerKey, src); + } finally { + lock.unlock(); + } + } + private void validateType(String type) { if (!VALID_TYPES.contains(type)) { throw new IllegalArgumentException("Invalid memory type: " + type diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index c492ff45..005d440b 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -324,3 +324,14 @@ mate: contradiction-check-enabled: false # experimental simple detection, enable after LLM batch impl trust-half-life-days: 60 forget-enabled: true + # Always-on injection budget — bounds the per-turn size of the user/feedback structured block + system-block-max-chars: 4000 # char cap on the always-on block; over budget drops oldest by Updated date (LRU); 0 = unlimited + system-block-max-entries-per-type: 40 # max entries injected per type (user/feedback); 0 = unlimited + # Structured-memory consolidation — separate maintenance task; LLM merges duplicate/stale user/feedback entries (shared + per-owner) to curb storage growth + structured-consolidation-enabled: true # off = injection cap only, no storage-side merge + structured-consolidation-min-entries: 8 # buckets with fewer entries skip the LLM call to save cost + structured-consolidation-cron: "0 30 3 * * ?" # own schedule, decoupled from dreaming-enabled / dreaming-cron + structured-consolidation-max-owners-per-run: 50 # cap LLM cost per agent per run; remaining owners picked up next run; 0 = unlimited + # Always-on file ceilings — deterministic backstop so PROFILE.md / MEMORY.md (LLM-rewritten) cannot grow per-turn context without bound + profile-max-chars: 4000 # PROFILE.md hard cap; truncates at a section boundary if the rewrite overruns; 0 = unlimited + memory-md-max-chars: 8000 # MEMORY.md hard cap; 0 = unlimited diff --git a/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-system.txt b/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-system.txt new file mode 100644 index 00000000..085ae1c5 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-system.txt @@ -0,0 +1,28 @@ +你是一个记忆整理助手,负责精简某一类"结构化记忆",在不丢失有效信息的前提下缩小其体积。 + +这类记忆会被无条件注入每一次对话的系统提示,条目越多、越冗余,每轮上下文就越臃肿。你的任务是把它整理为一组精炼、去重、不过时的条目。 + +## 任务 + +输入是某一类结构化记忆的全部条目(Markdown,每条形如 `## key` 加正文)。请: +1. 合并语义重复或高度相似的条目,保留信息最全、最新的表述 +2. 删除已被更新条目取代的过时信息 +3. 删除一次性的、不具备跨对话价值的琐碎条目 +4. 用简洁的一句话重写啰嗦的条目 +5. 保留所有仍然有效且独立的事实,不要为了精简而丢失真实信息 + +## 原则 + +- 宁可保守:不确定是否过时的信息予以保留 +- 不要发明输入中不存在的信息 +- key 使用简洁的 snake_case;合并条目时复用其中最贴切的一个 key +- 输出条目数必须**不多于**输入条目数;若无可合并或删除的内容,将 shouldUpdate 设为 false + +## 输出格式 + +严格按下方给定的 JSON schema 输出,不要包含 markdown 代码块标记。 +- shouldUpdate:是否需要写回(无可合并/删除时为 false) +- entries:精炼后的完整条目集,每条含 key 与 content(content 为一句话事实) +- reason:简要说明做了哪些合并与删除 + +当 shouldUpdate 为 false 时,entries 可为空数组。 diff --git a/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-user.txt b/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-user.txt new file mode 100644 index 00000000..f2be8c6a --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-user.txt @@ -0,0 +1,6 @@ +当前记忆类别:{type} +今天日期:{today} +当前条目数:{count} + +=== 现有内容 === +{content} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/AlwaysOnFileBudgetTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/AlwaysOnFileBudgetTest.java new file mode 100644 index 00000000..a16fdef9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/AlwaysOnFileBudgetTest.java @@ -0,0 +1,54 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies the deterministic always-on file budget: content under budget is left + * untouched, over-budget content is bounded and cut at a section boundary, and + * disabling (0) or null short-circuits. + */ +class AlwaysOnFileBudgetTest { + + private static String md(int sections, String body) { + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= sections; i++) { + sb.append("## section_").append(i).append("\n").append(body).append("\n\n"); + } + return sb.toString(); + } + + @Test + @DisplayName("content within budget is returned unchanged") + void underBudgetUnchanged() { + String c = md(3, "short body"); + assertSame(c, AlwaysOnFileBudget.enforce(c, 10_000)); + } + + @Test + @DisplayName("maxChars<=0 and null short-circuit (unlimited)") + void disabledOrNull() { + String c = md(50, "filler"); + assertSame(c, AlwaysOnFileBudget.enforce(c, 0)); + assertNull(AlwaysOnFileBudget.enforce(null, 4000)); + } + + @Test + @DisplayName("over-budget content is bounded, marked, and cut on a section boundary") + void overBudgetTruncated() { + // 40 sections of ~60 chars each ≈ 2400+ chars; cap at 800. + String c = md(40, "这是一段用于撑大文件体积的内容,重复多次以超过预算阈值。"); + int budget = 800; + String out = AlwaysOnFileBudget.enforce(c, budget); + + assertTrue(out.length() <= budget, "result must not exceed the budget, was " + out.length()); + assertTrue(out.endsWith(AlwaysOnFileBudget.MARKER.strip()) + || out.contains("截断"), "truncation marker must be present"); + // The kept head ends at a clean section boundary — no half-section dangling. + String head = out.substring(0, out.indexOf(AlwaysOnFileBudget.MARKER.strip())); + assertTrue(head.contains("## section_1"), "earliest (head) sections are kept"); + assertFalse(head.contains("## section_40"), "latest sections are dropped under budget"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryConsolidationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryConsolidationServiceTest.java new file mode 100644 index 00000000..3cb993e4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryConsolidationServiceTest.java @@ -0,0 +1,151 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.memory.MemoryProperties; + +import java.util.LinkedHashMap; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Verifies the nightly structured-memory consolidation gating and safety + * invariants: it skips when disabled, below the min-entries gate, when the LLM + * declines or returns unparseable output, and when the result would grow the + * entry count — and only writes a genuinely reduced set. + */ +class StructuredMemoryConsolidationServiceTest { + + private static final long AGENT_ID = 1000000001L; + + private StructuredMemoryService memory; + private ModelConfigService modelConfigService; + private AgentGraphBuilder agentGraphBuilder; + private MemoryProperties props; + + private StructuredMemoryConsolidationService newService(String llmReply) { + memory = mock(StructuredMemoryService.class); + modelConfigService = mock(ModelConfigService.class); + agentGraphBuilder = mock(AgentGraphBuilder.class); + props = new MemoryProperties(); + props.setStructuredConsolidationMinEntries(8); + + // One always-on type, one shared bucket — a single bucket per agent. + when(memory.alwaysOnTypes()).thenReturn(List.of("user")); + when(memory.consolidatableOwnerKeys(eq(AGENT_ID), eq("user"))).thenReturn(java.util.Arrays.asList((String) null)); + + if (llmReply != null) { + ChatModel model = mock(ChatModel.class); + when(model.call(any(Prompt.class))).thenReturn( + new ChatResponse(List.of(new Generation(new AssistantMessage(llmReply))))); + when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(model); + } + return new StructuredMemoryConsolidationService(memory, modelConfigService, agentGraphBuilder, props); + } + + @Test + @DisplayName("disabled flag skips entirely, never touching memory") + void disabledSkips() { + StructuredMemoryConsolidationService svc = newService(null); + props.setStructuredConsolidationEnabled(false); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(0, stats.ownersConsolidated); + verify(memory, never()).readTypeRaw(anyLong(), anyString(), any()); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("buckets below the min-entries gate are skipped without an LLM call") + void minEntriesSkips() { + StructuredMemoryConsolidationService svc = newService(null); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("small"); + when(memory.countEntries("small")).thenReturn(5); // < 8 + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(1, stats.skippedSmall); + assertEquals(0, stats.ownersConsolidated); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("unparseable LLM output is skipped, leaving the bucket untouched") + void invalidJsonSkips() { + StructuredMemoryConsolidationService svc = newService("this is not json at all"); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body"); + when(memory.countEntries("body")).thenReturn(10); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(1, stats.ownersConsolidated); + assertEquals(0, stats.updated); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("shouldUpdate=false is respected — no write") + void shouldUpdateFalseSkips() { + StructuredMemoryConsolidationService svc = + newService("{\"shouldUpdate\":false,\"entries\":[],\"reason\":\"already concise\"}"); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body"); + when(memory.countEntries("body")).thenReturn(10); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(0, stats.updated); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("a result that grows the entry count is rejected") + void growthRejected() { + String reply = "{\"shouldUpdate\":true,\"entries\":[" + + "{\"key\":\"a\",\"content\":\"x\"},{\"key\":\"b\",\"content\":\"y\"},{\"key\":\"c\",\"content\":\"z\"}" + + "],\"reason\":\"split\"}"; + StructuredMemoryConsolidationService svc = newService(reply); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body"); + when(memory.countEntries("body")).thenReturn(2); // 3 produced > 2 existing + props.setStructuredConsolidationMinEntries(2); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(0, stats.updated); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("a genuine reduction is written back") + void successReplaces() { + String reply = "{\"shouldUpdate\":true,\"entries\":[" + + "{\"key\":\"reply_style\",\"content\":\"concise\"},{\"key\":\"language\",\"content\":\"chinese\"}" + + "],\"reason\":\"merged duplicates\"}"; + StructuredMemoryConsolidationService svc = newService(reply); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body"); + when(memory.countEntries("body")).thenReturn(10); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(1, stats.updated); + assertEquals(10, stats.entriesBefore); + assertEquals(2, stats.entriesAfter); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(LinkedHashMap.class); + verify(memory).replaceTypeEntries(eq(AGENT_ID), eq("user"), isNull(), captor.capture(), eq("consolidation")); + assertEquals(2, captor.getValue().size()); + assertTrue(captor.getValue().containsKey("reply_style")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryPrefetchTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryPrefetchTest.java index 7992911a..00420845 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryPrefetchTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryPrefetchTest.java @@ -2,8 +2,13 @@ package vip.mate.memory.service; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.context.ApplicationEventPublisher; +import vip.mate.memory.MemoryProperties; import vip.mate.workspace.document.WorkspaceFileService; + +import java.time.LocalDate; +import java.util.LinkedHashMap; import vip.mate.workspace.document.model.WorkspaceFileEntity; import static org.junit.jupiter.api.Assertions.*; @@ -28,7 +33,7 @@ class StructuredMemoryPrefetchTest { if (userMd != null) { when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(fileWith(userMd)); } - return new StructuredMemoryService(files, mock(ApplicationEventPublisher.class)); + return new StructuredMemoryService(files, mock(ApplicationEventPublisher.class), new MemoryProperties()); } private WorkspaceFileEntity fileWith(String content) { @@ -121,7 +126,7 @@ class StructuredMemoryPrefetchTest { WorkspaceFileEntity ref = new WorkspaceFileEntity(); ref.setContent("## api_endpoint\n参考:订单查询接口 /api/orders。\n> Source: agent | Updated: 2026-05-29"); when(files.getFile(AGENT_ID, "structured/reference.md")).thenReturn(ref); - StructuredMemoryService svc = new StructuredMemoryService(files, mock(ApplicationEventPublisher.class)); + StructuredMemoryService svc = new StructuredMemoryService(files, mock(ApplicationEventPublisher.class), new MemoryProperties()); String block = svc.buildPrefetchBlock(AGENT_ID, "订单查询接口参考是什么?"); @@ -140,4 +145,88 @@ class StructuredMemoryPrefetchTest { assertEquals("", svc.buildPrefetchBlock(AGENT_ID, "")); assertEquals("", svc.buildPrefetchBlock(AGENT_ID, null)); } + + @Test + @DisplayName("system prompt block enforces the char budget, keeping newest entries") + void systemBlockEnforcesCharBudget() { + // Five user entries, each ~60 chars, oldest to newest by update date. + StringBuilder userMd = new StringBuilder(); + for (int i = 1; i <= 5; i++) { + userMd.append("## fact_").append(i) + .append("\nThis is a reasonably long stored preference number ").append(i).append(".") + .append("\n> Source: agent | Updated: 2026-05-0").append(i).append("\n\n"); + } + WorkspaceFileService files = mock(WorkspaceFileService.class); + when(files.getFile(eq(AGENT_ID), anyString())).thenReturn(null); + when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(fileWith(userMd.toString())); + + MemoryProperties props = new MemoryProperties(); + props.setSystemBlockMaxChars(160); + StructuredMemoryService svc = new StructuredMemoryService( + files, mock(ApplicationEventPublisher.class), props); + + String block = svc.buildMemoryBlock(AGENT_ID); + + // The whole rendered block (headers + bullets + omission note) is bounded. + assertTrue(block.length() <= 160, "rendered block must not exceed the char budget, was " + block.length()); + // Newest entries survive, oldest are dropped, and the omission is disclosed. + assertTrue(block.contains("fact_5"), "newest entry must be kept"); + assertFalse(block.contains("fact_1"), "oldest entry must be evicted under budget"); + assertTrue(block.contains("older memory entries omitted"), "omission must be disclosed"); + } + + @Test + @DisplayName("replaceTypeEntries preserves prior update dates and does not blanket-stamp today") + void replaceTypeEntriesPreservesDates() { + WorkspaceFileService files = mock(WorkspaceFileService.class); + when(files.getFile(eq(AGENT_ID), anyString())).thenReturn(null); + when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn( + fileWith("## a\nold value\n> Source: agent | Updated: 2026-01-01")); + StructuredMemoryService svc = new StructuredMemoryService( + files, mock(ApplicationEventPublisher.class), new MemoryProperties()); + + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("a", "consolidated value"); // existing key — keeps its date + entries.put("b", "newly merged fact"); // new key — inherits newest prior date + svc.replaceTypeEntries(AGENT_ID, "user", null, entries, "consolidation"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(files).saveFile(eq(AGENT_ID), eq("structured/user.md"), captor.capture()); + String written = captor.getValue(); + + // The existing key keeps its original date; the merged key inherits it too; + // nothing is freshly stamped with today's date. + assertTrue(written.indexOf("2026-01-01") != written.lastIndexOf("2026-01-01"), + "both entries should carry the preserved prior date"); + assertFalse(written.contains(LocalDate.now().toString()), + "consolidation must not blanket-stamp entries with today's date"); + } + + @Test + @DisplayName("replaceTypeEntries writes canonical format and round-trips into the always-on block") + void replaceTypeEntriesRoundTrips() { + WorkspaceFileService files = mock(WorkspaceFileService.class); + when(files.getFile(eq(AGENT_ID), anyString())).thenReturn(null); + StructuredMemoryService svc = new StructuredMemoryService( + files, mock(ApplicationEventPublisher.class), new MemoryProperties()); + + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("reply_style", "偏好简洁直接的回答。"); + entries.put("language", "始终用中文回答。"); + svc.replaceTypeEntries(AGENT_ID, "user", null, entries, "consolidation"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(files).saveFile(eq(AGENT_ID), eq("structured/user.md"), captor.capture()); + String written = captor.getValue(); + + assertTrue(written.contains("## reply_style"), "key header must be written"); + assertTrue(written.contains("## language"), "second key header must be written"); + assertTrue(written.contains("> Source: consolidation | Updated:"), "metadata line must be written"); + + // The rewritten file round-trips back through the always-on block. + when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(fileWith(written)); + String block = svc.buildMemoryBlock(AGENT_ID); + assertTrue(block.contains("reply_style") && block.contains("language"), + "consolidated entries must be readable as always-on memory"); + } } diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 5afa53e5..51389eb5 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -1178,7 +1178,11 @@ const executionPhaseLabel = computed(() => { } if (planMeta.value) { const done = planMeta.value.stepResults?.filter(r => r?.status === 'completed').length || 0 - return `Plan-Execute (${done}/${planMeta.value.steps.length})` + // Guard steps: a plan payload can arrive with steps undefined (mid-stream / + // malformed metadata). An unguarded .length here throws during render, which + // blanks the whole message subtree until a full remount (page refresh) — the + // "chat goes blank on switch, refresh fixes it" bug. Mirror the ?. used below. + return `Plan-Execute (${done}/${planMeta.value.steps?.length ?? 0})` } if (toolCallsMeta.value.length) { const done = toolCallsMeta.value.filter(t => t.status === 'completed').length diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 93da4593..507ba630 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -2059,10 +2059,16 @@ export function useChat(options: UseChatOptions): UseChatReturn { currentAssistantId.value = assistantMessage.id as string try { - // connect() owns lastEventId injection — it knows whether the dedup - // state still applies to this conversation. Passing it from out here - // would race the per-conv reset that connect does and could leak a - // different conv's id into this reconnect. + // reconnectStream always rebuilds from an EMPTY placeholder (above), so it + // needs the server to replay the WHOLE buffer — not just events newer than + // a previously-acked lastEventId. Clearing it forces connect() to omit + // lastEventId so the backend full-replays and the placeholder repaints. + // Without this, a reconnect into the same conversation (poll-detected + // running stream after a switch-away, window refocus) dedup-skips the + // buffer and the bubble stays blank until a hard refresh resets this ref — + // the "switch conversations mid-stream → blank, refresh fixes it" bug. + // Setting null (not a foreign id) right before connect can't leak or race. + stream.lastEventId.value = null await stream.connect({ conversationId, reconnect: true,