diff --git a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/en_US.json b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/en_US.json
index 99d3da3c3..42262acc4 100644
--- a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/en_US.json
+++ b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/en_US.json
@@ -77,5 +77,16 @@
"Traits": "Traits",
"Preferences": "Preferences",
"Notes": "Notes",
- "Updated": "Updated"
+ "Updated": "Updated",
+ "consolidationReview": "Review Memory Summary",
+ "consolidationHint": "The summary is an inference, not a verified fact. Compare it with every complete source record before applying. The model and run policy are configured on the memory KB.",
+ "previewConsolidation": "Preview Summary",
+ "consolidationReviewed": "I checked the inference against the source records.",
+ "applyConsolidation": "Apply Reviewed Summary",
+ "consolidationLoading": "Generating a proposal for review...",
+ "consolidationInference": "Inferred summary — verify against the sources",
+ "consolidationSources": "Complete source records",
+ "consolidationApplied": "Reviewed summary applied. Source records remain available.",
+ "consolidationUnavailable": "No reviewable summary proposal is available. Check the KB configuration and risk notes.",
+ "consolidationApplying": "Applying reviewed summary..."
}
diff --git a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/zh_Hans.json b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/zh_Hans.json
index 89df3e9a8..c300deeba 100644
--- a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/zh_Hans.json
+++ b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/zh_Hans.json
@@ -77,5 +77,16 @@
"Traits": "特征",
"Preferences": "偏好",
"Notes": "备注",
- "Updated": "更新时间"
+ "Updated": "更新时间",
+ "consolidationReview": "审核记忆摘要",
+ "consolidationHint": "摘要是推断,不是已核实事实。应用前请逐条核对完整来源。模型和执行开关在记忆知识库中配置。",
+ "previewConsolidation": "预览摘要",
+ "consolidationReviewed": "我已将推断摘要与来源记录核对。",
+ "applyConsolidation": "应用已审核摘要",
+ "consolidationLoading": "正在生成待审核提案……",
+ "consolidationInference": "推断摘要——请与来源核对",
+ "consolidationSources": "完整来源记录",
+ "consolidationApplied": "已应用审核后的摘要,来源记录仍保留。",
+ "consolidationUnavailable": "没有可审核的摘要提案。请检查知识库配置与风险说明。",
+ "consolidationApplying": "正在应用已审核摘要……"
}
diff --git a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/index.html b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/index.html
index 24817ece8..b9bebdd04 100644
--- a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/index.html
+++ b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/index.html
@@ -511,6 +511,16 @@
An ambiguous Host write fences its memory space until it is reconciled.
+
+
Review Memory Summary
+
+ The summary is an inference, not a verified fact. Compare it with every complete source record before applying. The model and run policy are configured on the memory KB.
+
+ Preview Summary
+ I checked the inference against the source records.
+ Apply Reviewed Summary
+
+
Raw summary
@@ -526,8 +536,16 @@
const EPISODE_STATUSES = ['active', 'superseded', 'archived', 'deleted'];
let summary = null;
let lastEpisodeMode = 'list';
+ let consolidationProposal = null;
+ let consolidationEpoch = 0;
const text = {
+ consolidationLoading: 'Generating a proposal for review...',
+ consolidationApplying: 'Applying reviewed summary...',
+ consolidationInference: 'Inferred summary — verify against the sources',
+ consolidationSources: 'Complete source records',
+ consolidationApplied: 'Reviewed summary applied. Source records remain available.',
+ consolidationUnavailable: 'No reviewable summary proposal is available. Check the KB configuration and risk notes.',
empty: 'No data',
noScope: 'No memory space selected.',
noKb: 'No memory knowledge base is configured.',
@@ -739,6 +757,7 @@
function applyKb(kb) {
if (!kb) return;
+ clearConsolidation();
setValue('collectionId', kb.id || '');
setValue('embeddingModel', kb.embedding_model_uuid || '');
setValue('isolation', kb.isolation || 'session');
@@ -779,6 +798,7 @@
}
function updateScopePreview() {
+ clearConsolidation();
$('scopePreview').textContent = value('scopeKey') || '-';
$('userPreview').textContent = value('userKey') || '-';
}
@@ -1162,6 +1182,91 @@
}
}
+ function consolidationKey() {
+ return JSON.stringify([value('collectionId'), value('scopeKey'), value('userKey')]);
+ }
+
+ function clearConsolidation() {
+ consolidationEpoch += 1;
+ consolidationProposal = null;
+ $('reviewConsolidation').checked = false;
+ $('reviewConsolidation').disabled = true;
+ $('applyConsolidationBtn').disabled = true;
+ $('previewConsolidationBtn').disabled = false;
+ $('consolidationOutput').replaceChildren();
+ }
+
+ function consolidationText(parent, tag, content) {
+ const element = document.createElement(tag);
+ element.textContent = String(content == null ? '' : content);
+ parent.appendChild(element);
+ return element;
+ }
+
+ async function previewConsolidation() {
+ clearConsolidation();
+ const target = $('consolidationOutput');
+ const scope = requireScope(target);
+ const kb = requireKb(target, false);
+ if (!scope || !kb) return;
+ const epoch = consolidationEpoch;
+ const key = consolidationKey();
+ $('previewConsolidationBtn').disabled = true;
+ target.textContent = text.consolidationLoading;
+ try {
+ const data = await api('/consolidation/preview', {
+ collection_id: kb.collectionId, scope_key: scope.scopeKey, user_key: scope.userKey,
+ });
+ if (epoch !== consolidationEpoch || key !== consolidationKey()) return;
+ target.replaceChildren();
+ consolidationText(target, 'h4', text.consolidationInference);
+ consolidationText(target, 'pre', data.summary_episode || text.consolidationUnavailable);
+ consolidationText(target, 'h4', text.consolidationSources);
+ (data.source_records || []).forEach((source) => {
+ consolidationText(target, 'pre', JSON.stringify({
+ id: source.id, sender_id: source.sender_id, timestamp: source.timestamp,
+ event_at: source.event_at, valid_from: source.valid_from, valid_until: source.valid_until,
+ claim_type: source.claim_type, source_ref: source.source_ref,
+ }, null, 2));
+ consolidationText(target, 'pre', source.content);
+ });
+ (data.risk_notes || []).forEach((note) => consolidationText(target, 'p', note));
+ if (data.proposal_id && data.summary_episode) {
+ consolidationProposal = { id: data.proposal_id, key: key };
+ $('reviewConsolidation').disabled = false;
+ }
+ } catch (err) {
+ if (epoch === consolidationEpoch) target.textContent = readableError(err);
+ } finally {
+ if (epoch === consolidationEpoch) $('previewConsolidationBtn').disabled = false;
+ }
+ }
+
+ async function applyConsolidation() {
+ const proposal = consolidationProposal;
+ if (!proposal || proposal.key !== consolidationKey() || !$('reviewConsolidation').checked) return;
+ const scope = selectedScope();
+ const kb = selectedKb();
+ const key = consolidationKey();
+ clearConsolidation();
+ const epoch = consolidationEpoch;
+ $('previewConsolidationBtn').disabled = true;
+ $('consolidationOutput').textContent = text.consolidationApplying;
+ try {
+ await api('/consolidation/run', {
+ collection_id: kb.collectionId, scope_key: scope.scopeKey, user_key: scope.userKey,
+ proposal_id: proposal.id,
+ });
+ if (epoch === consolidationEpoch && key === consolidationKey()) {
+ $('consolidationOutput').textContent = text.consolidationApplied;
+ }
+ } catch (err) {
+ if (epoch === consolidationEpoch) $('consolidationOutput').textContent = readableError(err);
+ } finally {
+ if (epoch === consolidationEpoch) $('previewConsolidationBtn').disabled = false;
+ }
+ }
+
function bindTabs() {
document.querySelectorAll('.tab').forEach((button) => {
button.addEventListener('click', () => {
@@ -1183,6 +1288,14 @@
$('searchEpisodesBtn').addEventListener('click', searchEpisodes);
$('loadAuditBtn').addEventListener('click', loadAudit);
$('runHealthBtn').addEventListener('click', runHealth);
+ $('previewConsolidationBtn').addEventListener('click', previewConsolidation);
+ $('applyConsolidationBtn').addEventListener('click', applyConsolidation);
+ $('reviewConsolidation').addEventListener('change', () => {
+ $('applyConsolidationBtn').disabled = !consolidationProposal || !$('reviewConsolidation').checked;
+ });
+ ['collectionId', 'scopeKey', 'userKey'].forEach((id) => {
+ $(id).addEventListener('input', clearConsolidation);
+ });
$('clearFenceBtn').addEventListener('click', clearFence);
$('loadInjectionBtn').addEventListener('click', loadInjection);
$('exportProfilesBtn').addEventListener('click', exportProfiles);
diff --git a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/memory_console.py b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/memory_console.py
index 87f897730..cbf466ed5 100644
--- a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/memory_console.py
+++ b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/memory_console.py
@@ -32,6 +32,10 @@ class MemoryConsolePage(Page):
return PageResponse.ok(await self._list_audit(self._body(request)))
if request.endpoint == "/audit/export" and request.method == "POST":
return PageResponse.ok(await self._export_audit(self._body(request)))
+ if request.endpoint == "/consolidation/preview" and request.method == "POST":
+ return PageResponse.ok(await self._consolidation_preview(self._body(request)))
+ if request.endpoint == "/consolidation/run" and request.method == "POST":
+ return PageResponse.ok(await self._consolidation_run(self._body(request)))
if request.endpoint == "/health" and request.method == "POST":
return PageResponse.ok(await self._health(self._body(request)))
if request.endpoint == "/injection" and request.method == "POST":
@@ -386,6 +390,61 @@ class MemoryConsolePage(Page):
)
return {"episode_id": episode_id, "deleted": deleted}
+ async def _consolidation_context(self, body: dict[str, Any], *, run: bool = False):
+ allowed = {"collection_id", "scope_key", "user_key"}
+ if run:
+ allowed.add("proposal_id")
+ if set(body) - allowed:
+ raise ValueError("unsupported consolidation fields; model and policy come from KB configuration")
+ collection_id = self._required_string(body, "collection_id")
+ scope_key = self._required_string(body, "scope_key")
+ user_key = self._required_string(body, "user_key")
+ config = (await self._store.get_kb_configs()).get(collection_id)
+ if config is None:
+ raise ValueError("memory knowledge base is not configured")
+ isolation = self._string(config, "isolation", "session")
+ if isolation not in {"session", "bot"}:
+ raise ValueError("KB isolation must be session or bot")
+ expected_key = self._infer_user_key(scope_key, isolation)
+ if user_key != expected_key:
+ raise ValueError("user_key does not match the selected scope and KB isolation")
+ if run:
+ if config.get("consolidation_enabled") is not True:
+ raise ValueError("consolidation run is disabled in the memory KB")
+ self._required_string(config, "embedding_model_uuid")
+ return collection_id, scope_key, user_key, config
+
+ async def _consolidation_preview(self, body: dict[str, Any]) -> dict[str, Any]:
+ collection_id, _scope_key, user_key, config = await self._consolidation_context(body)
+ return await self._store.preview_consolidation(
+ collection_id=collection_id,
+ user_key=user_key,
+ min_age_days=self._int(config, "consolidation_min_age_days", 7, 0, 36500),
+ max_candidates=self._int(config, "consolidation_max_candidates", 20, 1, 100),
+ summary_model_uuid=self._string(config, "consolidation_model_uuid"),
+ )
+
+ async def _consolidation_run(self, body: dict[str, Any]) -> dict[str, Any]:
+ proposal_id = self._required_string(body, "proposal_id")
+ collection_id, scope_key, user_key, config = await self._consolidation_context(body, run=True)
+ result = await self._store.apply_consolidation(
+ collection_id=collection_id,
+ embedding_model_uuid=self._required_string(config, "embedding_model_uuid"),
+ user_key=user_key,
+ proposal_id=proposal_id,
+ )
+ summary = result.get("summary_episode") or {}
+ await self._store.append_audit_entry(
+ scope_key=scope_key,
+ user_key=user_key,
+ operation="consolidate_run",
+ target_type="episode",
+ target_id=summary.get("id", proposal_id),
+ summary="Applied a reviewed inference summary from Memory Console",
+ metadata={"collection_id": collection_id, "proposal_id": proposal_id},
+ )
+ return result
+
async def _list_audit(self, body: dict[str, Any]) -> dict[str, Any]:
scope_key = self._required_string(body, "scope_key")
page = self._int(body, "page", 1, 1, 100000)
diff --git a/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review.py b/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review.py
new file mode 100644
index 000000000..cb6a013a0
--- /dev/null
+++ b/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review.py
@@ -0,0 +1,160 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from components.pages.memory_console.memory_console import MemoryConsolePage
+from langbot_plugin.api.definition.components.page import PageRequest
+
+
+@pytest.fixture
+def page():
+ component = MemoryConsolePage()
+ config = {
+ "isolation": "session", "consolidation_enabled": True,
+ "consolidation_model_uuid": "trusted-summary-model", "embedding_model_uuid": "trusted-embedding-model",
+ "consolidation_min_age_days": 10, "consolidation_max_candidates": 8,
+ }
+ source = "\n" + "Complete source fact. " * 500
+ store = SimpleNamespace(
+ configs={"kb": config},
+ get_kb_configs=AsyncMock(return_value={"kb": config}),
+ preview_consolidation=AsyncMock(return_value={
+ "proposal_id": "proposal-1", "summary_episode": "An inferred summary not HTML ",
+ "source_records": [{"id": "source-1", "content": source}],
+ "risk_notes": ["Review the inference"],
+ }),
+ apply_consolidation=AsyncMock(return_value={"summary_episode": {"id": "summary-1"}, "archived_episode_ids": []}),
+ append_audit_entry=AsyncMock(),
+ )
+ component.plugin = SimpleNamespace(memory_store=store)
+ return component
+
+
+def body(**changes):
+ return {"collection_id": "kb", "scope_key": "bot:group_room", "user_key": "bot:group_room", **changes}
+
+
+async def request(page, endpoint, payload):
+ return await page.handle_api(PageRequest(endpoint=endpoint, method="POST", body=payload))
+
+
+@pytest.mark.asyncio
+async def test_preview_uses_trusted_kb_model_and_preserves_complete_sources(page):
+ response = await request(page, "/consolidation/preview", body())
+ assert response.error is None
+ assert response.data["source_records"][0]["content"].endswith("Complete source fact. " * 500)
+ assert response.data["summary_episode"] == "An inferred summary not HTML "
+ page._store.preview_consolidation.assert_awaited_once_with(
+ collection_id="kb", user_key="bot:group_room", min_age_days=10, max_candidates=8,
+ summary_model_uuid="trusted-summary-model",
+ )
+ page._store.apply_consolidation.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_preview_is_available_when_run_is_disabled(page):
+ page._store.configs["kb"]["consolidation_enabled"] = False
+ response = await request(page, "/consolidation/preview", body())
+ assert response.error is None
+ page._store.preview_consolidation.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_run_requires_reviewed_proposal_and_trusted_embedding_model(page):
+ response = await request(page, "/consolidation/run", body(proposal_id="proposal-1"))
+ assert response.error is None
+ page._store.apply_consolidation.assert_awaited_once_with(
+ collection_id="kb", embedding_model_uuid="trusted-embedding-model",
+ user_key="bot:group_room", proposal_id="proposal-1",
+ )
+ audit = page._store.append_audit_entry.call_args.kwargs
+ assert audit["scope_key"] == "bot:group_room"
+ assert audit["user_key"] == "bot:group_room"
+ assert audit["metadata"] == {"collection_id": "kb", "proposal_id": "proposal-1"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("extra", [{"summary_model_uuid": "browser-model"}, {"embedding_model_uuid": "browser-model"}, {"consolidation_enabled": True}, {"min_age_days": 0}])
+@pytest.mark.parametrize("endpoint", ["preview", "run"])
+async def test_browser_cannot_override_models_or_policy(page, extra, endpoint):
+ payload = body(**extra)
+ if endpoint == "run":
+ payload["proposal_id"] = "proposal-1"
+ response = await request(page, "/consolidation/" + endpoint, payload)
+ assert response.error is not None
+ page._store.preview_consolidation.assert_not_awaited()
+ page._store.apply_consolidation.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("changes", [{"proposal_id": ""}, {"proposal_id": None}, {"proposal_id": 123}, {"proposal_id": "proposal-1", "user_key": "another-space"}, {"proposal_id": "proposal-1", "collection_id": "unknown-kb"}])
+async def test_invalid_run_never_reaches_store(page, changes):
+ response = await request(page, "/consolidation/run", body(**changes))
+ assert response.error is not None
+ page._store.apply_consolidation.assert_not_awaited()
+ page._store.append_audit_entry.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("disabled", [False, "false", None])
+async def test_run_is_fail_closed_when_not_explicitly_enabled(page, disabled):
+ page._store.configs["kb"]["consolidation_enabled"] = disabled
+ response = await request(page, "/consolidation/run", body(proposal_id="proposal-1"))
+ assert response.error is not None
+ page._store.apply_consolidation.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_run_requires_configured_embedding_model(page):
+ page._store.configs["kb"].pop("embedding_model_uuid")
+ response = await request(page, "/consolidation/run", body(proposal_id="proposal-1"))
+ assert response.error is not None
+ page._store.apply_consolidation.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_preview_scope_is_checked_against_kb_isolation(page):
+ page._store.configs["kb"]["isolation"] = "bot"
+ response = await request(page, "/consolidation/preview", body(user_key="bot:bot"))
+ assert response.error is None
+ assert page._store.preview_consolidation.call_args.kwargs["user_key"] == "bot:bot"
+ rejected = await request(page, "/consolidation/preview", body())
+ assert rejected.error is not None
+ assert page._store.preview_consolidation.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_store_rejects_cross_scope_or_stale_proposal_without_success_audit(page):
+ page._store.apply_consolidation.side_effect = ValueError("proposal scope changed; preview again")
+ response = await request(page, "/consolidation/run", body(proposal_id="proposal-from-another-space"))
+ assert response.error is not None
+ assert "preview again" in response.error
+ page._store.apply_consolidation.assert_awaited_once()
+ page._store.append_audit_entry.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("payload", [[], "body", 123])
+async def test_consolidation_body_must_be_an_object(page, payload):
+ response = await request(page, "/consolidation/preview", payload)
+ assert response.error is not None
+ page._store.preview_consolidation.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("changes", [{"collection_id": ""}, {"scope_key": ""}, {"user_key": ""}, {"collection_id": 123}, {"user_key": []}])
+async def test_preview_requires_string_kb_scope_and_user_identity(page, changes):
+ response = await request(page, "/consolidation/preview", body(**changes))
+ assert response.error is not None
+ page._store.preview_consolidation.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_unknown_kb_isolation_fails_closed(page):
+ page._store.configs["kb"]["isolation"] = "unknown"
+ response = await request(page, "/consolidation/preview", body(user_key="bot:bot"))
+ assert response.error is not None
+ page._store.preview_consolidation.assert_not_awaited()
diff --git a/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review_ui.py b/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review_ui.py
new file mode 100644
index 000000000..7325f95ec
--- /dev/null
+++ b/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review_ui.py
@@ -0,0 +1,84 @@
+"""Exercise the Console review flow without adding a browser dependency."""
+from __future__ import annotations
+
+from pathlib import Path
+import shutil
+import subprocess
+
+import pytest
+
+
+NODE_CHECK = r"""
+const fs = require('node:fs');
+const vm = require('node:vm');
+const assert = require('node:assert/strict');
+const html = fs.readFileSync(process.argv[1], 'utf8');
+const script = html.match(/