diff --git a/.gitignore b/.gitignore
index 7ae1acfc..656cfe90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,5 +92,6 @@ deploy/nginx/ssl/*.pem
deploy/.env
# Claude Code local settings
+CLAUDE.md
.claude/settings.local.json
.claude/plans/
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index f91143fb..4ac2c382 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -157,6 +157,28 @@ public class AgentGraphBuilder {
throw new MateClawException("err.agent.model_not_configured", "模型 " + runtimeModel.getModelName()
+ " 的 Provider(" + runtimeModel.getProvider() + ")未配置,请检查模型设置");
}
+
+ // Safety net: getDefaultModel() already skips unconfigured providers, but guard here
+ // too so a stale cached model doesn't silently proceed to a broken API call.
+ if (!modelProviderService.isProviderConfigured(provider.getProviderId())) {
+ String reason = modelProviderService.getProviderUnavailableReason(provider.getProviderId());
+ log.warn("Runtime model {}/{} provider not configured ({}); trying fallback",
+ runtimeModel.getProvider(), runtimeModel.getModelName(), reason);
+ ModelConfigEntity fallback = findFirstAvailableChatModel();
+ if (fallback == null) {
+ throw new MateClawException("err.agent.no_configured_model",
+ "默认模型 Provider「" + runtimeModel.getProvider() + "」未配置(" + reason
+ + "),且找不到其他已配置的 Provider,请先在「设置 → 模型」中完成配置");
+ }
+ runtimeModel = fallback;
+ try {
+ provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
+ } catch (Exception e) {
+ throw new MateClawException("err.agent.model_not_configured", "备用模型 " + runtimeModel.getModelName()
+ + " 的 Provider(" + runtimeModel.getProvider() + ")获取失败");
+ }
+ }
+
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
// 内置搜索检测(DashScope / Kimi),但不再移除 WebSearchTool — 两者协同而非互斥
@@ -727,6 +749,24 @@ public class AgentGraphBuilder {
return reordered;
}
+ /**
+ * Finds the first enabled chat model whose provider is fully configured.
+ * Used as a fallback when the default model's provider is not available.
+ */
+ private ModelConfigEntity findFirstAvailableChatModel() {
+ return modelConfigService.listByType("chat").stream()
+ .filter(m -> Boolean.TRUE.equals(m.getEnabled()))
+ .filter(m -> {
+ try {
+ return modelProviderService.isProviderConfigured(m.getProvider());
+ } catch (Exception e) {
+ return false;
+ }
+ })
+ .findFirst()
+ .orElse(null);
+ }
+
// PR-0b: legacy single-fallback buildFallbackModel deleted (already @Deprecated, no callers).
// PR-0b: isDashScopeSearchEnabled moved to AgentDashScopeChatModelBuilder.
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java
index 4ebc3064..8458a647 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java
@@ -7,6 +7,7 @@ import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import com.alibaba.cloud.ai.dashscope.spec.DashScopeApiSpec;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@@ -27,19 +28,23 @@ import java.util.Map;
* fallback-chain helpers for resolving API key / Base URL when the provider
* row is incomplete. PR-0b moved this code out of {@code AgentGraphBuilder}
* so the agent package no longer carries any DashScope schema knowledge.
+ *
+ *
DashScopeChatModel is injected via ObjectProvider so that the builder
+ * degrades gracefully when DashScope auto-configuration is disabled or the
+ * dependency is absent, rather than failing the entire application context.
*/
@Slf4j
@Component
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
- private final DashScopeChatModel dashScopeChatModel;
+ private final ObjectProvider dashScopeChatModelProvider;
private final DashScopeConnectionProperties dashScopeConnectionProperties;
private final ModelProviderService modelProviderService;
- public AgentDashScopeChatModelBuilder(DashScopeChatModel dashScopeChatModel,
+ public AgentDashScopeChatModelBuilder(ObjectProvider dashScopeChatModelProvider,
DashScopeConnectionProperties dashScopeConnectionProperties,
ModelProviderService modelProviderService) {
- this.dashScopeChatModel = dashScopeChatModel;
+ this.dashScopeChatModelProvider = dashScopeChatModelProvider;
this.dashScopeConnectionProperties = dashScopeConnectionProperties;
this.modelProviderService = modelProviderService;
}
@@ -51,9 +56,14 @@ public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
+ DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable();
+ if (defaultModel == null) {
+ throw new MateClawException("err.agent.dashscope_unavailable",
+ "DashScope 自动配置未激活(可能缺少依赖或被排除),无法构建 DashScope 模型");
+ }
DashScopeApi api = buildDashScopeApi(provider);
DashScopeChatOptions options = buildDashScopeOptions(model, provider);
- return dashScopeChatModel.mutate()
+ return defaultModel.mutate()
.dashScopeApi(api)
.defaultOptions(options)
.build();
@@ -200,9 +210,13 @@ public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
}
private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException {
+ DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable();
+ if (defaultModel == null) {
+ return null;
+ }
Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi");
apiField.setAccessible(true);
- Object api = apiField.get(dashScopeChatModel);
+ Object api = apiField.get(defaultModel);
return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null;
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java
index 186cab55..1759b5c3 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java
@@ -2,6 +2,8 @@ package vip.mate.llm.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import vip.mate.exception.MateClawException;
@@ -22,6 +24,14 @@ public class ModelConfigService {
private final ModelConfigMapper modelConfigMapper;
private final ApplicationEventPublisher eventPublisher;
+ /**
+ * Lazy to break circular dependency: ModelProviderService → ModelConfigService.
+ * Used only in getDefaultModel() to skip models whose provider is unconfigured.
+ */
+ @Lazy
+ @Autowired
+ private ModelProviderService modelProviderService;
+
public List listModels() {
return modelConfigMapper.selectList(new LambdaQueryWrapper()
.orderByDesc(ModelConfigEntity::getIsDefault)
@@ -91,25 +101,55 @@ public class ModelConfigService {
}
public ModelConfigEntity getDefaultModel() {
- // 默认 chat 模型:明确排除 embedding 类型
- ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper()
+ // Prefer the explicitly marked default chat model when its provider is configured.
+ ModelConfigEntity defaultMarked = modelConfigMapper.selectOne(new LambdaQueryWrapper()
.eq(ModelConfigEntity::getIsDefault, true)
.and(w -> w.isNull(ModelConfigEntity::getModelType)
.or().eq(ModelConfigEntity::getModelType, "chat"))
.last("LIMIT 1"));
- if (entity != null) {
- return entity;
+ if (defaultMarked != null && isProviderConfigured(defaultMarked.getProvider())) {
+ return defaultMarked;
}
- entity = modelConfigMapper.selectOne(new LambdaQueryWrapper()
+
+ // Default model's provider is unavailable (or no default set) — scan all enabled
+ // chat models and pick the first one whose provider is actually configured.
+ List candidates = modelConfigMapper.selectList(new LambdaQueryWrapper()
.eq(ModelConfigEntity::getEnabled, true)
.and(w -> w.isNull(ModelConfigEntity::getModelType)
.or().eq(ModelConfigEntity::getModelType, "chat"))
- .orderByAsc(ModelConfigEntity::getName)
- .last("LIMIT 1"));
- if (entity == null) {
- throw new MateClawException("err.llm.no_available_model", "没有可用的模型配置");
+ .orderByDesc(ModelConfigEntity::getIsDefault)
+ .orderByAsc(ModelConfigEntity::getName));
+ for (ModelConfigEntity candidate : candidates) {
+ if (isProviderConfigured(candidate.getProvider())) {
+ return candidate;
+ }
+ }
+
+ // No configured provider found — give a clearer error than the generic one.
+ if (!candidates.isEmpty()) {
+ String unconfiguredProvider = candidates.get(0).getProvider();
+ throw new MateClawException("err.llm.no_configured_provider",
+ "所有已启用的模型 Provider 均未完成配置(默认 Provider: " + unconfiguredProvider
+ + "),请在「设置 → 模型」中填写 API Key");
+ }
+ throw new MateClawException("err.llm.no_available_model", "没有可用的模型配置");
+ }
+
+ /**
+ * Checks whether a provider is fully configured (API key / credentials present).
+ * Delegates to ModelProviderService which is injected lazily to avoid a circular
+ * dependency. Falls back to {@code true} when the service is not yet available
+ * (e.g., during early bootstrap) so we don't accidentally block startup.
+ */
+ private boolean isProviderConfigured(String providerId) {
+ if (modelProviderService == null || providerId == null) {
+ return true;
+ }
+ try {
+ return modelProviderService.isProviderConfigured(providerId);
+ } catch (Exception e) {
+ return true; // conservative: don't filter if lookup fails
}
- return entity;
}
public ModelConfigEntity getDefaultModelByProvider(String providerId) {
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java
index 88830672..4ed7826f 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java
@@ -46,10 +46,14 @@ public class ModelDiscoveryService {
/**
* Explicit deny list: model ids listed by DashScope compatible-mode that are known
* to fail on the native protocol. Updated as we observe new failures.
+ * Note: the DASHSCOPE_NATIVE_UNSUPPORTED_PATTERN below also catches the whole
+ * dot-versioned family; this set makes individual blocked names searchable/auditable.
*/
private static final Set DASHSCOPE_NATIVE_DENY = Set.of(
"qwen3.5-max",
- "qwen3.5-plus"
+ "qwen3.5-plus",
+ "qwen3.6-plus",
+ "qwen3.6-max"
);
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/event/MemoryWriteEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/event/MemoryWriteEvent.java
new file mode 100644
index 00000000..035ba482
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/event/MemoryWriteEvent.java
@@ -0,0 +1,18 @@
+package vip.mate.memory.event;
+
+/**
+ * Published when canonical memory files are written (MEMORY.md, structured/*.md).
+ * Used by SoulSummarizerService for K-accumulate SOUL.md evolution.
+ *
+ * @param agentId the agent ID
+ * @param target which file was written (e.g. "MEMORY.md", "structured/user.md")
+ * @param action what happened ("remember", "consolidate", "update")
+ * @param content the written content (may be truncated for large writes)
+ * @author MateClaw Team
+ */
+public record MemoryWriteEvent(
+ Long agentId,
+ String target,
+ String action,
+ String content
+) {}
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 13faf2b7..7703b8a6 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
@@ -9,7 +9,9 @@ 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.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
+import vip.mate.memory.event.MemoryWriteEvent;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.llm.service.ModelConfigService;
@@ -46,6 +48,7 @@ public class MemoryEmergenceService {
private final MemoryRecallService recallService;
private final DreamReportMapper dreamReportMapper;
private final vip.mate.memory.archive.MemoryArchiveService archiveService;
+ private final ApplicationEventPublisher eventPublisher;
/**
* Legacy signature — delegates to NIGHTLY mode for backward compatibility.
@@ -154,6 +157,7 @@ public class MemoryEmergenceService {
}
workspaceFileService.saveFile(agentId, "MEMORY.md", newContent);
+ eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent));
String llmReason = root.path("reason").asText("");
log.info("[Memory] Emergence completed for agent={}: {}", agentId, llmReason);
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java
new file mode 100644
index 00000000..c012ac92
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java
@@ -0,0 +1,136 @@
+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.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+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 vip.mate.memory.event.MemoryWriteEvent;
+import vip.mate.workspace.document.WorkspaceFileService;
+import vip.mate.workspace.document.model.WorkspaceFileEntity;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * SOUL.md auto-evolution service.
+ *
+ * Subscribes to {@link MemoryWriteEvent}; after every K writes (configured by
+ * soulUpdateInterval), triggers an LLM call to regenerate SOUL.md from the
+ * agent's current memory state.
+ *
+ *
When soulUpdateInterval=0, this service is a no-op.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SoulSummarizerService {
+
+ private final WorkspaceFileService workspaceFileService;
+ private final ModelConfigService modelConfigService;
+ private final AgentGraphBuilder agentGraphBuilder;
+ private final MemoryProperties properties;
+
+ /** Per-agent write counter since last SOUL update */
+ private final Map writeCounters = new ConcurrentHashMap<>();
+
+ @Async
+ @EventListener
+ public void onMemoryWrite(MemoryWriteEvent event) {
+ int interval = properties.getSoulUpdateInterval();
+ if (interval <= 0) return;
+
+ Long agentId = event.agentId();
+ AtomicInteger counter = writeCounters.computeIfAbsent(agentId, k -> new AtomicInteger(0));
+ int count = counter.incrementAndGet();
+
+ if (count < interval) {
+ log.debug("[SOUL] Write {}/{} for agent={}, waiting...", count, interval, agentId);
+ return;
+ }
+
+ // Reset counter and trigger SOUL update
+ counter.set(0);
+ log.info("[SOUL] Triggering SOUL.md update for agent={} (after {} writes)", agentId, interval);
+
+ try {
+ updateSoul(agentId);
+ } catch (Exception e) {
+ log.warn("[SOUL] Failed to update SOUL.md for agent={}: {}", agentId, e.getMessage());
+ }
+ }
+
+ /**
+ * Regenerate SOUL.md from current agent memory state.
+ */
+ void updateSoul(Long agentId) {
+ // Read current files
+ String memoryContent = readSafe(agentId, "MEMORY.md");
+ String profileContent = readSafe(agentId, "PROFILE.md");
+ String currentSoul = readSafe(agentId, "SOUL.md");
+
+ String systemPrompt = PromptLoader.loadPrompt("memory/soul-summarize");
+ String userPrompt = String.format("""
+ ## Current SOUL.md
+ ```
+ %s
+ ```
+
+ ## PROFILE.md
+ ```
+ %s
+ ```
+
+ ## MEMORY.md
+ ```
+ %s
+ ```
+
+ Based on the above, regenerate SOUL.md. Keep it concise and personal.
+ Output ONLY the new SOUL.md content (no fences, no explanation).
+ """, currentSoul, profileContent, memoryContent);
+
+ ChatModel chatModel = buildChatModel();
+ Prompt prompt = new Prompt(List.of(
+ new SystemMessage(systemPrompt),
+ new UserMessage(userPrompt)
+ ));
+ ChatResponse response = chatModel.call(prompt);
+ String newSoul = response.getResult().getOutput().getText();
+
+ if (newSoul != null && !newSoul.isBlank() && newSoul.length() > 50) {
+ workspaceFileService.saveFile(agentId, "SOUL.md", newSoul.trim());
+ log.info("[SOUL] Updated SOUL.md for agent={} ({} chars)", agentId, newSoul.length());
+ } else {
+ log.debug("[SOUL] LLM returned empty/short response, skipping SOUL update");
+ }
+ }
+
+ private ChatModel buildChatModel() {
+ ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
+ return agentGraphBuilder.buildRuntimeChatModel(defaultModel);
+ }
+
+ private String readSafe(Long agentId, String filename) {
+ try {
+ WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
+ return file != null && file.getContent() != null ? file.getContent() : "";
+ } catch (Exception e) {
+ return "";
+ }
+ }
+}
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 73b54911..c5295fd8 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
@@ -2,7 +2,9 @@ package vip.mate.memory.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
+import vip.mate.memory.event.MemoryWriteEvent;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
@@ -35,6 +37,7 @@ public class StructuredMemoryService {
private static final Pattern SECTION_PATTERN = Pattern.compile("^## (.+)$", Pattern.MULTILINE);
private final WorkspaceFileService workspaceFileService;
+ private final ApplicationEventPublisher eventPublisher;
/** Per-file lock to prevent concurrent read-modify-write on the same file */
private final ConcurrentHashMap fileLocks = new ConcurrentHashMap<>();
@@ -69,6 +72,8 @@ public class StructuredMemoryService {
workspaceFileService.saveFile(agentId, filename, updated);
log.info("[StructuredMemory] {} entry '{}' for agent={} (source={})",
existingSection != null ? "Updated" : "Added", key, agentId, source);
+ // Publish event for SOUL auto-evolution (Phase 2)
+ eventPublisher.publishEvent(new MemoryWriteEvent(agentId, filename, "remember", content));
} finally {
lock.unlock();
}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
index bd5b5423..b5dd387f 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
@@ -1,8 +1,11 @@
package vip.mate.memory.spi;
+import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.memory.MemoryProperties;
+import vip.mate.memory.spi.decorator.MetricsMemoryProvider;
+import vip.mate.memory.spi.decorator.RetryableMemoryProvider;
import java.util.ArrayList;
import java.util.Comparator;
@@ -32,20 +35,43 @@ public class MemoryManager {
/** External plugin memory provider (single-select constraint) */
private volatile MemoryProvider externalPluginProvider = null;
- public MemoryManager(List allProviders, MemoryProperties properties) {
+ public MemoryManager(List allProviders, MemoryProperties properties,
+ org.springframework.beans.factory.ObjectProvider meterRegistryProvider) {
+ MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
Set disabled = properties.getDisabledProviders();
- this.providers = allProviders.stream()
+ List filtered = allProviders.stream()
.filter(MemoryProvider::isAvailable)
.filter(p -> !disabled.contains(p.id()))
.sorted(Comparator.comparingInt(MemoryProvider::order))
.collect(Collectors.toList());
+ // Assemble decorator chain based on flags
+ this.providers = filtered.stream()
+ .map(p -> wrapWithDecorators(p, properties, meterRegistry))
+ .collect(Collectors.toList());
+
if (!disabled.isEmpty()) {
log.info("[MemoryManager] Disabled providers: {}", disabled);
}
- log.info("[MemoryManager] Active providers ({}): {}",
+ String decorators = "";
+ if (properties.getProviderRetryAttempts() > 1) decorators += "+retry(" + properties.getProviderRetryAttempts() + ")";
+ if (properties.isProviderMetricsEnabled()) decorators += "+metrics";
+ log.info("[MemoryManager] Active providers ({}): {} {}",
this.providers.size(),
- this.providers.stream().map(MemoryProvider::id).collect(Collectors.joining(", ")));
+ filtered.stream().map(MemoryProvider::id).collect(Collectors.joining(", ")),
+ decorators);
+ }
+
+ private MemoryProvider wrapWithDecorators(MemoryProvider provider, MemoryProperties properties,
+ MeterRegistry meterRegistry) {
+ MemoryProvider result = provider;
+ if (properties.getProviderRetryAttempts() > 1) {
+ result = new RetryableMemoryProvider(result, properties.getProviderRetryAttempts());
+ }
+ if (properties.isProviderMetricsEnabled() && meterRegistry != null) {
+ result = new MetricsMemoryProvider(result, meterRegistry);
+ }
+ return result;
}
// ==================== System Prompt ====================
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
index a1fc086c..af8b3328 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
@@ -100,7 +100,7 @@ public interface MemoryProvider {
* Notification that a memory write occurred. Called after canonical memory
* files (structured/*.md, MEMORY.md) are updated.
*
- *
Phase 2: SOUL auto-evolution subscribes to this.
*
* @param agentId the agent ID
* @param target which file was written (e.g. "MEMORY.md", "structured/user_pref.md")
@@ -109,4 +109,24 @@ public interface MemoryProvider {
*/
default void onMemoryWrite(Long agentId, String target, String action, String content) {
}
+
+ /**
+ * Warm up provider internal state (embeddings, index handles, connection pools).
+ * Called when an agent session is likely to start. Providers decide what to cache.
+ *
+ *
Phase 2: provider internal-state cache (not recall text cache — F2).
+ *
+ * @param agentId the agent ID
+ */
+ default void warmup(Long agentId) {
+ }
+
+ /**
+ * Evict cached internal state for an agent. Called on agent deactivation or
+ * memory pressure.
+ *
+ * @param agentId the agent ID
+ */
+ default void evict(Long agentId) {
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java
new file mode 100644
index 00000000..850a7370
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java
@@ -0,0 +1,38 @@
+package vip.mate.memory.spi.decorator;
+
+import vip.mate.memory.spi.MemoryProvider;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Base decorator for MemoryProvider. All methods delegate to the wrapped provider.
+ * Subclass and override specific methods to add behavior (retry, metrics, etc.).
+ *
+ * @author MateClaw Team
+ */
+public abstract class MemoryProviderDecorator implements MemoryProvider {
+
+ protected final MemoryProvider delegate;
+
+ protected MemoryProviderDecorator(MemoryProvider delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override public String id() { return delegate.id(); }
+ @Override public int order() { return delegate.order(); }
+ @Override public boolean isAvailable() { return delegate.isAvailable(); }
+ @Override public String systemPromptBlock(Long agentId) { return delegate.systemPromptBlock(agentId); }
+ @Override public String prefetch(Long agentId, String userQuery) { return delegate.prefetch(agentId, userQuery); }
+ @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
+ delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
+ }
+ @Override public List