diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheEventListener.java b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheEventListener.java
new file mode 100644
index 00000000..bf89692d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheEventListener.java
@@ -0,0 +1,59 @@
+package vip.mate.wiki.hotcache;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import vip.mate.memory.event.ConversationCompletedEvent;
+import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
+import vip.mate.wiki.service.WikiKnowledgeBaseService;
+
+import java.util.List;
+
+/**
+ * Wires the wiki hot-cache rebuild trigger into the existing event flow.
+ *
+ *
Today only {@link ConversationCompletedEvent} is published — when a
+ * chat turn ends, we schedule a rebuild for the agent's primary KB.
+ * (Per the existing chat retrieval path, "primary" is the first entry
+ * from {@link WikiKnowledgeBaseService#listByAgentId}, ordered by recent
+ * update.)
+ *
+ *
The compile-completed and page-updated events that the design
+ * envisions don't have publishers in the codebase yet; their listeners
+ * will land in the PRs that introduce those publishers, to avoid
+ * shipping dead glue. The scheduler exposes {@code rebuildNowBlocking}
+ * so the future admin API can drive {@code MANUAL} rebuilds without
+ * going through events.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HotCacheEventListener {
+
+ private final HotCacheUpdateScheduler scheduler;
+ private final WikiKnowledgeBaseService kbService;
+
+ @EventListener
+ public void onConversationEnd(ConversationCompletedEvent event) {
+ Long agentId = event.agentId();
+ if (agentId == null) return;
+ Long kbId = resolvePrimaryKb(agentId);
+ if (kbId == null) {
+ log.debug("[HotCache] agent={} has no KB; skip post-conversation rebuild", agentId);
+ return;
+ }
+ scheduler.scheduleRebuild(kbId, HotCacheUpdateReason.CONVERSATION_END);
+ }
+
+ private Long resolvePrimaryKb(Long agentId) {
+ try {
+ List kbs = kbService.listByAgentId(agentId);
+ if (kbs.isEmpty()) return null;
+ return kbs.get(0).getId();
+ } catch (Exception e) {
+ log.debug("[HotCache] KB resolution failed for agent={}: {}", agentId, e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheProperties.java
new file mode 100644
index 00000000..0dccb953
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheProperties.java
@@ -0,0 +1,38 @@
+package vip.mate.wiki.hotcache;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Duration;
+
+/**
+ * Tunables for the wiki hot-cache rebuild pipeline. Defaults match the
+ * "ship cautiously" preset — short rebuild window, conservative LLM
+ * timeout, modest input slice.
+ *
+ * Override per-environment via {@code mateclaw.wiki.hot-cache.*}.
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "mateclaw.wiki.hot-cache")
+public class HotCacheProperties {
+
+ /** Minimum gap between two LLM-driven rebuilds for the same KB. */
+ private Duration debounce = Duration.ofMinutes(5);
+
+ /** Time window for "recent" log/page entries fed to the LLM. */
+ private Duration recentWindow = Duration.ofHours(24);
+
+ /** Hard cap on rendered hot cache body length (post-LLM truncation point). */
+ private int maxChars = 4096;
+
+ /** Cap on number of recent created/updated pages fed to the prompt (each side). */
+ private int maxRecentPages = 10;
+
+ /** Cap on chars from the previous hot cache body fed back as context. */
+ private int previousContentCap = 1500;
+
+ /** Cap on chars from the KB activity log markdown fed to the prompt. */
+ private int logExcerptCap = 2000;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheRebuildPromptBuilder.java b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheRebuildPromptBuilder.java
new file mode 100644
index 00000000..b214aee9
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheRebuildPromptBuilder.java
@@ -0,0 +1,67 @@
+package vip.mate.wiki.hotcache;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import vip.mate.agent.prompt.PromptLoader;
+import vip.mate.wiki.model.WikiPageEntity;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * Builds the user-facing prompt for one hot-cache rebuild call by
+ * substituting placeholders in {@code wiki/hot-cache-rebuild-user.txt}.
+ *
+ *
System prompt is loaded once and held identical across calls — the
+ * intent is to keep prompt-cache reuse high once
+ * {@code wiki.compile.cache.enabled} is wired in a follow-up PR.
+ */
+@Component
+@RequiredArgsConstructor
+public class HotCacheRebuildPromptBuilder {
+
+ private final HotCacheProperties props;
+
+ /**
+ * Returns a single user-message body with the placeholders substituted.
+ * Inputs are sliced to the configured caps before injection.
+ */
+ public String buildUser(String previousContent,
+ String logMarkdownExcerpt,
+ List recentCreates,
+ List recentUpdates) {
+ String template = PromptLoader.loadPrompt("wiki/hot-cache-rebuild-user");
+ return template
+ .replace("{iso_timestamp}", Instant.now().toString())
+ .replace("{recent_window}", props.getRecentWindow().toString())
+ .replace("{previous_content}", abbreviate(blankToNone(previousContent), props.getPreviousContentCap()))
+ .replace("{log_excerpt}", abbreviate(blankToNone(logMarkdownExcerpt), props.getLogExcerptCap()))
+ .replace("{recent_creates}", renderPages(recentCreates))
+ .replace("{recent_updates}", renderPages(recentUpdates));
+ }
+
+ public String buildSystem() {
+ return PromptLoader.loadPrompt("wiki/hot-cache-rebuild-system");
+ }
+
+ private static String renderPages(List pages) {
+ if (pages == null || pages.isEmpty()) return "(none)";
+ StringBuilder sb = new StringBuilder();
+ for (WikiPageEntity p : pages) {
+ String slug = p.getSlug() == null ? "?" : p.getSlug();
+ String title = p.getTitle() == null ? slug : p.getTitle();
+ sb.append("- [[").append(slug).append("]] ").append(title).append('\n');
+ }
+ return sb.toString().stripTrailing();
+ }
+
+ private static String blankToNone(String s) {
+ return (s == null || s.isBlank()) ? "(none)" : s;
+ }
+
+ private static String abbreviate(String s, int max) {
+ if (s == null) return "";
+ if (s.length() <= max) return s;
+ return s.substring(0, max - 1) + "…";
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheUpdateScheduler.java b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheUpdateScheduler.java
new file mode 100644
index 00000000..7a3277d2
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/HotCacheUpdateScheduler.java
@@ -0,0 +1,93 @@
+package vip.mate.wiki.hotcache;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import vip.mate.wiki.model.WikiHotCacheEntity;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Off-loads hot-cache rebuilds onto a virtual-thread executor and applies
+ * two safety nets:
+ *
+ *
+ * - A per-KB {@link ReentrantLock} so two threads can never race on
+ * the same row.
+ * - A debounce window ({@link HotCacheProperties#getDebounce()}):
+ * within that window of the last rebuild start, additional events
+ * are dropped because the next rebuild already covers them.
+ *
+ *
+ * Both checks are best-effort — there's no global coordination, so
+ * two JVMs can each do one rebuild back-to-back. The
+ * {@code last_rebuild_started_at} timestamp the updater writes is the
+ * cross-process signal that PR-2's debounce uses; a future PR can lift
+ * this to a distributed lock if churn shows up in metrics.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HotCacheUpdateScheduler {
+
+ private final HotCacheProperties props;
+ private final WikiHotCacheService cacheService;
+ private final WikiHotCacheUpdater updater;
+
+ private final ConcurrentMap locks = new ConcurrentHashMap<>();
+
+ /** Virtual threads — rebuilds are LLM-bound, not CPU-bound. */
+ private static final ExecutorService REBUILD_EXECUTOR =
+ Executors.newVirtualThreadPerTaskExecutor();
+
+ /** Schedule a rebuild for {@code kbId}; returns immediately. */
+ public void scheduleRebuild(Long kbId, HotCacheUpdateReason reason) {
+ if (kbId == null) return;
+ REBUILD_EXECUTOR.execute(() -> attemptRebuild(kbId, reason));
+ }
+
+ /** Synchronous variant — used by tests + the (future) admin API. */
+ public void rebuildNowBlocking(Long kbId, HotCacheUpdateReason reason) {
+ if (kbId == null) return;
+ attemptRebuild(kbId, reason);
+ }
+
+ private void attemptRebuild(Long kbId, HotCacheUpdateReason reason) {
+ ReentrantLock lock = locks.computeIfAbsent(kbId, k -> new ReentrantLock());
+ if (!lock.tryLock()) {
+ log.debug("[HotCache][kb={}] rebuild already running; reason={} skipped", kbId, reason);
+ return;
+ }
+ try {
+ if (withinDebounceWindow(kbId, reason)) return;
+ updater.rebuild(kbId, reason);
+ } catch (Exception e) {
+ log.warn("[HotCache][kb={}] scheduled rebuild crashed; reason={}: {}",
+ kbId, reason, e.getMessage(), e);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private boolean withinDebounceWindow(Long kbId, HotCacheUpdateReason reason) {
+ // MANUAL bypasses debounce — operators triggering by hand expect to
+ // see their click take effect.
+ if (reason == HotCacheUpdateReason.MANUAL) return false;
+
+ WikiHotCacheEntity existing = cacheService.findByKb(kbId).orElse(null);
+ if (existing == null || existing.getLastRebuildStartedAt() == null) return false;
+ Instant lastStart = existing.getLastRebuildStartedAt()
+ .atZone(ZoneId.systemDefault()).toInstant();
+ if (Instant.now().isBefore(lastStart.plus(props.getDebounce()))) {
+ log.debug("[HotCache][kb={}] within debounce window; reason={} skipped", kbId, reason);
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/WikiHotCacheUpdater.java b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/WikiHotCacheUpdater.java
new file mode 100644
index 00000000..86328b13
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/hotcache/WikiHotCacheUpdater.java
@@ -0,0 +1,266 @@
+package vip.mate.wiki.hotcache;
+
+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.prompt.Prompt;
+import org.springframework.retry.support.RetryTemplate;
+import org.springframework.stereotype.Service;
+import vip.mate.agent.AgentGraphBuilder;
+import vip.mate.llm.model.ModelConfigEntity;
+import vip.mate.llm.service.ModelConfigService;
+import vip.mate.system.featureflag.FeatureFlagService;
+import vip.mate.wiki.job.WikiJobStep;
+import vip.mate.wiki.job.WikiModelRoutingService;
+import vip.mate.wiki.metrics.WikiMetrics;
+import vip.mate.wiki.model.WikiHotCacheEntity;
+import vip.mate.wiki.model.WikiPageEntity;
+import vip.mate.wiki.repository.WikiHotCacheMapper;
+import vip.mate.wiki.service.WikiPageService;
+import vip.mate.wiki.service.WikiScaffoldService;
+
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Performs one LLM-driven hot cache rebuild for a single KB. Caller
+ * (typically {@link HotCacheUpdateScheduler}) handles debounce + per-KB
+ * locking; this class is otherwise stateless and idempotent.
+ *
+ * Pipeline: gather inputs (previous body + activity log excerpt +
+ * recent creates / updates) → build a system+user prompt pair → resolve a
+ * KB-routed chat model the same way the wiki narrative refresher does →
+ * call → truncate to the configured cap → diff against the existing
+ * content hash → write only if changed.
+ *
+ *
Failure modes (flag off, model resolution miss, LLM error) all
+ * surface as warnings in the row's {@code last_rebuild_error}. They never
+ * propagate up — the triggering event must complete regardless of cache
+ * health.
+ */
+@Slf4j
+@Service
+public class WikiHotCacheUpdater {
+
+ private static final String FLAG = "wiki.hot_cache.enabled";
+ private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build();
+
+ private final WikiHotCacheService cacheService;
+ private final WikiHotCacheMapper mapper;
+ private final HotCacheRebuildPromptBuilder promptBuilder;
+ private final HotCacheProperties props;
+ private final WikiModelRoutingService modelRoutingService;
+ private final ModelConfigService modelConfigService;
+ private final AgentGraphBuilder agentGraphBuilder;
+ private final FeatureFlagService featureFlagService;
+ private final WikiMetrics metrics;
+ private final WikiPageService pageService;
+
+ public WikiHotCacheUpdater(WikiHotCacheService cacheService,
+ WikiHotCacheMapper mapper,
+ HotCacheRebuildPromptBuilder promptBuilder,
+ HotCacheProperties props,
+ WikiModelRoutingService modelRoutingService,
+ ModelConfigService modelConfigService,
+ AgentGraphBuilder agentGraphBuilder,
+ FeatureFlagService featureFlagService,
+ WikiMetrics metrics,
+ WikiPageService pageService) {
+ this.cacheService = cacheService;
+ this.mapper = mapper;
+ this.promptBuilder = promptBuilder;
+ this.props = props;
+ this.modelRoutingService = modelRoutingService;
+ this.modelConfigService = modelConfigService;
+ this.agentGraphBuilder = agentGraphBuilder;
+ this.featureFlagService = featureFlagService;
+ this.metrics = metrics;
+ this.pageService = pageService;
+ }
+
+ /**
+ * Rebuild the hot cache row for {@code kbId}. Safe to call concurrently;
+ * the scheduler holds the lock that makes it serial per KB.
+ */
+ public void rebuild(Long kbId, HotCacheUpdateReason reason) {
+ if (kbId == null) return;
+ if (!featureFlagService.isEnabledForKb(FLAG, kbId)) {
+ log.debug("[HotCache][kb={}] flag disabled; skip rebuild reason={}", kbId, reason);
+ return;
+ }
+
+ Instant start = Instant.now();
+ markRebuildStarted(kbId);
+
+ try {
+ // 1. Inputs.
+ String prevContent = cacheService.getContentOrNull(kbId);
+ LocalDateTime since = LocalDateTime.now().minus(props.getRecentWindow());
+ List recentCreates =
+ pageService.findRecentCreated(kbId, since, props.getMaxRecentPages());
+ List recentUpdates =
+ pageService.findRecentUpdated(kbId, since, props.getMaxRecentPages());
+ String logExcerpt = readLogExcerpt(kbId);
+
+ // 2. Cheap exit: if the KB has had no activity at all in the
+ // window, skip the LLM call entirely (the snapshot would be
+ // no different from what's already there).
+ if (recentCreates.isEmpty() && recentUpdates.isEmpty()
+ && (logExcerpt == null || logExcerpt.isBlank())) {
+ log.debug("[HotCache][kb={}] no recent activity; skip rebuild reason={}", kbId, reason);
+ clearRebuildMarker(kbId);
+ return;
+ }
+
+ // 3. Resolve a chat model for this KB.
+ ChatModel chatModel = resolveChatModel(kbId);
+ if (chatModel == null) {
+ log.debug("[HotCache][kb={}] no chat model resolvable; skip rebuild", kbId);
+ recordError(kbId, "no chat model resolvable",
+ Duration.between(start, Instant.now()).toMillis());
+ return;
+ }
+
+ // 4. Build prompts + call LLM.
+ String system = promptBuilder.buildSystem();
+ String user = promptBuilder.buildUser(prevContent, logExcerpt, recentCreates, recentUpdates);
+ Prompt prompt = new Prompt(List.of(new SystemMessage(system), new UserMessage(user)));
+ String rendered = chatModel.call(prompt).getResult().getOutput().getText();
+
+ // 5. Truncate + persist.
+ String truncated = truncate(rendered, props.getMaxChars());
+ if (truncated == null || truncated.isBlank()) {
+ log.warn("[HotCache][kb={}] LLM returned empty body; treating as failure", kbId);
+ recordError(kbId, "LLM returned empty body",
+ Duration.between(start, Instant.now()).toMillis());
+ return;
+ }
+ String hash = sha256(truncated);
+ persistRebuild(kbId, truncated, hash, reason, start);
+
+ metrics.recordCompileStage("hot-cache-rebuild", kbId,
+ Duration.between(start, Instant.now()));
+ log.info("[HotCache][kb={}] rebuilt; reason={} chars={} duration_ms={}",
+ kbId, reason, truncated.length(),
+ Duration.between(start, Instant.now()).toMillis());
+
+ } catch (Exception e) {
+ log.warn("[HotCache][kb={}] rebuild failed; reason={}: {}",
+ kbId, reason, e.getMessage(), e);
+ recordError(kbId, e.getMessage(),
+ Duration.between(start, Instant.now()).toMillis());
+ // Intentionally swallow — caller's event must complete.
+ }
+ }
+
+ private void persistRebuild(Long kbId, String content, String hash,
+ HotCacheUpdateReason reason, Instant start) {
+ WikiHotCacheEntity row = cacheService.findByKb(kbId).orElseGet(() -> {
+ WikiHotCacheEntity fresh = new WikiHotCacheEntity();
+ fresh.setKbId(kbId);
+ fresh.setRebuildCount(0L);
+ return fresh;
+ });
+
+ boolean unchanged = hash.equals(row.getContentHash());
+ if (!unchanged) {
+ row.setContent(content);
+ row.setContentHash(hash);
+ row.setRebuildCount((row.getRebuildCount() == null ? 0L : row.getRebuildCount()) + 1);
+ }
+ row.setLastUpdated(LocalDateTime.now());
+ row.setUpdateReason(reason.name());
+ row.setLastRebuildDurationMs(Duration.between(start, Instant.now()).toMillis());
+ row.setLastRebuildError(null);
+
+ if (row.getId() == null) {
+ mapper.insert(row);
+ } else {
+ mapper.updateById(row);
+ }
+ if (unchanged) {
+ log.debug("[HotCache][kb={}] body unchanged; only timestamps refreshed", kbId);
+ }
+ }
+
+ /**
+ * Reads the most recent slice of the KB's activity log markdown page
+ * (created by {@code WikiLogService.append}). Bounded to
+ * {@link HotCacheProperties#getLogExcerptCap()} chars from the tail —
+ * older sections are dropped. Returns null if the log page is absent.
+ */
+ private String readLogExcerpt(Long kbId) {
+ try {
+ WikiPageEntity log = pageService.getBySlug(kbId, WikiScaffoldService.LOG_SLUG);
+ if (log == null) return null;
+ String content = log.getContent();
+ if (content == null) return null;
+ int cap = props.getLogExcerptCap();
+ if (content.length() <= cap) return content;
+ // Tail: latest entries are at the bottom of the markdown.
+ return "…\n" + content.substring(content.length() - cap);
+ } catch (Exception e) {
+ log.debug("[HotCache][kb={}] log page read failed: {}", kbId, e.getMessage());
+ return null;
+ }
+ }
+
+ private ChatModel resolveChatModel(Long kbId) {
+ try {
+ Long modelId = modelRoutingService.selectModelId(kbId, "hot_cache_rebuild", WikiJobStep.SUMMARY);
+ ModelConfigEntity model = modelConfigService.getModel(modelId);
+ if (model == null) return null;
+ return agentGraphBuilder.buildRuntimeChatModel(model, NO_RETRY);
+ } catch (Exception e) {
+ log.debug("[HotCache][kb={}] model routing failed: {}", kbId, e.getMessage());
+ return null;
+ }
+ }
+
+ private void markRebuildStarted(Long kbId) {
+ cacheService.findByKb(kbId).ifPresent(row -> {
+ row.setLastRebuildStartedAt(LocalDateTime.now());
+ mapper.updateById(row);
+ });
+ }
+
+ private void clearRebuildMarker(Long kbId) {
+ cacheService.findByKb(kbId).ifPresent(row -> {
+ row.setLastRebuildStartedAt(null);
+ mapper.updateById(row);
+ });
+ }
+
+ private void recordError(Long kbId, String error, long durationMs) {
+ cacheService.findByKb(kbId).ifPresent(row -> {
+ row.setLastRebuildError(error != null && error.length() > 500
+ ? error.substring(0, 500) : error);
+ row.setLastRebuildDurationMs(durationMs);
+ row.setLastRebuildStartedAt(null);
+ mapper.updateById(row);
+ });
+ }
+
+ private static String truncate(String s, int max) {
+ if (s == null) return "";
+ if (s.length() <= max) return s;
+ return s.substring(0, max - 1) + "…";
+ }
+
+ private static String sha256(String s) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(64);
+ for (byte b : digest) sb.append(String.format("%02x", b));
+ return sb.toString();
+ } catch (Exception e) {
+ return "no-hash";
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
index e4fd9e15..1fa2e91b 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
@@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageMapper;
+import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
@@ -72,6 +73,47 @@ public class WikiPageService {
* default {@link #listByKbId} filter. Used by the admin UI's "show archived"
* panel so users can see what they archived and recover it.
*/
+ /**
+ * Pages in {@code kbId} created at or after {@code since}, newest first.
+ * Used by the hot-cache rebuilder to surface "what was just added";
+ * archived pages and system pages (overview/log) are excluded so the
+ * snapshot stays focused on user-visible knowledge.
+ */
+ public List findRecentCreated(Long kbId, LocalDateTime since, int limit) {
+ if (kbId == null || since == null || limit <= 0) return java.util.List.of();
+ List rows = pageMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(WikiPageEntity::getKbId, kbId)
+ .ne(WikiPageEntity::getArchived, 1)
+ .ne(WikiPageEntity::getPageType, WikiScaffoldService.SYSTEM_PAGE_TYPE)
+ .ge(WikiPageEntity::getCreateTime, since)
+ .orderByDesc(WikiPageEntity::getCreateTime)
+ .last("LIMIT " + limit));
+ rows.forEach(p -> p.setContent(null));
+ return rows;
+ }
+
+ /**
+ * Pages in {@code kbId} updated at or after {@code since}, newest first.
+ * Same exclusions as {@link #findRecentCreated}.
+ *
+ * A row that was both created and updated in the window will appear
+ * in both lists — the caller deduplicates if needed.
+ */
+ public List findRecentUpdated(Long kbId, LocalDateTime since, int limit) {
+ if (kbId == null || since == null || limit <= 0) return java.util.List.of();
+ List rows = pageMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(WikiPageEntity::getKbId, kbId)
+ .ne(WikiPageEntity::getArchived, 1)
+ .ne(WikiPageEntity::getPageType, WikiScaffoldService.SYSTEM_PAGE_TYPE)
+ .ge(WikiPageEntity::getUpdateTime, since)
+ .orderByDesc(WikiPageEntity::getUpdateTime)
+ .last("LIMIT " + limit));
+ rows.forEach(p -> p.setContent(null));
+ return rows;
+ }
+
public List listArchivedByKbId(Long kbId) {
List pages = pageMapper.selectList(
new LambdaQueryWrapper()
diff --git a/mateclaw-server/src/main/resources/prompts/wiki/hot-cache-rebuild-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/hot-cache-rebuild-system.txt
new file mode 100644
index 00000000..39af1874
--- /dev/null
+++ b/mateclaw-server/src/main/resources/prompts/wiki/hot-cache-rebuild-system.txt
@@ -0,0 +1,39 @@
+You are the wiki hot cache rebuilder. Your job: produce a concise "what
+happened recently" snapshot for the given knowledge base, so the next AI
+session can pick up where the last one left off without re-reading every
+wiki page.
+
+Output STRICT markdown with this exact structure:
+
+---
+type: meta
+updated: {iso_timestamp}
+---
+
+## Last Updated
+{One sentence summarizing the most recent activity, ≤ 100 chars.}
+
+## Key Recent Facts
+- {Most important takeaway from recent ingest, ≤ 80 chars}
+- {Second most important, ≤ 80 chars}
+- {Third most important, ≤ 80 chars}
+
+## Recent Changes
+Created: {wikilinks separated by commas, e.g. [[page-1]], [[page-2]]}
+Updated: {same shape}
+Flagged: {contradictions or ambiguous links worth attention}
+
+## Active Threads
+- {Open research direction, ≤ 100 chars}
+- {Open question, ≤ 100 chars}
+
+CONSTRAINTS:
+- TOTAL output (including frontmatter) MUST be ≤ 4000 characters.
+- Use ONLY information from the inputs the user provides; do NOT
+ speculate, do NOT invent page slugs.
+- Sections may be empty (write "(none)" if so), but the four headers
+ MUST appear, in order, exactly as shown.
+- Output ONLY the markdown above — no fences, no preamble, no
+ explanation, no closing remarks.
+- Match the language of the input pages (Chinese inputs → Chinese
+ snapshot).
diff --git a/mateclaw-server/src/main/resources/prompts/wiki/hot-cache-rebuild-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/hot-cache-rebuild-user.txt
new file mode 100644
index 00000000..c9b42cec
--- /dev/null
+++ b/mateclaw-server/src/main/resources/prompts/wiki/hot-cache-rebuild-user.txt
@@ -0,0 +1,21 @@
+## Previous hot cache (for continuity; may be empty)
+
+{previous_content}
+
+## Recent activity log excerpt (last {recent_window})
+
+{log_excerpt}
+
+## Recently created pages
+
+{recent_creates}
+
+## Recently updated pages
+
+{recent_updates}
+
+---
+
+Produce the rebuilt hot cache snapshot now, using the structure and
+constraints in the system prompt. Use the timestamp {iso_timestamp} in
+the frontmatter.