fix(honcho): read Honcho 3.0.x response schema — memory reads were silently empty
Nightly Build / build (push) Successful in 3m48s
Nightly Build / build (push) Successful in 3m48s
Against a self-hosted Honcho 3.0.11 every read path came back empty while the server was healthy and full of derived facts: the plugin parsed a `conclusions`/`summary` shape the API no longer emits. - honcho-client: typed models for the real schema — `PeerContext` (`representation` markdown + `peer_card`), `SessionContext` (`summary` as an object, `peer_representation`), wrapped `PeerCard` (a bare array on PUT is a 422, which also broke `honcho_profile` writes). - plugin: the turn-time injection and `honcho_context` read the representation; `honcho_search` and the page's /search now use `conclusions/query` (observer/observed scoping inside `filters`) — a real ranked semantic search with fact ids, which `peer_context?search_query` never provided; /overview returns card + representation + conclusions. - compose: pin the Honcho image by digest (3.0.11) — ghcr publishes no v3 semver tags, and an untracked `:latest` pull is what drifted the schema. - tests: fixture tests from payloads captured on the live server, plus an env-gated live smoke test (HONCHO_E2E_URL/_WS/_PEER, `cargo test -p honcho-client -- --ignored`) — run it before any future Honcho bump.
This commit is contained in:
@@ -25,11 +25,28 @@ release PR may merge — and a section is closed at the commit that bumps it.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **Honcho long-term memory read nothing at all** against a self-hosted Honcho v3
|
||||||
|
server: the `honcho_context` and `honcho_search` tools answered "no context", the
|
||||||
|
automatic memory injection into chats silently never happened, and the *Long-term
|
||||||
|
memory* page showed an empty overview — while the server was healthy and full of
|
||||||
|
derived facts. The plugin parsed an outdated response shape; it now reads the
|
||||||
|
`representation` / `peer_card` fields Honcho 3.0.x actually returns, and search is a
|
||||||
|
real ranked semantic search whose fact ids can be deleted via `honcho_conclude`.
|
||||||
|
- The `honcho_profile` tool could not **write** the peer card (the API rejects a bare
|
||||||
|
array — it wants a `{"peer_card": …}` wrapper) and read back a raw JSON envelope;
|
||||||
|
writes now succeed and reads show the facts, or a clean "no card set yet".
|
||||||
- The **Providers** page said *API key missing* on every provider, including the ones
|
- The **Providers** page said *API key missing* on every provider, including the ones
|
||||||
with a perfectly good key. It now reports the real state. Editing a provider no longer
|
with a perfectly good key. It now reports the real state. Editing a provider no longer
|
||||||
shows the saved key in the form either — leave the field blank and the existing key is
|
shows the saved key in the form either — leave the field blank and the existing key is
|
||||||
kept, type a new one to replace it.
|
kept, type a new one to replace it.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The self-hosted Honcho compose setup (`honcho/docker-compose.yml`) pins the server
|
||||||
|
image **by digest (3.0.11)** instead of tracking `:latest` — ghcr publishes no v3
|
||||||
|
version tags, and an untracked pull is what silently changed the API schema under the
|
||||||
|
plugin. Upgrading Honcho is now a deliberate, verified step.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- An LLM provider's API key is never sent to the browser any more: the provider list and
|
- An LLM provider's API key is never sent to the browser any more: the provider list and
|
||||||
|
|||||||
Generated
+1
@@ -1636,6 +1636,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ tracing = "0.1"
|
|||||||
# The crate-level doc example (`#[tokio::main]`) compiles under `cargo test`.
|
# The crate-level doc example (`#[tokio::main]`) compiles under `cargo test`.
|
||||||
tokio = { version = "1", features = ["macros", "rt"] }
|
tokio = { version = "1", features = ["macros", "rt"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
# The live smoke test builds a reqwest client without the host app around, so
|
||||||
|
# it must install the rustls crypto provider itself (the app does it in main).
|
||||||
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
|
|||||||
@@ -171,3 +171,76 @@ fn urlencoding(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! Live smoke test against a real Honcho server — the regression net for
|
||||||
|
//! the schema drift that made every read return empty against 3.0.11.
|
||||||
|
//! Gated on env vars, run explicitly:
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! HONCHO_E2E_URL=http://host:8000 HONCHO_E2E_WS=<workspace> HONCHO_E2E_PEER=<peer> \
|
||||||
|
//! cargo test -p honcho-client -- --ignored --nocapture
|
||||||
|
//! ```
|
||||||
|
use super::HonchoClient;
|
||||||
|
use crate::models::*;
|
||||||
|
|
||||||
|
fn live() -> Option<(HonchoClient, String, String)> {
|
||||||
|
let url = std::env::var("HONCHO_E2E_URL").ok()?;
|
||||||
|
let ws = std::env::var("HONCHO_E2E_WS").ok()?;
|
||||||
|
let peer = std::env::var("HONCHO_E2E_PEER").ok()?;
|
||||||
|
Some((HonchoClient::with_base_url(url, ""), ws, peer))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs a live Honcho: set HONCHO_E2E_URL/_WS/_PEER"]
|
||||||
|
async fn live_read_path_smoke() {
|
||||||
|
// Standalone test process: no host app installed a crypto provider.
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let Some((client, ws, peer)) = live() else {
|
||||||
|
eprintln!("env vars not set — skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// peer_context: the representation string is where the facts live.
|
||||||
|
let ctx = client
|
||||||
|
.peer_context(&ws, &peer, &PeerRepresentationGet::default())
|
||||||
|
.await
|
||||||
|
.expect("peer_context");
|
||||||
|
println!("representation: {:?}", ctx.representation.as_deref().map(|r| &r[..r.len().min(120)]));
|
||||||
|
println!("peer_card: {:?}", ctx.peer_card);
|
||||||
|
|
||||||
|
// card endpoint: wrapped, nullable.
|
||||||
|
let card = client.get_peer_card(&ws, &peer, None).await.expect("get_peer_card");
|
||||||
|
println!("card endpoint: {:?}", card.peer_card);
|
||||||
|
|
||||||
|
// semantic search over conclusions, scoped via filters.
|
||||||
|
let hits = client
|
||||||
|
.query_conclusions(&ws, &ConclusionQuery {
|
||||||
|
query: "test".into(),
|
||||||
|
top_k: Some(5),
|
||||||
|
distance: None,
|
||||||
|
filters: Some(serde_json::json!({ "observer_id": peer, "observed_id": peer })),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("query_conclusions");
|
||||||
|
println!("conclusions hits: {}", hits.len());
|
||||||
|
for c in &hits {
|
||||||
|
assert!(!c.id.is_empty() && !c.content.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// conclusions list with the same scoping (what /overview shows).
|
||||||
|
let page = client
|
||||||
|
.list_conclusions(
|
||||||
|
&ws,
|
||||||
|
&PageParams { size: Some(50), ..Default::default() },
|
||||||
|
&ConclusionGet {
|
||||||
|
filters: Some(serde_json::json!({ "observer_id": peer, "observed_id": peer })),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("list_conclusions");
|
||||||
|
println!("conclusions total: {}", page.total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,13 +185,16 @@ pub struct MessageUpdate {
|
|||||||
// Conclusion
|
// Conclusion
|
||||||
// ──────────────────────────────────────────
|
// ──────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Conclusion {
|
pub struct Conclusion {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub observer_id: String,
|
pub observer_id: String,
|
||||||
pub observed_id: String,
|
pub observed_id: String,
|
||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
|
/// explicit | deductive | inductive | contradiction
|
||||||
|
#[serde(default)]
|
||||||
|
pub level: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +273,66 @@ pub struct PeerRepresentationGet {
|
|||||||
pub max_conclusions: Option<u32>,
|
pub max_conclusions: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
// Context / card responses (Honcho v3.0.x — verified against 3.0.11)
|
||||||
|
//
|
||||||
|
// These endpoints do NOT return a `conclusions` array: the derived facts are
|
||||||
|
// delivered as a single pre-rendered markdown `representation` string, and the
|
||||||
|
// curated card is a plain list of fact strings wrapped in an object. Parse
|
||||||
|
// these typed shapes — reading `conclusions`/`summary` off them yields nothing.
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/// `GET /workspaces/{ws}/peers/{peer}/context`
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct PeerContext {
|
||||||
|
pub peer_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub target_id: Option<String>,
|
||||||
|
/// Curated subset of the target peer's representation, as seen by the
|
||||||
|
/// observer — a pre-rendered markdown document (e.g. an
|
||||||
|
/// `## Explicit Observations` section with one dated line per fact).
|
||||||
|
#[serde(default)]
|
||||||
|
pub representation: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub peer_card: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /workspaces/{ws}/sessions/{session}/context`
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SessionContext {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub summary: Option<Summary>,
|
||||||
|
/// Representation of the session's peer, when a perspective is available.
|
||||||
|
#[serde(default)]
|
||||||
|
pub peer_representation: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub peer_card: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nested in [`SessionContext`]. Only `content` is consumed; the remaining
|
||||||
|
/// fields (`message_id`, `summary_type`, `created_at`) are ignored.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Summary {
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /workspaces/{ws}/peers/{peer}/card` — note the wrapper object: the
|
||||||
|
/// card never travels as a bare array.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PeerCard {
|
||||||
|
pub peer_card: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body of `PUT /workspaces/{ws}/peers/{peer}/card` — same wrapper: sending a
|
||||||
|
/// bare array is a 422.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PeerCardSet {
|
||||||
|
pub peer_card: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────
|
// ──────────────────────────────────────────
|
||||||
// Search
|
// Search
|
||||||
// ──────────────────────────────────────────
|
// ──────────────────────────────────────────
|
||||||
@@ -306,3 +369,82 @@ pub struct PageParams {
|
|||||||
pub size: Option<u64>,
|
pub size: Option<u64>,
|
||||||
pub reverse: Option<bool>,
|
pub reverse: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Real `GET /peers/{id}/context` payload captured from a self-hosted
|
||||||
|
/// Honcho 3.0.11. Guards the schema the plugin parses: facts live in the
|
||||||
|
/// `representation` markdown string, the card is `peer_card`, and there is
|
||||||
|
/// NO `conclusions` array (the bug this test would have caught).
|
||||||
|
const PEER_CONTEXT_3_0_11: &str = r###"{
|
||||||
|
"peer_id": "506cd15e-ae2a-47f1-9553-85ffe33e3e5b",
|
||||||
|
"target_id": "506cd15e-ae2a-47f1-9553-85ffe33e3e5b",
|
||||||
|
"representation": "## Explicit Observations\n\n[2026-09-09 16:55:39] Daniele ha una passione per i vulcani e ha dormito al bordo del cratere del Nyiragongo in RD Congo nel 2015.\n[2026-09-09 16:57:12] 506cd15e-ae2a-47f1-9553-85ffe33e3e5b speaks Italian (greeted with \"Ciao\")\n",
|
||||||
|
"peer_card": null
|
||||||
|
}"###;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_context_parses_representation_and_card() {
|
||||||
|
let ctx: PeerContext = serde_json::from_str(PEER_CONTEXT_3_0_11).unwrap();
|
||||||
|
assert_eq!(ctx.peer_id, "506cd15e-ae2a-47f1-9553-85ffe33e3e5b");
|
||||||
|
let rep = ctx.representation.unwrap();
|
||||||
|
assert!(rep.contains("Nyiragongo"));
|
||||||
|
assert!(ctx.peer_card.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_context_tolerates_empty_peer() {
|
||||||
|
let ctx: PeerContext = serde_json::from_str(
|
||||||
|
r#"{"peer_id":"p","target_id":"p","representation":null,"peer_card":null}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(ctx.representation.is_none());
|
||||||
|
assert!(ctx.peer_card.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real `GET /sessions/{id}/context` payload: `summary` is an OBJECT
|
||||||
|
/// (not a string) and the session-scoped facts are in
|
||||||
|
/// `peer_representation`.
|
||||||
|
#[test]
|
||||||
|
fn session_context_summary_is_an_object() {
|
||||||
|
let ctx: SessionContext = serde_json::from_str(
|
||||||
|
r###"{
|
||||||
|
"id": "skaldcircle-u-1",
|
||||||
|
"messages": [],
|
||||||
|
"summary": {"content": "They talked about commuting.", "message_id": "m1", "summary_type": "short", "created_at": "2026-09-09T16:00:00Z"},
|
||||||
|
"peer_representation": "## Explicit Observations\n\n[2026-09-09] fact",
|
||||||
|
"peer_card": ["likes volcanoes"]
|
||||||
|
}"###,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ctx.summary.unwrap().content, "They talked about commuting.");
|
||||||
|
assert!(ctx.peer_representation.unwrap().contains("fact"));
|
||||||
|
assert_eq!(ctx.peer_card.unwrap(), vec!["likes volcanoes".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real `GET /peers/{id}/card` payload: the card is wrapped in an object
|
||||||
|
/// and null until curated.
|
||||||
|
#[test]
|
||||||
|
fn peer_card_is_wrapped_and_nullable() {
|
||||||
|
let card: PeerCard = serde_json::from_str(r#"{"peer_card":null}"#).unwrap();
|
||||||
|
assert!(card.peer_card.is_none());
|
||||||
|
let card: PeerCard =
|
||||||
|
serde_json::from_str(r#"{"peer_card":["a","b"]}"#).unwrap();
|
||||||
|
assert_eq!(card.peer_card.unwrap(), vec!["a".to_string(), "b".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real conclusion entries (from `conclusions/query`), including `level`.
|
||||||
|
#[test]
|
||||||
|
fn conclusion_tolerates_level_and_null_session() {
|
||||||
|
let c: Conclusion = serde_json::from_str(
|
||||||
|
r#"{"id":"uM0BSpIuDMNp9wL97c6M-","content":"Daniele ha una passione per i vulcani","observer_id":"p","observed_id":"p","session_id":null,"level":"explicit","created_at":"2026-09-09T16:55:39.863412Z"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(c.level.as_deref(), Some("explicit"));
|
||||||
|
assert!(c.session_id.is_none());
|
||||||
|
// Round-trip: the router serializes conclusions into its JSON responses.
|
||||||
|
assert!(serde_json::to_string(&c).unwrap().contains("uM0BSpIuDMNp9wL97c6M-"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,13 +92,15 @@ impl HonchoClient {
|
|||||||
|
|
||||||
// ── Context ───────────────────────────────────────────────────────────
|
// ── Context ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Get context for a peer (conclusions + card, ready for injection into prompts).
|
/// Get context for a peer: the pre-rendered markdown `representation` of
|
||||||
|
/// everything derived about them, plus their curated card. (No
|
||||||
|
/// `conclusions` array exists in this response — see [`PeerContext`].)
|
||||||
pub async fn peer_context(
|
pub async fn peer_context(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
peer_id: &str,
|
peer_id: &str,
|
||||||
opts: &PeerRepresentationGet,
|
opts: &PeerRepresentationGet,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<PeerContext> {
|
||||||
let mut q: Vec<(&str, String)> = vec![];
|
let mut q: Vec<(&str, String)> = vec![];
|
||||||
if let Some(ref v) = opts.target {
|
if let Some(ref v) = opts.target {
|
||||||
q.push(("target", v.clone()));
|
q.push(("target", v.clone()));
|
||||||
@@ -132,7 +134,7 @@ impl HonchoClient {
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
peer_id: &str,
|
peer_id: &str,
|
||||||
target: Option<&str>,
|
target: Option<&str>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<PeerCard> {
|
||||||
let mut q: Vec<(&str, String)> = vec![];
|
let mut q: Vec<(&str, String)> = vec![];
|
||||||
if let Some(t) = target {
|
if let Some(t) = target {
|
||||||
q.push(("target", t.to_owned()));
|
q.push(("target", t.to_owned()));
|
||||||
@@ -144,13 +146,15 @@ impl HonchoClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Overwrite the peer card. The API wraps the list in an object
|
||||||
|
/// (`{"peer_card": [...]}`) — a bare array is rejected with 422.
|
||||||
pub async fn set_peer_card(
|
pub async fn set_peer_card(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
peer_id: &str,
|
peer_id: &str,
|
||||||
target: Option<&str>,
|
target: Option<&str>,
|
||||||
card: serde_json::Value,
|
card: Vec<String>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<PeerCard> {
|
||||||
let mut q: Vec<(&str, String)> = vec![];
|
let mut q: Vec<(&str, String)> = vec![];
|
||||||
if let Some(t) = target {
|
if let Some(t) = target {
|
||||||
q.push(("target", t.to_owned()));
|
q.push(("target", t.to_owned()));
|
||||||
@@ -158,7 +162,7 @@ impl HonchoClient {
|
|||||||
self.put_with_query(
|
self.put_with_query(
|
||||||
&format!("/v3/workspaces/{workspace_id}/peers/{peer_id}/card"),
|
&format!("/v3/workspaces/{workspace_id}/peers/{peer_id}/card"),
|
||||||
&q,
|
&q,
|
||||||
&card,
|
&PeerCardSet { peer_card: card },
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,14 +161,17 @@ impl HonchoClient {
|
|||||||
|
|
||||||
// ── Context / Summaries ───────────────────────────────────────────────
|
// ── Context / Summaries ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// Retrieve context for a session (messages + peer conclusions, token-budgeted).
|
/// Retrieve context for a session (messages + summary + the session peer's
|
||||||
|
/// representation, token-budgeted). The summary is a `Summary` object and
|
||||||
|
/// the facts live in `peer_representation` — there is no `conclusions`
|
||||||
|
/// array (see [`SessionContext`]).
|
||||||
pub async fn session_context(
|
pub async fn session_context(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
tokens: Option<u32>,
|
tokens: Option<u32>,
|
||||||
search_query: Option<&str>,
|
search_query: Option<&str>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<SessionContext> {
|
||||||
let mut q: Vec<(&str, String)> = vec![];
|
let mut q: Vec<(&str, String)> = vec![];
|
||||||
if let Some(t) = tokens {
|
if let Some(t) = tokens {
|
||||||
q.push(("tokens", t.to_string()));
|
q.push(("tokens", t.to_string()));
|
||||||
|
|||||||
+170
-58
@@ -70,8 +70,8 @@ use core_api::tool::{
|
|||||||
use core_api::user_plugin_config::PluginUserConfigApi;
|
use core_api::user_plugin_config::PluginUserConfigApi;
|
||||||
use honcho_client::HonchoClient;
|
use honcho_client::HonchoClient;
|
||||||
use honcho_client::models::{
|
use honcho_client::models::{
|
||||||
ConclusionCreate, MessageCreate, PeerCreate, PeerRepresentationGet,
|
Conclusion, ConclusionCreate, ConclusionQuery, MessageCreate, PeerContext, PeerCreate,
|
||||||
SessionCreate, SessionPeerConfig, WorkspaceCreate,
|
PeerRepresentationGet, SessionContext, SessionCreate, SessionPeerConfig, WorkspaceCreate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PLUGIN_ID: &str = "honcho";
|
const PLUGIN_ID: &str = "honcho";
|
||||||
@@ -225,8 +225,8 @@ impl Memory for HonchoMemory {
|
|||||||
},
|
},
|
||||||
).await {
|
).await {
|
||||||
Ok(ctx) => {
|
Ok(ctx) => {
|
||||||
trace!(session_id, raw_json = %ctx, "honcho: peer_context raw response");
|
trace!(session_id, response = ?ctx, "honcho: peer_context raw response");
|
||||||
let f = format_context(ctx);
|
let f = format_peer_context(&ctx);
|
||||||
debug!(
|
debug!(
|
||||||
"honcho: peer_context (global) for session {session_id} ({} chars)",
|
"honcho: peer_context (global) for session {session_id} ({} chars)",
|
||||||
f.as_deref().map_or(0, |s| s.len())
|
f.as_deref().map_or(0, |s| s.len())
|
||||||
@@ -253,8 +253,8 @@ impl Memory for HonchoMemory {
|
|||||||
Some(user_message),
|
Some(user_message),
|
||||||
).await {
|
).await {
|
||||||
Ok(ctx) => {
|
Ok(ctx) => {
|
||||||
trace!(session_id, raw_json = %ctx, "honcho: session_context raw response");
|
trace!(session_id, response = ?ctx, "honcho: session_context raw response");
|
||||||
let f = format_context(ctx);
|
let f = format_session_context(&ctx);
|
||||||
debug!(
|
debug!(
|
||||||
"honcho: session_context for session {session_id} ({} chars)",
|
"honcho: session_context for session {session_id} ({} chars)",
|
||||||
f.as_deref().map_or(0, |s| s.len())
|
f.as_deref().map_or(0, |s| s.len())
|
||||||
@@ -474,19 +474,31 @@ impl Tool for HonchoProfileTool {
|
|||||||
gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move {
|
gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move {
|
||||||
match card_update {
|
match card_update {
|
||||||
Some(facts) => {
|
Some(facts) => {
|
||||||
|
let facts: Vec<String> = facts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| f.as_str().map(str::to_string))
|
||||||
|
.collect();
|
||||||
|
let n = facts.len();
|
||||||
client
|
client
|
||||||
.set_peer_card(&workspace_id, &peer, None, json!(facts))
|
.set_peer_card(&workspace_id, &peer, None, facts)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?;
|
||||||
Ok(format!("Peer card updated ({} facts).", facts.len()))
|
Ok(format!("Peer card updated ({n} facts)."))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let card = client
|
let card = client
|
||||||
.get_peer_card(&workspace_id, &peer, None)
|
.get_peer_card(&workspace_id, &peer, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?;
|
||||||
Ok(serde_json::to_string_pretty(&card)
|
// The response wraps the list: {"peer_card": [...] | null}.
|
||||||
.unwrap_or_else(|_| card.to_string()))
|
match card.peer_card.filter(|c| !c.is_empty()) {
|
||||||
|
Some(facts) => Ok(format!(
|
||||||
|
"Peer card ({} facts):\n- {}",
|
||||||
|
facts.len(),
|
||||||
|
facts.join("\n- ")
|
||||||
|
)),
|
||||||
|
None => Ok("No peer card set yet.".to_string()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -532,56 +544,53 @@ impl Tool for HonchoSearchTool {
|
|||||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||||
let client = Arc::clone(&self.client);
|
let client = Arc::clone(&self.client);
|
||||||
let workspace_id = self.workspace_id.clone();
|
let workspace_id = self.workspace_id.clone();
|
||||||
// Honcho's `conclusions/query` endpoint requires observer/observed
|
// `conclusions/query` is the semantic search over the user's derived
|
||||||
// filters; the proven path (shared with the read-path) is `peer_context`
|
// facts: ranked results WITH their ids (needed by `honcho_conclude`'s
|
||||||
// with a `search_query`, which ranks the user's conclusions by relevance.
|
// delete). The observer/observed scoping goes inside `filters` — that
|
||||||
|
// is the whole trick, the endpoint is the right one. (`peer_context`
|
||||||
|
// with a search_query is not: it returns the whole representation
|
||||||
|
// once it fits the token budget, and no ids.)
|
||||||
gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move {
|
gated_execution(Arc::clone(&self.user_config), ctx.user_id.clone(), move |peer| async move {
|
||||||
let query = args["query"]
|
let query = args["query"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("honcho_search: missing 'query' argument"))?
|
.ok_or_else(|| anyhow::anyhow!("honcho_search: missing 'query' argument"))?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let ctx = client
|
let conclusions = client
|
||||||
.peer_context(
|
.query_conclusions(
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
&peer,
|
&ConclusionQuery {
|
||||||
&PeerRepresentationGet {
|
query,
|
||||||
search_query: Some(query),
|
top_k: Some(10),
|
||||||
search_top_k: Some(10),
|
distance: None,
|
||||||
..Default::default()
|
filters: Some(json!({ "observer_id": peer, "observed_id": peer })),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("honcho_search: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("honcho_search: {e}"))?;
|
||||||
|
|
||||||
Ok(format_conclusions(&ctx)
|
Ok(format_conclusions(&conclusions)
|
||||||
.unwrap_or_else(|| "No relevant context found.".to_string()))
|
.unwrap_or_else(|| "No relevant context found.".to_string()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats the `conclusions` array of a Honcho `peer_context` response as a
|
/// Formats a list of conclusions as a ranked bullet list, prefixing each fact
|
||||||
/// ranked bullet list, prefixing each fact with its `id` when present so the
|
/// with its `id` so the model can target it via `honcho_conclude`. Returns
|
||||||
/// model can target it via `honcho_conclude`. Returns `None` when empty.
|
/// `None` when empty.
|
||||||
fn format_conclusions(ctx: &Value) -> Option<String> {
|
fn format_conclusions(conclusions: &[Conclusion]) -> Option<String> {
|
||||||
let conclusions = ctx.get("conclusions")?.as_array()?;
|
|
||||||
let lines: Vec<String> = conclusions
|
let lines: Vec<String> = conclusions
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|c| {
|
.map(|c| format!("- [{}] {}", c.id, c.content))
|
||||||
let content = c.get("content").and_then(|v| v.as_str())?;
|
|
||||||
match c.get("id").and_then(|v| v.as_str()) {
|
|
||||||
Some(id) => Some(format!("- [{id}] {content}")),
|
|
||||||
None => Some(format!("- {content}")),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
(!lines.is_empty()).then(|| lines.join("\n"))
|
(!lines.is_empty()).then(|| lines.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HonchoContextTool ─────────────────────────────────────────────────────────
|
// ── HonchoContextTool ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Retrieves a full context snapshot for the calling user (conclusions, card,
|
/// Retrieves a full context snapshot for the calling user (the markdown
|
||||||
/// summary) from Honcho's `peer_context` endpoint. No LLM synthesis.
|
/// representation of everything derived about them, plus their peer card)
|
||||||
|
/// from Honcho's `peer_context` endpoint. No LLM synthesis.
|
||||||
struct HonchoContextTool {
|
struct HonchoContextTool {
|
||||||
client: Arc<HonchoClient>,
|
client: Arc<HonchoClient>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -626,7 +635,7 @@ impl Tool for HonchoContextTool {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("honcho_context: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("honcho_context: {e}"))?;
|
||||||
|
|
||||||
Ok(format_context(ctx).unwrap_or_else(|| "No context available yet.".to_string()))
|
Ok(format_peer_context(&ctx).unwrap_or_else(|| "No context available yet.".to_string()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -713,36 +722,51 @@ impl Tool for HonchoConcludeTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts a human-readable string from the raw Honcho `session_context` /
|
/// Formats a Honcho `peer_context` response for injection into the system
|
||||||
/// `peer_context` JSON response.
|
/// prompt / as the `honcho_context` tool result.
|
||||||
///
|
///
|
||||||
/// Returns `None` if there is nothing *new* to inject — i.e. when the response
|
/// Honcho 3.0.x delivers everything a peer knows as a single pre-rendered
|
||||||
/// contains only raw messages (which are already present in the LLM's own
|
/// markdown `representation` string (sections like `## Explicit Observations`
|
||||||
/// conversation history) or is otherwise empty.
|
/// with one dated line per fact), plus the curated `peer_card`. There is no
|
||||||
|
/// `conclusions` array and no `summary` string in this response — parsing
|
||||||
|
/// those keys silently yields nothing (the bug that made every read come back
|
||||||
|
/// empty against a healthy server).
|
||||||
///
|
///
|
||||||
/// Only synthesised knowledge is injected:
|
/// Raw `messages` are not part of this response at all; the representation is
|
||||||
/// - `conclusions` — facts about the user derived by Honcho's background processing
|
/// already the synthesised knowledge, so it is injected verbatim.
|
||||||
/// - `summary` — a narrative summary produced by Honcho
|
fn format_peer_context(ctx: &PeerContext) -> Option<String> {
|
||||||
///
|
|
||||||
/// Raw `messages` are intentionally ignored: they are redundant with the local
|
|
||||||
/// `chat_history` already sent to the LLM and would waste context tokens.
|
|
||||||
fn format_context(ctx: Value) -> Option<String> {
|
|
||||||
let mut parts: Vec<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
|
||||||
if let Some(conclusions) = ctx.get("conclusions").and_then(|v| v.as_array()) {
|
if let Some(card) = ctx.peer_card.as_ref().filter(|c| !c.is_empty()) {
|
||||||
let facts: Vec<&str> = conclusions
|
parts.push(format!("Peer card (curated key facts):\n- {}", card.join("\n- ")));
|
||||||
.iter()
|
|
||||||
.filter_map(|c| c.get("content").and_then(|v| v.as_str()))
|
|
||||||
.collect();
|
|
||||||
if !facts.is_empty() {
|
|
||||||
parts.push(format!("Known facts about the user:\n- {}", facts.join("\n- ")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(summary) = ctx.get("summary").and_then(|v| v.as_str()) {
|
if let Some(rep) = ctx.representation.as_deref().map(str::trim).filter(|r| !r.is_empty()) {
|
||||||
if !summary.trim().is_empty() {
|
parts.push(format!("Known facts about the user:\n{rep}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"--- Honcho memory context ---\n{}\n--- end of memory context ---",
|
||||||
|
parts.join("\n\n")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a Honcho `session_context` response: the running `summary` (an
|
||||||
|
/// object — only its `content` is used) plus the session-scoped
|
||||||
|
/// `peer_representation`. Returns `None` when neither is present.
|
||||||
|
fn format_session_context(ctx: &SessionContext) -> Option<String> {
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(summary) = ctx.summary.as_ref().map(|s| s.content.trim()).filter(|s| !s.is_empty()) {
|
||||||
parts.push(format!("Conversation summary:\n{summary}"));
|
parts.push(format!("Conversation summary:\n{summary}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(rep) = ctx.peer_representation.as_deref().map(str::trim).filter(|r| !r.is_empty()) {
|
||||||
|
parts.push(format!("Session observations:\n{rep}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if parts.is_empty() {
|
if parts.is_empty() {
|
||||||
@@ -1147,3 +1171,91 @@ async fn get_or_create_session(
|
|||||||
let mut map = session_map.write().await;
|
let mut map = session_map.write().await;
|
||||||
Ok(map.entry(key).or_insert(session.id).clone())
|
Ok(map.entry(key).or_insert(session.id).clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Real `peer_context` payload shape captured from a self-hosted Honcho
|
||||||
|
/// 3.0.11: the derived facts live in the `representation` markdown string
|
||||||
|
/// and the card is null. The pre-fix code looked for `conclusions`/
|
||||||
|
/// `summary` keys and reported "No context available yet" against exactly
|
||||||
|
/// this response.
|
||||||
|
fn real_peer_context() -> PeerContext {
|
||||||
|
serde_json::from_value(json!({
|
||||||
|
"peer_id": "506cd15e-ae2a-47f1-9553-85ffe33e3e5b",
|
||||||
|
"target_id": "506cd15e-ae2a-47f1-9553-85ffe33e3e5b",
|
||||||
|
"representation": "## Explicit Observations\n\n[2026-09-09 16:20:06] 506cd15e works in an office at Battersea Power Station in London, Zone 1.\n[2026-09-09 16:55:39] Daniele ha una passione per i vulcani e ha dormito al bordo del cratere del Nyiragongo in RD Congo nel 2015.\n",
|
||||||
|
"peer_card": null
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_context_formats_the_representation() {
|
||||||
|
let out = format_peer_context(&real_peer_context()).unwrap();
|
||||||
|
assert!(out.contains("Nyiragongo"));
|
||||||
|
assert!(out.contains("Battersea"));
|
||||||
|
assert!(out.contains("--- Honcho memory context ---"));
|
||||||
|
assert!(!out.contains("Peer card"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_context_includes_the_card_when_present() {
|
||||||
|
let mut ctx = real_peer_context();
|
||||||
|
ctx.peer_card = Some(vec!["Software engineer".to_string()]);
|
||||||
|
let out = format_peer_context(&ctx).unwrap();
|
||||||
|
assert!(out.contains("Peer card (curated key facts):\n- Software engineer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_context_empty_means_none() {
|
||||||
|
let ctx: PeerContext = serde_json::from_value(json!({
|
||||||
|
"peer_id": "p", "target_id": "p", "representation": null, "peer_card": null
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(format_peer_context(&ctx).is_none());
|
||||||
|
|
||||||
|
let ctx: PeerContext = serde_json::from_value(json!({
|
||||||
|
"peer_id": "p", "target_id": "p", "representation": " \n ", "peer_card": []
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(format_peer_context(&ctx).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_context_reads_summary_object_and_representation() {
|
||||||
|
// Real 3.0.11 shape: `summary` is an OBJECT, not a string.
|
||||||
|
let ctx: SessionContext = serde_json::from_value(json!({
|
||||||
|
"id": "ws-user-1",
|
||||||
|
"messages": [],
|
||||||
|
"summary": {"content": "They planned a commute comparison.", "message_id": "m", "summary_type": "short", "created_at": "2026-09-09T16:00:00Z"},
|
||||||
|
"peer_representation": "## Explicit Observations\n\n[2026-09-09] fact",
|
||||||
|
"peer_card": null
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let out = format_session_context(&ctx).unwrap();
|
||||||
|
assert!(out.contains("Conversation summary:\nThey planned a commute comparison."));
|
||||||
|
assert!(out.contains("Session observations:\n## Explicit Observations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_context_empty_means_none() {
|
||||||
|
let ctx: SessionContext = serde_json::from_value(json!({
|
||||||
|
"id": "s", "messages": [], "summary": null, "peer_representation": null, "peer_card": null
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(format_session_context(&ctx).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conclusions_format_with_ids_for_deletion() {
|
||||||
|
let conclusions: Vec<Conclusion> = serde_json::from_value(json!([
|
||||||
|
{"id":"abc","content":"likes volcanoes","observer_id":"p","observed_id":"p","session_id":null,"level":"explicit","created_at":"2026-09-09T16:55:39Z"}
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let out = format_conclusions(&conclusions).unwrap();
|
||||||
|
assert_eq!(out, "- [abc] likes volcanoes");
|
||||||
|
assert!(format_conclusions(&[]).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! - `GET /status` — user-facing service health (reachability + the
|
//! - `GET /status` — user-facing service health (reachability + the
|
||||||
//! caller's own processing queue).
|
//! caller's own processing queue).
|
||||||
//! - `GET /overview` — the caller's full memory snapshot (peer card +
|
//! - `GET /overview` — the caller's full memory snapshot (peer card +
|
||||||
//! conclusions + summary). Cheap GETs, no LLM.
|
//! representation digest + derived conclusions with ids). Cheap, no LLM.
|
||||||
//! - `POST /search` — semantic search over the caller's derived facts.
|
//! - `POST /search` — semantic search over the caller's derived facts.
|
||||||
//! - `POST /ask` — Dialectic: Honcho's server-side LLM answers a
|
//! - `POST /ask` — Dialectic: Honcho's server-side LLM answers a
|
||||||
//! natural-language question from the caller's memory.
|
//! natural-language question from the caller's memory.
|
||||||
@@ -63,7 +63,10 @@ use core_api::user_channel::UserChannelApi;
|
|||||||
use core_api::user_plugin_config::PluginUserConfigApi;
|
use core_api::user_plugin_config::PluginUserConfigApi;
|
||||||
use honcho_client::HonchoClient;
|
use honcho_client::HonchoClient;
|
||||||
use honcho_client::error::HonchoError;
|
use honcho_client::error::HonchoError;
|
||||||
use honcho_client::models::{DialecticOptions, PageParams, PeerRepresentationGet, WorkspaceGet};
|
use honcho_client::models::{
|
||||||
|
ConclusionGet, ConclusionQuery, 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`.
|
||||||
@@ -334,8 +337,14 @@ async fn user_status(
|
|||||||
// ── GET /overview ─────────────────────────────────────────────────────────────
|
// ── GET /overview ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// The caller's full memory snapshot — the direct answer to "what does Honcho
|
/// The caller's full memory snapshot — the direct answer to "what does Honcho
|
||||||
/// know about me?": peer card (curated key facts) + conclusions (derived facts,
|
/// know about me?": the peer card (curated key facts), the pre-rendered
|
||||||
/// with their ids) + summary. Two cheap GETs, no LLM synthesis.
|
/// 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(
|
async fn user_overview(
|
||||||
State(cell): State<WebCell>,
|
State(cell): State<WebCell>,
|
||||||
Extension(caller): Extension<Caller>,
|
Extension(caller): Extension<Caller>,
|
||||||
@@ -349,10 +358,12 @@ async fn user_overview(
|
|||||||
Err(r) => return r,
|
Err(r) => return r,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A 404 on either call is "no peer/card yet", not a failure — the peer is
|
// 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.
|
// 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 {
|
let card = match web.client.get_peer_card(&web.workspace_id, &peer, None).await {
|
||||||
Ok(v) => v,
|
Ok(c) => json!(c.peer_card),
|
||||||
Err(e @ HonchoError::Http { status: 404, .. }) => {
|
Err(e @ HonchoError::Http { status: 404, .. }) => {
|
||||||
debug!("honcho /overview: no card for peer '{peer}' yet: {e}");
|
debug!("honcho /overview: no card for peer '{peer}' yet: {e}");
|
||||||
Value::Null
|
Value::Null
|
||||||
@@ -360,7 +371,7 @@ async fn user_overview(
|
|||||||
Err(e) => return honcho_error(&web, &caller, &e).await,
|
Err(e) => return honcho_error(&web, &caller, &e).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
let ctx = match web.client.peer_context(
|
let representation = match web.client.peer_context(
|
||||||
&web.workspace_id,
|
&web.workspace_id,
|
||||||
&peer,
|
&peer,
|
||||||
&PeerRepresentationGet {
|
&PeerRepresentationGet {
|
||||||
@@ -368,27 +379,39 @@ async fn user_overview(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
).await {
|
).await {
|
||||||
Ok(v) => v,
|
Ok(ctx) => ctx.representation,
|
||||||
Err(e @ HonchoError::Http { status: 404, .. }) => {
|
Err(e @ HonchoError::Http { status: 404, .. }) => {
|
||||||
debug!("honcho /overview: no context for peer '{peer}' yet: {e}");
|
debug!("honcho /overview: no context for peer '{peer}' yet: {e}");
|
||||||
Value::Null
|
None
|
||||||
}
|
}
|
||||||
Err(e) => return honcho_error(&web, &caller, &e).await,
|
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!({
|
Json(json!({
|
||||||
"card": card,
|
"card": card,
|
||||||
"conclusions": ctx.get("conclusions").cloned().unwrap_or(Value::Array(vec![])),
|
"representation": representation,
|
||||||
"summary": ctx.get("summary").and_then(Value::as_str),
|
"conclusions": conclusions,
|
||||||
})).into_response()
|
})).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /search ──────────────────────────────────────────────────────────────
|
// ── POST /search ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Semantic search over the caller's derived facts: `peer_context` with a
|
/// Semantic search over the caller's derived facts: `conclusions/query`,
|
||||||
/// `search_query`, ranked raw excerpts with their ids — no LLM synthesis. The
|
/// ranked raw excerpts with their ids — no LLM synthesis. The same path as the
|
||||||
/// same proven path as the `honcho_search` tool (the direct
|
/// `honcho_search` tool; the observer/observed scoping lives in `filters`
|
||||||
/// `conclusions/query` endpoint needs observer/observed filters and is not it).
|
/// (and `peer_context` would not do: it returns the whole representation with
|
||||||
|
/// no ids once it fits the budget).
|
||||||
async fn user_search(
|
async fn user_search(
|
||||||
State(cell): State<WebCell>,
|
State(cell): State<WebCell>,
|
||||||
Extension(caller): Extension<Caller>,
|
Extension(caller): Extension<Caller>,
|
||||||
@@ -407,24 +430,16 @@ async fn user_search(
|
|||||||
Err(r) => return r,
|
Err(r) => return r,
|
||||||
};
|
};
|
||||||
|
|
||||||
match web.client.peer_context(
|
match web.client.query_conclusions(
|
||||||
&web.workspace_id,
|
&web.workspace_id,
|
||||||
&peer,
|
&ConclusionQuery {
|
||||||
&PeerRepresentationGet {
|
query,
|
||||||
search_query: Some(query),
|
top_k: Some(SEARCH_TOP_K),
|
||||||
search_top_k: Some(SEARCH_TOP_K),
|
distance: None,
|
||||||
..Default::default()
|
filters: Some(json!({ "observer_id": peer, "observed_id": peer })),
|
||||||
},
|
},
|
||||||
).await {
|
).await {
|
||||||
Ok(ctx) => Json(json!({
|
Ok(conclusions) => Json(json!({ "conclusions": conclusions })).into_response(),
|
||||||
"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,
|
Err(e) => honcho_error(&web, &caller, &e).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default {
|
|||||||
// "What does it remember?" panel (shown once opted in)
|
// "What does it remember?" panel (shown once opted in)
|
||||||
[`${P}.panel.title`]: 'What Honcho remembers about you',
|
[`${P}.panel.title`]: 'What Honcho remembers about you',
|
||||||
[`${P}.panel.guide_title`]: 'How to use this page',
|
[`${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_overview`]: 'Overview shows everything Honcho has derived about you so far: your card (key facts), the individual facts, and the full digest it hands to the assistant. 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_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.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_title`]: 'Service status',
|
||||||
@@ -54,7 +54,7 @@ export default {
|
|||||||
[`${P}.panel.overview_title`]: 'Overview',
|
[`${P}.panel.overview_title`]: 'Overview',
|
||||||
[`${P}.panel.card_title`]: 'Your card',
|
[`${P}.panel.card_title`]: 'Your card',
|
||||||
[`${P}.panel.facts_title`]: 'Facts',
|
[`${P}.panel.facts_title`]: 'Facts',
|
||||||
[`${P}.panel.summary_title`]: 'Summary',
|
[`${P}.panel.representation_title`]: 'Representation (what the assistant receives)',
|
||||||
[`${P}.panel.no_memory`]: 'Honcho has no memory about you yet — it builds up as you chat.',
|
[`${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_title`]: 'Search or ask',
|
||||||
[`${P}.panel.query_hint`]: 'Words to find facts, or a full question for the AI.',
|
[`${P}.panel.query_hint`]: 'Words to find facts, or a full question for the AI.',
|
||||||
@@ -98,7 +98,7 @@ export default {
|
|||||||
// Pannello "cosa ricorda di te?" (visibile dopo il consenso)
|
// Pannello "cosa ricorda di te?" (visibile dopo il consenso)
|
||||||
[`${P}.panel.title`]: 'Cosa ricorda Honcho di te',
|
[`${P}.panel.title`]: 'Cosa ricorda Honcho di te',
|
||||||
[`${P}.panel.guide_title`]: 'Come usare questa pagina',
|
[`${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_overview`]: 'La panoramica mostra tutto ciò che Honcho ha ricavato su di te finora: la tua scheda (fatti chiave), i singoli fatti e il riassunto completo che consegna all’assistente. 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_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.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_title`]: 'Stato del servizio',
|
||||||
@@ -109,7 +109,7 @@ export default {
|
|||||||
[`${P}.panel.overview_title`]: 'Panoramica',
|
[`${P}.panel.overview_title`]: 'Panoramica',
|
||||||
[`${P}.panel.card_title`]: 'La tua scheda',
|
[`${P}.panel.card_title`]: 'La tua scheda',
|
||||||
[`${P}.panel.facts_title`]: 'Fatti',
|
[`${P}.panel.facts_title`]: 'Fatti',
|
||||||
[`${P}.panel.summary_title`]: 'Riassunto',
|
[`${P}.panel.representation_title`]: 'Rappresentazione (quella che riceve l’assistente)',
|
||||||
[`${P}.panel.no_memory`]: 'Honcho non ha ancora nessun ricordo di te — si costruisce chiacchierando.',
|
[`${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_title`]: 'Cerca o chiedi',
|
||||||
[`${P}.panel.query_hint`]: 'Parole per trovare fatti, oppure una domanda completa per l’AI.',
|
[`${P}.panel.query_hint`]: 'Parole per trovare fatti, oppure una domanda completa per l’AI.',
|
||||||
@@ -153,7 +153,7 @@ export default {
|
|||||||
// Panneau « que retient-il de vous ? » (visible après le consentement)
|
// Panneau « que retient-il de vous ? » (visible après le consentement)
|
||||||
[`${P}.panel.title`]: 'Ce que Honcho retient de vous',
|
[`${P}.panel.title`]: 'Ce que Honcho retient de vous',
|
||||||
[`${P}.panel.guide_title`]: 'Comment utiliser cette page',
|
[`${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_overview`]: 'L’aperçu montre tout ce que Honcho a déduit de vous jusqu’ici : votre fiche (faits clés), les faits individuels et la synthèse complète remise à l’assistant. 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_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.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_title`]: 'État du service',
|
||||||
@@ -164,7 +164,7 @@ export default {
|
|||||||
[`${P}.panel.overview_title`]: 'Aperçu',
|
[`${P}.panel.overview_title`]: 'Aperçu',
|
||||||
[`${P}.panel.card_title`]: 'Votre fiche',
|
[`${P}.panel.card_title`]: 'Votre fiche',
|
||||||
[`${P}.panel.facts_title`]: 'Faits',
|
[`${P}.panel.facts_title`]: 'Faits',
|
||||||
[`${P}.panel.summary_title`]: 'Résumé',
|
[`${P}.panel.representation_title`]: 'Représentation (celle que reçoit l’assistant)',
|
||||||
[`${P}.panel.no_memory`]: 'Honcho n’a pas encore de mémoire vous concernant — elle se construit au fil des conversations.',
|
[`${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_title`]: 'Rechercher ou demander',
|
||||||
[`${P}.panel.query_hint`]: 'Des mots pour trouver des faits, ou une question complète pour l’IA.',
|
[`${P}.panel.query_hint`]: 'Des mots pour trouver des faits, ou une question complète pour l’IA.',
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
// 2. Once opted in (saved flag, not the draft toggle): the "what does Honcho
|
// 2. Once opted in (saved flag, not the draft toggle): the "what does Honcho
|
||||||
// remember about me?" debug panel, backed by this plugin's own opt-in-gated
|
// remember about me?" debug panel, backed by this plugin's own opt-in-gated
|
||||||
// endpoints — `GET ${api}/status` (service health + the caller's own
|
// endpoints — `GET ${api}/status` (service health + the caller's own
|
||||||
// processing queue), `GET ${api}/overview` (card + facts + summary, no
|
// processing queue), `GET ${api}/overview` (card + facts + representation,
|
||||||
// input), and one text field with two actions: `POST ${api}/search` (raw
|
// no input), and one text field with two actions: `POST ${api}/search`
|
||||||
// ranked facts) and `POST ${api}/ask` (Honcho's server-side LLM answers).
|
// (raw ranked facts) and `POST ${api}/ask` (Honcho's server-side LLM
|
||||||
// The built-in mini-guide explains the difference, because "words → facts"
|
// answers). The built-in mini-guide explains the difference, because
|
||||||
// vs "question → AI answer" is not obvious.
|
// "words → facts" vs "question → AI answer" is not obvious.
|
||||||
//
|
//
|
||||||
// Errors from these endpoints arrive already localized *and specific* (the
|
// Errors from these endpoints arrive already localized *and specific* (the
|
||||||
// backend forwards the real Honcho transport/HTTP detail) — they are surfaced
|
// backend forwards the real Honcho transport/HTTP detail) — they are surfaced
|
||||||
@@ -37,12 +37,12 @@ export default class HonchoMemoryPage extends HonchoBase {
|
|||||||
// Debug panel (only used once the *saved* opt-in flag is on).
|
// Debug panel (only used once the *saved* opt-in flag is on).
|
||||||
_svc: { state: true }, // null | { ok, latency_ms?, queue? } | { ok:false, error }
|
_svc: { state: true }, // null | { ok, latency_ms?, queue? } | { ok:false, error }
|
||||||
_svcBusy: { state: true },
|
_svcBusy: { state: true },
|
||||||
_ov: { state: true }, // null | { card, conclusions, summary }
|
_ov: { state: true }, // null | { card, representation, conclusions }
|
||||||
_ovBusy: { state: true },
|
_ovBusy: { state: true },
|
||||||
_ovErr: { state: true }, // string | null
|
_ovErr: { state: true }, // string | null
|
||||||
_q: { state: true }, // query input value
|
_q: { state: true }, // query input value
|
||||||
_qBusy: { state: true }, // null | 'search' | 'ask'
|
_qBusy: { state: true }, // null | 'search' | 'ask'
|
||||||
_qRes: { state: true }, // null | { kind:'search', conclusions, empty } | { kind:'ask', answer }
|
_qRes: { state: true }, // null | { kind:'search', conclusions } | { kind:'ask', answer }
|
||||||
_qErr: { state: true }, // string | null
|
_qErr: { state: true }, // string | null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ export default class HonchoMemoryPage extends HonchoBase {
|
|||||||
body: JSON.stringify({ query: q }),
|
body: JSON.stringify({ query: q }),
|
||||||
});
|
});
|
||||||
this._qRes = kind === 'search'
|
this._qRes = kind === 'search'
|
||||||
? { kind, conclusions: r?.conclusions ?? [], empty: !!r?.empty }
|
? { kind, conclusions: r?.conclusions ?? [] }
|
||||||
: { kind, answer: r?.answer ?? '' };
|
: { kind, answer: r?.answer ?? '' };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._qErr = e.message;
|
this._qErr = e.message;
|
||||||
@@ -276,19 +276,10 @@ export default class HonchoMemoryPage extends HonchoBase {
|
|||||||
<div class="mt-2">${body}</div>`;
|
<div class="mt-2">${body}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize the peer card into renderable pieces: an array (or an object with
|
// The backend already unwraps Honcho's `{"peer_card": …}` envelope: the card
|
||||||
// an array under a known key) becomes items; anything else is shown as JSON.
|
// arrives as a bare array of fact strings, or null when none was curated.
|
||||||
_cardItems(card) {
|
_cardItems(card) {
|
||||||
if (card == null) return null;
|
return Array.isArray(card) && card.length ? card : 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() {
|
_renderOverview() {
|
||||||
@@ -305,24 +296,22 @@ export default class HonchoMemoryPage extends HonchoBase {
|
|||||||
} else if (this._ov) {
|
} else if (this._ov) {
|
||||||
const conclusions = this._ov.conclusions ?? [];
|
const conclusions = this._ov.conclusions ?? [];
|
||||||
const card = this._cardItems(this._ov.card);
|
const card = this._cardItems(this._ov.card);
|
||||||
const summary = (this._ov.summary ?? '').trim();
|
const representation = (this._ov.representation ?? '').trim();
|
||||||
if (!card && !conclusions.length && !summary) {
|
if (!card && !conclusions.length && !representation) {
|
||||||
body = html`<div class="text-body-secondary" style="font-size:.8rem">${t(`${P}.panel.no_memory`)}</div>`;
|
body = html`<div class="text-body-secondary" style="font-size:.8rem">${t(`${P}.panel.no_memory`)}</div>`;
|
||||||
} else {
|
} else {
|
||||||
body = html`
|
body = html`
|
||||||
${card ? html`
|
${card ? html`
|
||||||
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.card_title`)}</div>
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.card_title`)}</div>
|
||||||
${Array.isArray(card)
|
<ul class="mb-2" style="font-size:.82rem">${card.map((c, i) => html`<li key=${i}>${c}</li>`)}</ul>
|
||||||
? 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}
|
` : nothing}
|
||||||
${conclusions.length ? html`
|
${conclusions.length ? html`
|
||||||
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.facts_title`)}</div>
|
<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>
|
<ul class="mb-2" style="font-size:.82rem">${conclusions.map(this._factLi)}</ul>
|
||||||
` : nothing}
|
` : nothing}
|
||||||
${summary ? html`
|
${representation ? html`
|
||||||
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.summary_title`)}</div>
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.representation_title`)}</div>
|
||||||
<div style="font-size:.82rem; white-space:pre-wrap">${summary}</div>
|
<div style="font-size:.82rem; white-space:pre-wrap">${representation}</div>
|
||||||
` : nothing}`;
|
` : nothing}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -347,9 +336,7 @@ export default class HonchoMemoryPage extends HonchoBase {
|
|||||||
if (this._qErr) {
|
if (this._qErr) {
|
||||||
result = html`<div class="alert alert-danger py-2" style="font-size:.8rem">${this._qErr}</div>`;
|
result = html`<div class="alert alert-danger py-2" style="font-size:.8rem">${this._qErr}</div>`;
|
||||||
} else if (this._qRes?.kind === 'search') {
|
} else if (this._qRes?.kind === 'search') {
|
||||||
result = this._qRes.empty
|
result = this._qRes.conclusions.length
|
||||||
? 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`<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>`;
|
: html`<div class="text-body-secondary" style="font-size:.8rem">${t(`${P}.panel.search_empty`)}</div>`;
|
||||||
} else if (this._qRes?.kind === 'ask') {
|
} else if (this._qRes?.kind === 'ask') {
|
||||||
|
|||||||
@@ -11,3 +11,5 @@
|
|||||||
**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`.
|
**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`.
|
||||||
|
|
||||||
|
**Honcho's response schema is version-specific and bit us once.** Verified against self-hosted **3.0.11** (the compose pins the image by digest — ghcr publishes no v3 semver tags, and a silent `:latest` bump is exactly how the drift arrived): `peers/{id}/context` returns `{representation: markdown string, peer_card: string[]|null}` — **no `conclusions` array** — and `sessions/{id}/context` returns `summary` as an *object* (`{content,…}`) plus `peer_representation`. The card endpoints wrap the list (`{"peer_card": …}`) in both directions — a bare array on PUT is a 422. Semantic search with ranked ids is `POST conclusions/query` with the observer/observed scoping inside a `filters` object — `peer_context?search_query=…` is *not* a substitute (it returns the whole representation once it fits the budget, with no ids). All of this is typed in `honcho-client::models` and pinned by fixture tests from real payloads, plus a live smoke test (`HONCHO_E2E_*` env vars, `cargo test -p honcho-client -- --ignored`). If a future Honcho upgrade changes shapes again, that test is the five-second check.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Long-term memory is **off for every user until they turn it on themselves**. Onc
|
|||||||
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:
|
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".
|
- **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.
|
- **Overview** — everything Honcho has derived so far: the user's "card" (curated key facts — empty until some are set, e.g. via the `honcho_profile` tool), the individual facts (each with its id), and the full **representation** digest Honcho hands to the assistant on every turn. Loads automatically; nothing to type.
|
||||||
- **Search or ask** — one text field, two actions, with a mini-guide on the page:
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
+12
-6
@@ -1,8 +1,8 @@
|
|||||||
# Honcho — self-hosted Docker package
|
# Honcho — self-hosted Docker package
|
||||||
|
|
||||||
This folder contains a ready-to-run Docker Compose setup for [Honcho](https://honcho.dev),
|
This folder contains a ready-to-run Docker Compose setup for [Honcho](https://honcho.dev),
|
||||||
the memory server used by the personal-agent's Honcho plugin
|
the memory server used by the Honcho plugin
|
||||||
([`src/plugin/honcho/`](../src/plugin/honcho/)).
|
([`crates/plugin-honcho/`](../crates/plugin-honcho/)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,11 +10,16 @@ the memory server used by the personal-agent's Honcho plugin
|
|||||||
|
|
||||||
| Service | Image | Port | Role |
|
| Service | Image | Port | Role |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `api` | `ghcr.io/plastic-labs/honcho:latest` | **8000** | REST API (the endpoint personal-agent talks to) |
|
| `api` | `ghcr.io/plastic-labs/honcho@sha256:59f0…8c6b` (**3.0.11**, digest-pinned) | **8000** | REST API (the endpoint the app talks to) |
|
||||||
| `deriver` | same image | — | Background worker: extracts conclusions, summaries, peer representations |
|
| `deriver` | same image | — | Background worker: extracts conclusions, summaries, peer representations |
|
||||||
| `db` | `pgvector/pgvector:pg17` | 5432 (internal) | PostgreSQL + pgvector (vector search) |
|
| `db` | `pgvector/pgvector:pg17` | 5432 (internal) | PostgreSQL + pgvector (vector search) |
|
||||||
| `redis` | `redis:7-alpine` | 6379 (internal) | Cache for session context |
|
| `redis` | `redis:7-alpine` | 6379 (internal) | Cache for session context |
|
||||||
|
|
||||||
|
> The Honcho image is **pinned by digest** because ghcr publishes no v3 semver
|
||||||
|
> tags (only `latest`), and the plugin parses a version-specific API schema —
|
||||||
|
> a silent `:latest` bump already changed response shapes once. Upgrade
|
||||||
|
> deliberately: pick the new digest, verify the plugin against it, then edit.
|
||||||
|
|
||||||
Data is stored in named Docker volumes (`honcho_db`, `honcho_redis`) and survives container restarts.
|
Data is stored in named Docker volumes (`honcho_db`, `honcho_redis`) and survives container restarts.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -140,7 +145,8 @@ docker compose restart api
|
|||||||
# Stop and wipe all data (destructive!)
|
# Stop and wipe all data (destructive!)
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
|
|
||||||
# Upgrade to a newer Honcho image
|
# Upgrade Honcho: bump the pinned digest in docker-compose.yml (deliberately —
|
||||||
|
# the plugin's API parsing is verified against the pinned version), then
|
||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
@@ -181,5 +187,5 @@ docker compose up -d --build
|
|||||||
- [Honcho GitHub](https://github.com/plastic-labs/honcho)
|
- [Honcho GitHub](https://github.com/plastic-labs/honcho)
|
||||||
- [Honcho docs](https://docs.honcho.dev)
|
- [Honcho docs](https://docs.honcho.dev)
|
||||||
- [Self-hosting guide (official)](https://docs.honcho.dev/v3/contributing/self-hosting)
|
- [Self-hosting guide (official)](https://docs.honcho.dev/v3/contributing/self-hosting)
|
||||||
- [personal-agent Honcho plugin docs](../docs/honcho.md)
|
- [Honcho plugin docs](../docs/plugins/honcho.md)
|
||||||
- [personal-agent Memory architecture](../docs/memory.md)
|
- [Memory architecture](../docs/memory.md)
|
||||||
|
|||||||
@@ -13,6 +13,11 @@
|
|||||||
#
|
#
|
||||||
# The API will be available at http://localhost:8000
|
# The API will be available at http://localhost:8000
|
||||||
# Interactive docs: http://localhost:8000/docs
|
# Interactive docs: http://localhost:8000/docs
|
||||||
|
#
|
||||||
|
# The Honcho image is PINNED BY DIGEST (ghcr publishes no v3 semver tags, only
|
||||||
|
# `latest`): this digest is Honcho 3.0.11. The plugin parses a version-specific
|
||||||
|
# API schema (`representation`/`peer_card`, wrapped card, `conclusions/query`
|
||||||
|
# filters) — bump the digest deliberately and re-verify, never via `:latest`.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -20,7 +25,7 @@ services:
|
|||||||
# Applies Alembic migrations before the API starts.
|
# Applies Alembic migrations before the API starts.
|
||||||
# Exits with code 0 when done; Docker Compose marks it "completed".
|
# Exits with code 0 when done; Docker Compose marks it "completed".
|
||||||
migrate:
|
migrate:
|
||||||
image: ghcr.io/plastic-labs/honcho:latest
|
image: ghcr.io/plastic-labs/honcho@sha256:59f099ad85713105608c9b239a4f1386fbebd8647323d3a04646bb3c975d8c6b # Honcho 3.0.11, pinned — see header
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
DB_CONNECTION_URI: postgresql+psycopg://${POSTGRES_USER:-honcho}:${POSTGRES_PASSWORD:-honcho}@db:5432/${POSTGRES_DB:-honcho}
|
DB_CONNECTION_URI: postgresql+psycopg://${POSTGRES_USER:-honcho}:${POSTGRES_PASSWORD:-honcho}@db:5432/${POSTGRES_DB:-honcho}
|
||||||
@@ -33,7 +38,7 @@ services:
|
|||||||
|
|
||||||
# ── API ────────────────────────────────────────────────────────────────────
|
# ── API ────────────────────────────────────────────────────────────────────
|
||||||
api:
|
api:
|
||||||
image: ghcr.io/plastic-labs/honcho:latest
|
image: ghcr.io/plastic-labs/honcho@sha256:59f099ad85713105608c9b239a4f1386fbebd8647323d3a04646bb3c975d8c6b # Honcho 3.0.11, pinned — see header
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${HONCHO_PORT:-8000}:8000"
|
- "${HONCHO_PORT:-8000}:8000"
|
||||||
@@ -65,7 +70,7 @@ services:
|
|||||||
# Without a working LLM key this service will fail to process messages;
|
# Without a working LLM key this service will fail to process messages;
|
||||||
# the API itself will still work but no long-term memory will be built.
|
# the API itself will still work but no long-term memory will be built.
|
||||||
deriver:
|
deriver:
|
||||||
image: ghcr.io/plastic-labs/honcho:latest
|
image: ghcr.io/plastic-labs/honcho@sha256:59f099ad85713105608c9b239a4f1386fbebd8647323d3a04646bb3c975d8c6b # Honcho 3.0.11, pinned — see header
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["/app/.venv/bin/python", "-m", "src.deriver"]
|
command: ["/app/.venv/bin/python", "-m", "src.deriver"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
|||||||
Reference in New Issue
Block a user