diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java
index 2246c6f8..af892600 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java
@@ -2,5 +2,21 @@ package vip.mate.wiki.dto;
/**
* RFC-029: Lightweight page projection without content.
+ *
+ * RFC-051 PR-2: {@code pageType} added so callers can default-filter system
+ * pages from list/search/related results. Backwards-compatible 4-arg
+ * factory keeps older constructors (e.g. {@code new WikiPageLite(id, slug,
+ * title, summary)}) compiling — they yield {@code pageType=null}, which the
+ * filter treats as "not system".
*/
-public record WikiPageLite(Long id, String slug, String title, String summary) {}
+public record WikiPageLite(Long id, String slug, String title, String summary, String pageType) {
+
+ public WikiPageLite(Long id, String slug, String title, String summary) {
+ this(id, slug, title, summary, null);
+ }
+
+ /** True when this page should be hidden from default tool/search results. */
+ public boolean isSystem() {
+ return "system".equals(pageType);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java
index 785607c4..a6cf03ad 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java
@@ -58,6 +58,13 @@ public class WikiPageEntity {
/** 最后更新者:ai / manual */
private String lastUpdatedBy;
+ /**
+ * RFC-051 PR-2: protection flag. {@code locked=1} blocks AI/tool/UI deletion
+ * and batch cleanup; combined with {@code pageType="system"} for the
+ * built-in {@code overview} / {@code log} pages.
+ */
+ private Integer locked;
+
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java
index 4d2ef695..727454a1 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java
@@ -35,7 +35,7 @@ public interface WikiPageMapper extends BaseMapper {
/**
* Batch-fetch lightweight page projections by IDs (no content).
*/
- @Select("")
List selectBatchLite(@Param("ids") Collection ids);
@@ -43,7 +43,7 @@ public interface WikiPageMapper extends BaseMapper {
/**
* List all pages as lightweight projections (no content).
*/
- @Select("SELECT id, slug, title, summary FROM mate_wiki_page " +
+ @Select("SELECT id, slug, title, summary, page_type AS pageType FROM mate_wiki_page " +
"WHERE kb_id = #{kbId} AND deleted = 0 ORDER BY update_time DESC")
List selectAllLite(@Param("kbId") Long kbId);
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java
index c2042632..fd17c30c 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java
@@ -117,6 +117,8 @@ public class HybridRetriever {
for (RankedItem ri : fused.stream().limit(topK).toList()) {
WikiPageLite lite = liteMap.get(ri.pageId);
if (lite == null) continue;
+ // RFC-051 PR-2: hide system pages (overview / log) from search results.
+ if (lite.isSystem()) continue;
String snippet = null;
if (!ri.matchedBy.contains("relation_boost")) {
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java
index 41fcf5b9..6930004d 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java
@@ -22,6 +22,16 @@ public class WikiKnowledgeBaseService {
private final WikiKnowledgeBaseMapper kbMapper;
+ /**
+ * RFC-051 PR-2: optional system-page scaffold (overview / log). Marked
+ * required=false + Lazy so the KB service has no construction dependency
+ * on a service that needs WikiPageService — handy for the older tests that
+ * still wire this class manually.
+ */
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ @org.springframework.context.annotation.Lazy
+ private WikiScaffoldService scaffoldService;
+
private static final String DEFAULT_CONFIG = """
# Wiki Processing Rules
@@ -96,6 +106,10 @@ public class WikiKnowledgeBaseService {
entity.setRawCount(0);
kbMapper.insert(entity);
log.info("[Wiki] Knowledge base created: id={}, name={}, workspaceId={}", entity.getId(), name, workspaceId);
+ // RFC-051 PR-2: ensure overview / log system pages exist for every new KB.
+ if (scaffoldService != null) {
+ scaffoldService.ensureScaffold(entity.getId());
+ }
return entity;
}
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 6b296f7a..d1305c8e 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
@@ -374,8 +374,30 @@ public class WikiPageService {
.collect(Collectors.toList());
}
+ /**
+ * RFC-051 PR-2: a page is protected from AI / tool / batch deletion when
+ * either {@code locked == 1} or {@code pageType == "system"}. The system
+ * pages ({@code overview} / {@code log}) carry both flags; users may set
+ * {@code locked} on individual curated pages without making them system.
+ */
+ public static boolean isProtected(WikiPageEntity page) {
+ if (page == null) return false;
+ if (page.getLocked() != null && page.getLocked() == 1) return true;
+ return "system".equals(page.getPageType());
+ }
+
@Transactional
public void delete(Long kbId, String slug) {
+ WikiPageEntity existing = getBySlug(kbId, slug);
+ if (existing == null) {
+ // Nothing to delete; preserve idempotent behavior.
+ return;
+ }
+ if (isProtected(existing)) {
+ log.warn("[Wiki] Refusing to delete protected page kbId={}, slug={}, type={}, locked={}",
+ kbId, slug, existing.getPageType(), existing.getLocked());
+ return;
+ }
pageMapper.delete(
new LambdaQueryWrapper()
.eq(WikiPageEntity::getKbId, kbId)
@@ -409,6 +431,9 @@ public class WikiPageService {
int deleted = 0;
for (WikiPageEntity page : allPages) {
if ("manual".equals(page.getLastUpdatedBy())) continue;
+ // RFC-051 PR-2: never sweep system / locked pages, even when their
+ // source raw is being reprocessed.
+ if (isProtected(page)) continue;
List sourceIds = parseSourceRawIds(page.getSourceRawIds());
if (sourceIds.contains(rawId)) {
if (sourceIds.size() == 1) {
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java
index f2926808..5e67491d 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java
@@ -70,6 +70,14 @@ public class WikiProcessingService {
@org.springframework.beans.factory.annotation.Autowired(required = false)
private DocumentPreprocessService preprocessService;
+ /**
+ * RFC-051 PR-2: ensures system-page scaffold (overview / log) exists for
+ * the KB before each ingest. Optional so the older lazy-only unit tests
+ * don't need to wire it.
+ */
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ private WikiScaffoldService scaffoldService;
+
/** Parallel chunk / material processing executor (JDK 21 virtual threads) */
public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
@@ -157,6 +165,12 @@ public class WikiProcessingService {
kbService.updateStatus(kb.getId(), "processing");
+ // RFC-051 PR-2: every ingest path opens with a scaffold check so older
+ // KBs get their overview / log pages on first use without a manual step.
+ if (scaffoldService != null) {
+ scaffoldService.ensureScaffold(kb.getId());
+ }
+
// RFC-051 PR-1b: lazy ingest short-circuit. Per KB config, skip the heavy
// pipeline entirely: extract → chunk → embed → completed. 0 pages is the
// expected outcome, not a failure. ingestMode==null keeps existing behavior.
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java
index 6b0e4a7d..47a5bd0d 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java
@@ -68,6 +68,8 @@ public class WikiRelationService {
return topIds.stream()
.filter(liteMap::containsKey)
+ // RFC-051 PR-2: hide system pages (overview / log) from related results.
+ .filter(pid -> !liteMap.get(pid).isSystem())
.map(pid -> new RelatedPageResult(
liteMap.get(pid).slug(),
liteMap.get(pid).title(),
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java
new file mode 100644
index 00000000..70eb1746
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java
@@ -0,0 +1,111 @@
+package vip.mate.wiki.service;
+
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import vip.mate.wiki.model.WikiPageEntity;
+import vip.mate.wiki.repository.WikiPageMapper;
+
+/**
+ * RFC-051 PR-2: idempotent system-page scaffold for a knowledge base.
+ *
+ * A KB always has two system pages once {@link #ensureScaffold(Long)} has run:
+ *
+ * - {@code overview} — entry point describing scope and recent updates.
+ * - {@code log} — append-only ingest / compile / edit record.
+ *
+ * Both carry {@code page_type='system'} and {@code locked=1} so neither AI
+ * tools nor batch deletes can remove them. The deterministic-rebuild logic
+ * for the overview body and the activity-log writer land as follow-ups; this
+ * skeleton ships only the create-if-missing path.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WikiScaffoldService {
+
+ public static final String SYSTEM_PAGE_TYPE = "system";
+ public static final String OVERVIEW_SLUG = "overview";
+ public static final String LOG_SLUG = "log";
+
+ private final WikiPageService pageService;
+ private final WikiPageMapper pageMapper;
+
+ /**
+ * Make sure both system pages exist for {@code kbId}. Re-creates them with
+ * default content if missing; otherwise refreshes their protection flags
+ * (in case an earlier version stored them without {@code locked=1}).
+ *
+ * Safe to call repeatedly: it issues at most two SELECTs and one or two
+ * INSERT/UPDATEs. Throws nothing; logs on failure.
+ */
+ public void ensureScaffold(Long kbId) {
+ if (kbId == null) return;
+ try {
+ ensureSystemPage(kbId, OVERVIEW_SLUG, "Overview", DEFAULT_OVERVIEW);
+ ensureSystemPage(kbId, LOG_SLUG, "Log", initialLog());
+ } catch (Exception e) {
+ log.warn("[WikiScaffold] ensureScaffold failed for kbId={}: {}", kbId, e.getMessage());
+ }
+ }
+
+ private void ensureSystemPage(Long kbId, String slug, String title, String content) {
+ WikiPageEntity existing = pageService.getBySlug(kbId, slug);
+ if (existing == null) {
+ WikiPageEntity created = pageService.createPage(kbId, slug, title, content,
+ summaryOf(content), null, SYSTEM_PAGE_TYPE);
+ // createPage doesn't set locked; flip it on now via a targeted update.
+ pageMapper.update(null,
+ new LambdaUpdateWrapper()
+ .eq(WikiPageEntity::getId, created.getId())
+ .set(WikiPageEntity::getLocked, 1));
+ log.info("[WikiScaffold] Created system page slug={} for kbId={}", slug, kbId);
+ return;
+ }
+ // Heal previously-created system pages that lack the locked flag.
+ boolean needsLockFix = existing.getLocked() == null || existing.getLocked() != 1;
+ boolean needsTypeFix = !SYSTEM_PAGE_TYPE.equals(existing.getPageType());
+ if (needsLockFix || needsTypeFix) {
+ LambdaUpdateWrapper upd = new LambdaUpdateWrapper()
+ .eq(WikiPageEntity::getId, existing.getId());
+ if (needsLockFix) upd.set(WikiPageEntity::getLocked, 1);
+ if (needsTypeFix) upd.set(WikiPageEntity::getPageType, SYSTEM_PAGE_TYPE);
+ pageMapper.update(null, upd);
+ log.info("[WikiScaffold] Repaired protection flags for slug={} kbId={}", slug, kbId);
+ }
+ }
+
+ private String summaryOf(String content) {
+ if (content == null) return "";
+ String oneline = content.replaceAll("\\s+", " ").trim();
+ return oneline.length() > 200 ? oneline.substring(0, 200) + "..." : oneline;
+ }
+
+ private String initialLog() {
+ return "# Log\n\n## " + java.time.LocalDate.now() + " init\n\n- System pages initialized.\n";
+ }
+
+ private static final String DEFAULT_OVERVIEW = """
+ # Overview
+
+
+ ## Scope
+
+ - Sources: 0
+ - Wiki pages: 0
+ - Chunks: 0
+ - Last ingest: -
+
+ ## Recent Updates
+
+ No updates yet.
+
+ ## Coverage
+
+ - Pages with citations: 0
+ - Pages with wikilinks: 0
+ - Isolated pages: 0
+
+ """;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java
index 29b53b0f..44621d27 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java
@@ -134,18 +134,23 @@ public class WikiTool {
List pages;
if (query != null && !query.isBlank()) {
List ids = pageService.searchPages(kbId, query).stream()
+ .filter(p -> !"system".equals(p.getPageType()))
.map(WikiPageEntity::getId).limit(30).toList();
if (ids.isEmpty()) {
pages = List.of();
} else {
pages = pageService.listSummaries(kbId).stream()
+ .filter(p -> !"system".equals(p.getPageType()))
.filter(p -> ids.stream().anyMatch(id -> Objects.equals(id, p.getId())))
- .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary()))
+ .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType()))
.toList();
}
} else {
+ // RFC-051 PR-2: hide system pages (overview / log) from default listings.
+ // Agents can still wiki_read_page("overview") explicitly.
pages = pageService.listSummaries(kbId).stream()
- .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary()))
+ .filter(p -> !"system".equals(p.getPageType()))
+ .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType()))
.toList();
}
@@ -382,6 +387,13 @@ public class WikiTool {
return error("Cannot delete manually curated page: " + page.getTitle() + ". Please manage via admin UI.");
}
+ // RFC-051 PR-2: refuse to delete system pages (overview/log) or any
+ // user-locked page, even when the agent has tool access.
+ if (WikiPageService.isProtected(page)) {
+ return error("Cannot delete protected page: " + page.getTitle()
+ + (page.getLocked() != null && page.getLocked() == 1 ? " (locked)" : " (system)"));
+ }
+
pageService.delete(kbId, slug);
log.info("[WikiTool] Deleted page: {} (slug={}, kbId={})", page.getTitle(), slug, kbId);
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql b/mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql
new file mode 100644
index 00000000..3b1d81e5
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql
@@ -0,0 +1,4 @@
+-- V40: RFC-051 PR-2 — page protection flag.
+-- locked=1 blocks AI / tool / UI deletion and batch cleanup. Combined with
+-- page_type='system' for the built-in overview / log pages.
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS locked TINYINT NOT NULL DEFAULT 0;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql
new file mode 100644
index 00000000..0aaeb394
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql
@@ -0,0 +1,5 @@
+-- V40: RFC-051 PR-2 — page protection flag.
+-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard.
+SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' AND COLUMN_NAME = 'locked');
+SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_page ADD COLUMN locked TINYINT NOT NULL DEFAULT 0', 'SELECT 1');
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;