From 7e3fa3caadb193dfc9426fbe2060720b96da2c25 Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 9 Sep 2026 19:15:11 +0100 Subject: [PATCH] =?UTF-8?q?fix(honcho):=20read=20Honcho=203.0.x=20response?= =?UTF-8?q?=20schema=20=E2=80=94=20memory=20reads=20were=20silently=20empt?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 17 ++ Cargo.lock | 1 + crates/honcho-client/Cargo.toml | 3 + crates/honcho-client/src/client.rs | 73 +++++++++ crates/honcho-client/src/models.rs | 144 ++++++++++++++++- crates/honcho-client/src/peers.rs | 16 +- crates/honcho-client/src/sessions.rs | 7 +- crates/plugin-honcho/src/lib.rs | 232 ++++++++++++++++++++------- crates/plugin-honcho/src/router.rs | 77 +++++---- crates/plugin-honcho/web/i18n.js | 12 +- crates/plugin-honcho/web/memory.js | 53 +++--- dev-docs/plugins.md | 2 + docs/plugins/honcho.md | 2 +- honcho/README.md | 18 ++- honcho/docker-compose.yml | 11 +- 15 files changed, 519 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 521e293..5470f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,11 +25,28 @@ release PR may merge — and a section is closed at the commit that bumps it. ### 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 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 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 - An LLM provider's API key is never sent to the browser any more: the provider list and diff --git a/Cargo.lock b/Cargo.lock index 2b81d15..6fe8487 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1636,6 +1636,7 @@ version = "0.1.0" dependencies = [ "anyhow", "reqwest 0.13.4", + "rustls", "serde", "serde_json", "tokio", diff --git a/crates/honcho-client/Cargo.toml b/crates/honcho-client/Cargo.toml index 509bb99..5e5ec7d 100644 --- a/crates/honcho-client/Cargo.toml +++ b/crates/honcho-client/Cargo.toml @@ -13,3 +13,6 @@ tracing = "0.1" # The crate-level doc example (`#[tokio::main]`) compiles under `cargo test`. tokio = { version = "1", features = ["macros", "rt"] } 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"] } diff --git a/crates/honcho-client/src/client.rs b/crates/honcho-client/src/client.rs index b37622d..f5234f2 100644 --- a/crates/honcho-client/src/client.rs +++ b/crates/honcho-client/src/client.rs @@ -171,3 +171,76 @@ fn urlencoding(s: &str) -> String { } 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= HONCHO_E2E_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); + } +} diff --git a/crates/honcho-client/src/models.rs b/crates/honcho-client/src/models.rs index ebe293c..1b0212a 100644 --- a/crates/honcho-client/src/models.rs +++ b/crates/honcho-client/src/models.rs @@ -185,13 +185,16 @@ pub struct MessageUpdate { // Conclusion // ────────────────────────────────────────── -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Conclusion { pub id: String, pub content: String, pub observer_id: String, pub observed_id: String, pub session_id: Option, + /// explicit | deductive | inductive | contradiction + #[serde(default)] + pub level: Option, pub created_at: String, } @@ -270,6 +273,66 @@ pub struct PeerRepresentationGet { pub max_conclusions: Option, } +// ────────────────────────────────────────── +// 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, + /// 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, + #[serde(default)] + pub peer_card: Option>, +} + +/// `GET /workspaces/{ws}/sessions/{session}/context` +#[derive(Debug, Clone, Deserialize)] +pub struct SessionContext { + pub id: String, + #[serde(default)] + pub messages: Vec, + #[serde(default)] + pub summary: Option, + /// Representation of the session's peer, when a perspective is available. + #[serde(default)] + pub peer_representation: Option, + #[serde(default)] + pub peer_card: Option>, +} + +/// 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>, +} + +/// 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, +} + // ────────────────────────────────────────── // Search // ────────────────────────────────────────── @@ -306,3 +369,82 @@ pub struct PageParams { pub size: Option, pub reverse: Option, } + +#[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-")); + } +} diff --git a/crates/honcho-client/src/peers.rs b/crates/honcho-client/src/peers.rs index 1312321..83e8337 100644 --- a/crates/honcho-client/src/peers.rs +++ b/crates/honcho-client/src/peers.rs @@ -92,13 +92,15 @@ impl HonchoClient { // ── 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( &self, workspace_id: &str, peer_id: &str, opts: &PeerRepresentationGet, - ) -> Result { + ) -> Result { let mut q: Vec<(&str, String)> = vec![]; if let Some(ref v) = opts.target { q.push(("target", v.clone())); @@ -132,7 +134,7 @@ impl HonchoClient { workspace_id: &str, peer_id: &str, target: Option<&str>, - ) -> Result { + ) -> Result { let mut q: Vec<(&str, String)> = vec![]; if let Some(t) = target { q.push(("target", t.to_owned())); @@ -144,13 +146,15 @@ impl HonchoClient { .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( &self, workspace_id: &str, peer_id: &str, target: Option<&str>, - card: serde_json::Value, - ) -> Result { + card: Vec, + ) -> Result { let mut q: Vec<(&str, String)> = vec![]; if let Some(t) = target { q.push(("target", t.to_owned())); @@ -158,7 +162,7 @@ impl HonchoClient { self.put_with_query( &format!("/v3/workspaces/{workspace_id}/peers/{peer_id}/card"), &q, - &card, + &PeerCardSet { peer_card: card }, ) .await } diff --git a/crates/honcho-client/src/sessions.rs b/crates/honcho-client/src/sessions.rs index 200bc25..4a3ec0a 100644 --- a/crates/honcho-client/src/sessions.rs +++ b/crates/honcho-client/src/sessions.rs @@ -161,14 +161,17 @@ impl HonchoClient { // ── 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( &self, workspace_id: &str, session_id: &str, tokens: Option, search_query: Option<&str>, - ) -> Result { + ) -> Result { let mut q: Vec<(&str, String)> = vec![]; if let Some(t) = tokens { q.push(("tokens", t.to_string())); diff --git a/crates/plugin-honcho/src/lib.rs b/crates/plugin-honcho/src/lib.rs index b3d42e6..ac4f3c0 100644 --- a/crates/plugin-honcho/src/lib.rs +++ b/crates/plugin-honcho/src/lib.rs @@ -70,8 +70,8 @@ use core_api::tool::{ use core_api::user_plugin_config::PluginUserConfigApi; use honcho_client::HonchoClient; use honcho_client::models::{ - ConclusionCreate, MessageCreate, PeerCreate, PeerRepresentationGet, - SessionCreate, SessionPeerConfig, WorkspaceCreate, + Conclusion, ConclusionCreate, ConclusionQuery, MessageCreate, PeerContext, PeerCreate, + PeerRepresentationGet, SessionContext, SessionCreate, SessionPeerConfig, WorkspaceCreate, }; const PLUGIN_ID: &str = "honcho"; @@ -225,8 +225,8 @@ impl Memory for HonchoMemory { }, ).await { Ok(ctx) => { - trace!(session_id, raw_json = %ctx, "honcho: peer_context raw response"); - let f = format_context(ctx); + trace!(session_id, response = ?ctx, "honcho: peer_context raw response"); + let f = format_peer_context(&ctx); debug!( "honcho: peer_context (global) for session {session_id} ({} chars)", f.as_deref().map_or(0, |s| s.len()) @@ -253,8 +253,8 @@ impl Memory for HonchoMemory { Some(user_message), ).await { Ok(ctx) => { - trace!(session_id, raw_json = %ctx, "honcho: session_context raw response"); - let f = format_context(ctx); + trace!(session_id, response = ?ctx, "honcho: session_context raw response"); + let f = format_session_context(&ctx); debug!( "honcho: session_context for session {session_id} ({} chars)", 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 { match card_update { Some(facts) => { + let facts: Vec = facts + .iter() + .filter_map(|f| f.as_str().map(str::to_string)) + .collect(); + let n = facts.len(); client - .set_peer_card(&workspace_id, &peer, None, json!(facts)) + .set_peer_card(&workspace_id, &peer, None, facts) .await .map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?; - Ok(format!("Peer card updated ({} facts).", facts.len())) + Ok(format!("Peer card updated ({n} facts).")) } None => { let card = client .get_peer_card(&workspace_id, &peer, None) .await .map_err(|e| anyhow::anyhow!("honcho_profile: {e}"))?; - Ok(serde_json::to_string_pretty(&card) - .unwrap_or_else(|_| card.to_string())) + // The response wraps the list: {"peer_card": [...] | null}. + 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 { let client = Arc::clone(&self.client); let workspace_id = self.workspace_id.clone(); - // Honcho's `conclusions/query` endpoint requires observer/observed - // filters; the proven path (shared with the read-path) is `peer_context` - // with a `search_query`, which ranks the user's conclusions by relevance. + // `conclusions/query` is the semantic search over the user's derived + // facts: ranked results WITH their ids (needed by `honcho_conclude`'s + // 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 { let query = args["query"] .as_str() .ok_or_else(|| anyhow::anyhow!("honcho_search: missing 'query' argument"))? .to_string(); - let ctx = client - .peer_context( + let conclusions = client + .query_conclusions( &workspace_id, - &peer, - &PeerRepresentationGet { - search_query: Some(query), - search_top_k: Some(10), - ..Default::default() + &ConclusionQuery { + query, + top_k: Some(10), + distance: None, + filters: Some(json!({ "observer_id": peer, "observed_id": peer })), }, ) .await .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())) }) } } -/// Formats the `conclusions` array of a Honcho `peer_context` response as a -/// ranked bullet list, prefixing each fact with its `id` when present so the -/// model can target it via `honcho_conclude`. Returns `None` when empty. -fn format_conclusions(ctx: &Value) -> Option { - let conclusions = ctx.get("conclusions")?.as_array()?; +/// Formats a list of conclusions as a ranked bullet list, prefixing each fact +/// with its `id` so the model can target it via `honcho_conclude`. Returns +/// `None` when empty. +fn format_conclusions(conclusions: &[Conclusion]) -> Option { let lines: Vec = conclusions .iter() - .filter_map(|c| { - 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}")), - } - }) + .map(|c| format!("- [{}] {}", c.id, c.content)) .collect(); (!lines.is_empty()).then(|| lines.join("\n")) } // ── HonchoContextTool ───────────────────────────────────────────────────────── -/// Retrieves a full context snapshot for the calling user (conclusions, card, -/// summary) from Honcho's `peer_context` endpoint. No LLM synthesis. +/// Retrieves a full context snapshot for the calling user (the markdown +/// representation of everything derived about them, plus their peer card) +/// from Honcho's `peer_context` endpoint. No LLM synthesis. struct HonchoContextTool { client: Arc, workspace_id: String, @@ -626,7 +635,7 @@ impl Tool for HonchoContextTool { .await .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` / -/// `peer_context` JSON response. +/// Formats a Honcho `peer_context` response for injection into the system +/// prompt / as the `honcho_context` tool result. /// -/// Returns `None` if there is nothing *new* to inject — i.e. when the response -/// contains only raw messages (which are already present in the LLM's own -/// conversation history) or is otherwise empty. +/// Honcho 3.0.x delivers everything a peer knows as a single pre-rendered +/// markdown `representation` string (sections like `## Explicit Observations` +/// 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: -/// - `conclusions` — facts about the user derived by Honcho's background processing -/// - `summary` — a narrative summary produced by Honcho -/// -/// 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 { +/// Raw `messages` are not part of this response at all; the representation is +/// already the synthesised knowledge, so it is injected verbatim. +fn format_peer_context(ctx: &PeerContext) -> Option { let mut parts: Vec = Vec::new(); - if let Some(conclusions) = ctx.get("conclusions").and_then(|v| v.as_array()) { - let facts: Vec<&str> = conclusions - .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(card) = ctx.peer_card.as_ref().filter(|c| !c.is_empty()) { + parts.push(format!("Peer card (curated key facts):\n- {}", card.join("\n- "))); } - if let Some(summary) = ctx.get("summary").and_then(|v| v.as_str()) { - if !summary.trim().is_empty() { - parts.push(format!("Conversation summary:\n{summary}")); - } + if let Some(rep) = ctx.representation.as_deref().map(str::trim).filter(|r| !r.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 { + let mut parts: Vec = 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}")); + } + + 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() { @@ -1147,3 +1171,91 @@ async fn get_or_create_session( let mut map = session_map.write().await; 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 = 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()); + } +} diff --git a/crates/plugin-honcho/src/router.rs b/crates/plugin-honcho/src/router.rs index 8266fa3..14725a3 100644 --- a/crates/plugin-honcho/src/router.rs +++ b/crates/plugin-honcho/src/router.rs @@ -8,7 +8,7 @@ //! - `GET /status` — user-facing service health (reachability + the //! caller's own processing queue). //! - `GET /overview` — the caller's full memory snapshot (peer card + -//! conclusions + summary). Cheap GETs, no LLM. +//! representation digest + derived conclusions with ids). Cheap, no LLM. //! - `POST /search` — semantic search over the caller's derived facts. //! - `POST /ask` — Dialectic: Honcho's server-side LLM answers a //! natural-language question from the caller's memory. @@ -63,7 +63,10 @@ use core_api::user_channel::UserChannelApi; use core_api::user_plugin_config::PluginUserConfigApi; use honcho_client::HonchoClient; use honcho_client::error::HonchoError; -use honcho_client::models::{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 // `../i18n/*.json`), resolved to the caller's language via `web.i18n`. @@ -334,8 +337,14 @@ async fn user_status( // ── 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. +/// know about me?": the peer card (curated key facts), the pre-rendered +/// markdown `representation` (what the assistant actually receives), and the +/// individual conclusions with their ids (for targeted deletion). Cheap, no +/// LLM synthesis. +/// +/// Schema note (Honcho 3.0.x): `peer_context` carries `representation` + +/// `peer_card`, NOT a `conclusions` array — the ids come from a separate +/// `conclusions/list` scoped to the caller's peer. async fn user_overview( State(cell): State, Extension(caller): Extension, @@ -349,10 +358,12 @@ async fn user_overview( 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. + // The card response wraps the list: `{"peer_card": [...] | null}` — unwrap + // it so the page receives the bare value. let card = match web.client.get_peer_card(&web.workspace_id, &peer, None).await { - Ok(v) => v, + Ok(c) => json!(c.peer_card), Err(e @ HonchoError::Http { status: 404, .. }) => { debug!("honcho /overview: no card for peer '{peer}' yet: {e}"); Value::Null @@ -360,7 +371,7 @@ async fn user_overview( 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, &peer, &PeerRepresentationGet { @@ -368,27 +379,39 @@ async fn user_overview( ..Default::default() }, ).await { - Ok(v) => v, + Ok(ctx) => ctx.representation, Err(e @ HonchoError::Http { status: 404, .. }) => { debug!("honcho /overview: no context for peer '{peer}' yet: {e}"); - Value::Null + None } Err(e) => return honcho_error(&web, &caller, &e).await, }; + let conclusions = match web.client.list_conclusions( + &web.workspace_id, + &PageParams { size: Some(OVERVIEW_MAX_CONCLUSIONS as u64), ..Default::default() }, + &ConclusionGet { + filters: Some(json!({ "observer_id": peer, "observed_id": peer })), + }, + ).await { + Ok(page) => page.items, + Err(e) => return honcho_error(&web, &caller, &e).await, + }; + Json(json!({ - "card": card, - "conclusions": ctx.get("conclusions").cloned().unwrap_or(Value::Array(vec![])), - "summary": ctx.get("summary").and_then(Value::as_str), + "card": card, + "representation": representation, + "conclusions": conclusions, })).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). +/// Semantic search over the caller's derived facts: `conclusions/query`, +/// ranked raw excerpts with their ids — no LLM synthesis. The same path as the +/// `honcho_search` tool; the observer/observed scoping lives in `filters` +/// (and `peer_context` would not do: it returns the whole representation with +/// no ids once it fits the budget). async fn user_search( State(cell): State, Extension(caller): Extension, @@ -407,24 +430,16 @@ async fn user_search( Err(r) => return r, }; - match web.client.peer_context( + match web.client.query_conclusions( &web.workspace_id, - &peer, - &PeerRepresentationGet { - search_query: Some(query), - search_top_k: Some(SEARCH_TOP_K), - ..Default::default() + &ConclusionQuery { + query, + top_k: Some(SEARCH_TOP_K), + distance: None, + filters: Some(json!({ "observer_id": peer, "observed_id": peer })), }, ).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() - } + Ok(conclusions) => Json(json!({ "conclusions": conclusions })).into_response(), Err(e) => honcho_error(&web, &caller, &e).await, } } diff --git a/crates/plugin-honcho/web/i18n.js b/crates/plugin-honcho/web/i18n.js index d27495a..b8bcf37 100644 --- a/crates/plugin-honcho/web/i18n.js +++ b/crates/plugin-honcho/web/i18n.js @@ -43,7 +43,7 @@ export default { // "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_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_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', @@ -54,7 +54,7 @@ export default { [`${P}.panel.overview_title`]: 'Overview', [`${P}.panel.card_title`]: 'Your card', [`${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.query_title`]: 'Search or ask', [`${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) [`${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_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_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', @@ -109,7 +109,7 @@ export default { [`${P}.panel.overview_title`]: 'Panoramica', [`${P}.panel.card_title`]: 'La tua scheda', [`${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.query_title`]: 'Cerca o chiedi', [`${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) [`${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_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_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', @@ -164,7 +164,7 @@ export default { [`${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.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.query_title`]: 'Rechercher ou demander', [`${P}.panel.query_hint`]: 'Des mots pour trouver des faits, ou une question complète pour l’IA.', diff --git a/crates/plugin-honcho/web/memory.js b/crates/plugin-honcho/web/memory.js index b67aeb6..f27ad1e 100644 --- a/crates/plugin-honcho/web/memory.js +++ b/crates/plugin-honcho/web/memory.js @@ -9,11 +9,11 @@ // 2. Once opted in (saved flag, not the draft toggle): the "what does Honcho // remember about me?" debug panel, backed by this plugin's own opt-in-gated // endpoints — `GET ${api}/status` (service health + the caller's own -// processing queue), `GET ${api}/overview` (card + facts + summary, no -// input), and one text field with two actions: `POST ${api}/search` (raw -// ranked facts) and `POST ${api}/ask` (Honcho's server-side LLM answers). -// The built-in mini-guide explains the difference, because "words → facts" -// vs "question → AI answer" is not obvious. +// processing queue), `GET ${api}/overview` (card + facts + representation, +// 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 @@ -37,12 +37,12 @@ export default class HonchoMemoryPage extends HonchoBase { // 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 } + _ov: { state: true }, // null | { card, representation, conclusions } _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 } + _qRes: { state: true }, // null | { kind:'search', conclusions } | { kind:'ask', answer } _qErr: { state: true }, // string | null }; } @@ -145,7 +145,7 @@ export default class HonchoMemoryPage extends HonchoBase { body: JSON.stringify({ query: q }), }); this._qRes = kind === 'search' - ? { kind, conclusions: r?.conclusions ?? [], empty: !!r?.empty } + ? { kind, conclusions: r?.conclusions ?? [] } : { kind, answer: r?.answer ?? '' }; } catch (e) { this._qErr = e.message; @@ -276,19 +276,10 @@ export default class HonchoMemoryPage extends HonchoBase {
${body}
`; } - // Normalize the peer card into renderable pieces: an array (or an object with - // an array under a known key) becomes items; anything else is shown as JSON. + // The backend already unwraps Honcho's `{"peer_card": …}` envelope: the card + // arrives as a bare array of fact strings, or null when none was curated. _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; + return Array.isArray(card) && card.length ? card : null; } _renderOverview() { @@ -305,24 +296,22 @@ export default class HonchoMemoryPage extends HonchoBase { } 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) { + const representation = (this._ov.representation ?? '').trim(); + if (!card && !conclusions.length && !representation) { body = html`
${t(`${P}.panel.no_memory`)}
`; } else { body = html` ${card ? html`
${t(`${P}.panel.card_title`)}
- ${Array.isArray(card) - ? html`
    ${card.map((c, i) => html`
  • ${typeof c === 'string' ? c : JSON.stringify(c)}
  • `)}
` - : html`
${card.raw}
`} +
    ${card.map((c, i) => html`
  • ${c}
  • `)}
` : nothing} ${conclusions.length ? html`
${t(`${P}.panel.facts_title`)}
    ${conclusions.map(this._factLi)}
` : nothing} - ${summary ? html` -
${t(`${P}.panel.summary_title`)}
-
${summary}
+ ${representation ? html` +
${t(`${P}.panel.representation_title`)}
+
${representation}
` : nothing}`; } } else { @@ -347,11 +336,9 @@ export default class HonchoMemoryPage extends HonchoBase { if (this._qErr) { result = html`
${this._qErr}
`; } else if (this._qRes?.kind === 'search') { - result = this._qRes.empty - ? html`
${t(`${P}.panel.no_memory`)}
` - : this._qRes.conclusions.length - ? html`
    ${this._qRes.conclusions.map(this._factLi)}
` - : html`
${t(`${P}.panel.search_empty`)}
`; + result = this._qRes.conclusions.length + ? html`
    ${this._qRes.conclusions.map(this._factLi)}
` + : html`
${t(`${P}.panel.search_empty`)}
`; } else if (this._qRes?.kind === 'ask') { result = html`
${t(`${P}.panel.answer_title`)}
diff --git a/dev-docs/plugins.md b/dev-docs/plugins.md index 46fb9a1..7274b32 100644 --- a/dev-docs/plugins.md +++ b/dev-docs/plugins.md @@ -11,3 +11,5 @@ **Plugin HTTP routes & web pages.** Every plugin's `http_router()` mounts at boot under `/api/plugin//` — **enabled or not**: two shared gates wrap each router (`require_auth`, then `guard::plugin_enabled_gate`, which re-checks the DB flag per request and answers 404 while disabled), so enable/disable serves/stops routes immediately with no restart, and plugin responses carry `Cache-Control: no-cache`. The router contract: cheap and safe to build pre-start, handlers tolerant of the not-running state (resolve runtime state per request through a shared cell, as mobile-connector does). A plugin may also contribute **frontend pages** via `Plugin::web_pages()` (`PluginPage { page_id, title, icon, entry, admin_only, priority }`): `GET /api/plugins/pages` returns the caller's visible pages (admin: all; others: non-`admin_only` pages of granted, enabled plugins) with `entry_url` resolved, and the sidebar renders them as menu entries routed `#plugin//`. A single `` (`web/components/plugin-page-host.js`) dynamic-imports the fragment ES module the plugin serves from its own router, registers its default-exported HTMLElement class, and mounts it with the `plugin-id` attribute — the fragment talks to its backend only through `/api/plugin//…` and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior. **A plugin page with its own backend (Honcho is the reference).** Most per-user plugin pages (Telegram's) need no backend of their own — the core `/api/plugins/…` endpoints carry their blob. Honcho's opt-in page grew a **debug panel** over the external memory server, and its router (`crates/plugin-honcho/src/router.rs`) is the pattern for that case, with three load-bearing rules. (1) **The peer id is the multi-user boundary**: the Honcho workspace is shared by every user, so every introspection handler receives its peer *already resolved* from the authenticated `Caller` — `require_peer` returns it or a 403 — and a client-supplied peer/workspace can never reach the external server. (2) **The opt-in gate is server-side and fail-closed** (`require_peer` re-reads the flag, same as the tools and the write path) — the panel hiding client-side when the flag is off is cosmetics, not the control. (3) **Errors are specific, never "service unavailable"**: transport failures and the external server's HTTP status+body are localized and forwarded (truncated), because the page exists to debug the integration — and a 404 from the external server is translated per-endpoint as "no memory yet", which is a state, not a failure. The shared `WebCell` carries the live client + workspace + user-config store alongside the router's other deps, filled by `start`/`stop`. + +**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. diff --git a/docs/plugins/honcho.md b/docs/plugins/honcho.md index 145a489..b4333b8 100644 --- a/docs/plugins/honcho.md +++ b/docs/plugins/honcho.md @@ -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: - **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** 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. diff --git a/honcho/README.md b/honcho/README.md index 5bf3f09..f697b93 100644 --- a/honcho/README.md +++ b/honcho/README.md @@ -1,8 +1,8 @@ # Honcho — self-hosted Docker package 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 -([`src/plugin/honcho/`](../src/plugin/honcho/)). +the memory server used by the Honcho plugin +([`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 | | --- | --- | --- | --- | -| `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 | | `db` | `pgvector/pgvector:pg17` | 5432 (internal) | PostgreSQL + pgvector (vector search) | | `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. --- @@ -140,7 +145,8 @@ docker compose restart api # Stop and wipe all data (destructive!) 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 up -d ``` @@ -181,5 +187,5 @@ docker compose up -d --build - [Honcho GitHub](https://github.com/plastic-labs/honcho) - [Honcho docs](https://docs.honcho.dev) - [Self-hosting guide (official)](https://docs.honcho.dev/v3/contributing/self-hosting) -- [personal-agent Honcho plugin docs](../docs/honcho.md) -- [personal-agent Memory architecture](../docs/memory.md) +- [Honcho plugin docs](../docs/plugins/honcho.md) +- [Memory architecture](../docs/memory.md) diff --git a/honcho/docker-compose.yml b/honcho/docker-compose.yml index 65090d5..3586325 100644 --- a/honcho/docker-compose.yml +++ b/honcho/docker-compose.yml @@ -13,6 +13,11 @@ # # The API will be available at http://localhost:8000 # 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: @@ -20,7 +25,7 @@ services: # Applies Alembic migrations before the API starts. # Exits with code 0 when done; Docker Compose marks it "completed". 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 environment: DB_CONNECTION_URI: postgresql+psycopg://${POSTGRES_USER:-honcho}:${POSTGRES_PASSWORD:-honcho}@db:5432/${POSTGRES_DB:-honcho} @@ -33,7 +38,7 @@ services: # ── 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 ports: - "${HONCHO_PORT:-8000}:8000" @@ -65,7 +70,7 @@ services: # 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. 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 command: ["/app/.venv/bin/python", "-m", "src.deriver"] env_file: .env