feat(plugin-honcho): show what Honcho remembers about you
Nightly Build / build (push) Successful in 2m24s

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.
This commit is contained in:
Daniele
2026-08-24 21:47:36 +01:00
parent 5c2bec043e
commit 9feaaaff29
10 changed files with 749 additions and 49 deletions
+364 -18
View File
@@ -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<dyn UserChannelApi>,
pub i18n: Arc<dyn I18nApi>,
/// Live Honcho client — the same one the memory read/write paths use.
pub client: Arc<HonchoClient>,
/// 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<dyn PluginUserConfigApi>,
}
/// 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<String, Response> {
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<String, Response> {
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<TestBody>,
) -> 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<WebCell>,
Extension(caller): Extension<Caller>,
) -> 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<WebCell>,
Extension(caller): Extension<Caller>,
) -> 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<WebCell>,
Extension(caller): Extension<Caller>,
Json(body): Json<QueryBody>,
) -> 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<WebCell>,
Extension(caller): Extension<Caller>,
Json(body): Json<QueryBody>,
) -> 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,
}
}