//! Honcho's HTTP surface, mounted by the main `WebFrontend` under //! `/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: //! //! - `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 + //! representation digest + derived conclusions with ids). Cheap, 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`]; 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 //! request that arrives while the plugin is enabled-but-not-running gets a clean //! 503 rather than a stale snapshot. use std::sync::Arc; use std::time::Instant; use axum::extract::{Extension, State}; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::Deserialize; 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::error::HonchoError; use honcho_client::models::{ ConclusionGet, ConclusionQuery, 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_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 /// cheaply and shared between the plugin (`start`/`stop`) and the router. pub type WebCell = Arc>>; /// Build the plugin's router. Takes the shared cell so each request resolves the /// *current* wiring — not a snapshot from startup. pub fn build(cell: WebCell) -> Router { Router::new() // Page fragments (served as ES modules to the browser). .route("/web/config.js", get(|| async { serve_js(include_str!("../web/config.js")) })) .route("/web/memory.js", get(|| async { serve_js(include_str!("../web/memory.js")) })) .route("/web/common.js", get(|| async { serve_js(include_str!("../web/common.js")) })) .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)) // 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) } fn serve_js(body: &'static str) -> Response { ([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], body).into_response() } /// Resolve the live wiring, or `503` while the plugin is enabled but not running. async fn web_or_503(cell: &WebCell) -> Result { cell.lock().await.clone().ok_or_else(|| { (StatusCode::SERVICE_UNAVAILABLE, "honcho is not running").into_response() }) } /// Fail-closed admin gate for the built-in admin role. async fn require_admin(web: &HonchoWeb, caller: &Caller) -> Result<(), Response> { if web.user_channel.is_admin(&caller.user_id).await { Ok(()) } else { let msg = web.i18n.for_user(&caller.user_id, KEY_ADMIN_ONLY, &[]).await; Err((StatusCode::FORBIDDEN, msg).into_response()) } } /// 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 { #[serde(default)] base_url: String, #[serde(default)] api_key: String, } /// Admin connectivity check against a *candidate* config (the unsaved draft), so /// an admin can validate a URL/key before saving. Builds a throwaway client and /// lists workspaces — verifies the URL is reachable and the key is accepted /// without creating or mutating anything on the server. async fn admin_test( 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, }; if let Err(r) = require_admin(&web, &caller).await { return r; } let base_url = body.base_url.trim(); if base_url.is_empty() { let msg = web.i18n.for_user(&caller.user_id, KEY_BASE_URL_EMPTY, &[]).await; return (StatusCode::BAD_REQUEST, msg).into_response(); } let client = HonchoClient::with_base_url(base_url, body.api_key.trim()); match client .list_workspaces(&PageParams::default(), &WorkspaceGet::default()) .await { Ok(page) => Json(json!({ "ok": true, "workspaces": page.total })).into_response(), Err(e) => { let msg = web .i18n .for_user(&caller.user_id, KEY_TEST_FAILED, &[("detail", &e.to_string())]) .await; (StatusCode::BAD_GATEWAY, msg).into_response() } } } // ── 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?": the peer card (curated key facts), the pre-rendered /// markdown `representation` (what the assistant actually receives), and the /// individual conclusions with their ids (for targeted deletion). Cheap, no /// LLM synthesis. /// /// Schema note (Honcho 3.0.x): `peer_context` carries `representation` + /// `peer_card`, NOT a `conclusions` array — the ids come from a separate /// `conclusions/list` scoped to the caller's peer. 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 the context call is "no peer yet", not a failure — the peer is // created lazily by the write path on the user's first forwarded turn. // The card response wraps the list: `{"peer_card": [...] | null}` — unwrap // it so the page receives the bare value. let card = match web.client.get_peer_card(&web.workspace_id, &peer, None).await { Ok(c) => json!(c.peer_card), 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 representation = match web.client.peer_context( &web.workspace_id, &peer, &PeerRepresentationGet { max_conclusions: Some(OVERVIEW_MAX_CONCLUSIONS), ..Default::default() }, ).await { Ok(ctx) => ctx.representation, Err(e @ HonchoError::Http { status: 404, .. }) => { debug!("honcho /overview: no context for peer '{peer}' yet: {e}"); None } Err(e) => return honcho_error(&web, &caller, &e).await, }; let conclusions = match web.client.list_conclusions( &web.workspace_id, &PageParams { size: Some(OVERVIEW_MAX_CONCLUSIONS as u64), ..Default::default() }, &ConclusionGet { filters: Some(json!({ "observer_id": peer, "observed_id": peer })), }, ).await { Ok(page) => page.items, Err(e) => return honcho_error(&web, &caller, &e).await, }; Json(json!({ "card": card, "representation": representation, "conclusions": conclusions, })).into_response() } // ── POST /search ────────────────────────────────────────────────────────────── /// Semantic search over the caller's derived facts: `conclusions/query`, /// ranked raw excerpts with their ids — no LLM synthesis. The same path as the /// `honcho_search` tool; the observer/observed scoping lives in `filters` /// (and `peer_context` would not do: it returns the whole representation with /// no ids once it fits the budget). 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.query_conclusions( &web.workspace_id, &ConclusionQuery { query, top_k: Some(SEARCH_TOP_K), distance: None, filters: Some(json!({ "observer_id": peer, "observed_id": peer })), }, ).await { Ok(conclusions) => Json(json!({ "conclusions": conclusions })).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, } }