From 9feaaaff294c25cd586041d021710be8958b4d5d Mon Sep 17 00:00:00 2001 From: Daniele Date: Mon, 24 Aug 2026 21:47:36 +0100 Subject: [PATCH] feat(plugin-honcho): show what Honcho remembers about you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-in page gains a debug panel, once the user's saved flag is on: service status (reachability, latency, the caller's own processing queue, with the specific Honcho error when something is wrong), a full overview (peer card, derived facts with ids, summary) and one text field with two actions — search (raw ranked facts) and ask (Honcho's server-side LLM answers), plus an in-page mini-guide. Every endpoint gates on the per-user opt-in server-side, fail closed, and derives the peer from the authenticated Caller — never from the request body — since the workspace is shared. Honcho 404s are translated per-endpoint as 'no memory yet' rather than failures. --- CHANGELOG.md | 7 + crates/plugin-honcho/i18n/en.json | 7 +- crates/plugin-honcho/i18n/fr.json | 7 +- crates/plugin-honcho/i18n/it.json | 7 +- crates/plugin-honcho/src/lib.rs | 15 +- crates/plugin-honcho/src/router.rs | 382 +++++++++++++++++++++++++++-- crates/plugin-honcho/web/i18n.js | 81 +++++- crates/plugin-honcho/web/memory.js | 278 +++++++++++++++++++-- dev-docs/plugins.md | 2 + docs/plugins/honcho.md | 12 + 10 files changed, 749 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0955c00..bc614cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ release PR may merge — and a section is closed at the commit that bumps it. ### Added +- The **Long-term memory** page (Honcho plugin) now shows, once you have opted in, what + Honcho actually remembers about you: a service-status line (connected/unreachable with + the specific error, and your memory's processing queue), a full overview (your card, + derived facts, summary) and a search-or-ask box — *search* returns the raw stored facts + matching your words, *ask* has Honcho's AI answer a question in its own words. A + built-in mini-guide explains the difference. Errors say what went wrong (unreachable + host, rejected key, server error), not just "unavailable". - The assistant can now explain the **file viewer**, the **Tasks page**, your **Profile** and the admin's **Users** page: ask it what a document's history button does, why a `.tex` is shown instead of a PDF, how to stop a recurring job without losing it, what a diff --git a/crates/plugin-honcho/i18n/en.json b/crates/plugin-honcho/i18n/en.json index 58c16c1..1d8df77 100644 --- a/crates/plugin-honcho/i18n/en.json +++ b/crates/plugin-honcho/i18n/en.json @@ -1,5 +1,10 @@ { "plugin.honcho.err.admin_only": "Admin only.", "plugin.honcho.err.base_url_empty": "Enter the Honcho server URL first.", - "plugin.honcho.err.test_failed": "Could not reach Honcho: {detail}" + "plugin.honcho.err.test_failed": "Could not reach Honcho: {detail}", + "plugin.honcho.err.not_opted_in": "Long-term memory is off for your account — turn it on above before using this.", + "plugin.honcho.err.query_required": "Enter some text first.", + "plugin.honcho.err.honcho_unreachable": "Cannot reach the Honcho server: {detail}", + "plugin.honcho.err.honcho_error": "Honcho returned an error (HTTP {status}): {detail}", + "plugin.honcho.err.no_data": "Honcho has no memory about you yet — it builds up as you chat." } diff --git a/crates/plugin-honcho/i18n/fr.json b/crates/plugin-honcho/i18n/fr.json index 67f0599..24a5ab0 100644 --- a/crates/plugin-honcho/i18n/fr.json +++ b/crates/plugin-honcho/i18n/fr.json @@ -1,5 +1,10 @@ { "plugin.honcho.err.admin_only": "Administrateur uniquement.", "plugin.honcho.err.base_url_empty": "Saisissez d'abord l'URL du serveur Honcho.", - "plugin.honcho.err.test_failed": "Impossible de joindre Honcho : {detail}" + "plugin.honcho.err.test_failed": "Impossible de joindre Honcho : {detail}", + "plugin.honcho.err.not_opted_in": "La mémoire à long terme est désactivée pour votre compte — activez-la ci-dessus avant de l'utiliser.", + "plugin.honcho.err.query_required": "Saisissez d'abord un texte.", + "plugin.honcho.err.honcho_unreachable": "Impossible de contacter le serveur Honcho : {detail}", + "plugin.honcho.err.honcho_error": "Honcho a renvoyé une erreur (HTTP {status}) : {detail}", + "plugin.honcho.err.no_data": "Honcho n'a pas encore de mémoire vous concernant — elle se construit au fil des conversations." } diff --git a/crates/plugin-honcho/i18n/it.json b/crates/plugin-honcho/i18n/it.json index 771c248..6a93f63 100644 --- a/crates/plugin-honcho/i18n/it.json +++ b/crates/plugin-honcho/i18n/it.json @@ -1,5 +1,10 @@ { "plugin.honcho.err.admin_only": "Solo amministratore.", "plugin.honcho.err.base_url_empty": "Inserisci prima l'URL del server Honcho.", - "plugin.honcho.err.test_failed": "Impossibile raggiungere Honcho: {detail}" + "plugin.honcho.err.test_failed": "Impossibile raggiungere Honcho: {detail}", + "plugin.honcho.err.not_opted_in": "La memoria a lungo termine è spenta per il tuo account — attivala qui sopra prima di usarla.", + "plugin.honcho.err.query_required": "Inserisci prima un testo.", + "plugin.honcho.err.honcho_unreachable": "Impossibile contattare il server Honcho: {detail}", + "plugin.honcho.err.honcho_error": "Honcho ha restituito un errore (HTTP {status}): {detail}", + "plugin.honcho.err.no_data": "Honcho non ha ancora nessun ricordo di te — si costruisce chiacchierando." } diff --git a/crates/plugin-honcho/src/lib.rs b/crates/plugin-honcho/src/lib.rs index e8c8f2c..b3d42e6 100644 --- a/crates/plugin-honcho/src/lib.rs +++ b/crates/plugin-honcho/src/lib.rs @@ -764,10 +764,11 @@ pub struct HonchoPlugin { handle: Mutex>>, /// Shared Memory implementation — created once, updated on start/stop. honcho_memory: Arc, - /// Deps the HTTP router (config/opt-in pages + `POST /admin/test`) needs at - /// request time. Handed to the router once at boot as a shared cell; `start` - /// fills it and `stop` clears it, so handlers resolve the current wiring and - /// answer 503 while the plugin is enabled but not running. + /// Deps the HTTP router (config/opt-in pages, `POST /admin/test`, and the + /// opt-in-gated introspection endpoints) needs at request time. Handed to + /// the router once at boot as a shared cell; `start` fills it and `stop` + /// clears it, so handlers resolve the current wiring and answer 503 while + /// the plugin is enabled but not running. web: WebCell, } @@ -920,10 +921,14 @@ impl core_api::plugin::Plugin for HonchoPlugin { let workspace_id = cfg.workspace_id.clone(); let user_config = Arc::clone(&ctx.user_config); - // Wire the HTTP router (config/opt-in pages + admin test endpoint). + // Wire the HTTP router (config/opt-in pages + the admin test and the + // opt-in-gated introspection endpoints). *self.web.lock().await = Some(HonchoWeb { user_channel: Arc::clone(&ctx.user_channel), i18n: Arc::clone(&ctx.i18n), + client: Arc::clone(&client), + workspace_id: workspace_id.clone(), + user_config: Arc::clone(&user_config), }); self.honcho_memory.activate(Arc::clone(&client), workspace_id.clone(), Arc::clone(&user_config)); diff --git a/crates/plugin-honcho/src/router.rs b/crates/plugin-honcho/src/router.rs index 75cb6d7..8266fa3 100644 --- a/crates/plugin-honcho/src/router.rs +++ b/crates/plugin-honcho/src/router.rs @@ -2,16 +2,43 @@ //! `/api/plugin/honcho/` behind Skald's normal auth + enabled-gate. //! //! Deliberately small. It serves the two page fragments (the admin config page -//! and the user opt-in page) and one admin action, `POST /admin/test`, a -//! connectivity check against a candidate config. The opt-in toggle and the -//! config save reuse the **core** plugin endpoints (`PUT /api/plugins/honcho` -//! and `/api/plugins/honcho/my-config`), so nothing about persistence lives -//! here. +//! and the user opt-in page) and: +//! +//! - `POST /admin/test` — admin connectivity check against a candidate config. +//! - `GET /status` — user-facing service health (reachability + the +//! caller's own processing queue). +//! - `GET /overview` — the caller's full memory snapshot (peer card + +//! conclusions + summary). Cheap GETs, no LLM. +//! - `POST /search` — semantic search over the caller's derived facts. +//! - `POST /ask` — Dialectic: Honcho's server-side LLM answers a +//! natural-language question from the caller's memory. +//! +//! The opt-in toggle and the config save reuse the **core** plugin endpoints +//! (`PUT /api/plugins/honcho` and `/api/plugins/honcho/my-config`), so nothing +//! about persistence lives here. +//! +//! # Multi-user boundary (the workspace is shared) +//! +//! Every introspection handler derives the Honcho peer from the authenticated +//! [`Caller`]'s user id via [`require_peer`] — never from the request body — +//! and the workspace id from server config. A client can therefore never name +//! another user's peer, and a bug in a handler can't either: the peer id is +//! handed to the handler already resolved. +//! +//! # Error reporting +//! +//! This page exists to *debug* the integration, so errors are specific, not +//! "service unavailable": transport failures and Honcho HTTP errors are +//! localized with the real detail forwarded (see [`honcho_error`] — the body is +//! truncated, not swallowed). The one status code that is *not* an error is +//! Honcho's 404: for a just-opted-in user with no traffic yet it means "no +//! memory about you yet", and each handler translates it accordingly. //! //! Honcho does **not** `manages_own_access`, so — unlike mobile-connector — the //! `plugin_access` grant is *not* an admin check (it is `true` for every granted //! user). The admin endpoint therefore gates on the real -//! [`UserChannelApi::is_admin`]. +//! [`UserChannelApi::is_admin`]; the introspection endpoints gate on the +//! per-user **opt-in** flag instead (fail closed, like the tools). //! //! Every request resolves the *current* wiring through the shared [`WebCell`] //! (filled on `start`, cleared on `stop`), so a reconfigure is transparent and a @@ -19,6 +46,7 @@ //! 503 rather than a stale snapshot. use std::sync::Arc; +use std::time::Instant; use axum::extract::{Extension, State}; use axum::http::{header, StatusCode}; @@ -26,25 +54,49 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::Deserialize; -use serde_json::json; +use serde_json::{json, Value}; +use tracing::debug; use core_api::i18n::I18nApi; use core_api::plugin::Caller; use core_api::user_channel::UserChannelApi; +use core_api::user_plugin_config::PluginUserConfigApi; use honcho_client::HonchoClient; -use honcho_client::models::{PageParams, WorkspaceGet}; +use honcho_client::error::HonchoError; +use honcho_client::models::{DialecticOptions, PageParams, PeerRepresentationGet, WorkspaceGet}; // Namespaced i18n keys for the router's user-facing strings (backend tables in // `../i18n/*.json`), resolved to the caller's language via `web.i18n`. -const KEY_ADMIN_ONLY: &str = "plugin.honcho.err.admin_only"; -const KEY_BASE_URL_EMPTY: &str = "plugin.honcho.err.base_url_empty"; -const KEY_TEST_FAILED: &str = "plugin.honcho.err.test_failed"; +const KEY_ADMIN_ONLY: &str = "plugin.honcho.err.admin_only"; +const KEY_BASE_URL_EMPTY: &str = "plugin.honcho.err.base_url_empty"; +const KEY_TEST_FAILED: &str = "plugin.honcho.err.test_failed"; +const KEY_NOT_OPTED_IN: &str = "plugin.honcho.err.not_opted_in"; +const KEY_QUERY_REQUIRED: &str = "plugin.honcho.err.query_required"; +const KEY_HONCHO_UNREACHABLE: &str = "plugin.honcho.err.honcho_unreachable"; +const KEY_HONCHO_ERROR: &str = "plugin.honcho.err.honcho_error"; +const KEY_NO_DATA: &str = "plugin.honcho.err.no_data"; + +/// Max characters of a Honcho error body forwarded to the user — enough to stay +/// specific, short enough not to flood the page with a server stack dump. +const ERR_DETAIL_MAX: usize = 300; + +/// Conclusions shown in the overview snapshot (the debug page wants more than +/// the read-path's token-budgeted subset). +const OVERVIEW_MAX_CONCLUSIONS: u32 = 50; +/// Facts returned by `/search` (ranked, raw excerpts). +const SEARCH_TOP_K: u32 = 20; /// Deps the router needs at request time. #[derive(Clone)] pub struct HonchoWeb { pub user_channel: Arc, pub i18n: Arc, + /// Live Honcho client — the same one the memory read/write paths use. + pub client: Arc, + /// The instance's shared workspace id, from server config. + pub workspace_id: String, + /// Per-user opt-in store; gates every introspection endpoint. + pub user_config: Arc, } /// Shared cell: an `Arc` to a `Mutex` holding the (optional) live wiring. Cloned @@ -62,11 +114,11 @@ pub fn build(cell: WebCell) -> Router { .route("/web/i18n.js", get(|| async { serve_js(include_str!("../web/i18n.js")) })) // Admin: validate a candidate connection before saving it. .route("/admin/test", post(admin_test)) - // Predisposition for the user page's future "what does Honcho know about - // me?" panel: a `GET /whoami` here would resolve the `Caller`'s user id, - // gate on `opted_in`, and call the live `HonchoMemory` client's - // `peer_chat` (Dialectic) / `peer_context` for that user's peer. Not - // shipped in v1 — the opt-in page needs no backend of its own. + // User-facing introspection (all gated on the per-user opt-in). + .route("/status", get(user_status)) + .route("/overview", get(user_overview)) + .route("/search", post(user_search)) + .route("/ask", post(user_ask)) .with_state(cell) } @@ -91,7 +143,104 @@ async fn require_admin(web: &HonchoWeb, caller: &Caller) -> Result<(), Response> } } -// ── POST /admin/test ──────────────────────────────────────────────────────────── +/// The opt-in gate for every introspection endpoint — same privacy control as +/// the tools and the write path, resolved server-side, fail closed. +/// +/// Returns the **caller's** peer id: in the shared workspace the peer id *is* +/// the multi-user boundary, so it is derived here, from the authenticated user, +/// and handed to the handler already resolved — a client-supplied peer can never +/// reach Honcho. +async fn require_peer(web: &HonchoWeb, caller: &Caller) -> Result { + if crate::opted_in(&web.user_config, &caller.user_id).await { + Ok(caller.user_id.clone()) + } else { + let msg = web.i18n.for_user(&caller.user_id, KEY_NOT_OPTED_IN, &[]).await; + Err((StatusCode::FORBIDDEN, msg).into_response()) + } +} + +/// Localized, *specific* message for a Honcho failure — transport cause or HTTP +/// status + body — because "service unavailable" is exactly what this page must +/// not say. Shared by the JSON-200 `/status` (message only) and the error +/// responses of the other handlers. +async fn honcho_error_text(web: &HonchoWeb, caller: &Caller, e: &HonchoError) -> String { + match e { + HonchoError::Http { status, body } => web.i18n + .for_user(&caller.user_id, KEY_HONCHO_ERROR, &[ + ("status", &status.to_string()), + ("detail", &truncate_detail(body)), + ]) + .await, + // `Request`'s Display walks the whole source chain, so the real cause + // ("connection refused", "dns error", …) is already in here. + e @ (HonchoError::Request(_) | HonchoError::Json(_)) => web.i18n + .for_user(&caller.user_id, KEY_HONCHO_UNREACHABLE, &[("detail", &e.to_string())]) + .await, + } +} + +/// Error response built from [`honcho_error_text`]. 502: the failure happened +/// on the Honcho side, not in this handler. +async fn honcho_error(web: &HonchoWeb, caller: &Caller, e: &HonchoError) -> Response { + (StatusCode::BAD_GATEWAY, honcho_error_text(web, caller, e).await).into_response() +} + +/// Char-boundary-safe truncation of an error body for display. +fn truncate_detail(s: &str) -> String { + if s.len() <= ERR_DETAIL_MAX { + return s.to_string(); + } + let mut end = ERR_DETAIL_MAX; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +#[cfg(test)] +mod tests { + use super::truncate_detail; + + #[test] + fn truncate_keeps_short_bodies_intact() { + assert_eq!(truncate_detail("boom"), "boom"); + // Exactly at the limit is kept whole; one over is cut at the limit. + assert_eq!(truncate_detail(&"x".repeat(300)), "x".repeat(300)); + assert_eq!(truncate_detail(&"x".repeat(301)), format!("{}…", "x".repeat(300))); + } + + #[test] + fn truncate_never_lands_inside_a_multibyte_char() { + // 300 'è' = 600 bytes: a naive byte cut at 300 would split a codepoint, + // but byte 300 happens to fall on a boundary — the cut is 150 whole + // chars plus the ellipsis. + let long = "è".repeat(300); + let out = truncate_detail(&long); + assert!(out.ends_with('…')); + assert!(out.chars().all(|c| c == 'è' || c == '…')); + assert_eq!(out.chars().count(), 151); + } +} + +/// Body of `/search` and `/ask`. +#[derive(Deserialize)] +struct QueryBody { + #[serde(default)] + query: String, +} + +/// Reject an empty/whitespace query with a localized 400. +async fn require_query(web: &HonchoWeb, caller: &Caller, body: &QueryBody) -> Result { + let q = body.query.trim(); + if q.is_empty() { + let msg = web.i18n.for_user(&caller.user_id, KEY_QUERY_REQUIRED, &[]).await; + Err((StatusCode::BAD_REQUEST, msg).into_response()) + } else { + Ok(q.to_string()) + } +} + +// ── POST /admin/test ────────────────────────────────────────────────────────── #[derive(Deserialize)] struct TestBody { @@ -111,7 +260,7 @@ async fn admin_test( Json(body): Json, ) -> Response { let web = match web_or_503(&cell).await { - Ok(w) => w, + Ok(w) => w, Err(r) => return r, }; if let Err(r) = require_admin(&web, &caller).await { @@ -139,3 +288,200 @@ async fn admin_test( } } } + +// ── GET /status ─────────────────────────────────────────────────────────────── + +/// Service health for the debug panel: one cheap GET (`queue/status`) that +/// proves the server is reachable, the key is accepted and the workspace +/// exists, plus the caller's own processing queue and the round-trip latency. +/// +/// Scoped to the caller's observer id — the workspace is shared, and one user's +/// page must not surface the whole instance's queue. +/// +/// Failures are reported as `{ ok: false, error }` with HTTP 200: the *endpoint* +/// worked, and the badge needs the specific message rather than an exception. +async fn user_status( + State(cell): State, + Extension(caller): Extension, +) -> Response { + let web = match web_or_503(&cell).await { + Ok(w) => w, + Err(r) => return r, + }; + let peer = match require_peer(&web, &caller).await { + Ok(p) => p, + Err(r) => return r, + }; + + let started = Instant::now(); + match web.client.queue_status(&web.workspace_id, Some(&peer), None, None).await { + Ok(q) => Json(json!({ + "ok": true, + "latency_ms": started.elapsed().as_millis() as u64, + "queue": { + "pending": q.pending_work_units, + "in_progress": q.in_progress_work_units, + "completed": q.completed_work_units, + }, + })).into_response(), + Err(e) => Json(json!({ + "ok": false, + "error": honcho_error_text(&web, &caller, &e).await, + })).into_response(), + } +} + +// ── GET /overview ───────────────────────────────────────────────────────────── + +/// The caller's full memory snapshot — the direct answer to "what does Honcho +/// know about me?": peer card (curated key facts) + conclusions (derived facts, +/// with their ids) + summary. Two cheap GETs, no LLM synthesis. +async fn user_overview( + State(cell): State, + Extension(caller): Extension, +) -> Response { + let web = match web_or_503(&cell).await { + Ok(w) => w, + Err(r) => return r, + }; + let peer = match require_peer(&web, &caller).await { + Ok(p) => p, + Err(r) => return r, + }; + + // A 404 on either call is "no peer/card yet", not a failure — the peer is + // created lazily by the write path on the user's first forwarded turn. + let card = match web.client.get_peer_card(&web.workspace_id, &peer, None).await { + Ok(v) => v, + Err(e @ HonchoError::Http { status: 404, .. }) => { + debug!("honcho /overview: no card for peer '{peer}' yet: {e}"); + Value::Null + } + Err(e) => return honcho_error(&web, &caller, &e).await, + }; + + let ctx = match web.client.peer_context( + &web.workspace_id, + &peer, + &PeerRepresentationGet { + max_conclusions: Some(OVERVIEW_MAX_CONCLUSIONS), + ..Default::default() + }, + ).await { + Ok(v) => v, + Err(e @ HonchoError::Http { status: 404, .. }) => { + debug!("honcho /overview: no context for peer '{peer}' yet: {e}"); + Value::Null + } + Err(e) => return honcho_error(&web, &caller, &e).await, + }; + + Json(json!({ + "card": card, + "conclusions": ctx.get("conclusions").cloned().unwrap_or(Value::Array(vec![])), + "summary": ctx.get("summary").and_then(Value::as_str), + })).into_response() +} + +// ── POST /search ────────────────────────────────────────────────────────────── + +/// Semantic search over the caller's derived facts: `peer_context` with a +/// `search_query`, ranked raw excerpts with their ids — no LLM synthesis. The +/// same proven path as the `honcho_search` tool (the direct +/// `conclusions/query` endpoint needs observer/observed filters and is not it). +async fn user_search( + State(cell): State, + Extension(caller): Extension, + Json(body): Json, +) -> Response { + let web = match web_or_503(&cell).await { + Ok(w) => w, + Err(r) => return r, + }; + let peer = match require_peer(&web, &caller).await { + Ok(p) => p, + Err(r) => return r, + }; + let query = match require_query(&web, &caller, &body).await { + Ok(q) => q, + Err(r) => return r, + }; + + match web.client.peer_context( + &web.workspace_id, + &peer, + &PeerRepresentationGet { + search_query: Some(query), + search_top_k: Some(SEARCH_TOP_K), + ..Default::default() + }, + ).await { + Ok(ctx) => Json(json!({ + "conclusions": ctx.get("conclusions").cloned().unwrap_or(Value::Array(vec![])), + })).into_response(), + // No peer in Honcho yet ⇒ nothing was ever derived: distinguish it from + // "nothing matches" so the debug page can say which of the two it is. + Err(e @ HonchoError::Http { status: 404, .. }) => { + debug!("honcho /search: no context for peer '{peer}' yet: {e}"); + Json(json!({ "conclusions": [], "empty": true })).into_response() + } + Err(e) => honcho_error(&web, &caller, &e).await, + } +} + +// ── POST /ask ───────────────────────────────────────────────────────────────── + +/// Dialectic query: Honcho's **server-side** LLM reads the caller's memory and +/// synthesizes an answer in natural language. Slower and costlier than +/// `/search` (an LLM round-trip inside Honcho) — that is why it is a separate +/// action in the UI, and why it runs at `reasoning_level: low`. +async fn user_ask( + State(cell): State, + Extension(caller): Extension, + Json(body): Json, +) -> Response { + let web = match web_or_503(&cell).await { + Ok(w) => w, + Err(r) => return r, + }; + let peer = match require_peer(&web, &caller).await { + Ok(p) => p, + Err(r) => return r, + }; + let query = match require_query(&web, &caller, &body).await { + Ok(q) => q, + Err(r) => return r, + }; + + let opts = DialecticOptions { + query, + session_id: None, + target: None, + stream: Some(false), + reasoning_level: Some("low".to_string()), + }; + match web.client.peer_chat(&web.workspace_id, &peer, &opts).await { + Ok(response) => { + // Same extraction as the `memory_query` tool: known content fields, + // falling back to pretty-printed JSON so nothing is ever hidden. + let answer = response.get("content") + .or_else(|| response.get("response")) + .or_else(|| response.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| { + serde_json::to_string_pretty(&response) + .unwrap_or_else(|_| response.to_string()) + }); + Json(json!({ "answer": answer })).into_response() + } + // No peer in Honcho yet: not an error — answer with the localized + // "no memory yet" line so it reads naturally in the panel. + Err(e @ HonchoError::Http { status: 404, .. }) => { + debug!("honcho /ask: no peer '{peer}' yet: {e}"); + let msg = web.i18n.for_user(&caller.user_id, KEY_NO_DATA, &[]).await; + Json(json!({ "answer": msg })).into_response() + } + Err(e) => honcho_error(&web, &caller, &e).await, + } +} diff --git a/crates/plugin-honcho/web/i18n.js b/crates/plugin-honcho/web/i18n.js index 783cf86..d27495a 100644 --- a/crates/plugin-honcho/web/i18n.js +++ b/crates/plugin-honcho/web/i18n.js @@ -39,8 +39,31 @@ export default { [`${P}.memory.saved`]: 'Saved.', [`${P}.memory.loading`]: 'Loading…', [`${P}.memory.unavailable`]: 'Long-term memory is not available to you yet. Ask your administrator to grant access.', - [`${P}.memory.soon_title`]: 'Coming soon', - [`${P}.memory.soon_body`]: 'Soon you will be able to ask Honcho what it remembers about you, and manage it, right from this page.', + + // "What does it remember?" panel (shown once opted in) + [`${P}.panel.title`]: 'What Honcho remembers about you', + [`${P}.panel.guide_title`]: 'How to use this page', + [`${P}.panel.guide_overview`]: 'Overview shows everything Honcho has derived about you so far: your card (key facts), the facts it concluded, and a summary. Nothing to type.', + [`${P}.panel.guide_search`]: 'Search finds the stored facts most relevant to the words you type. Fast and exact — no AI rewrite, you see the raw facts.', + [`${P}.panel.guide_ask`]: 'Ask sends your question to Honcho’s AI, which reads your memory and writes an answer in its own words. Slower, but it can connect the dots.', + [`${P}.panel.status_title`]: 'Service status', + [`${P}.panel.status_ok`]: 'Connected', + [`${P}.panel.status_queue`]: 'Processing: {wip} in progress, {pending} pending, {done} completed', + [`${P}.panel.status_down`]: 'Unreachable', + [`${P}.panel.refresh`]: 'Refresh', + [`${P}.panel.overview_title`]: 'Overview', + [`${P}.panel.card_title`]: 'Your card', + [`${P}.panel.facts_title`]: 'Facts', + [`${P}.panel.summary_title`]: 'Summary', + [`${P}.panel.no_memory`]: 'Honcho has no memory about you yet — it builds up as you chat.', + [`${P}.panel.query_title`]: 'Search or ask', + [`${P}.panel.query_hint`]: 'Words to find facts, or a full question for the AI.', + [`${P}.panel.search_btn`]: 'Search', + [`${P}.panel.ask_btn`]: 'Ask', + [`${P}.panel.searching`]: 'Searching…', + [`${P}.panel.asking`]: 'Asking Honcho…', + [`${P}.panel.search_empty`]: 'No facts match those words.', + [`${P}.panel.answer_title`]: 'Answer', }, it: { @@ -71,8 +94,31 @@ export default { [`${P}.memory.saved`]: 'Salvato.', [`${P}.memory.loading`]: 'Caricamento…', [`${P}.memory.unavailable`]: 'La memoria a lungo termine non è ancora disponibile per te. Chiedi all’amministratore di darti l’accesso.', - [`${P}.memory.soon_title`]: 'In arrivo', - [`${P}.memory.soon_body`]: 'Presto potrai chiedere a Honcho cosa ricorda di te e gestirlo, direttamente da questa pagina.', + + // Pannello "cosa ricorda di te?" (visibile dopo il consenso) + [`${P}.panel.title`]: 'Cosa ricorda Honcho di te', + [`${P}.panel.guide_title`]: 'Come usare questa pagina', + [`${P}.panel.guide_overview`]: 'La panoramica mostra tutto ciò che Honcho ha ricavato su di te finora: la tua scheda (fatti chiave), i fatti dedotti e un riassunto. Non serve scrivere nulla.', + [`${P}.panel.guide_search`]: 'Cerca trova i fatti memorizzati più rilevanti per le parole che scrivi. Veloce ed esatto — niente riscritture dell’AI, vedi i fatti grezzi.', + [`${P}.panel.guide_ask`]: 'Chiedi invia la tua domanda all’AI di Honcho, che legge la tua memoria e scrive una risposta con parole sue. Più lento, ma sa collegare i puntini.', + [`${P}.panel.status_title`]: 'Stato del servizio', + [`${P}.panel.status_ok`]: 'Connesso', + [`${P}.panel.status_queue`]: 'Elaborazione: {wip} in corso, {pending} in attesa, {done} completati', + [`${P}.panel.status_down`]: 'Irraggiungibile', + [`${P}.panel.refresh`]: 'Aggiorna', + [`${P}.panel.overview_title`]: 'Panoramica', + [`${P}.panel.card_title`]: 'La tua scheda', + [`${P}.panel.facts_title`]: 'Fatti', + [`${P}.panel.summary_title`]: 'Riassunto', + [`${P}.panel.no_memory`]: 'Honcho non ha ancora nessun ricordo di te — si costruisce chiacchierando.', + [`${P}.panel.query_title`]: 'Cerca o chiedi', + [`${P}.panel.query_hint`]: 'Parole per trovare fatti, oppure una domanda completa per l’AI.', + [`${P}.panel.search_btn`]: 'Cerca', + [`${P}.panel.ask_btn`]: 'Chiedi', + [`${P}.panel.searching`]: 'Ricerca…', + [`${P}.panel.asking`]: 'Chiedo a Honcho…', + [`${P}.panel.search_empty`]: 'Nessun fatto corrisponde a quelle parole.', + [`${P}.panel.answer_title`]: 'Risposta', }, fr: { @@ -103,7 +149,30 @@ export default { [`${P}.memory.saved`]: 'Enregistré.', [`${P}.memory.loading`]: 'Chargement…', [`${P}.memory.unavailable`]: 'La mémoire à long terme ne vous est pas encore accessible. Demandez l’accès à votre administrateur.', - [`${P}.memory.soon_title`]: 'Bientôt disponible', - [`${P}.memory.soon_body`]: 'Bientôt, vous pourrez demander à Honcho ce qu’il retient de vous et le gérer, directement depuis cette page.', + + // Panneau « que retient-il de vous ? » (visible après le consentement) + [`${P}.panel.title`]: 'Ce que Honcho retient de vous', + [`${P}.panel.guide_title`]: 'Comment utiliser cette page', + [`${P}.panel.guide_overview`]: 'L’aperçu montre tout ce que Honcho a déduit de vous jusqu’ici : votre fiche (faits clés), les faits conclus et un résumé. Rien à saisir.', + [`${P}.panel.guide_search`]: 'Rechercher trouve les faits stockés les plus pertinents pour les mots saisis. Rapide et exact — pas de réécriture par l’IA, vous voyez les faits bruts.', + [`${P}.panel.guide_ask`]: 'Demander envoie votre question à l’IA de Honcho, qui lit votre mémoire et rédige une réponse avec ses mots. Plus lent, mais elle relie les points.', + [`${P}.panel.status_title`]: 'État du service', + [`${P}.panel.status_ok`]: 'Connecté', + [`${P}.panel.status_queue`]: 'Traitement : {wip} en cours, {pending} en attente, {done} terminés', + [`${P}.panel.status_down`]: 'Injoignable', + [`${P}.panel.refresh`]: 'Actualiser', + [`${P}.panel.overview_title`]: 'Aperçu', + [`${P}.panel.card_title`]: 'Votre fiche', + [`${P}.panel.facts_title`]: 'Faits', + [`${P}.panel.summary_title`]: 'Résumé', + [`${P}.panel.no_memory`]: 'Honcho n’a pas encore de mémoire vous concernant — elle se construit au fil des conversations.', + [`${P}.panel.query_title`]: 'Rechercher ou demander', + [`${P}.panel.query_hint`]: 'Des mots pour trouver des faits, ou une question complète pour l’IA.', + [`${P}.panel.search_btn`]: 'Rechercher', + [`${P}.panel.ask_btn`]: 'Demander', + [`${P}.panel.searching`]: 'Recherche…', + [`${P}.panel.asking`]: 'Interrogation de Honcho…', + [`${P}.panel.search_empty`]: 'Aucun fait ne correspond à ces mots.', + [`${P}.panel.answer_title`]: 'Réponse', }, }; diff --git a/crates/plugin-honcho/web/memory.js b/crates/plugin-honcho/web/memory.js index 6776838..b67aeb6 100644 --- a/crates/plugin-honcho/web/memory.js +++ b/crates/plugin-honcho/web/memory.js @@ -1,11 +1,24 @@ // Honcho user opt-in page (page_id `memory`, visible to any user with a // `plugin_access` grant). // -// The per-user consent to long-term memory. Reuses the core per-user config -// endpoints — `GET /api/plugins/mine` to read the current flag, -// `PUT /api/plugins/honcho/my-config` to save `{ enabled }` — so this fragment -// needs no backend of its own. Structured in sections so the future "what does -// Honcho know about me?" panel is a drop-in addition (see the `soon` section). +// Two halves: +// +// 1. The per-user consent to long-term memory. Reuses the core per-user config +// endpoints — `GET /api/plugins/mine` to read the current flag, +// `PUT /api/plugins/honcho/my-config` to save `{ enabled }`. +// 2. Once opted in (saved flag, not the draft toggle): the "what does Honcho +// remember about me?" debug panel, backed by this plugin's own opt-in-gated +// endpoints — `GET ${api}/status` (service health + the caller's own +// processing queue), `GET ${api}/overview` (card + facts + summary, no +// input), and one text field with two actions: `POST ${api}/search` (raw +// ranked facts) and `POST ${api}/ask` (Honcho's server-side LLM answers). +// The built-in mini-guide explains the difference, because "words → facts" +// vs "question → AI answer" is not obvious. +// +// Errors from these endpoints arrive already localized *and specific* (the +// backend forwards the real Honcho transport/HTTP detail) — they are surfaced +// verbatim, never as a generic "unavailable". +// // Default-exports the element class; the host registers it. import { html, nothing } from 'lit'; import { HonchoBase, jf, t } from './common.js'; @@ -18,9 +31,19 @@ export default class HonchoMemoryPage extends HonchoBase { return { _row: { state: true }, // UserPluginView | null (null once loaded = not granted) _enabled: { state: true }, // draft toggle - _status: { state: true }, // { ok?, err? } + _status: { state: true }, // { ok?, err? } for the opt-in save _error: { state: true }, _loading: { state: true }, + // Debug panel (only used once the *saved* opt-in flag is on). + _svc: { state: true }, // null | { ok, latency_ms?, queue? } | { ok:false, error } + _svcBusy: { state: true }, + _ov: { state: true }, // null | { card, conclusions, summary } + _ovBusy: { state: true }, + _ovErr: { state: true }, // string | null + _q: { state: true }, // query input value + _qBusy: { state: true }, // null | 'search' | 'ask' + _qRes: { state: true }, // null | { kind:'search', conclusions, empty } | { kind:'ask', answer } + _qErr: { state: true }, // string | null }; } @@ -31,6 +54,15 @@ export default class HonchoMemoryPage extends HonchoBase { this._status = {}; this._error = null; this._loading = true; + this._svc = null; + this._svcBusy = false; + this._ov = null; + this._ovBusy = false; + this._ovErr = null; + this._q = ''; + this._qBusy = null; + this._qRes = null; + this._qErr = null; } connectedCallback() { @@ -46,6 +78,12 @@ export default class HonchoMemoryPage extends HonchoBase { const row = (mine ?? []).find(x => x.id === ID) ?? null; this._row = row; this._enabled = !!row?.user_config?.enabled; + // The panel reads the *saved* flag; when it just turned on (save → reload) + // this is also what triggers the first fetch of panel data. + if (row?.user_config?.enabled) { + this._refreshStatus(); + this._refreshOverview(); + } } catch (e) { this._error = e.message; } finally { @@ -67,6 +105,57 @@ export default class HonchoMemoryPage extends HonchoBase { } } + // ── Debug panel: data ───────────────────────────────────────────────────── + + async _refreshStatus() { + this._svcBusy = true; + try { + // 200 with { ok:false, error } when Honcho is down — the badge wants the + // specific message, not an exception. Other statuses (503, 403…) still + // throw and land in the same place. + this._svc = await jf(`${this.api}/status`); + } catch (e) { + this._svc = { ok: false, error: e.message }; + } finally { + this._svcBusy = false; + } + } + + async _refreshOverview() { + this._ovBusy = true; + this._ovErr = null; + try { + this._ov = await jf(`${this.api}/overview`); + } catch (e) { + this._ovErr = e.message; + } finally { + this._ovBusy = false; + } + } + + async _run(kind) { + const q = this._q.trim(); + if (!q || this._qBusy) return; + this._qBusy = kind; + this._qErr = null; + this._qRes = null; + try { + const r = await jf(`${this.api}/${kind}`, { + method: 'POST', + body: JSON.stringify({ query: q }), + }); + this._qRes = kind === 'search' + ? { kind, conclusions: r?.conclusions ?? [], empty: !!r?.empty } + : { kind, answer: r?.answer ?? '' }; + } catch (e) { + this._qErr = e.message; + } finally { + this._qBusy = null; + } + } + + // ── Render ──────────────────────────────────────────────────────────────── + render() { return html`
@@ -116,20 +205,175 @@ export default class HonchoMemoryPage extends HonchoBase { ${t(`${P}.memory.save`)} - ${this._renderSoon()}`; + ${this._row?.user_config?.enabled ? this._renderPanel() : nothing}`; } - // Placeholder for the future "what does Honcho know about me?" panel. When - // built, this section gains a button that calls a new `GET ${this.api}/whoami` - // (opt-in-gated) and renders the returned summary; only this method + that one - // route change. - _renderSoon() { - if (!this._enabled) return nothing; + // ── Debug panel ─────────────────────────────────────────────────────────── + + _sectionTitle(icon, key, extra = nothing) { return html` -
-
-
${t(`${P}.memory.soon_title`)}
-
${t(`${P}.memory.soon_body`)}
+
+
${t(`${P}.${key}`)}
+ ${extra}
`; } + + _renderPanel() { + return html` +
+ ${this._sectionTitle('bi-person-lines-fill', 'panel.title')} +
${this._renderGuide()}
+
${this._renderStatus()}
+
${this._renderOverview()}
+
${this._renderQuery()}
`; + } + + _renderGuide() { + const row = (icon, key) => html` +
+ +
${t(`${P}.panel.${key}`)}
+
`; + return html` +
+
${t(`${P}.panel.guide_title`)}
+
+ ${row('bi-list-stars', 'guide_overview')} + ${row('bi-search', 'guide_search')} + ${row('bi-chat-left-text', 'guide_ask')} +
+
`; + } + + _renderStatus() { + const s = this._svc; + const refresh = html` + `; + let body; + if (!s && this._svcBusy) { + body = html``; + } else if (s?.ok) { + const q = s.queue ?? {}; + body = html` +
+ ${t(`${P}.panel.status_ok`)} · ${s.latency_ms ?? '?'} ms +
+ ${t(`${P}.panel.status_queue`, { wip: q.in_progress ?? 0, pending: q.pending ?? 0, done: q.completed ?? 0 })} +
+
`; + } else { + body = html` +
+ ${t(`${P}.panel.status_down`)} +
${s?.error}
+
`; + } + return html` + ${this._sectionTitle('bi-activity', 'panel.status_title', refresh)} +
${body}
`; + } + + // Normalize the peer card into renderable pieces: an array (or an object with + // an array under a known key) becomes items; anything else is shown as JSON. + _cardItems(card) { + if (card == null) return null; + if (Array.isArray(card)) return card.length ? card : null; + if (typeof card === 'object') { + for (const k of ['card', 'facts', 'items']) { + if (Array.isArray(card[k]) && card[k].length) return card[k]; + } + return { raw: JSON.stringify(card, null, 2) }; + } + if (typeof card === 'string') return card.trim() ? [card] : null; + return null; + } + + _renderOverview() { + const refresh = html` + `; + let body; + if (this._ovErr) { + body = html`
${this._ovErr}
`; + } else if (!this._ov && this._ovBusy) { + body = html`
`; + } else if (this._ov) { + const conclusions = this._ov.conclusions ?? []; + const card = this._cardItems(this._ov.card); + const summary = (this._ov.summary ?? '').trim(); + if (!card && !conclusions.length && !summary) { + body = html`
${t(`${P}.panel.no_memory`)}
`; + } else { + body = html` + ${card ? html` +
${t(`${P}.panel.card_title`)}
+ ${Array.isArray(card) + ? html`
    ${card.map((c, i) => html`
  • ${typeof c === 'string' ? c : JSON.stringify(c)}
  • `)}
` + : html`
${card.raw}
`} + ` : nothing} + ${conclusions.length ? html` +
${t(`${P}.panel.facts_title`)}
+
    ${conclusions.map(this._factLi)}
+ ` : nothing} + ${summary ? html` +
${t(`${P}.panel.summary_title`)}
+
${summary}
+ ` : nothing}`; + } + } else { + body = nothing; + } + return html` + ${this._sectionTitle('bi-list-stars', 'panel.overview_title', refresh)} +
${body}
`; + } + + _factLi(c) { + const content = c?.content ?? ''; + const id = c?.id; + return html`
  • + ${id ? html`${id} ` : nothing}${content} +
  • `; + } + + _renderQuery() { + const busy = !!this._qBusy; + let result = nothing; + if (this._qErr) { + result = html`
    ${this._qErr}
    `; + } else if (this._qRes?.kind === 'search') { + result = this._qRes.empty + ? html`
    ${t(`${P}.panel.no_memory`)}
    ` + : this._qRes.conclusions.length + ? html`
      ${this._qRes.conclusions.map(this._factLi)}
    ` + : html`
    ${t(`${P}.panel.search_empty`)}
    `; + } else if (this._qRes?.kind === 'ask') { + result = html` +
    ${t(`${P}.panel.answer_title`)}
    +
    ${this._qRes.answer}
    `; + } + return html` + ${this._sectionTitle('bi-chat-left-text', 'panel.query_title')} + { this._q = e.target.value; }} + @keydown=${(e) => { if (e.key === 'Enter') this._run('search'); }} /> +
    + + + ${this._qBusy ? html`` : nothing} +
    + ${this._qRes || this._qErr ? html`
    ${result}
    ` : nothing}`; + } } diff --git a/dev-docs/plugins.md b/dev-docs/plugins.md index 91f620f..46fb9a1 100644 --- a/dev-docs/plugins.md +++ b/dev-docs/plugins.md @@ -9,3 +9,5 @@ **Plugin visibility & per-user config.** The admin surface is `#plugins` (`plugin-catalog.js`), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus `#plugin-detail?id=` (`plugin-detail.js`), which holds the instance-config form for one plugin (the plugin counterpart of `connector-detail.js`). **Granting is user-side, exactly like a connector grant**: the checkboxes live in the **Plugins** section of `#users/{id}` (`users-page.js`), right below that person's connectors, and the plugin's own page keeps only a read-only roster of who holds it, linking there. The question an admin asks is "what may this person use", and answering it plugin-by-plugin meant opening every plugin in turn; one write path also means the two surfaces cannot disagree. Unlike an MCP grant — which gates a runtime snapshotted at login and so needs a synchronous revoke — a plugin grant is re-read from `plugin_access` on every request that depends on it (sidebar pages, `/plugins/mine`, and each inbound channel message: Telegram checks it per message), so a revoke lands with no push and nothing on the bus. Binding-managed plugins (`Plugin::manages_own_access`, e.g. mobile-connector) are absent from the user-side list and rejected by its writer — a box that controls nothing is worse than no box. There is **no generic per-user plugin page**: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via `Plugin::web_pages()`, like mobile-connector. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is a row in `plugin_access(plugin_id, user_id)`, which grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle); the table is deny-by-default but the rows are **written for you at install time** — see [default-access.md](default-access.md). Per-user values are stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: its pairing page (a `web_pages()` fragment with no backend of its own) reads the `{linked, chat_id}` status blob from `GET /api/plugins/mine` and submits the code through `PUT /api/plugins/{id}/my-config`; the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool). Endpoints: admin `GET/PUT /api/plugins[/{id}]`, `GET /api/plugins/{id}/access` (read-only roster) + **`GET/PUT /api/users/{id}/plugins`** (the grant write path, the twin of `/api/users/{id}/connectors`); user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`. **Plugin HTTP routes & web pages.** Every plugin's `http_router()` mounts at boot under `/api/plugin//` — **enabled or not**: two shared gates wrap each router (`require_auth`, then `guard::plugin_enabled_gate`, which re-checks the DB flag per request and answers 404 while disabled), so enable/disable serves/stops routes immediately with no restart, and plugin responses carry `Cache-Control: no-cache`. The router contract: cheap and safe to build pre-start, handlers tolerant of the not-running state (resolve runtime state per request through a shared cell, as mobile-connector does). A plugin may also contribute **frontend pages** via `Plugin::web_pages()` (`PluginPage { page_id, title, icon, entry, admin_only, priority }`): `GET /api/plugins/pages` returns the caller's visible pages (admin: all; others: non-`admin_only` pages of granted, enabled plugins) with `entry_url` resolved, and the sidebar renders them as menu entries routed `#plugin//`. A single `` (`web/components/plugin-page-host.js`) dynamic-imports the fragment ES module the plugin serves from its own router, registers its default-exported HTMLElement class, and mounts it with the `plugin-id` attribute — the fragment talks to its backend only through `/api/plugin//…` and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior. + +**A plugin page with its own backend (Honcho is the reference).** Most per-user plugin pages (Telegram's) need no backend of their own — the core `/api/plugins/…` endpoints carry their blob. Honcho's opt-in page grew a **debug panel** over the external memory server, and its router (`crates/plugin-honcho/src/router.rs`) is the pattern for that case, with three load-bearing rules. (1) **The peer id is the multi-user boundary**: the Honcho workspace is shared by every user, so every introspection handler receives its peer *already resolved* from the authenticated `Caller` — `require_peer` returns it or a 403 — and a client-supplied peer/workspace can never reach the external server. (2) **The opt-in gate is server-side and fail-closed** (`require_peer` re-reads the flag, same as the tools and the write path) — the panel hiding client-side when the flag is off is cosmetics, not the control. (3) **Errors are specific, never "service unavailable"**: transport failures and the external server's HTTP status+body are localized and forwarded (truncated), because the page exists to debug the integration — and a 404 from the external server is translated per-endpoint as "no memory yet", which is a state, not a failure. The shared `WebCell` carries the live client + workspace + user-config store alongside the router's other deps, filled by `start`/`stop`. diff --git a/docs/plugins/honcho.md b/docs/plugins/honcho.md index 9fa2909..145a489 100644 --- a/docs/plugins/honcho.md +++ b/docs/plugins/honcho.md @@ -28,6 +28,18 @@ Streams a user's completed chat turns to an external [Honcho](https://honcho.dev Long-term memory is **off for every user until they turn it on themselves**. Once the plugin is enabled and the user has been granted access (admin: Users → that person → **Plugins** → tick Honcho), they'll see a **"Long-term memory"** page in their sidebar with a single opt-in toggle. If a user asks the assistant to "remember things long-term" or asks why it doesn't remember past conversations, and this plugin is enabled, point them to that page rather than trying to enable it on their behalf. +## "What does it remember about me?" + +Once a user has opted in, that same page shows a **debug panel** over their own memory — useful to check the integration is working, and to see (and question) what has been derived: + +- **Service status** — whether the Honcho server is reachable (with the response time), how the user's own memory processing is doing (units in progress / pending / completed), and *the specific error* when something is wrong (server down, key rejected, server-side error), not just "unavailable". +- **Overview** — everything Honcho has derived so far: the user's "card" (curated key facts), the individual facts (each with its id), and a summary. Loads automatically; nothing to type. +- **Search or ask** — one text field, two actions, with a mini-guide on the page: + - **Search** finds the stored facts most relevant to the words typed — fast, exact, raw facts. + - **Ask** sends the question to Honcho's AI, which reads the user's memory and writes an answer in its own words — slower, but it connects the dots. + +Only the user's own memory is ever shown: the page always queries the logged-in user's memory and nothing else. Users cannot delete individual facts from the page yet (they can ask the assistant to, via the `honcho_conclude` tool). + ## Notes - Explain the privacy trade-off honestly if a user asks: their messages get stored in cleartext on the Honcho server, outside the encrypted database this app otherwise uses. Some users may not want that.