feat(plugin-honcho): show what Honcho remembers about you
Nightly Build / build (push) Successful in 2m24s
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:
@@ -10,6 +10,13 @@ release PR may merge — and a section is closed at the commit that bumps it.
|
|||||||
|
|
||||||
### Added
|
### 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**
|
- 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
|
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
|
`.tex` is shown instead of a PDF, how to stop a recurring job without losing it, what a
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"plugin.honcho.err.admin_only": "Admin only.",
|
"plugin.honcho.err.admin_only": "Admin only.",
|
||||||
"plugin.honcho.err.base_url_empty": "Enter the Honcho server URL first.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"plugin.honcho.err.admin_only": "Administrateur uniquement.",
|
"plugin.honcho.err.admin_only": "Administrateur uniquement.",
|
||||||
"plugin.honcho.err.base_url_empty": "Saisissez d'abord l'URL du serveur Honcho.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"plugin.honcho.err.admin_only": "Solo amministratore.",
|
"plugin.honcho.err.admin_only": "Solo amministratore.",
|
||||||
"plugin.honcho.err.base_url_empty": "Inserisci prima l'URL del server Honcho.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -764,10 +764,11 @@ pub struct HonchoPlugin {
|
|||||||
handle: Mutex<Option<JoinHandle<()>>>,
|
handle: Mutex<Option<JoinHandle<()>>>,
|
||||||
/// Shared Memory implementation — created once, updated on start/stop.
|
/// Shared Memory implementation — created once, updated on start/stop.
|
||||||
honcho_memory: Arc<HonchoMemory>,
|
honcho_memory: Arc<HonchoMemory>,
|
||||||
/// Deps the HTTP router (config/opt-in pages + `POST /admin/test`) needs at
|
/// Deps the HTTP router (config/opt-in pages, `POST /admin/test`, and the
|
||||||
/// request time. Handed to the router once at boot as a shared cell; `start`
|
/// opt-in-gated introspection endpoints) needs at request time. Handed to
|
||||||
/// fills it and `stop` clears it, so handlers resolve the current wiring and
|
/// the router once at boot as a shared cell; `start` fills it and `stop`
|
||||||
/// answer 503 while the plugin is enabled but not running.
|
/// clears it, so handlers resolve the current wiring and answer 503 while
|
||||||
|
/// the plugin is enabled but not running.
|
||||||
web: WebCell,
|
web: WebCell,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,10 +921,14 @@ impl core_api::plugin::Plugin for HonchoPlugin {
|
|||||||
let workspace_id = cfg.workspace_id.clone();
|
let workspace_id = cfg.workspace_id.clone();
|
||||||
let user_config = Arc::clone(&ctx.user_config);
|
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 {
|
*self.web.lock().await = Some(HonchoWeb {
|
||||||
user_channel: Arc::clone(&ctx.user_channel),
|
user_channel: Arc::clone(&ctx.user_channel),
|
||||||
i18n: Arc::clone(&ctx.i18n),
|
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));
|
self.honcho_memory.activate(Arc::clone(&client), workspace_id.clone(), Arc::clone(&user_config));
|
||||||
|
|||||||
@@ -2,16 +2,43 @@
|
|||||||
//! `/api/plugin/honcho/` behind Skald's normal auth + enabled-gate.
|
//! `/api/plugin/honcho/` behind Skald's normal auth + enabled-gate.
|
||||||
//!
|
//!
|
||||||
//! Deliberately small. It serves the two page fragments (the admin config page
|
//! 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
|
//! and the user opt-in page) and:
|
||||||
//! connectivity check against a candidate config. The opt-in toggle and the
|
//!
|
||||||
//! config save reuse the **core** plugin endpoints (`PUT /api/plugins/honcho`
|
//! - `POST /admin/test` — admin connectivity check against a candidate config.
|
||||||
//! and `/api/plugins/honcho/my-config`), so nothing about persistence lives
|
//! - `GET /status` — user-facing service health (reachability + the
|
||||||
//! here.
|
//! 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
|
//! 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
|
//! `plugin_access` grant is *not* an admin check (it is `true` for every granted
|
||||||
//! user). The admin endpoint therefore gates on the real
|
//! 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`]
|
//! Every request resolves the *current* wiring through the shared [`WebCell`]
|
||||||
//! (filled on `start`, cleared on `stop`), so a reconfigure is transparent and a
|
//! (filled on `start`, cleared on `stop`), so a reconfigure is transparent and a
|
||||||
@@ -19,6 +46,7 @@
|
|||||||
//! 503 rather than a stale snapshot.
|
//! 503 rather than a stale snapshot.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use axum::extract::{Extension, State};
|
use axum::extract::{Extension, State};
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
@@ -26,25 +54,49 @@ use axum::response::{IntoResponse, Response};
|
|||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
use core_api::i18n::I18nApi;
|
use core_api::i18n::I18nApi;
|
||||||
use core_api::plugin::Caller;
|
use core_api::plugin::Caller;
|
||||||
use core_api::user_channel::UserChannelApi;
|
use core_api::user_channel::UserChannelApi;
|
||||||
|
use core_api::user_plugin_config::PluginUserConfigApi;
|
||||||
use honcho_client::HonchoClient;
|
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
|
// Namespaced i18n keys for the router's user-facing strings (backend tables in
|
||||||
// `../i18n/*.json`), resolved to the caller's language via `web.i18n`.
|
// `../i18n/*.json`), resolved to the caller's language via `web.i18n`.
|
||||||
const KEY_ADMIN_ONLY: &str = "plugin.honcho.err.admin_only";
|
const KEY_ADMIN_ONLY: &str = "plugin.honcho.err.admin_only";
|
||||||
const KEY_BASE_URL_EMPTY: &str = "plugin.honcho.err.base_url_empty";
|
const KEY_BASE_URL_EMPTY: &str = "plugin.honcho.err.base_url_empty";
|
||||||
const KEY_TEST_FAILED: &str = "plugin.honcho.err.test_failed";
|
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.
|
/// Deps the router needs at request time.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct HonchoWeb {
|
pub struct HonchoWeb {
|
||||||
pub user_channel: Arc<dyn UserChannelApi>,
|
pub user_channel: Arc<dyn UserChannelApi>,
|
||||||
pub i18n: Arc<dyn I18nApi>,
|
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
|
/// 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")) }))
|
.route("/web/i18n.js", get(|| async { serve_js(include_str!("../web/i18n.js")) }))
|
||||||
// Admin: validate a candidate connection before saving it.
|
// Admin: validate a candidate connection before saving it.
|
||||||
.route("/admin/test", post(admin_test))
|
.route("/admin/test", post(admin_test))
|
||||||
// Predisposition for the user page's future "what does Honcho know about
|
// User-facing introspection (all gated on the per-user opt-in).
|
||||||
// me?" panel: a `GET /whoami` here would resolve the `Caller`'s user id,
|
.route("/status", get(user_status))
|
||||||
// gate on `opted_in`, and call the live `HonchoMemory` client's
|
.route("/overview", get(user_overview))
|
||||||
// `peer_chat` (Dialectic) / `peer_context` for that user's peer. Not
|
.route("/search", post(user_search))
|
||||||
// shipped in v1 — the opt-in page needs no backend of its own.
|
.route("/ask", post(user_ask))
|
||||||
.with_state(cell)
|
.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)]
|
#[derive(Deserialize)]
|
||||||
struct TestBody {
|
struct TestBody {
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,8 +39,31 @@ export default {
|
|||||||
[`${P}.memory.saved`]: 'Saved.',
|
[`${P}.memory.saved`]: 'Saved.',
|
||||||
[`${P}.memory.loading`]: 'Loading…',
|
[`${P}.memory.loading`]: 'Loading…',
|
||||||
[`${P}.memory.unavailable`]: 'Long-term memory is not available to you yet. Ask your administrator to grant access.',
|
[`${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: {
|
it: {
|
||||||
@@ -71,8 +94,31 @@ export default {
|
|||||||
[`${P}.memory.saved`]: 'Salvato.',
|
[`${P}.memory.saved`]: 'Salvato.',
|
||||||
[`${P}.memory.loading`]: 'Caricamento…',
|
[`${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.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: {
|
fr: {
|
||||||
@@ -103,7 +149,30 @@ export default {
|
|||||||
[`${P}.memory.saved`]: 'Enregistré.',
|
[`${P}.memory.saved`]: 'Enregistré.',
|
||||||
[`${P}.memory.loading`]: 'Chargement…',
|
[`${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.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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
// Honcho user opt-in page (page_id `memory`, visible to any user with a
|
// Honcho user opt-in page (page_id `memory`, visible to any user with a
|
||||||
// `plugin_access` grant).
|
// `plugin_access` grant).
|
||||||
//
|
//
|
||||||
// The per-user consent to long-term memory. Reuses the core per-user config
|
// 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,
|
// endpoints — `GET /api/plugins/mine` to read the current flag,
|
||||||
// `PUT /api/plugins/honcho/my-config` to save `{ enabled }` — so this fragment
|
// `PUT /api/plugins/honcho/my-config` to save `{ enabled }`.
|
||||||
// needs no backend of its own. Structured in sections so the future "what does
|
// 2. Once opted in (saved flag, not the draft toggle): the "what does Honcho
|
||||||
// Honcho know about me?" panel is a drop-in addition (see the `soon` section).
|
// 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.
|
// Default-exports the element class; the host registers it.
|
||||||
import { html, nothing } from 'lit';
|
import { html, nothing } from 'lit';
|
||||||
import { HonchoBase, jf, t } from './common.js';
|
import { HonchoBase, jf, t } from './common.js';
|
||||||
@@ -18,9 +31,19 @@ export default class HonchoMemoryPage extends HonchoBase {
|
|||||||
return {
|
return {
|
||||||
_row: { state: true }, // UserPluginView | null (null once loaded = not granted)
|
_row: { state: true }, // UserPluginView | null (null once loaded = not granted)
|
||||||
_enabled: { state: true }, // draft toggle
|
_enabled: { state: true }, // draft toggle
|
||||||
_status: { state: true }, // { ok?, err? }
|
_status: { state: true }, // { ok?, err? } for the opt-in save
|
||||||
_error: { state: true },
|
_error: { state: true },
|
||||||
_loading: { 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._status = {};
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this._loading = true;
|
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() {
|
connectedCallback() {
|
||||||
@@ -46,6 +78,12 @@ export default class HonchoMemoryPage extends HonchoBase {
|
|||||||
const row = (mine ?? []).find(x => x.id === ID) ?? null;
|
const row = (mine ?? []).find(x => x.id === ID) ?? null;
|
||||||
this._row = row;
|
this._row = row;
|
||||||
this._enabled = !!row?.user_config?.enabled;
|
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) {
|
} catch (e) {
|
||||||
this._error = e.message;
|
this._error = e.message;
|
||||||
} finally {
|
} 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() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="um-page">
|
<div class="um-page">
|
||||||
@@ -116,20 +205,175 @@ export default class HonchoMemoryPage extends HonchoBase {
|
|||||||
<i class="bi bi-check-lg me-1"></i>${t(`${P}.memory.save`)}
|
<i class="bi bi-check-lg me-1"></i>${t(`${P}.memory.save`)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
${this._renderSoon()}`;
|
${this._row?.user_config?.enabled ? this._renderPanel() : nothing}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder for the future "what does Honcho know about me?" panel. When
|
// ── Debug panel ───────────────────────────────────────────────────────────
|
||||||
// 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
|
_sectionTitle(icon, key, extra = nothing) {
|
||||||
// route change.
|
|
||||||
_renderSoon() {
|
|
||||||
if (!this._enabled) return nothing;
|
|
||||||
return html`
|
return html`
|
||||||
<hr class="my-4" style="opacity:.15" />
|
<div class="d-flex align-items-center justify-content-between mt-1">
|
||||||
<div style="opacity:.7">
|
<div style="font-size:.85rem; font-weight:600"><i class="bi ${icon} me-1"></i>${t(`${P}.${key}`)}</div>
|
||||||
<div style="font-size:.85rem; font-weight:600"><i class="bi bi-hourglass-split me-1"></i>${t(`${P}.memory.soon_title`)}</div>
|
${extra}
|
||||||
<div class="text-body-secondary" style="font-size:.82rem; margin-top:.25rem">${t(`${P}.memory.soon_body`)}</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderPanel() {
|
||||||
|
return html`
|
||||||
|
<hr class="my-4" style="opacity:.15" />
|
||||||
|
${this._sectionTitle('bi-person-lines-fill', 'panel.title')}
|
||||||
|
<div class="mt-3">${this._renderGuide()}</div>
|
||||||
|
<div class="mt-3">${this._renderStatus()}</div>
|
||||||
|
<div class="mt-3">${this._renderOverview()}</div>
|
||||||
|
<div class="mt-3">${this._renderQuery()}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderGuide() {
|
||||||
|
const row = (icon, key) => html`
|
||||||
|
<div class="d-flex gap-2" style="font-size:.8rem">
|
||||||
|
<i class="bi ${icon} mt-1" style="opacity:.6"></i>
|
||||||
|
<div>${t(`${P}.panel.${key}`)}</div>
|
||||||
|
</div>`;
|
||||||
|
return html`
|
||||||
|
<div style="border:1px solid var(--bs-border-color); border-radius:var(--radius-sm, .375rem); padding:.65rem .8rem">
|
||||||
|
<div style="font-size:.8rem; font-weight:600; margin-bottom:.35rem">${t(`${P}.panel.guide_title`)}</div>
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
${row('bi-list-stars', 'guide_overview')}
|
||||||
|
${row('bi-search', 'guide_search')}
|
||||||
|
${row('bi-chat-left-text', 'guide_ask')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStatus() {
|
||||||
|
const s = this._svc;
|
||||||
|
const refresh = html`
|
||||||
|
<button class="btn btn-outline-secondary btn-sm py-0" ?disabled=${this._svcBusy}
|
||||||
|
title=${t(`${P}.panel.refresh`)} @click=${() => this._refreshStatus()}>
|
||||||
|
<i class="bi ${this._svcBusy ? 'bi-arrow-repeat' : 'bi-arrow-clockwise'}"></i>
|
||||||
|
</button>`;
|
||||||
|
let body;
|
||||||
|
if (!s && this._svcBusy) {
|
||||||
|
body = html`<span class="text-body-secondary" style="font-size:.8rem"><i class="bi bi-hourglass-split"></i></span>`;
|
||||||
|
} else if (s?.ok) {
|
||||||
|
const q = s.queue ?? {};
|
||||||
|
body = html`
|
||||||
|
<div>
|
||||||
|
<span class="badge text-bg-success">${t(`${P}.panel.status_ok`)} · ${s.latency_ms ?? '?'} ms</span>
|
||||||
|
<div class="text-body-secondary" style="font-size:.75rem; margin-top:.3rem">
|
||||||
|
${t(`${P}.panel.status_queue`, { wip: q.in_progress ?? 0, pending: q.pending ?? 0, done: q.completed ?? 0 })}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
body = html`
|
||||||
|
<div>
|
||||||
|
<span class="badge text-bg-danger">${t(`${P}.panel.status_down`)}</span>
|
||||||
|
<div class="text-danger" style="font-size:.75rem; margin-top:.3rem">${s?.error}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
${this._sectionTitle('bi-activity', 'panel.status_title', refresh)}
|
||||||
|
<div class="mt-2">${body}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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`
|
||||||
|
<button class="btn btn-outline-secondary btn-sm py-0" ?disabled=${this._ovBusy}
|
||||||
|
title=${t(`${P}.panel.refresh`)} @click=${() => this._refreshOverview()}>
|
||||||
|
<i class="bi ${this._ovBusy ? 'bi-arrow-repeat' : 'bi-arrow-clockwise'}"></i>
|
||||||
|
</button>`;
|
||||||
|
let body;
|
||||||
|
if (this._ovErr) {
|
||||||
|
body = html`<div class="alert alert-danger py-2" style="font-size:.8rem">${this._ovErr}</div>`;
|
||||||
|
} else if (!this._ov && this._ovBusy) {
|
||||||
|
body = html`<div class="um-empty" style="padding:.5rem"><i class="bi bi-hourglass-split"></i></div>`;
|
||||||
|
} 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`<div class="text-body-secondary" style="font-size:.8rem">${t(`${P}.panel.no_memory`)}</div>`;
|
||||||
|
} else {
|
||||||
|
body = html`
|
||||||
|
${card ? html`
|
||||||
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.card_title`)}</div>
|
||||||
|
${Array.isArray(card)
|
||||||
|
? html`<ul class="mb-2" style="font-size:.82rem">${card.map((c, i) => html`<li key=${i}>${typeof c === 'string' ? c : JSON.stringify(c)}</li>`)}</ul>`
|
||||||
|
: html`<pre class="mb-2" style="font-size:.72rem; white-space:pre-wrap">${card.raw}</pre>`}
|
||||||
|
` : nothing}
|
||||||
|
${conclusions.length ? html`
|
||||||
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.facts_title`)}</div>
|
||||||
|
<ul class="mb-2" style="font-size:.82rem">${conclusions.map(this._factLi)}</ul>
|
||||||
|
` : nothing}
|
||||||
|
${summary ? html`
|
||||||
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.summary_title`)}</div>
|
||||||
|
<div style="font-size:.82rem; white-space:pre-wrap">${summary}</div>
|
||||||
|
` : nothing}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body = nothing;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
${this._sectionTitle('bi-list-stars', 'panel.overview_title', refresh)}
|
||||||
|
<div class="mt-2">${body}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_factLi(c) {
|
||||||
|
const content = c?.content ?? '';
|
||||||
|
const id = c?.id;
|
||||||
|
return html`<li style="margin-bottom:.2rem">
|
||||||
|
${id ? html`<code style="font-size:.68rem; opacity:.55">${id}</code> ` : nothing}${content}
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderQuery() {
|
||||||
|
const busy = !!this._qBusy;
|
||||||
|
let result = nothing;
|
||||||
|
if (this._qErr) {
|
||||||
|
result = html`<div class="alert alert-danger py-2" style="font-size:.8rem">${this._qErr}</div>`;
|
||||||
|
} else if (this._qRes?.kind === 'search') {
|
||||||
|
result = this._qRes.empty
|
||||||
|
? html`<div class="alert alert-info py-2" style="font-size:.8rem">${t(`${P}.panel.no_memory`)}</div>`
|
||||||
|
: this._qRes.conclusions.length
|
||||||
|
? html`<ul style="font-size:.82rem">${this._qRes.conclusions.map(this._factLi)}</ul>`
|
||||||
|
: html`<div class="text-body-secondary" style="font-size:.8rem">${t(`${P}.panel.search_empty`)}</div>`;
|
||||||
|
} else if (this._qRes?.kind === 'ask') {
|
||||||
|
result = html`
|
||||||
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.answer_title`)}</div>
|
||||||
|
<div style="font-size:.85rem; white-space:pre-wrap">${this._qRes.answer}</div>`;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
${this._sectionTitle('bi-chat-left-text', 'panel.query_title')}
|
||||||
|
<input class="form-control form-control-sm mt-2" type="text"
|
||||||
|
placeholder=${t(`${P}.panel.query_hint`)} .value=${this._q}
|
||||||
|
@input=${(e) => { this._q = e.target.value; }}
|
||||||
|
@keydown=${(e) => { if (e.key === 'Enter') this._run('search'); }} />
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" ?disabled=${busy || !this._q.trim()}
|
||||||
|
@click=${() => this._run('search')}>
|
||||||
|
<i class="bi bi-search me-1"></i>${this._qBusy === 'search' ? t(`${P}.panel.searching`) : t(`${P}.panel.search_btn`)}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" ?disabled=${busy || !this._q.trim()}
|
||||||
|
@click=${() => this._run('ask')}>
|
||||||
|
<i class="bi bi-chat-left-dots me-1"></i>${this._qBusy === 'ask' ? t(`${P}.panel.asking`) : t(`${P}.panel.ask_btn`)}
|
||||||
|
</button>
|
||||||
|
${this._qBusy ? html`<i class="bi bi-hourglass-split text-body-secondary"></i>` : nothing}
|
||||||
|
</div>
|
||||||
|
${this._qRes || this._qErr ? html`<div class="mt-3">${result}</div>` : nothing}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=<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 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=<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/<id>/` — **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/<plugin_id>/<page_id>`. A single `<plugin-page-host>` (`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/<id>/…` and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior.
|
**Plugin HTTP routes & web pages.** Every plugin's `http_router()` mounts at boot under `/api/plugin/<id>/` — **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/<plugin_id>/<page_id>`. A single `<plugin-page-host>` (`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/<id>/…` 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`.
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user