);
}
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index fe1c7ff736..7fda1c24a3 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -925,6 +925,10 @@ const messages: TranslationMap = {
'memoryTree.status.title': 'شجرة الذاكرة',
'memoryTree.status.autoSyncLabel': 'النظام الآلي',
'memoryTree.status.autoSyncDescription': 'توقف عن الابتلاع (ويكي) الحالي يبقى قابلاً للتساؤل',
+ 'memoryTree.status.cloudSummarizationLabel': 'التلخيص السحابي',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'يتيح إنشاء أشجار التلخيص دون ذكاء اصطناعي محلي. تُرسَل ملخصات الذاكرة إلى مزوّد السحابة الذي أعددته.',
+ 'memoryTree.status.cloudSummarizationToggleFailed': 'تعذّر تغيير التلخيص السحابي',
'memoryTree.status.statusTile': 'الحالة',
'memoryTree.status.lastSyncTile': 'آخر تزامن',
'memoryTree.status.totalChunksTile': 'أوراق شجرة الملخصات',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 6ae2a65470..182e04068d 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -952,6 +952,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'স্বয়ংক্রিয়-sync',
'memoryTree.status.autoSyncDescription':
'নতুন অভিযান থামাতে বিরতি দাও। এখানে বিদ্যমান উইকি অনুসন্ধানের জন্য অপেক্ষা করছে।',
+ 'memoryTree.status.cloudSummarizationLabel': 'ক্লাউড সারাংশ',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'স্থানীয় AI ছাড়াই সারাংশ ট্রি তৈরি করতে দেয়। মেমরি সারাংশ আপনার কনফিগার করা ক্লাউড প্রদানকারীর কাছে পাঠানো হয়।',
+ 'memoryTree.status.cloudSummarizationToggleFailed': 'ক্লাউড সারাংশ পরিবর্তন করা যায়নি',
'memoryTree.status.statusTile': 'অবস্থা',
'memoryTree.status.lastSyncTile': 'সর্বশেষ সুসংগতি',
'memoryTree.status.totalChunksTile': 'সারাংশ-বৃক্ষের পাতা',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 8f5fafdc15..38002b378a 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -986,6 +986,11 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Auto Sync',
'memoryTree.status.autoSyncDescription':
'Pausieren Sie, um die erneute Einnahme zu stoppen. Vorhandenes Wiki bleibt abfragbar.',
+ 'memoryTree.status.cloudSummarizationLabel': 'Cloud-Zusammenfassung',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'Ermöglicht Zusammenfassungsbäume ohne lokale KI. Speicher-Zusammenfassungen werden an Ihren konfigurierten Cloud-Anbieter gesendet.',
+ 'memoryTree.status.cloudSummarizationToggleFailed':
+ 'Cloud-Zusammenfassung konnte nicht geändert werden',
'memoryTree.status.statusTile': 'Status',
'memoryTree.status.lastSyncTile': 'Letzte Synchronisierung',
'memoryTree.status.totalChunksTile': 'Blätter des Zusammenfassungsbaums',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 8e816cab95..654acfb48e 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -909,6 +909,10 @@ const en: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Auto-sync',
'memoryTree.status.autoSyncDescription':
'Pause to stop new ingestion. Existing wiki stays queryable.',
+ 'memoryTree.status.cloudSummarizationLabel': 'Cloud summarization',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'Lets summary trees build without local AI. Memory summaries are sent to your configured cloud provider.',
+ 'memoryTree.status.cloudSummarizationToggleFailed': "Couldn't change cloud summarization",
'memoryTree.status.statusTile': 'Status',
'memoryTree.status.lastSyncTile': 'Last sync',
'memoryTree.status.totalChunksTile': 'Summary-tree leaves',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 13a9bb57c8..030ad6bb3d 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -973,6 +973,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Auto-sincronización',
'memoryTree.status.autoSyncDescription':
'Pausa para detener la nueva ingestión. El wiki existente sigue siendo consultable.',
+ 'memoryTree.status.cloudSummarizationLabel': 'Resumen en la nube',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'Permite crear árboles de resumen sin IA local. Los resúmenes de memoria se envían a tu proveedor de nube configurado.',
+ 'memoryTree.status.cloudSummarizationToggleFailed': 'No se pudo cambiar el resumen en la nube',
'memoryTree.status.statusTile': 'Estado',
'memoryTree.status.lastSyncTile': 'Última sincronización',
'memoryTree.status.totalChunksTile': 'Hojas del árbol de resúmenes',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 557ff3bdbe..72ac214be6 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -985,6 +985,11 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Auto-synchronisation',
'memoryTree.status.autoSyncDescription':
'Pause pour arrêter la nouvelle ingestion. Le wiki existant reste consultable.',
+ 'memoryTree.status.cloudSummarizationLabel': 'Résumé dans le cloud',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'Permet de créer des arbres de résumé sans IA locale. Les résumés de mémoire sont envoyés à votre fournisseur cloud configuré.',
+ 'memoryTree.status.cloudSummarizationToggleFailed':
+ 'Impossible de modifier le résumé dans le cloud',
'memoryTree.status.statusTile': 'Statut',
'memoryTree.status.lastSyncTile': 'Dernière synchronisation',
'memoryTree.status.totalChunksTile': "Feuilles de l'arbre de synthèse",
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index f2cb6c40f2..11bd853a88 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -951,6 +951,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'ऑटो सिंक',
'memoryTree.status.autoSyncDescription':
'नए अंतर्ग्रहण को रोकने के लिए रोकें। मौजूदा विकि क्वेरी योग्य रहता है।',
+ 'memoryTree.status.cloudSummarizationLabel': 'क्लाउड सारांश',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'स्थानीय AI के बिना सारांश ट्री बनाने देता है। मेमोरी सारांश आपके कॉन्फ़िगर किए गए क्लाउड प्रदाता को भेजे जाते हैं।',
+ 'memoryTree.status.cloudSummarizationToggleFailed': 'क्लाउड सारांश नहीं बदला जा सका',
'memoryTree.status.statusTile': 'स्थिति',
'memoryTree.status.lastSyncTile': 'अंतिम सिंक',
'memoryTree.status.totalChunksTile': 'सारांश-वृक्ष की पत्तियाँ',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 6a29e24434..b56d5398b4 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -962,6 +962,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Sinkronisasi otomatis',
'memoryTree.status.autoSyncDescription':
'Jeda untuk menghentikan ingest baru. Wiki yang ada tetap dapat dikueri.',
+ 'memoryTree.status.cloudSummarizationLabel': 'Ringkasan cloud',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'Memungkinkan pohon ringkasan dibuat tanpa AI lokal. Ringkasan memori dikirim ke penyedia cloud yang Anda konfigurasikan.',
+ 'memoryTree.status.cloudSummarizationToggleFailed': 'Tidak dapat mengubah ringkasan cloud',
'memoryTree.status.statusTile': 'Status',
'memoryTree.status.lastSyncTile': 'Sinkronisasi terakhir',
'memoryTree.status.totalChunksTile': 'Daun pohon ringkasan',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index 3f9a9e9487..3d2b612e3b 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -977,6 +977,11 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Sincronizzazione automatica',
'memoryTree.status.autoSyncDescription':
'Pausa per interrompere la nuova ingestione. La wiki esistente rimane interrogabile.',
+ 'memoryTree.status.cloudSummarizationLabel': 'Riepilogo nel cloud',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'Consente di creare alberi di riepilogo senza IA locale. I riepiloghi della memoria vengono inviati al provider cloud configurato.',
+ 'memoryTree.status.cloudSummarizationToggleFailed':
+ 'Impossibile modificare il riepilogo nel cloud',
'memoryTree.status.statusTile': 'Stato',
'memoryTree.status.lastSyncTile': 'Ultima sincronizzazione',
'memoryTree.status.totalChunksTile': "Foglie dell'albero dei riassunti",
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 2a8e01ce65..558e156157 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -941,6 +941,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': '자동 동기화',
'memoryTree.status.autoSyncDescription':
'새 수집을 중지하려면 일시 중지하세요. 기존 위키는 계속 쿼리할 수 있습니다.',
+ 'memoryTree.status.cloudSummarizationLabel': '클라우드 요약',
+ 'memoryTree.status.cloudSummarizationDescription':
+ '로컬 AI 없이도 요약 트리를 만듭니다. 메모리 요약이 설정된 클라우드 제공자로 전송됩니다.',
+ 'memoryTree.status.cloudSummarizationToggleFailed': '클라우드 요약 설정을 변경할 수 없습니다.',
'memoryTree.status.statusTile': '상태',
'memoryTree.status.lastSyncTile': '마지막 동기화',
'memoryTree.status.totalChunksTile': '요약 트리 리프',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 9dfb955250..09b4e445c6 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -967,6 +967,11 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Automatyczna synchronizacja',
'memoryTree.status.autoSyncDescription':
'Wstrzymaj, aby zatrzymać nowe pobieranie. Istniejąca wiki pozostanie dostępna do zapytań.',
+ 'memoryTree.status.cloudSummarizationLabel': 'Podsumowanie w chmurze',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'Pozwala tworzyć drzewa podsumowań bez lokalnej AI. Podsumowania pamięci są wysyłane do skonfigurowanego dostawcy chmury.',
+ 'memoryTree.status.cloudSummarizationToggleFailed':
+ 'Nie udało się zmienić podsumowania w chmurze',
'memoryTree.status.statusTile': 'Stan',
'memoryTree.status.lastSyncTile': 'Ostatnia synchronizacja',
'memoryTree.status.totalChunksTile': 'Liście drzewa podsumowań',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 968177ecbc..8d1686e068 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -972,6 +972,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Auto-sincronização',
'memoryTree.status.autoSyncDescription':
'Pausa para parar a nova ingestão. O wiki existente permanece consultável.',
+ 'memoryTree.status.cloudSummarizationLabel': 'Resumo na nuvem',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'Permite criar árvores de resumo sem IA local. Os resumos de memória são enviados ao provedor de nuvem configurado.',
+ 'memoryTree.status.cloudSummarizationToggleFailed': 'Não foi possível alterar o resumo na nuvem',
'memoryTree.status.statusTile': 'Status',
'memoryTree.status.lastSyncTile': 'Última sincronização',
'memoryTree.status.totalChunksTile': 'Folhas da árvore de resumos',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index a93515fe9b..2b4910f61d 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -961,6 +961,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Автосинхронизация',
'memoryTree.status.autoSyncDescription':
'Пауза, чтобы остановить новый прием. Существующая вики остается доступной для запросов.',
+ 'memoryTree.status.cloudSummarizationLabel': 'Облачное резюмирование',
+ 'memoryTree.status.cloudSummarizationDescription':
+ 'Позволяет строить деревья сводок без локального ИИ. Сводки памяти отправляются вашему настроенному облачному провайдеру.',
+ 'memoryTree.status.cloudSummarizationToggleFailed': 'Не удалось изменить облачное резюмирование',
'memoryTree.status.statusTile': 'Статус',
'memoryTree.status.lastSyncTile': 'Последняя синхронизация',
'memoryTree.status.totalChunksTile': 'Листья дерева сводок',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index ed2129d007..fac87bb1d7 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -893,6 +893,10 @@ const messages: TranslationMap = {
'memoryTree.status.title': '记忆树',
'memoryTree.status.autoSyncLabel': '自动同步',
'memoryTree.status.autoSyncDescription': '暂停后将停止新的摄取。现有 wiki 仍可查询。',
+ 'memoryTree.status.cloudSummarizationLabel': '云端摘要',
+ 'memoryTree.status.cloudSummarizationDescription':
+ '无需本地 AI 也能构建摘要树。记忆摘要会发送到你配置的云提供方。',
+ 'memoryTree.status.cloudSummarizationToggleFailed': '无法更改云端摘要',
'memoryTree.status.statusTile': '状态',
'memoryTree.status.lastSyncTile': '上次同步',
'memoryTree.status.totalChunksTile': '摘要树叶子节点',
diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts
index b00c9b1c0d..2ac0ea2ef6 100644
--- a/app/src/services/analytics.ts
+++ b/app/src/services/analytics.ts
@@ -106,6 +106,7 @@ const ALLOWED_EVENT_NAMES = [
'automation_run_cancelled',
'memory_repair_succeeded',
'memory_tree_retry_succeeded',
+ 'memory_tree_cloud_summarization_changed',
'skill_install',
'skill_uninstall',
'tab_bar_change',
diff --git a/app/src/utils/tauriCommands/memoryTree.test.ts b/app/src/utils/tauriCommands/memoryTree.test.ts
index a7b6a0300c..7c7c2fd3b1 100644
--- a/app/src/utils/tauriCommands/memoryTree.test.ts
+++ b/app/src/utils/tauriCommands/memoryTree.test.ts
@@ -25,6 +25,7 @@ import {
memoryTreeResetTree,
memoryTreeRetryFailed,
memoryTreeSearch,
+ memoryTreeSetCloudSummarization,
memoryTreeSetLlm,
memoryTreeTopEntities,
memoryTreeWipeAll,
@@ -574,3 +575,43 @@ describe('memoryTreeBackfillConnectorTrees', () => {
expect(out.more_pending).toBe(true);
});
});
+
+describe('memoryTreeSetCloudSummarization', () => {
+ test('patches only the consent field, so sibling memory settings survive', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ result: {}, logs: [] });
+
+ await memoryTreeSetCloudSummarization(true);
+
+ // The method is the shared memory-settings mutator, and it applies only the
+ // fields it is given. Sending the whole settings object here would let this
+ // toggle silently rewrite the embedder or the memory window, so the params
+ // are asserted exactly rather than with `objectContaining`.
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.config_update_memory_settings',
+ params: { cloud_summarization_opt_in: true },
+ });
+ });
+
+ test('sends an explicit false rather than omitting the field', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ result: {}, logs: [] });
+
+ await memoryTreeSetCloudSummarization(false);
+
+ // Withdrawal has to travel as `false`. An omitted field means "leave this
+ // alone" to the core, so dropping it would make the off position of the
+ // toggle a no-op — consent that cannot be taken back.
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.config_update_memory_settings',
+ params: { cloud_summarization_opt_in: false },
+ });
+ });
+
+ test('propagates a transport failure instead of resolving', async () => {
+ mockCallCoreRpc.mockRejectedValueOnce(new Error('core unreachable'));
+
+ // The caller renders an error toast and re-reads the stored value off the
+ // next status poll. Swallowing the rejection here would leave the switch
+ // showing a consent state the core never recorded.
+ await expect(memoryTreeSetCloudSummarization(true)).rejects.toThrow('core unreachable');
+ });
+});
diff --git a/app/src/utils/tauriCommands/memoryTree.ts b/app/src/utils/tauriCommands/memoryTree.ts
index 3393468e31..4e87233d18 100644
--- a/app/src/utils/tauriCommands/memoryTree.ts
+++ b/app/src/utils/tauriCommands/memoryTree.ts
@@ -948,6 +948,14 @@ export interface MemoryTreePipelineStatus {
* not connected when it happened. Absent when nothing was quarantined.
*/
quarantine?: MemoryTreeQuarantine | null;
+ /**
+ * Current `memory_tree.cloud_summarization_opt_in`. The panel owns the
+ * toggle for it — the remediation for `summarizer_unavailable` names this
+ * flag — so the control must render the stored value rather than a default.
+ * Optional for back-compat with cores that predate the field; absent ⇒
+ * treat as not opted in.
+ */
+ cloud_summarization_opt_in?: boolean;
}
/** A corrupt-store quarantine as `memory_tree_pipeline_status` reports it. */
@@ -1053,6 +1061,26 @@ export async function memoryTreeSetEnabled(
return out;
}
+/**
+ * Set `memory_tree.cloud_summarization_opt_in`.
+ *
+ * Consent to summarize workspace memory through the configured cloud provider
+ * when local AI is off. Without it (and without local AI) "Build Summary
+ * Trees" has no summarizer, the tree stops growing, and the memory-health
+ * panel reports `summarizer_unavailable` — whose remediation names this flag.
+ *
+ * Backed by `openhuman.config_update_memory_settings`, which patches only the
+ * fields it is given, so the other memory settings are untouched.
+ */
+export async function memoryTreeSetCloudSummarization(optIn: boolean): Promise {
+ console.debug('[memory-tree-rpc] memoryTreeSetCloudSummarization: entry opt_in=%s', optIn);
+ await callCoreRpc({
+ method: 'openhuman.config_update_memory_settings',
+ params: { cloud_summarization_opt_in: optIn },
+ });
+ console.debug('[memory-tree-rpc] memoryTreeSetCloudSummarization: exit opt_in=%s', optIn);
+}
+
// ── Sync Audit Log ─────────────────────────────────────────────────
export interface SyncAuditEntry {
diff --git a/src/openhuman/config/ops/model.rs b/src/openhuman/config/ops/model.rs
index 41089503cf..c55605dd62 100644
--- a/src/openhuman/config/ops/model.rs
+++ b/src/openhuman/config/ops/model.rs
@@ -58,6 +58,14 @@ pub struct MemorySettingsPatch {
/// Unknown values are silently ignored so old clients can keep
/// posting partial patches.
pub memory_window: Option,
+ /// Consent to summarize workspace memory through the configured cloud
+ /// provider when local AI is off (`memory_tree.cloud_summarization_opt_in`).
+ ///
+ /// Exposed here because the memory-health remediation names this flag as
+ /// the fix for `summarizer_unavailable`, and until now nothing in the app
+ /// could set it — the only routes were an env var or hand-editing
+ /// `config.toml` on the host.
+ pub cloud_summarization_opt_in: Option,
}
#[derive(Debug, Clone, Default)]
@@ -329,6 +337,9 @@ pub async fn apply_memory_settings(
if let Some(dimensions) = update.embedding_dimensions {
config.memory.embedding_dimensions = dimensions;
}
+ if let Some(opt_in) = update.cloud_summarization_opt_in {
+ config.memory_tree.cloud_summarization_opt_in = opt_in;
+ }
if let Some(window_label) = update.memory_window.as_deref() {
if let Some(window) =
crate::openhuman::config::schema::MemoryContextWindow::from_str_opt(window_label)
diff --git a/src/openhuman/config/ops_tests_part_02_tests.rs b/src/openhuman/config/ops_tests_part_02_tests.rs
index 535d360726..2f8d514843 100644
--- a/src/openhuman/config/ops_tests_part_02_tests.rs
+++ b/src/openhuman/config/ops_tests_part_02_tests.rs
@@ -326,9 +326,11 @@ async fn apply_memory_settings_updates_all_provided_fields() {
embedding_model: Some("nomic".into()),
embedding_dimensions: Some(768),
memory_window: Some("extended".into()),
+ cloud_summarization_opt_in: Some(true),
};
let _ = apply_memory_settings(&mut cfg, patch).await.expect("apply");
assert_eq!(cfg.memory.backend, "sqlite");
+ assert!(cfg.memory_tree.cloud_summarization_opt_in);
assert!(cfg.memory.auto_save);
assert_eq!(cfg.memory.embedding_provider, "ollama");
assert_eq!(cfg.memory.embedding_model, "nomic");
@@ -639,3 +641,50 @@ async fn apply_analytics_settings_updates_enabled() {
.expect("apply");
assert!(!cfg.observability.analytics_enabled);
}
+
+/// The consent flag has to be settable *off* as well as on. A patch that only
+/// ever turned it on would leave the user unable to withdraw consent from the
+/// same control that granted it — which is the whole point of an opt-in.
+#[tokio::test]
+async fn apply_memory_settings_can_withdraw_cloud_summarization_consent() {
+ let tmp = tempdir().unwrap();
+ let mut cfg = tmp_config(&tmp);
+ cfg.memory_tree.cloud_summarization_opt_in = true;
+
+ let _ = apply_memory_settings(
+ &mut cfg,
+ MemorySettingsPatch {
+ cloud_summarization_opt_in: Some(false),
+ ..MemorySettingsPatch::default()
+ },
+ )
+ .await
+ .expect("apply");
+
+ assert!(!cfg.memory_tree.cloud_summarization_opt_in);
+}
+
+/// An absent field must not be read as `false`. Older clients post partial
+/// patches, and one that omits this must not silently revoke consent the user
+/// granted elsewhere.
+#[tokio::test]
+async fn apply_memory_settings_leaves_cloud_summarization_alone_when_absent() {
+ let tmp = tempdir().unwrap();
+ let mut cfg = tmp_config(&tmp);
+ cfg.memory_tree.cloud_summarization_opt_in = true;
+
+ let _ = apply_memory_settings(
+ &mut cfg,
+ MemorySettingsPatch {
+ backend: Some("sqlite".into()),
+ ..MemorySettingsPatch::default()
+ },
+ )
+ .await
+ .expect("apply");
+
+ assert!(
+ cfg.memory_tree.cloud_summarization_opt_in,
+ "a patch that does not mention the flag must not clear it"
+ );
+}
diff --git a/src/openhuman/config/schemas/controllers_part_01.rs b/src/openhuman/config/schemas/controllers_part_01.rs
index b26c516357..ccfcafbcdc 100644
--- a/src/openhuman/config/schemas/controllers_part_01.rs
+++ b/src/openhuman/config/schemas/controllers_part_01.rs
@@ -384,6 +384,7 @@ fn handle_update_memory_settings(params: Map) -> ControllerFuture
embedding_model: update.embedding_model,
embedding_dimensions: update.embedding_dimensions,
memory_window: update.memory_window,
+ cloud_summarization_opt_in: update.cloud_summarization_opt_in,
};
to_json(config_rpc::load_and_apply_memory_settings(patch).await?)
})
diff --git a/src/openhuman/config/schemas/helpers.rs b/src/openhuman/config/schemas/helpers.rs
index 301b73cc80..e361b8b43f 100644
--- a/src/openhuman/config/schemas/helpers.rs
+++ b/src/openhuman/config/schemas/helpers.rs
@@ -83,6 +83,9 @@ pub(super) struct MemorySettingsUpdate {
pub(super) embedding_dimensions: Option,
/// One of `"minimal" | "balanced" | "extended" | "maximum"`.
pub(super) memory_window: Option,
+ /// `memory_tree.cloud_summarization_opt_in` — consent to summarize
+ /// workspace memory through the configured cloud provider.
+ pub(super) cloud_summarization_opt_in: Option,
}
#[derive(Debug, Deserialize)]
diff --git a/src/openhuman/config/schemas/schemas_schema_part_01.rs b/src/openhuman/config/schemas/schemas_schema_part_01.rs
index 52e150bcc2..a29d24258d 100644
--- a/src/openhuman/config/schemas/schemas_schema_part_01.rs
+++ b/src/openhuman/config/schemas/schemas_schema_part_01.rs
@@ -123,6 +123,12 @@ pub(super) fn lookup(function: &str) -> Option {
"memory_window",
"Stepped long-term memory window preset: minimal | balanced | extended | maximum.",
),
+ FieldSchema {
+ name: "cloud_summarization_opt_in",
+ ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
+ comment: "Summarize workspace memory through the configured cloud provider when local AI is off.",
+ required: false,
+ },
],
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
}),
diff --git a/src/openhuman/memory/tree/tree/rpc_part_02.rs b/src/openhuman/memory/tree/tree/rpc_part_02.rs
index e59322c66f..fa8196d307 100644
--- a/src/openhuman/memory/tree/tree/rpc_part_02.rs
+++ b/src/openhuman/memory/tree/tree/rpc_part_02.rs
@@ -224,6 +224,13 @@ pub struct PipelineStatusResponse {
/// Reported until the rebuilt store holds a chunk again (`resynced`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quarantine: Option,
+ /// Current `memory_tree.cloud_summarization_opt_in`. The status panel owns
+ /// the toggle for it (the remediation for `summarizer_unavailable` names
+ /// this flag), and a toggle that renders a default instead of the stored
+ /// value would misreport the machine's consent state. Additive
+ /// (`#[serde(default)]` → `false` for older clients).
+ #[serde(default)]
+ pub cloud_summarization_opt_in: bool,
}
/// `memory_tree_pipeline_status` RPC handler (#1856 Part 1).
@@ -439,6 +446,7 @@ pub async fn pipeline_status_rpc(
degraded,
first_blocking_cause,
extraction_coverage,
+ cloud_summarization_opt_in: config.memory_tree.cloud_summarization_opt_in,
quarantine,
};
diff --git a/src/openhuman/modules/memory_host.rs b/src/openhuman/modules/memory_host.rs
index 7470fa3b0c..fd7f215c11 100644
--- a/src/openhuman/modules/memory_host.rs
+++ b/src/openhuman/modules/memory_host.rs
@@ -89,11 +89,77 @@ impl ChatCallbacks {
role: String,
request: ModelRequest,
) -> tinybus::Result {
- let model = resolve_chat_model(&role, &self.0).map_err(method_error)?;
+ let config = self.config_for_role(&role).await?;
+ let model = resolve_chat_model(&role, config.as_ref()).map_err(method_error)?;
model.invoke(&(), request).await.map_err(method_error)
}
}
+impl ChatCallbacks {
+ /// The config a role's route should be resolved against.
+ ///
+ /// `ChatCallbacks` is built **once**, at module-serve time, from an
+ /// `Arc` that is never refreshed. For most roles that is only a
+ /// staleness wart. For `"summarization"` it is a consent bug: the ladder in
+ /// [`resolve_chat_model`] refuses the cloud route unless
+ /// `memory_tree.cloud_summarization_opt_in` is set, and reading that flag
+ /// off the boot snapshot means a user who *withdraws* consent keeps having
+ /// workspace memory summarised through their cloud provider until the core
+ /// restarts. The withdrawal is durable on disk and ignored at the point of
+ /// use, which is the worst shape a consent control can have.
+ ///
+ /// So the summarization role — and only it — re-reads from disk, the way
+ /// [`ComposioCallbacks::live_config`] already does in this file. Every
+ /// other role keeps the snapshot: they carry no consent decision, and
+ /// re-reading for all of them would put a file read in front of every
+ /// module-side model call.
+ ///
+ /// # Why a read failure blocks only the cloud route
+ ///
+ /// A config that cannot be read is not evidence that consent still holds,
+ /// so continuing on the snapshot could route memory out on a permission
+ /// the user has since revoked. But refusing outright would break
+ /// summarization for local-AI users, who never owed consent at all and
+ /// whose route the ladder resolves without consulting the flag.
+ ///
+ /// So a failed re-read refuses **only** when the held snapshot says the
+ /// route would be the cloud one (local AI off and the opt-in granted) —
+ /// exactly the case where the answer might have changed to "no". Local AI
+ /// on, or consent never granted, falls through to the snapshot and lets
+ /// the ladder decide as it always has.
+ async fn config_for_role(&self, role: &str) -> tinybus::Result> {
+ if role != "summarization" {
+ return Ok(Arc::clone(&self.0));
+ }
+ match crate::openhuman::config::rpc::reload_config_snapshot_with_timeout(&self.0).await {
+ Ok(fresh) => {
+ log::debug!(
+ "[memory_tree::summarise] consent re-read role={role} local_ai={} opted_in={}",
+ fresh.local_ai.runtime_enabled,
+ fresh.memory_tree.cloud_summarization_opt_in
+ );
+ Ok(Arc::new(fresh))
+ }
+ Err(error) => {
+ let would_route_to_cloud = !self.0.local_ai.runtime_enabled
+ && self.0.memory_tree.cloud_summarization_opt_in;
+ log::warn!(
+ "[memory_tree::summarise] consent re-read failed role={role} \
+ snapshot_routes_to_cloud={would_route_to_cloud}: {error}"
+ );
+ if would_route_to_cloud {
+ return Err(method_error(
+ "cloud summarization refused: the consent setting \
+ (memory_tree.cloud_summarization_opt_in) could not be re-read, and a \
+ stale grant is not consent",
+ ));
+ }
+ Ok(Arc::clone(&self.0))
+ }
+ }
+ }
+}
+
/// Resolve the model a module-side chat call runs on, by role.
///
/// The `"summarization"` role is special-cased through the tree summarizer's
diff --git a/src/openhuman/modules/memory_host_tests.rs b/src/openhuman/modules/memory_host_tests.rs
index c01b9abf23..f702af145e 100644
--- a/src/openhuman/modules/memory_host_tests.rs
+++ b/src/openhuman/modules/memory_host_tests.rs
@@ -17,8 +17,8 @@
//! can be honest, in `tinymemory`'s own module E2E against a real module.
use super::{
- serve_interfaces, ComposioCallbacks, EmbeddingCallbacks, CHAT_NAME, CHAT_PATH, COMPOSIO_NAME,
- COMPOSIO_PATH, EMBEDDING_NAME, EMBEDDING_PATH, RUNTIME_NAME, RUNTIME_PATH,
+ serve_interfaces, ChatCallbacks, ComposioCallbacks, EmbeddingCallbacks, CHAT_NAME, CHAT_PATH,
+ COMPOSIO_NAME, COMPOSIO_PATH, EMBEDDING_NAME, EMBEDDING_PATH, RUNTIME_NAME, RUNTIME_PATH,
};
use crate::openhuman::config::Config;
use crate::openhuman::integrations::composio::client::create_composio_client;
@@ -393,3 +393,114 @@ fn non_summarization_roles_keep_the_role_factory() {
super::resolve_chat_model("chat", &config)
.expect("a non-summarization role resolves through the factory (test override)");
}
+
+/// Write a `config.toml` under `dir` carrying just the consent flag, so a
+/// re-read has something on disk that can disagree with the held snapshot.
+fn write_consent_config(dir: &Path, opt_in: bool) {
+ std::fs::create_dir_all(dir).expect("config dir");
+ std::fs::write(
+ dir.join("config.toml"),
+ format!("[memory_tree]\ncloud_summarization_opt_in = {opt_in}\n"),
+ )
+ .expect("write config");
+}
+
+/// The consent flag is read at the point of use, not at boot.
+///
+/// `ChatCallbacks` holds one `Arc` for the life of the process, so
+/// without this a user who withdraws consent keeps having workspace memory
+/// summarised through their cloud provider until the core restarts — the
+/// withdrawal durable on disk and ignored where it matters.
+#[tokio::test]
+async fn summarization_role_re_reads_withdrawn_consent_from_disk() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ write_consent_config(dir.path(), false);
+
+ // The boot snapshot still carries the grant the user has since withdrawn.
+ let mut snapshot = Config::default();
+ snapshot.workspace_dir = dir.path().join("workspace");
+ snapshot.config_path = dir.path().join("config.toml");
+ snapshot.memory_tree.cloud_summarization_opt_in = true;
+
+ let callbacks = ChatCallbacks(Arc::new(snapshot));
+ let fresh = callbacks
+ .config_for_role("summarization")
+ .await
+ .expect("a readable config must resolve");
+
+ assert!(
+ !fresh.memory_tree.cloud_summarization_opt_in,
+ "the withdrawal on disk must win over the boot snapshot"
+ );
+}
+
+/// Every other role keeps the snapshot. They carry no consent decision, and
+/// re-reading for all of them would put a file read in front of every
+/// module-side model call.
+#[tokio::test]
+async fn other_roles_keep_the_boot_snapshot() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ write_consent_config(dir.path(), false);
+
+ let mut snapshot = Config::default();
+ snapshot.workspace_dir = dir.path().join("workspace");
+ snapshot.config_path = dir.path().join("config.toml");
+ snapshot.memory_tree.cloud_summarization_opt_in = true;
+ let held = Arc::new(snapshot);
+
+ let callbacks = ChatCallbacks(Arc::clone(&held));
+ let resolved = callbacks.config_for_role("chat").await.expect("no refusal");
+
+ assert!(
+ Arc::ptr_eq(&held, &resolved),
+ "a non-summarization role must not pay for a re-read"
+ );
+}
+
+/// A config that cannot be read is not evidence that consent still holds, so
+/// the cloud route is refused rather than taken on a possibly-stale grant.
+#[tokio::test]
+async fn an_unreadable_config_refuses_the_cloud_route() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ // A *directory* at the config path, not a missing file: `load_from_config_path`
+ // treats absence as "fresh install" and hands back defaults, which is a
+ // successful read and correctly not a refusal. Corruption is the case that
+ // actually errors, and it is the one where a held grant cannot be confirmed.
+ let config_path = dir.path().join("config.toml");
+ std::fs::create_dir_all(&config_path).expect("directory at the config path");
+ let mut snapshot = Config::default();
+ snapshot.workspace_dir = dir.path().join("workspace");
+ snapshot.config_path = config_path;
+ snapshot.local_ai.runtime_enabled = false;
+ snapshot.memory_tree.cloud_summarization_opt_in = true;
+
+ let callbacks = ChatCallbacks(Arc::new(snapshot));
+ match callbacks.config_for_role("summarization").await {
+ Err(error) => assert!(
+ error.to_string().contains("cloud_summarization_opt_in"),
+ "the refusal must name the setting it could not confirm: {error}"
+ ),
+ Ok(_) => panic!("an unconfirmable grant must not route memory to the cloud"),
+ }
+}
+
+/// ...but only the cloud route. A local-AI user never owed consent, and the
+/// ladder resolves their route without consulting the flag, so a transient
+/// read failure must not break their summarization.
+#[tokio::test]
+async fn an_unreadable_config_leaves_the_local_route_alone() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let config_path = dir.path().join("config.toml");
+ std::fs::create_dir_all(&config_path).expect("directory at the config path");
+ let mut snapshot = Config::default();
+ snapshot.workspace_dir = dir.path().join("workspace");
+ snapshot.config_path = config_path;
+ snapshot.local_ai.runtime_enabled = true;
+ snapshot.memory_tree.cloud_summarization_opt_in = false;
+
+ let callbacks = ChatCallbacks(Arc::new(snapshot));
+ assert!(
+ callbacks.config_for_role("summarization").await.is_ok(),
+ "local AI needs no consent, so a failed re-read must not refuse it"
+ );
+}