diff --git a/docs/whatsapp-data-flow.md b/docs/whatsapp-data-flow.md index 4da4540bd6d..c1fd6976b61 100644 --- a/docs/whatsapp-data-flow.md +++ b/docs/whatsapp-data-flow.md @@ -61,7 +61,7 @@ The scanner write-path RPCs are registered as **internal-only** in [`src/core/al The agent surfaces are exclusively read-only: - [`src/openhuman/tools/impl/whatsapp_data/`](../src/openhuman/tools/impl/whatsapp_data/) — `whatsapp_data_list_chats`, `whatsapp_data_list_messages`, `whatsapp_data_search_messages`. All three wrap their RPC counterparts and emit a `"provider": "whatsapp"` tag in the response so the agent can cite WhatsApp as the source. -- [`src/openhuman/tools/impl/memory/tree/`](../src/openhuman/tools/impl/memory/tree/) — generic `memory_tree_*` tools. Filter by `source_kind: "chat"` or query directly; WhatsApp chat-day transcripts are tagged `whatsapp` so they surface in cross-source flows. +- [`src/openhuman/memory_tree/tools/`](../src/openhuman/memory_tree/tools/) — generic `memory_tree_*` tools. Filter by `source_kind: "chat"` or query directly; WhatsApp chat-day transcripts are tagged `whatsapp` so they surface in cross-source flows. ## Why the orchestrator only lists three of these diff --git a/src/bin/gmail_backfill_3d.rs b/src/bin/gmail_backfill_3d.rs index 682443894b9..4400388c218 100644 --- a/src/bin/gmail_backfill_3d.rs +++ b/src/bin/gmail_backfill_3d.rs @@ -41,11 +41,11 @@ use openhuman_core::openhuman::composio::providers::registry::{ get_provider, init_default_providers, }; use openhuman_core::openhuman::config::Config; -use openhuman_core::openhuman::memory::tree::content_store::read::{ +use openhuman_core::openhuman::memory_tree::content_store::read::{ verify_chunk_file, verify_summary_file, VerifyResult, }; -use openhuman_core::openhuman::memory::tree::jobs::drain_until_idle; -use openhuman_core::openhuman::memory::tree::store::{ +use openhuman_core::openhuman::memory_tree::jobs::drain_until_idle; +use openhuman_core::openhuman::memory_tree::store::{ get_chunk_content_pointers, list_chunks, list_summaries_with_content_path, ListChunksQuery, }; diff --git a/src/bin/memory_tree_init_smoke.rs b/src/bin/memory_tree_init_smoke.rs index 34a4a47dd5d..0e916d56afe 100644 --- a/src/bin/memory_tree_init_smoke.rs +++ b/src/bin/memory_tree_init_smoke.rs @@ -30,7 +30,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use openhuman_core::openhuman::config::Config; -use openhuman_core::openhuman::memory::tree::store::with_connection; +use openhuman_core::openhuman::memory_tree::store::with_connection; fn main() -> ExitCode { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) diff --git a/src/bin/slack_backfill.rs b/src/bin/slack_backfill.rs index aaedacdc860..1ec525be3c6 100644 --- a/src/bin/slack_backfill.rs +++ b/src/bin/slack_backfill.rs @@ -211,8 +211,8 @@ async fn main() -> Result<()> { if cli.seal_probe { use chrono::{Duration, Utc}; - use openhuman_core::openhuman::memory::tree::canonicalize::chat::{ChatBatch, ChatMessage}; - use openhuman_core::openhuman::memory::tree::ingest::ingest_chat; + use openhuman_core::openhuman::memory_tree::canonicalize::chat::{ChatBatch, ChatMessage}; + use openhuman_core::openhuman::memory_tree::ingest::ingest_chat; let connection_id = cli.connection_id.clone().ok_or_else(|| { anyhow::anyhow!( diff --git a/src/core/all.rs b/src/core/all.rs index bf74cf66ed7..d87a3792f1a 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -185,9 +185,9 @@ fn build_registered_controllers() -> Vec { // Document and knowledge graph storage controllers.extend(crate::openhuman::memory::all_memory_registered_controllers()); // Memory tree ingestion layer (#707 — canonicalised chunks with provenance) - controllers.extend(crate::openhuman::memory::all_memory_tree_registered_controllers()); + controllers.extend(crate::openhuman::memory_tree::all_memory_tree_registered_controllers()); // Memory tree retrieval layer (#710 — LLM-callable read tools over the tree) - controllers.extend(crate::openhuman::memory::all_retrieval_registered_controllers()); + controllers.extend(crate::openhuman::memory_tree::all_retrieval_registered_controllers()); // Slack → memory-tree ingestion engine (per-message ingest, no bucketing) controllers.extend( crate::openhuman::composio::providers::slack::all_slack_memory_registered_controllers(), @@ -226,8 +226,7 @@ fn build_registered_controllers() -> Vec { // Core binary update management controllers.extend(crate::openhuman::update::all_update_registered_controllers()); // Hierarchical knowledge summarization - controllers - .extend(crate::openhuman::tree_summarizer::all_tree_summarizer_registered_controllers()); + controllers.extend(crate::openhuman::memory_tree::all_tree_summarizer_registered_controllers()); // Self-learning and user context enrichment controllers.extend(crate::openhuman::learning::all_learning_registered_controllers()); // Conversation thread and message management @@ -314,8 +313,8 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::tools::all_tools_controller_schemas()); schemas.extend(crate::openhuman::tool_registry::all_tool_registry_controller_schemas()); schemas.extend(crate::openhuman::memory::all_memory_controller_schemas()); - schemas.extend(crate::openhuman::memory::all_memory_tree_controller_schemas()); - schemas.extend(crate::openhuman::memory::all_retrieval_controller_schemas()); + schemas.extend(crate::openhuman::memory_tree::all_memory_tree_controller_schemas()); + schemas.extend(crate::openhuman::memory_tree::all_retrieval_controller_schemas()); schemas.extend( crate::openhuman::composio::providers::slack::all_slack_memory_controller_schemas(), ); @@ -333,7 +332,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::subconscious::all_subconscious_controller_schemas()); schemas.extend(crate::openhuman::webhooks::all_webhooks_controller_schemas()); schemas.extend(crate::openhuman::update::all_update_controller_schemas()); - schemas.extend(crate::openhuman::tree_summarizer::all_tree_summarizer_controller_schemas()); + schemas.extend(crate::openhuman::memory_tree::all_tree_summarizer_controller_schemas()); schemas.extend(crate::openhuman::learning::all_learning_controller_schemas()); // Conversation thread and message management schemas.extend(crate::openhuman::threads::all_threads_controller_schemas()); diff --git a/src/core/cli.rs b/src/core/cli.rs index fde529da262..dd2d53ae1bf 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -68,7 +68,7 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { } "text-input" => crate::openhuman::text_input::cli::run_text_input_command(&args[1..]), "tree-summarizer" => { - crate::openhuman::tree_summarizer::cli::run_tree_summarizer_command(&args[1..]) + crate::openhuman::memory_tree::summarizer::cli::run_tree_summarizer_command(&args[1..]) } "memory" => crate::core::memory_cli::run_memory_command(&args[1..]), "agent" => { diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 4e4b32c70ff..cb1013bbe42 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -1558,7 +1558,7 @@ fn register_domain_subscribers( ); } - crate::openhuman::memory::tree::jobs::start(config.clone()); + crate::openhuman::memory_tree::jobs::start(config.clone()); // Restart requests go through a subscriber so every trigger path shares // the same respawn logic. diff --git a/src/openhuman/agent/harness/archivist.rs b/src/openhuman/agent/harness/archivist.rs index 5696b455bdb..f453f1be3a1 100644 --- a/src/openhuman/agent/harness/archivist.rs +++ b/src/openhuman/agent/harness/archivist.rs @@ -24,17 +24,17 @@ use crate::openhuman::memory::store::profile::{self, FacetType}; use crate::openhuman::memory::store::segments::{ self, BoundaryConfig, BoundaryDecision, ConversationSegment, }; -use crate::openhuman::memory::tree::canonicalize::chat::{ChatBatch, ChatMessage}; -use crate::openhuman::memory::tree::chat::{ChatConsumer, ChatProvider}; -use crate::openhuman::memory::tree::ingest; -use crate::openhuman::memory::tree::score::embed::{build_embedder_from_config, Embedder}; -use crate::openhuman::memory::tree::tree_source::summariser::llm::{ +use crate::openhuman::memory_tree::canonicalize::chat::{ChatBatch, ChatMessage}; +use crate::openhuman::memory_tree::chat::{ChatConsumer, ChatProvider}; +use crate::openhuman::memory_tree::ingest; +use crate::openhuman::memory_tree::score::embed::{build_embedder_from_config, Embedder}; +use crate::openhuman::memory_tree::tree_source::summariser::llm::{ LlmSummariser, LlmSummariserConfig, }; -use crate::openhuman::memory::tree::tree_source::summariser::{ +use crate::openhuman::memory_tree::tree_source::summariser::{ Summariser, SummaryContext, SummaryInput, }; -use crate::openhuman::memory::tree::tree_source::types::TreeKind; +use crate::openhuman::memory_tree::tree_source::types::TreeKind; use async_trait::async_trait; use parking_lot::Mutex; use rusqlite::Connection; @@ -98,7 +98,7 @@ impl ArchivistHook { pub fn with_config(mut self, config: Config) -> Self { // Build the LLM chat provider for segment recap. let chat_provider: Option> = - match crate::openhuman::memory::tree::chat::build_chat_provider( + match crate::openhuman::memory_tree::chat::build_chat_provider( &config, ChatConsumer::Summarise, ) { @@ -650,7 +650,7 @@ impl ArchivistHook { .iter() .filter(|e| !e.content.trim().is_empty()) .map(|e| { - use crate::openhuman::memory::tree::types::approx_token_count; + use crate::openhuman::memory_tree::types::approx_token_count; let content = e.content.clone(); let token_count = approx_token_count(&content); let ts = chrono::DateTime::from_timestamp(e.timestamp as i64, 0) diff --git a/src/openhuman/agent/harness/archivist_tests.rs b/src/openhuman/agent/harness/archivist_tests.rs index b37df7588dd..9ae3349a17e 100644 --- a/src/openhuman/agent/harness/archivist_tests.rs +++ b/src/openhuman/agent/harness/archivist_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::openhuman::agent::hooks::{ToolCallRecord, TurnContext}; use crate::openhuman::memory::store::{events as ev, fts5, segments as seg}; -use crate::openhuman::memory::tree::chat::ChatPrompt; +use crate::openhuman::memory_tree::chat::ChatPrompt; fn setup_conn() -> Arc> { let conn = Connection::open_in_memory().unwrap(); @@ -343,7 +343,7 @@ async fn phase0_episodic_rows_and_segment_without_learning_enabled() { struct StubChatProvider; #[async_trait::async_trait] -impl crate::openhuman::memory::tree::chat::ChatProvider for StubChatProvider { +impl crate::openhuman::memory_tree::chat::ChatProvider for StubChatProvider { fn name(&self) -> &str { "stub:test" } @@ -361,7 +361,7 @@ impl crate::openhuman::memory::tree::chat::ChatProvider for StubChatProvider { struct StubEmbedder; #[async_trait::async_trait] -impl crate::openhuman::memory::tree::score::embed::Embedder for StubEmbedder { +impl crate::openhuman::memory_tree::score::embed::Embedder for StubEmbedder { fn name(&self) -> &'static str { "stub-embedder-v1" } @@ -546,7 +546,7 @@ async fn phase1_flush_open_segment_finalizes_trailing_segment() { // g) flush_open_segment also triggers tree ingest. use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::store::{count_chunks, list_chunks, ListChunksQuery}; +use crate::openhuman::memory_tree::store::{count_chunks, list_chunks, ListChunksQuery}; use tempfile::TempDir; /// Build a Config that points at a temp workspace, suitable for tree-ingest tests. diff --git a/src/openhuman/agent/harness/payload_summarizer.rs b/src/openhuman/agent/harness/payload_summarizer.rs index c4209143e98..21870059c13 100644 --- a/src/openhuman/agent/harness/payload_summarizer.rs +++ b/src/openhuman/agent/harness/payload_summarizer.rs @@ -309,7 +309,7 @@ impl PayloadSummarizer for SubagentPayloadSummarizer { } /// Rough token estimate: ~4 characters per token. Mirrors -/// [`crate::openhuman::tree_summarizer::types::estimate_tokens`] but +/// [`crate::openhuman::memory_tree::summarizer::types::estimate_tokens`] but /// returns `usize` (not `u32`) and lives here to avoid a cross-module /// dependency from the agent harness on the tree summarizer. fn estimate_tokens(text: &str) -> usize { diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index ee3c420de9a..89d5a8a935e 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -2150,7 +2150,7 @@ impl Agent { } /// Wrapper around -/// [`crate::openhuman::tree_summarizer::store::collect_root_summaries_with_caps`] +/// [`crate::openhuman::memory_tree::summarizer::store::collect_root_summaries_with_caps`] /// that takes user-resolved per-namespace and total caps. The actual /// limits are derived from the active /// [`crate::openhuman::config::schema::agent::MemoryContextWindow`] @@ -2160,7 +2160,7 @@ fn collect_tree_root_summaries( per_namespace_cap: usize, total_cap: usize, ) -> Vec<(String, String)> { - crate::openhuman::tree_summarizer::store::collect_root_summaries_with_caps( + crate::openhuman::memory_tree::summarizer::store::collect_root_summaries_with_caps( workspace_dir, per_namespace_cap, total_cap, diff --git a/src/openhuman/agent/harness/subagent_runner/handoff.rs b/src/openhuman/agent/harness/subagent_runner/handoff.rs index e6955e0711d..b423f0dae8a 100644 --- a/src/openhuman/agent/harness/subagent_runner/handoff.rs +++ b/src/openhuman/agent/harness/subagent_runner/handoff.rs @@ -29,7 +29,7 @@ use std::sync::Mutex as StdMutex; /// cache instead of being pushed into history raw. Token count is /// estimated at ~4 chars/token (mirrors /// `crate::openhuman::agent::harness::payload_summarizer` and -/// `crate::openhuman::tree_summarizer::types::estimate_tokens`). +/// `crate::openhuman::memory_tree::summarizer::types::estimate_tokens`). /// /// Set at `50_000` so the clean Gmail / Notion envelopes emitted by provider /// post-processing fit through unchanged for normal workloads — only diff --git a/src/openhuman/agent/tree_loader.rs b/src/openhuman/agent/tree_loader.rs index ffdcb580667..2ec5a87cc8c 100644 --- a/src/openhuman/agent/tree_loader.rs +++ b/src/openhuman/agent/tree_loader.rs @@ -21,7 +21,7 @@ //! concatenate without branching. use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::retrieval::query_global; +use crate::openhuman::memory_tree::retrieval::query_global; /// Default lookback window for the eager digest. Mirrors the language in /// the orchestrator prompt ("7-day digest pre-loaded into session context"). diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index deb05f53726..9138f9a5b9e 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -587,7 +587,7 @@ pub async fn start_channels(config: Config) -> Result<()> { }; // Register the tree summarizer event subscriber for observability logging. let _tree_summarizer_handle = bus.subscribe(Arc::new( - crate::openhuman::tree_summarizer::bus::TreeSummarizerEventSubscriber::new(), + crate::openhuman::memory_tree::summarizer::bus::TreeSummarizerEventSubscriber::new(), )); let max_in_flight_messages = compute_max_in_flight_messages(channels.len()); diff --git a/src/openhuman/composio/ops_test.rs b/src/openhuman/composio/ops_test.rs index 5f4959c385c..661be857e56 100644 --- a/src/openhuman/composio/ops_test.rs +++ b/src/openhuman/composio/ops_test.rs @@ -576,8 +576,8 @@ async fn composio_execute_via_mock_propagates_backend_error() { #[tokio::test] async fn composio_sync_gmail_via_mock_archives_raw_email_and_updates_outcome() { use crate::openhuman::config::TEST_ENV_LOCK; - use crate::openhuman::memory::tree::content_store::raw::{raw_rel_path, RawKind}; - use crate::openhuman::memory::tree::rpc::{list_chunks_rpc, ListChunksRequest}; + use crate::openhuman::memory_tree::content_store::raw::{raw_rel_path, RawKind}; + use crate::openhuman::memory_tree::rpc::{list_chunks_rpc, ListChunksRequest}; let _cache_guard = CACHE_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); let _env_guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/src/openhuman/composio/providers/gmail/ingest.rs b/src/openhuman/composio/providers/gmail/ingest.rs index 076f64a26f5..7c2ba94e2d7 100644 --- a/src/openhuman/composio/providers/gmail/ingest.rs +++ b/src/openhuman/composio/providers/gmail/ingest.rs @@ -21,16 +21,14 @@ use anyhow::Result; use serde_json::Value; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::canonicalize::email::{EmailMessage, EmailThread}; -use crate::openhuman::memory::tree::canonicalize::email_clean::{ - extract_email, parse_message_date, -}; -use crate::openhuman::memory::tree::content_store::raw::{ +use crate::openhuman::memory_tree::canonicalize::email::{EmailMessage, EmailThread}; +use crate::openhuman::memory_tree::canonicalize::email_clean::{extract_email, parse_message_date}; +use crate::openhuman::memory_tree::content_store::raw::{ self as raw_store, raw_rel_path, slug_account_email, RawItem, RawKind, }; -use crate::openhuman::memory::tree::ingest::{ingest_email, IngestResult}; -use crate::openhuman::memory::tree::store::{set_chunk_raw_refs, RawRef}; -use crate::openhuman::memory::tree::util::redact::redact; +use crate::openhuman::memory_tree::ingest::{ingest_email, IngestResult}; +use crate::openhuman::memory_tree::store::{set_chunk_raw_refs, RawRef}; +use crate::openhuman::memory_tree::util::redact::redact; /// Provider name embedded in the canonical email-thread header. Matches /// the value `memory::tree::retrieval::source::PLATFORM_KINDS` expects. diff --git a/src/openhuman/composio/providers/profile.rs b/src/openhuman/composio/providers/profile.rs index 6d92430d14b..90c6e3cabaf 100644 --- a/src/openhuman/composio/providers/profile.rs +++ b/src/openhuman/composio/providers/profile.rs @@ -32,7 +32,7 @@ use std::collections::BTreeMap; /// Shape of an identifier persisted against a connection. Mirrors the /// matching dimensions of the memory tree's -/// `crate::openhuman::memory::tree::score::extract::EntityKind` so the +/// `crate::openhuman::memory_tree::score::extract::EntityKind` so the /// self-check is a direct `(toolkit, kind, value)` lookup. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IdentityKind { diff --git a/src/openhuman/composio/providers/slack/ingest.rs b/src/openhuman/composio/providers/slack/ingest.rs index 9db66df4d91..6dc8d0d363a 100644 --- a/src/openhuman/composio/providers/slack/ingest.rs +++ b/src/openhuman/composio/providers/slack/ingest.rs @@ -2,7 +2,7 @@ //! //! Owns the conversion from a page of [`SlackMessage`]s (post-processed //! and enriched by [`super::sync`]) into per-channel [`ChatBatch`]es and -//! drives [`memory::tree::ingest::ingest_chat`] per message. +//! drives [`memory_tree::ingest::ingest_chat`] per message. //! //! ## Source-id scope //! @@ -30,16 +30,16 @@ use anyhow::Result; use super::types::SlackMessage; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::canonicalize::chat::{ChatBatch, ChatMessage}; -use crate::openhuman::memory::tree::content_store::raw::{ +use crate::openhuman::memory_tree::canonicalize::chat::{ChatBatch, ChatMessage}; +use crate::openhuman::memory_tree::content_store::raw::{ self as raw_store, raw_rel_path, RawItem, RawKind, }; -use crate::openhuman::memory::tree::ingest::ingest_chat; -use crate::openhuman::memory::tree::store::{set_chunk_raw_refs, RawRef}; -use crate::openhuman::memory::tree::util::redact::redact; +use crate::openhuman::memory_tree::ingest::ingest_chat; +use crate::openhuman::memory_tree::store::{set_chunk_raw_refs, RawRef}; +use crate::openhuman::memory_tree::util::redact::redact; /// Platform identifier embedded in the canonical chat transcript header. -/// Matches the value `memory::tree::retrieval::source::PLATFORM_KINDS` expects. +/// Matches the value `memory_tree::retrieval::source::PLATFORM_KINDS` expects. pub const SLACK_PLATFORM: &str = "slack"; /// Tags attached to every Slack-ingested chunk. Stable list — retrieval diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 5285a5b74b7..13ac7794a14 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -573,7 +573,7 @@ pub async fn apply_model_settings( // so a UI embedder switch recovers prior memory under the new // signature. Coverage-gated + non-fatal: if the active signature did // not actually change, this enqueues nothing. - crate::openhuman::memory::tree::jobs::ensure_reembed_backfill(config); + crate::openhuman::memory_tree::jobs::ensure_reembed_backfill(config); let snapshot = snapshot_config_json(config)?; Ok(RpcOutcome::new( snapshot, @@ -623,7 +623,7 @@ pub async fn apply_memory_settings( // dark. Idempotent + non-fatal (covered space enqueues nothing; errors // are logged, never fail the settings save). §7's migration is // one-shot so it does not cover a later switch — this does. - crate::openhuman::memory::tree::jobs::ensure_reembed_backfill(config); + crate::openhuman::memory_tree::jobs::ensure_reembed_backfill(config); let snapshot = snapshot_config_json(config)?; Ok(RpcOutcome::new( snapshot, diff --git a/src/openhuman/context/segment_recap_summarizer_tests.rs b/src/openhuman/context/segment_recap_summarizer_tests.rs index 86a1a8c45d0..476f8c1f66e 100644 --- a/src/openhuman/context/segment_recap_summarizer_tests.rs +++ b/src/openhuman/context/segment_recap_summarizer_tests.rs @@ -16,7 +16,7 @@ use crate::openhuman::agent::hooks::{PostTurnHook as _, TurnContext}; use crate::openhuman::context::summarizer::{Summarizer, SummaryStats}; use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage}; use crate::openhuman::memory::store::{fts5, segments as seg}; -use crate::openhuman::memory::tree::chat::ChatPrompt; +use crate::openhuman::memory_tree::chat::ChatPrompt; use anyhow::Result; use async_trait::async_trait; use parking_lot::Mutex; @@ -40,7 +40,7 @@ fn setup_conn() -> Arc> { struct StubChatProvider; #[async_trait] -impl crate::openhuman::memory::tree::chat::ChatProvider for StubChatProvider { +impl crate::openhuman::memory_tree::chat::ChatProvider for StubChatProvider { fn name(&self) -> &str { "stub:test" } @@ -56,7 +56,7 @@ impl crate::openhuman::memory::tree::chat::ChatProvider for StubChatProvider { struct FailingChatProvider; #[async_trait] -impl crate::openhuman::memory::tree::chat::ChatProvider for FailingChatProvider { +impl crate::openhuman::memory_tree::chat::ChatProvider for FailingChatProvider { fn name(&self) -> &str { "stub:failing" } @@ -72,7 +72,7 @@ impl crate::openhuman::memory::tree::chat::ChatProvider for FailingChatProvider struct StubEmbedder; #[async_trait] -impl crate::openhuman::memory::tree::score::embed::Embedder for StubEmbedder { +impl crate::openhuman::memory_tree::score::embed::Embedder for StubEmbedder { fn name(&self) -> &'static str { "stub-embedder-v1" } diff --git a/src/openhuman/doctor/core.rs b/src/openhuman/doctor/core.rs index 9dc9727db26..8ed86ef35b7 100644 --- a/src/openhuman/doctor/core.rs +++ b/src/openhuman/doctor/core.rs @@ -817,7 +817,7 @@ fn check_memory_tree_db(config: &Config, items: &mut Vec) { } // ── Probe connection ───────────────────────────────────────────── - match crate::openhuman::memory::tree::store::with_connection(config, |conn| { + match crate::openhuman::memory_tree::store::with_connection(config, |conn| { let n: i64 = conn.query_row("SELECT COUNT(*) FROM mem_tree_chunks", [], |r| r.get(0))?; Ok(n) }) { diff --git a/src/openhuman/doctor/core_tests.rs b/src/openhuman/doctor/core_tests.rs index fcf9d8b929a..08faacd6ede 100644 --- a/src/openhuman/doctor/core_tests.rs +++ b/src/openhuman/doctor/core_tests.rs @@ -89,7 +89,7 @@ fn check_memory_tree_db_ok_when_accessible() { let cfg = test_config_in(&tmp); // Trigger DB creation. - crate::openhuman::memory::tree::store::with_connection(&cfg, |_conn| Ok(())) + crate::openhuman::memory_tree::store::with_connection(&cfg, |_conn| Ok(())) .expect("DB init must succeed"); let mut items = vec![]; diff --git a/src/openhuman/inference/local/model_requirements.rs b/src/openhuman/inference/local/model_requirements.rs index 25e7c241816..a23eb482ea6 100644 --- a/src/openhuman/inference/local/model_requirements.rs +++ b/src/openhuman/inference/local/model_requirements.rs @@ -2,7 +2,7 @@ //! //! The memory tree's embedder (`bge-m3`) is requested with //! `num_ctx = 8192` (see -//! [`crate::openhuman::memory::tree::score::embed::ollama::EMBED_NUM_CTX`]) +//! [`crate::openhuman::memory_tree::score::embed::ollama::EMBED_NUM_CTX`]) //! and the summariser hard-caps its output to fit that 8192-token embed //! ceiling. A local model whose native context window is below this floor //! silently truncates chunks/summaries and corrupts recall, so we refuse @@ -21,7 +21,7 @@ use serde::Serialize; /// time. Changing the embedder's context request automatically moves the /// acceptance floor with it. pub const MIN_CONTEXT_TOKENS: u64 = - crate::openhuman::memory::tree::score::embed::ollama::EMBED_NUM_CTX as u64; + crate::openhuman::memory_tree::score::embed::ollama::EMBED_NUM_CTX as u64; /// Verdict for a single model's context window against /// [`MIN_CONTEXT_TOKENS`]. Serialized into the diagnostics payload so the @@ -79,7 +79,7 @@ mod tests { // requests; this guards against the two drifting apart. assert_eq!( MIN_CONTEXT_TOKENS, - crate::openhuman::memory::tree::score::embed::ollama::EMBED_NUM_CTX as u64 + crate::openhuman::memory_tree::score::embed::ollama::EMBED_NUM_CTX as u64 ); assert_eq!(MIN_CONTEXT_TOKENS, 8_192); } diff --git a/src/openhuman/memory/mod.rs b/src/openhuman/memory/mod.rs index bb44a8cbd86..64e73ea4120 100644 --- a/src/openhuman/memory/mod.rs +++ b/src/openhuman/memory/mod.rs @@ -19,8 +19,6 @@ pub mod store; pub mod sync_status; pub mod tool_memory; pub mod traits; -pub mod tree; - pub use ingestion::{ ExtractedEntity, ExtractedRelation, ExtractionMode, IngestionJob, IngestionQueue, IngestionState, IngestionStatusSnapshot, MemoryIngestionConfig, MemoryIngestionRequest, @@ -51,7 +49,3 @@ pub use tool_memory::{ TOOL_MEMORY_PROMPT_CAP, }; pub use traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}; -pub use tree::{ - all_memory_tree_controller_schemas, all_memory_tree_registered_controllers, - all_retrieval_controller_schemas, all_retrieval_registered_controllers, -}; diff --git a/src/openhuman/memory/ops/learn.rs b/src/openhuman/memory/ops/learn.rs index 8103f6b9263..5c188ba4b94 100644 --- a/src/openhuman/memory/ops/learn.rs +++ b/src/openhuman/memory/ops/learn.rs @@ -112,7 +112,8 @@ pub async fn memory_learn_all( namespace ); let outcome = - crate::openhuman::tree_summarizer::ops::tree_summarizer_run(&config, namespace).await; + crate::openhuman::memory_tree::summarizer::ops::tree_summarizer_run(&config, namespace) + .await; match outcome { Ok(_) => { tracing::info!("[memory.learn] namespace='{}' ok", namespace); diff --git a/src/openhuman/memory/stm_recall/recall_tests.rs b/src/openhuman/memory/stm_recall/recall_tests.rs index b4ac54bc18e..ca87bbc1300 100644 --- a/src/openhuman/memory/stm_recall/recall_tests.rs +++ b/src/openhuman/memory/stm_recall/recall_tests.rs @@ -586,7 +586,7 @@ fn render_empty_block_returns_empty_string() { #[tokio::test] async fn e2e_stm_recall_chain() { - use crate::openhuman::memory::tree::chat::ChatPrompt; + use crate::openhuman::memory_tree::chat::ChatPrompt; let conn = setup_conn(); @@ -597,7 +597,7 @@ async fn e2e_stm_recall_chain() { // requiring a live LLM or Ollama daemon. struct StubChat; - use crate::openhuman::memory::tree::chat::ChatProvider; + use crate::openhuman::memory_tree::chat::ChatProvider; #[async_trait::async_trait] impl ChatProvider for StubChat { fn name(&self) -> &str { @@ -611,10 +611,10 @@ async fn e2e_stm_recall_chain() { } } - use crate::openhuman::memory::tree::score::embed::InertEmbedder; - let chat_provider: Arc = + use crate::openhuman::memory_tree::score::embed::InertEmbedder; + let chat_provider: Arc = Arc::new(StubChat); - let embedder: Arc = + let embedder: Arc = Arc::new(InertEmbedder::new()); let archivist = ArchivistHook::new_with_stubs(conn.clone(), chat_provider, embedder); diff --git a/src/openhuman/memory/sync_status/rpc.rs b/src/openhuman/memory/sync_status/rpc.rs index 9afac862ead..52ed5835598 100644 --- a/src/openhuman/memory/sync_status/rpc.rs +++ b/src/openhuman/memory/sync_status/rpc.rs @@ -44,7 +44,7 @@ //! progress signal. use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::store::with_connection; +use crate::openhuman::memory_tree::store::with_connection; use crate::rpc::RpcOutcome; use rusqlite::Connection; @@ -247,7 +247,7 @@ mod tests { /// column is NULL. #[test] fn pending_and_processed_key_off_sidecar_not_inline_column() { - use crate::openhuman::memory::tree::store::with_connection; + use crate::openhuman::memory_tree::store::with_connection; use rusqlite::params; use tempfile::TempDir; @@ -311,7 +311,7 @@ mod tests { /// hides the progress bar): `batch_total = batch_processed = 0`. #[test] fn fully_embedded_provider_reports_no_active_wave() { - use crate::openhuman::memory::tree::store::with_connection; + use crate::openhuman::memory_tree::store::with_connection; use rusqlite::params; use tempfile::TempDir; @@ -357,7 +357,7 @@ mod tests { /// provider whose only leftovers are terminal drains to 0 pending / no wave. #[test] fn dropped_and_skipped_chunks_count_as_resolved_not_pending() { - use crate::openhuman::memory::tree::store::with_connection; + use crate::openhuman::memory_tree::store::with_connection; use rusqlite::params; use tempfile::TempDir; @@ -422,7 +422,7 @@ mod tests { /// still reflects the old straggler. #[test] fn stale_out_of_window_pending_does_not_open_a_wave() { - use crate::openhuman::memory::tree::store::with_connection; + use crate::openhuman::memory_tree::store::with_connection; use rusqlite::params; use tempfile::TempDir; diff --git a/src/openhuman/memory/tree/README.md b/src/openhuman/memory_tree/README.md similarity index 100% rename from src/openhuman/memory/tree/README.md rename to src/openhuman/memory_tree/README.md diff --git a/src/openhuman/memory/tree/canonicalize/README.md b/src/openhuman/memory_tree/canonicalize/README.md similarity index 100% rename from src/openhuman/memory/tree/canonicalize/README.md rename to src/openhuman/memory_tree/canonicalize/README.md diff --git a/src/openhuman/memory/tree/canonicalize/chat.rs b/src/openhuman/memory_tree/canonicalize/chat.rs similarity index 99% rename from src/openhuman/memory/tree/canonicalize/chat.rs rename to src/openhuman/memory_tree/canonicalize/chat.rs index 43e180c2dc5..42f8037dbec 100644 --- a/src/openhuman/memory/tree/canonicalize/chat.rs +++ b/src/openhuman/memory_tree/canonicalize/chat.rs @@ -18,7 +18,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use super::{normalize_source_ref, CanonicalisedSource}; -use crate::openhuman::memory::tree::types::{Metadata, SourceKind}; +use crate::openhuman::memory_tree::types::{Metadata, SourceKind}; /// One chat message in a channel/group. #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/openhuman/memory/tree/canonicalize/document.rs b/src/openhuman/memory_tree/canonicalize/document.rs similarity index 99% rename from src/openhuman/memory/tree/canonicalize/document.rs rename to src/openhuman/memory_tree/canonicalize/document.rs index 191426b2d65..3ea7fe6422a 100644 --- a/src/openhuman/memory/tree/canonicalize/document.rs +++ b/src/openhuman/memory_tree/canonicalize/document.rs @@ -9,7 +9,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Deserializer, Serialize}; use super::{normalize_source_ref, CanonicalisedSource}; -use crate::openhuman::memory::tree::types::{Metadata, SourceKind}; +use crate::openhuman::memory_tree::types::{Metadata, SourceKind}; // ── Serde helpers ───────────────────────────────────────────────────────────── diff --git a/src/openhuman/memory/tree/canonicalize/email.rs b/src/openhuman/memory_tree/canonicalize/email.rs similarity index 99% rename from src/openhuman/memory/tree/canonicalize/email.rs rename to src/openhuman/memory_tree/canonicalize/email.rs index 77e15116fc9..7dc123e88b4 100644 --- a/src/openhuman/memory/tree/canonicalize/email.rs +++ b/src/openhuman/memory_tree/canonicalize/email.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use super::{email_clean, normalize_source_ref, CanonicalisedSource}; -use crate::openhuman::memory::tree::types::{Metadata, SourceKind}; +use crate::openhuman::memory_tree::types::{Metadata, SourceKind}; /// One email in a thread. #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/openhuman/memory/tree/canonicalize/email_clean.rs b/src/openhuman/memory_tree/canonicalize/email_clean.rs similarity index 100% rename from src/openhuman/memory/tree/canonicalize/email_clean.rs rename to src/openhuman/memory_tree/canonicalize/email_clean.rs diff --git a/src/openhuman/memory/tree/canonicalize/mod.rs b/src/openhuman/memory_tree/canonicalize/mod.rs similarity index 96% rename from src/openhuman/memory/tree/canonicalize/mod.rs rename to src/openhuman/memory_tree/canonicalize/mod.rs index 71f47613896..c515678d127 100644 --- a/src/openhuman/memory/tree/canonicalize/mod.rs +++ b/src/openhuman/memory_tree/canonicalize/mod.rs @@ -16,7 +16,7 @@ pub mod email_clean; use serde::{Deserialize, Serialize}; -use crate::openhuman::memory::tree::types::{Metadata, SourceRef}; +use crate::openhuman::memory_tree::types::{Metadata, SourceRef}; /// Output of a canonicaliser — one per logical source record /// (a chat batch, an email, a document). diff --git a/src/openhuman/memory/tree/chat/cloud.rs b/src/openhuman/memory_tree/chat/cloud.rs similarity index 100% rename from src/openhuman/memory/tree/chat/cloud.rs rename to src/openhuman/memory_tree/chat/cloud.rs diff --git a/src/openhuman/memory/tree/chat/local.rs b/src/openhuman/memory_tree/chat/local.rs similarity index 100% rename from src/openhuman/memory/tree/chat/local.rs rename to src/openhuman/memory_tree/chat/local.rs diff --git a/src/openhuman/memory/tree/chat/mod.rs b/src/openhuman/memory_tree/chat/mod.rs similarity index 100% rename from src/openhuman/memory/tree/chat/mod.rs rename to src/openhuman/memory_tree/chat/mod.rs diff --git a/src/openhuman/memory/tree/chunker.rs b/src/openhuman/memory_tree/chunker.rs similarity index 99% rename from src/openhuman/memory/tree/chunker.rs rename to src/openhuman/memory_tree/chunker.rs index 064e6a205ee..de6a1367b99 100644 --- a/src/openhuman/memory/tree/chunker.rs +++ b/src/openhuman/memory_tree/chunker.rs @@ -16,8 +16,8 @@ //! becomes one chunk. Same oversize fallback as Chat. //! - **Document**: original paragraph-based greedy packing (unchanged). -use crate::openhuman::memory::tree::types::{approx_token_count, Chunk, Metadata, SourceKind}; -use crate::openhuman::memory::tree::util::redact::redact; +use crate::openhuman::memory_tree::types::{approx_token_count, Chunk, Metadata, SourceKind}; +use crate::openhuman::memory_tree::util::redact::redact; /// Default upper bound on per-chunk tokens. /// diff --git a/src/openhuman/memory/tree/content_store/README.md b/src/openhuman/memory_tree/content_store/README.md similarity index 100% rename from src/openhuman/memory/tree/content_store/README.md rename to src/openhuman/memory_tree/content_store/README.md diff --git a/src/openhuman/memory/tree/content_store/atomic.rs b/src/openhuman/memory_tree/content_store/atomic.rs similarity index 98% rename from src/openhuman/memory/tree/content_store/atomic.rs rename to src/openhuman/memory_tree/content_store/atomic.rs index cde3272cfd8..b1a3c427620 100644 --- a/src/openhuman/memory/tree/content_store/atomic.rs +++ b/src/openhuman/memory_tree/content_store/atomic.rs @@ -230,8 +230,8 @@ fn uuid_v4_hex() -> String { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::content_store::compose::SummaryComposeInput; - use crate::openhuman::memory::tree::content_store::paths::SummaryTreeKind; + use crate::openhuman::memory_tree::content_store::compose::SummaryComposeInput; + use crate::openhuman::memory_tree::content_store::paths::SummaryTreeKind; use tempfile::TempDir; #[test] diff --git a/src/openhuman/memory/tree/content_store/compose.rs b/src/openhuman/memory_tree/content_store/compose.rs similarity index 99% rename from src/openhuman/memory/tree/content_store/compose.rs rename to src/openhuman/memory_tree/content_store/compose.rs index 8ec1822f274..67216080c95 100644 --- a/src/openhuman/memory/tree/content_store/compose.rs +++ b/src/openhuman/memory_tree/content_store/compose.rs @@ -38,10 +38,10 @@ use chrono::{DateTime, Utc}; -use crate::openhuman::memory::tree::content_store::paths::{ +use crate::openhuman::memory_tree::content_store::paths::{ slugify_source_id, summary_filename, SummaryTreeKind, }; -use crate::openhuman::memory::tree::types::{Chunk, SourceKind}; +use crate::openhuman::memory_tree::types::{Chunk, SourceKind}; pub const MEMORY_ARTIFACT_FORMAT: u32 = 2; pub const OPENHUMAN_CORE_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -624,8 +624,8 @@ fn yaml_scalar(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::content_store::paths::SummaryTreeKind; - use crate::openhuman::memory::tree::types::{Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::content_store::paths::SummaryTreeKind; + use crate::openhuman::memory_tree::types::{Metadata, SourceKind, SourceRef}; use chrono::TimeZone; fn sample_chunk() -> Chunk { diff --git a/src/openhuman/memory/tree/content_store/mod.rs b/src/openhuman/memory_tree/content_store/mod.rs similarity index 97% rename from src/openhuman/memory/tree/content_store/mod.rs rename to src/openhuman/memory_tree/content_store/mod.rs index 9eac121cf4f..cd2c7bdd419 100644 --- a/src/openhuman/memory/tree/content_store/mod.rs +++ b/src/openhuman/memory_tree/content_store/mod.rs @@ -22,7 +22,7 @@ pub mod tags; use std::path::Path; -use crate::openhuman::memory::tree::types::Chunk; +use crate::openhuman::memory_tree::types::Chunk; pub use atomic::StagedSummary; pub use compose::SummaryComposeInput; @@ -70,7 +70,7 @@ pub fn update_summary_tags( /// /// `content_root` — absolute path to the root of the content store. pub fn stage_chunks(content_root: &Path, chunks: &[Chunk]) -> anyhow::Result> { - use crate::openhuman::memory::tree::types::SourceKind; + use crate::openhuman::memory_tree::types::SourceKind; let mut staged = Vec::with_capacity(chunks.len()); for chunk in chunks { @@ -128,7 +128,7 @@ pub fn stage_chunks(content_root: &Path, chunks: &[Chunk]) -> anyhow::Result/wiki/summaries/` — flattened diff --git a/src/openhuman/memory/tree/content_store/raw.rs b/src/openhuman/memory_tree/content_store/raw.rs similarity index 99% rename from src/openhuman/memory/tree/content_store/raw.rs rename to src/openhuman/memory_tree/content_store/raw.rs index 05eb4acb5df..e9d5fee0c34 100644 --- a/src/openhuman/memory/tree/content_store/raw.rs +++ b/src/openhuman/memory_tree/content_store/raw.rs @@ -132,7 +132,7 @@ pub fn raw_kind_dir(content_root: &Path, source_id: &str, kind: RawKind) -> Path /// Forward-slash relative path of a raw file under `/`, /// e.g. `"raw/gmail-acct/emails/1700000000000_msg-1.md"`. Used by -/// callers that record a [`crate::openhuman::memory::tree::store::RawRef`] +/// callers that record a [`crate::openhuman::memory_tree::store::RawRef`] /// so reads can resolve the file later without re-deriving the layout. pub fn raw_rel_path(source_id: &str, kind: RawKind, created_at_ms: i64, uid: &str) -> String { let slug = slugify_source_id(source_id); diff --git a/src/openhuman/memory/tree/content_store/read.rs b/src/openhuman/memory_tree/content_store/read.rs similarity index 95% rename from src/openhuman/memory/tree/content_store/read.rs rename to src/openhuman/memory_tree/content_store/read.rs index e48791d646d..4bf755cf7f2 100644 --- a/src/openhuman/memory/tree/content_store/read.rs +++ b/src/openhuman/memory_tree/content_store/read.rs @@ -4,7 +4,7 @@ use std::path::Path; use super::atomic::sha256_hex; use super::compose::split_front_matter; -use crate::openhuman::memory::tree::util::redact::redact; +use crate::openhuman::memory_tree::util::redact::redact; /// The result of reading a chunk file from disk. pub struct ChunkFileContents { @@ -143,7 +143,7 @@ pub fn read_chunk_body( config: &crate::openhuman::config::Config, chunk_id: &str, ) -> anyhow::Result { - use crate::openhuman::memory::tree::store::{get_chunk_content_pointers, get_chunk_raw_refs}; + use crate::openhuman::memory_tree::store::{get_chunk_content_pointers, get_chunk_raw_refs}; // Path 1: chunk has raw-archive pointers (today: email). Read each // referenced file, slice by byte range, join with `\n\n` (the @@ -230,7 +230,7 @@ use anyhow::Context as _; /// missing raw file doesn't take the whole chunk down. fn read_chunk_body_from_raw( config: &crate::openhuman::config::Config, - refs: &[crate::openhuman::memory::tree::store::RawRef], + refs: &[crate::openhuman::memory_tree::store::RawRef], ) -> anyhow::Result { let content_root = config.memory_tree_content_root(); let mut parts: Vec = Vec::with_capacity(refs.len()); @@ -287,7 +287,7 @@ pub fn read_summary_body( config: &crate::openhuman::config::Config, summary_id: &str, ) -> anyhow::Result { - use crate::openhuman::memory::tree::store::get_summary_content_pointers; + use crate::openhuman::memory_tree::store::get_summary_content_pointers; let pointers = get_summary_content_pointers(config, summary_id)?.ok_or_else(|| { anyhow::anyhow!( @@ -339,9 +339,9 @@ pub fn read_summary_body( #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::content_store::atomic::{sha256_hex, write_if_new}; - use crate::openhuman::memory::tree::content_store::compose::compose_chunk_file; - use crate::openhuman::memory::tree::types::{Chunk, Metadata, SourceKind}; + use crate::openhuman::memory_tree::content_store::atomic::{sha256_hex, write_if_new}; + use crate::openhuman::memory_tree::content_store::compose::compose_chunk_file; + use crate::openhuman::memory_tree::types::{Chunk, Metadata, SourceKind}; use chrono::TimeZone; use tempfile::TempDir; @@ -412,11 +412,11 @@ mod tests { // ─── summary read / verify tests ───────────────────────────────────────── fn write_summary_file(dir: &TempDir, body: &str) -> (std::path::PathBuf, String) { - use crate::openhuman::memory::tree::content_store::atomic::{sha256_hex, write_if_new}; - use crate::openhuman::memory::tree::content_store::compose::{ + use crate::openhuman::memory_tree::content_store::atomic::{sha256_hex, write_if_new}; + use crate::openhuman::memory_tree::content_store::compose::{ compose_summary_md, SummaryComposeInput, }; - use crate::openhuman::memory::tree::content_store::paths::SummaryTreeKind; + use crate::openhuman::memory_tree::content_store::paths::SummaryTreeKind; use chrono::TimeZone; let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(); let input = SummaryComposeInput { diff --git a/src/openhuman/memory/tree/content_store/tags.rs b/src/openhuman/memory_tree/content_store/tags.rs similarity index 96% rename from src/openhuman/memory/tree/content_store/tags.rs rename to src/openhuman/memory_tree/content_store/tags.rs index b698161bc44..50e02dfff9c 100644 --- a/src/openhuman/memory/tree/content_store/tags.rs +++ b/src/openhuman/memory_tree/content_store/tags.rs @@ -14,8 +14,8 @@ use super::compose::{ split_front_matter, }; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::score::store::list_entity_ids_for_node; -use crate::openhuman::memory::tree::store::get_summary_content_pointers; +use crate::openhuman::memory_tree::score::store::list_entity_ids_for_node; +use crate::openhuman::memory_tree::store::get_summary_content_pointers; /// Rewrite the `tags:` block in a chunk's on-disk `.md` file. /// @@ -334,9 +334,9 @@ fn crate_temp_id() -> String { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::content_store::atomic::{sha256_hex, write_if_new}; - use crate::openhuman::memory::tree::content_store::compose::compose_chunk_file; - use crate::openhuman::memory::tree::types::{Chunk, Metadata, SourceKind}; + use crate::openhuman::memory_tree::content_store::atomic::{sha256_hex, write_if_new}; + use crate::openhuman::memory_tree::content_store::compose::compose_chunk_file; + use crate::openhuman::memory_tree::types::{Chunk, Metadata, SourceKind}; use chrono::TimeZone; use tempfile::TempDir; @@ -430,10 +430,10 @@ mod tests { /// Write a summary .md file to disk with empty tags and verify rewriting works. #[test] fn rewrite_summary_tags_preserves_body_and_replaces_tags() { - use crate::openhuman::memory::tree::content_store::compose::{ + use crate::openhuman::memory_tree::content_store::compose::{ compose_summary_md, SummaryComposeInput, }; - use crate::openhuman::memory::tree::content_store::paths::SummaryTreeKind; + use crate::openhuman::memory_tree::content_store::paths::SummaryTreeKind; let dir = TempDir::new().unwrap(); let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(); @@ -483,7 +483,7 @@ mod tests { assert!(updated.ends_with(body)); // Body sha unchanged - use crate::openhuman::memory::tree::content_store::compose::split_front_matter; + use crate::openhuman::memory_tree::content_store::compose::split_front_matter; let (_, body_after) = split_front_matter(&updated).unwrap(); let sha = sha256_hex(body_after.as_bytes()); let expected_sha = sha256_hex(body.as_bytes()); diff --git a/src/openhuman/memory/tree/ingest.rs b/src/openhuman/memory_tree/ingest.rs similarity index 96% rename from src/openhuman/memory/tree/ingest.rs rename to src/openhuman/memory_tree/ingest.rs index e026c06ca31..49a0f52fb1b 100644 --- a/src/openhuman/memory/tree/ingest.rs +++ b/src/openhuman/memory_tree/ingest.rs @@ -11,19 +11,19 @@ use serde::{Deserialize, Serialize}; use crate::core::event_bus::{publish_global, DomainEvent}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::canonicalize::{ +use crate::openhuman::memory_tree::canonicalize::{ chat::{self, ChatBatch}, document::{self, DocumentInput}, email::{self, EmailThread}, CanonicalisedSource, }; -use crate::openhuman::memory::tree::chunker::{chunk_markdown, ChunkerInput, ChunkerOptions}; -use crate::openhuman::memory::tree::content_store; -use crate::openhuman::memory::tree::jobs::{self, ExtractChunkPayload, NewJob}; -use crate::openhuman::memory::tree::score::{self, ScoreResult, ScoringConfig}; -use crate::openhuman::memory::tree::store; -use crate::openhuman::memory::tree::types::SourceKind; -use crate::openhuman::memory::tree::util::redact::redact; +use crate::openhuman::memory_tree::chunker::{chunk_markdown, ChunkerInput, ChunkerOptions}; +use crate::openhuman::memory_tree::content_store; +use crate::openhuman::memory_tree::jobs::{self, ExtractChunkPayload, NewJob}; +use crate::openhuman::memory_tree::score::{self, ScoreResult, ScoringConfig}; +use crate::openhuman::memory_tree::store; +use crate::openhuman::memory_tree::types::SourceKind; +use crate::openhuman::memory_tree::util::redact::redact; use std::time::{SystemTime, UNIX_EPOCH}; const BODY_PREVIEW_MAX_BYTES: usize = 2048; @@ -176,7 +176,7 @@ async fn persist( Err(_) => { log::error!( "[memory_tree::ingest] markdown_body_preview panicked for source_id_hash={}; falling back to no preview", - crate::openhuman::memory::tree::util::redact::redact(source_id) + crate::openhuman::memory_tree::util::redact::redact(source_id) ); None } @@ -419,14 +419,14 @@ fn markdown_body_preview(md: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::canonicalize::chat::ChatMessage; - use crate::openhuman::memory::tree::jobs::drain_until_idle; - use crate::openhuman::memory::tree::score::store::{count_scores, lookup_entity}; - use crate::openhuman::memory::tree::store::{ + use crate::openhuman::memory_tree::canonicalize::chat::ChatMessage; + use crate::openhuman::memory_tree::jobs::drain_until_idle; + use crate::openhuman::memory_tree::score::store::{count_scores, lookup_entity}; + use crate::openhuman::memory_tree::store::{ count_chunks, count_chunks_by_lifecycle_status, get_chunk_embedding, list_chunks, ListChunksQuery, CHUNK_STATUS_BUFFERED, CHUNK_STATUS_DROPPED, }; - use crate::openhuman::memory::tree::types::SourceKind; + use crate::openhuman::memory_tree::types::SourceKind; use chrono::{TimeZone, Utc}; use tempfile::TempDir; diff --git a/src/openhuman/memory/tree/jobs/README.md b/src/openhuman/memory_tree/jobs/README.md similarity index 100% rename from src/openhuman/memory/tree/jobs/README.md rename to src/openhuman/memory_tree/jobs/README.md diff --git a/src/openhuman/memory/tree/jobs/handlers/README.md b/src/openhuman/memory_tree/jobs/handlers/README.md similarity index 100% rename from src/openhuman/memory/tree/jobs/handlers/README.md rename to src/openhuman/memory_tree/jobs/handlers/README.md diff --git a/src/openhuman/memory/tree/jobs/handlers/mod.rs b/src/openhuman/memory_tree/jobs/handlers/mod.rs similarity index 93% rename from src/openhuman/memory/tree/jobs/handlers/mod.rs rename to src/openhuman/memory_tree/jobs/handlers/mod.rs index b69aaa8c3ff..46fb8f3032f 100644 --- a/src/openhuman/memory/tree/jobs/handlers/mod.rs +++ b/src/openhuman/memory_tree/jobs/handlers/mod.rs @@ -12,26 +12,26 @@ use anyhow::{Context, Result}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::{ +use crate::openhuman::memory_tree::content_store::{ self as content_store, read as content_read, tags as content_tags, }; -use crate::openhuman::memory::tree::jobs::store; -use crate::openhuman::memory::tree::jobs::types::{ +use crate::openhuman::memory_tree::jobs::store; +use crate::openhuman::memory_tree::jobs::types::{ AppendBufferPayload, AppendTarget, DigestDailyPayload, ExtractChunkPayload, FlushStalePayload, Job, JobKind, JobOutcome, NewJob, NodeRef, ReembedBackfillPayload, SealPayload, TopicRoutePayload, }; -use crate::openhuman::memory::tree::score; -use crate::openhuman::memory::tree::score::embed::{build_embedder_from_config, pack_checked}; -use crate::openhuman::memory::tree::score::extract::build_summary_extractor; -use crate::openhuman::memory::tree::score::store as score_store; -use crate::openhuman::memory::tree::store as chunk_store; -use crate::openhuman::memory::tree::tree_global::digest::{self, DigestOutcome}; -use crate::openhuman::memory::tree::tree_source::store as summary_store; -use crate::openhuman::memory::tree::tree_source::{ +use crate::openhuman::memory_tree::score; +use crate::openhuman::memory_tree::score::embed::{build_embedder_from_config, pack_checked}; +use crate::openhuman::memory_tree::score::extract::build_summary_extractor; +use crate::openhuman::memory_tree::score::store as score_store; +use crate::openhuman::memory_tree::store as chunk_store; +use crate::openhuman::memory_tree::tree_global::digest::{self, DigestOutcome}; +use crate::openhuman::memory_tree::tree_source::store as summary_store; +use crate::openhuman::memory_tree::tree_source::{ build_summariser, get_or_create_source_tree, LabelStrategy, LeafRef, }; -use crate::openhuman::memory::tree::tree_topic::curator; +use crate::openhuman::memory_tree::tree_topic::curator; /// Default age for L0 flush_stale when the caller doesn't override. /// 1 hour means low-volume sources get summaries within a working session. @@ -214,7 +214,7 @@ async fn handle_extract(config: &Config, job: &Job) -> Result { log::warn!( "[memory_tree::jobs] failed to update tags in chunk file chunk_id={} path_hash={}: {e}", chunk.id, - crate::openhuman::memory::tree::util::redact::redact(&content_path), + crate::openhuman::memory_tree::util::redact::redact(&content_path), ); // Non-fatal: tag rewrite failure does not block the pipeline. } else { @@ -239,8 +239,8 @@ async fn handle_extract(config: &Config, job: &Job) -> Result { } async fn handle_append_buffer(config: &Config, job: &Job) -> Result { - use crate::openhuman::memory::tree::tree_source::bucket_seal::should_seal; - use crate::openhuman::memory::tree::tree_source::store as src_store; + use crate::openhuman::memory_tree::tree_source::bucket_seal::should_seal; + use crate::openhuman::memory_tree::tree_source::store as src_store; let payload: AppendBufferPayload = serde_json::from_str(&job.payload_json).context("parse AppendBuffer payload")?; @@ -377,9 +377,9 @@ async fn handle_append_buffer(config: &Config, job: &Job) -> Result } async fn handle_seal(config: &Config, job: &Job) -> Result { - use crate::openhuman::memory::tree::tree_source::bucket_seal::{seal_one_level, should_seal}; - use crate::openhuman::memory::tree::tree_source::store as src_store; - use crate::openhuman::memory::tree::tree_source::types::TreeKind; + use crate::openhuman::memory_tree::tree_source::bucket_seal::{seal_one_level, should_seal}; + use crate::openhuman::memory_tree::tree_source::store as src_store; + use crate::openhuman::memory_tree::tree_source::types::TreeKind; let payload: SealPayload = serde_json::from_str(&job.payload_json).context("parse Seal payload")?; @@ -467,7 +467,7 @@ async fn handle_topic_route(config: &Config, job: &Job) -> Result { chunk_id.clone() } NodeRef::Summary { summary_id } => { - if crate::openhuman::memory::tree::tree_source::store::get_summary(config, summary_id)? + if crate::openhuman::memory_tree::tree_source::store::get_summary(config, summary_id)? .is_none() { log::warn!( @@ -488,9 +488,9 @@ async fn handle_topic_route(config: &Config, job: &Job) -> Result { let summariser = build_summariser(config); for entity_id in entity_ids { let _ = curator::maybe_spawn_topic_tree(config, &entity_id, summariser.as_ref()).await?; - if let Some(tree) = crate::openhuman::memory::tree::tree_source::store::get_tree_by_scope( + if let Some(tree) = crate::openhuman::memory_tree::tree_source::store::get_tree_by_scope( config, - crate::openhuman::memory::tree::tree_source::types::TreeKind::Topic, + crate::openhuman::memory_tree::tree_source::types::TreeKind::Topic, &entity_id, )? { let job = NewJob::append_buffer(&AppendBufferPayload { @@ -536,7 +536,7 @@ async fn handle_flush_stale(config: &Config, job: &Job) -> Result { let age_secs = payload.max_age_secs.unwrap_or(L0_DEFAULT_FLUSH_AGE_SECS); let cutoff = chrono::Utc::now() - chrono::Duration::seconds(age_secs); let buffers = - crate::openhuman::memory::tree::tree_source::store::list_stale_buffers(config, cutoff)?; + crate::openhuman::memory_tree::tree_source::store::list_stale_buffers(config, cutoff)?; for buf in buffers { let seal = SealPayload { tree_id: buf.tree_id.clone(), @@ -671,13 +671,13 @@ async fn handle_reembed_backfill(config: &Config, job: &Job) -> Result Result Result crate::openhuman::memory::tree::tree_source::types::Tree { - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::types::{ + ) -> crate::openhuman::memory_tree::tree_source::types::Tree { + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::types::{ chunk_id, Chunk, Metadata, SourceKind, SourceRef, }; let tree = get_or_create_source_tree(cfg, "slack:#eng").unwrap(); @@ -892,7 +892,7 @@ mod tests { let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap(); with_connection(cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) @@ -969,14 +969,14 @@ mod tests { // Spawn a topic tree directly via the registry (skipping curator's // hotness gate — we just need a TreeKind::Topic with leaves). let topic_tree = - crate::openhuman::memory::tree::tree_topic::registry::get_or_create_topic_tree( + crate::openhuman::memory_tree::tree_topic::registry::get_or_create_topic_tree( &cfg, "topic:phoenix-migration", ) .unwrap(); // Push a single 10k-token leaf so L0 is gate-ready. - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::types::{ + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::types::{ chunk_id, Chunk, Metadata, SourceKind, SourceRef, }; let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(); @@ -1005,7 +1005,7 @@ mod tests { let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap(); with_connection(&cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) @@ -1046,7 +1046,7 @@ mod tests { // 1. Create a target topic tree with a clean L0 buffer. let topic_tree = - crate::openhuman::memory::tree::tree_topic::registry::get_or_create_topic_tree( + crate::openhuman::memory_tree::tree_topic::registry::get_or_create_topic_tree( &cfg, "email:alice@example.com", ) @@ -1058,9 +1058,9 @@ mod tests { // is to create a separate source tree, push two 6k leaves into // it, and let the seal produce a summary we can address. let source_tree = get_or_create_source_tree(&cfg, "slack:#eng").unwrap(); - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_source::bucket_seal::seal_one_level; - use crate::openhuman::memory::tree::types::{ + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_source::bucket_seal::seal_one_level; + use crate::openhuman::memory_tree::types::{ chunk_id, Chunk, Metadata, SourceKind, SourceRef, }; let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(); @@ -1090,7 +1090,7 @@ mod tests { let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap(); with_connection(&cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) @@ -1114,7 +1114,7 @@ mod tests { &source_tree, &buf, summariser.as_ref(), - &crate::openhuman::memory::tree::tree_source::bucket_seal::LabelStrategy::Empty, + &crate::openhuman::memory_tree::tree_source::bucket_seal::LabelStrategy::Empty, // No follow-up enqueues — the test scopes assertions to the // append_buffer handler, not seal-side fan-out. false, @@ -1164,11 +1164,11 @@ mod tests { /// deterministic effects are what this test pins.) #[tokio::test] async fn reembed_backfill_repopulates_then_completes() { - use crate::openhuman::memory::tree::store::{ + use crate::openhuman::memory_tree::store::{ get_chunk_embedding_for_signature, tree_active_signature, upsert_chunks, upsert_staged_chunks_tx, }; - use crate::openhuman::memory::tree::types::{ + use crate::openhuman::memory_tree::types::{ chunk_id, Chunk, Metadata, SourceKind, SourceRef, }; @@ -1268,11 +1268,11 @@ mod tests { /// is covered (or the chain would re-arm on every config save). #[tokio::test] async fn reembed_backfill_tombstones_orphan_and_terminates() { - use crate::openhuman::memory::tree::store::{ + use crate::openhuman::memory_tree::store::{ get_chunk_content_path, get_chunk_embedding_for_signature, tree_active_signature, upsert_chunks, upsert_staged_chunks_tx, }; - use crate::openhuman::memory::tree::types::{ + use crate::openhuman::memory_tree::types::{ chunk_id, Chunk, Metadata, SourceKind, SourceRef, }; @@ -1383,11 +1383,11 @@ mod tests { /// #2358: clearing a tombstone re-opens the row for the backfill worklist. #[tokio::test] async fn clear_chunk_reembed_skipped_reopens_worklist() { - use crate::openhuman::memory::tree::store::{ + use crate::openhuman::memory_tree::store::{ clear_chunk_reembed_skipped, get_chunk_content_path, mark_chunk_reembed_skipped, tree_active_signature, upsert_chunks, upsert_staged_chunks_tx, }; - use crate::openhuman::memory::tree::types::{ + use crate::openhuman::memory_tree::types::{ chunk_id, Chunk, Metadata, SourceKind, SourceRef, }; @@ -1456,9 +1456,9 @@ mod tests { /// empty/covered space. #[tokio::test] async fn ensure_reembed_backfill_enqueues_only_when_uncovered() { - use crate::openhuman::memory::tree::jobs::ensure_reembed_backfill; - use crate::openhuman::memory::tree::store::{upsert_chunks, upsert_staged_chunks_tx}; - use crate::openhuman::memory::tree::types::{ + use crate::openhuman::memory_tree::jobs::ensure_reembed_backfill; + use crate::openhuman::memory_tree::store::{upsert_chunks, upsert_staged_chunks_tx}; + use crate::openhuman::memory_tree::types::{ chunk_id, Chunk, Metadata, SourceKind, SourceRef, }; diff --git a/src/openhuman/memory/tree/jobs/mod.rs b/src/openhuman/memory_tree/jobs/mod.rs similarity index 95% rename from src/openhuman/memory/tree/jobs/mod.rs rename to src/openhuman/memory_tree/jobs/mod.rs index 95b74e6e719..30e00352d4c 100644 --- a/src/openhuman/memory/tree/jobs/mod.rs +++ b/src/openhuman/memory_tree/jobs/mod.rs @@ -71,9 +71,9 @@ pub fn backfill_in_progress() -> bool { /// covered space enqueues nothing. Errors are logged, never propagated — /// a failed enqueue must not fail the user's settings save. pub fn ensure_reembed_backfill(config: &crate::openhuman::config::Config) { - let sig = crate::openhuman::memory::tree::store::tree_active_signature(config); - let result = crate::openhuman::memory::tree::store::with_connection(config, |conn| { - Ok(crate::openhuman::memory::tree::store::has_uncovered_reembed_work(conn, &sig)?) + let sig = crate::openhuman::memory_tree::store::tree_active_signature(config); + let result = crate::openhuman::memory_tree::store::with_connection(config, |conn| { + Ok(crate::openhuman::memory_tree::store::has_uncovered_reembed_work(conn, &sig)?) }); match result { Ok(true) => { diff --git a/src/openhuman/memory/tree/jobs/redact.rs b/src/openhuman/memory_tree/jobs/redact.rs similarity index 100% rename from src/openhuman/memory/tree/jobs/redact.rs rename to src/openhuman/memory_tree/jobs/redact.rs diff --git a/src/openhuman/memory/tree/jobs/scheduler.rs b/src/openhuman/memory_tree/jobs/scheduler.rs similarity index 97% rename from src/openhuman/memory/tree/jobs/scheduler.rs rename to src/openhuman/memory_tree/jobs/scheduler.rs index 42d65150d12..f0cf6181561 100644 --- a/src/openhuman/memory/tree/jobs/scheduler.rs +++ b/src/openhuman/memory_tree/jobs/scheduler.rs @@ -9,8 +9,8 @@ use anyhow::Result; use chrono::{Datelike, Duration as ChronoDuration, NaiveDate, TimeZone, Timelike, Utc}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::jobs::store; -use crate::openhuman::memory::tree::jobs::types::{DigestDailyPayload, FlushStalePayload, NewJob}; +use crate::openhuman::memory_tree::jobs::store; +use crate::openhuman::memory_tree::jobs::types::{DigestDailyPayload, FlushStalePayload, NewJob}; static STARTED: std::sync::Once = std::sync::Once::new(); @@ -175,10 +175,10 @@ fn next_sleep_duration() -> Duration { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::jobs::store::{ + use crate::openhuman::memory_tree::jobs::store::{ claim_next, count_by_status, count_total, mark_done, DEFAULT_LOCK_DURATION_MS, }; - use crate::openhuman::memory::tree::jobs::types::JobStatus; + use crate::openhuman::memory_tree::jobs::types::JobStatus; use tempfile::TempDir; fn test_config() -> (TempDir, Config) { diff --git a/src/openhuman/memory/tree/jobs/store.rs b/src/openhuman/memory_tree/jobs/store.rs similarity index 99% rename from src/openhuman/memory/tree/jobs/store.rs rename to src/openhuman/memory_tree/jobs/store.rs index c6641d7c748..f4b1b9709ff 100644 --- a/src/openhuman/memory/tree/jobs/store.rs +++ b/src/openhuman/memory_tree/jobs/store.rs @@ -22,9 +22,9 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction}; use uuid::Uuid; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::jobs::redact::scrub_for_log; -use crate::openhuman::memory::tree::jobs::types::{Job, JobKind, JobStatus, NewJob}; -use crate::openhuman::memory::tree::store::with_connection; +use crate::openhuman::memory_tree::jobs::redact::scrub_for_log; +use crate::openhuman::memory_tree::jobs::types::{Job, JobKind, JobStatus, NewJob}; +use crate::openhuman::memory_tree::store::with_connection; /// Default visibility lock — a worker that crashes mid-job will have its /// row recovered after this window. 5 min is comfortably larger than any @@ -420,7 +420,7 @@ fn backoff_ms(attempts_so_far: u32) -> i64 { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::jobs::types::{ + use crate::openhuman::memory_tree::jobs::types::{ AppendBufferPayload, AppendTarget, ExtractChunkPayload, NodeRef, }; use tempfile::TempDir; diff --git a/src/openhuman/memory/tree/jobs/testing.rs b/src/openhuman/memory_tree/jobs/testing.rs similarity index 100% rename from src/openhuman/memory/tree/jobs/testing.rs rename to src/openhuman/memory_tree/jobs/testing.rs diff --git a/src/openhuman/memory/tree/jobs/types.rs b/src/openhuman/memory_tree/jobs/types.rs similarity index 100% rename from src/openhuman/memory/tree/jobs/types.rs rename to src/openhuman/memory_tree/jobs/types.rs diff --git a/src/openhuman/memory/tree/jobs/worker.rs b/src/openhuman/memory_tree/jobs/worker.rs similarity index 98% rename from src/openhuman/memory/tree/jobs/worker.rs rename to src/openhuman/memory_tree/jobs/worker.rs index fe581128a8d..b8f520fb2c2 100644 --- a/src/openhuman/memory/tree/jobs/worker.rs +++ b/src/openhuman/memory_tree/jobs/worker.rs @@ -15,13 +15,13 @@ use anyhow::Result; use tokio::sync::Notify; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::jobs::handlers; -use crate::openhuman::memory::tree::jobs::redact::scrub_for_log; -use crate::openhuman::memory::tree::jobs::store::{ +use crate::openhuman::memory_tree::jobs::handlers; +use crate::openhuman::memory_tree::jobs::redact::scrub_for_log; +use crate::openhuman::memory_tree::jobs::store::{ claim_next, mark_deferred, mark_done, mark_failed, recover_stale_locks, DEFAULT_LOCK_DURATION_MS, }; -use crate::openhuman::memory::tree::jobs::types::JobOutcome; +use crate::openhuman::memory_tree::jobs::types::JobOutcome; /// Number of concurrent job-worker tasks. Each worker claims one job /// at a time via `claim_next` (atomic UPDATE under SQLite WAL with diff --git a/src/openhuman/memory/tree/mod.rs b/src/openhuman/memory_tree/mod.rs similarity index 92% rename from src/openhuman/memory/tree/mod.rs rename to src/openhuman/memory_tree/mod.rs index 73368f50ed7..d9dcaeac853 100644 --- a/src/openhuman/memory/tree/mod.rs +++ b/src/openhuman/memory_tree/mod.rs @@ -34,6 +34,8 @@ pub mod rpc; pub mod schemas; pub mod score; pub mod store; +pub mod summarizer; +pub mod tools; pub mod tree_global; pub mod tree_source; pub mod tree_topic; @@ -45,3 +47,6 @@ pub use schemas::{ all_controller_schemas as all_memory_tree_controller_schemas, all_registered_controllers as all_memory_tree_registered_controllers, }; +pub use summarizer::{ + all_tree_summarizer_controller_schemas, all_tree_summarizer_registered_controllers, +}; diff --git a/src/openhuman/memory/tree/read_rpc.rs b/src/openhuman/memory_tree/read_rpc.rs similarity index 98% rename from src/openhuman/memory/tree/read_rpc.rs rename to src/openhuman/memory_tree/read_rpc.rs index 9a4e1ccf502..175a86cddcc 100644 --- a/src/openhuman/memory/tree/read_rpc.rs +++ b/src/openhuman/memory_tree/read_rpc.rs @@ -31,11 +31,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::read as content_read; -use crate::openhuman::memory::tree::retrieval::types::NodeKind; -use crate::openhuman::memory::tree::score::store as score_store; -use crate::openhuman::memory::tree::store::{self as chunk_store, with_connection}; -use crate::openhuman::memory::tree::types::SourceKind; +use crate::openhuman::memory_tree::content_store::read as content_read; +use crate::openhuman::memory_tree::retrieval::types::NodeKind; +use crate::openhuman::memory_tree::score::store as score_store; +use crate::openhuman::memory_tree::store::{self as chunk_store, with_connection}; +use crate::openhuman::memory_tree::types::SourceKind; use crate::rpc::RpcOutcome; const PREVIEW_MAX_CHARS: usize = 500; @@ -46,7 +46,7 @@ const MAX_LIST_LIMIT: u32 = 1_000; /// Wire-shape chunk returned by the read RPCs. /// -/// Distinct from [`crate::openhuman::memory::tree::types::Chunk`] in two +/// Distinct from [`crate::openhuman::memory_tree::types::Chunk`] in two /// ways: serialised timestamps are ms-since-epoch (matches the rest of the /// JSON-RPC surface) and the body is replaced with a `≤500-char preview` /// + a flag indicating whether the row has an embedding. UIs needing the @@ -462,7 +462,7 @@ pub async fn recall_rpc( // Reuse the source-tree retrieval path which already does cosine // rerank against query embeddings. We pull more summaries than `k` // because each summary expands into multiple leaves. - let resp = crate::openhuman::memory::tree::retrieval::query_source( + let resp = crate::openhuman::memory_tree::retrieval::query_source( config, None, None, @@ -780,7 +780,7 @@ pub async fn chunk_score_rpc( ScoreBreakdown { signals, total: r.total, - threshold: crate::openhuman::memory::tree::score::DEFAULT_DROP_THRESHOLD, + threshold: crate::openhuman::memory_tree::score::DEFAULT_DROP_THRESHOLD, kept: !r.dropped, llm_consulted, } @@ -843,7 +843,7 @@ pub async fn delete_chunk_rpc( if e.kind() != std::io::ErrorKind::NotFound { log::warn!( "[memory_tree::read::delete] failed to remove chunk file path_hash={}: {e}", - crate::openhuman::memory::tree::util::redact::redact(&rel), + crate::openhuman::memory_tree::util::redact::redact(&rel), ); } } @@ -1001,7 +1001,7 @@ pub async fn graph_export_rpc( mode, resp.nodes.len(), resp.edges.len(), - crate::openhuman::memory::tree::util::redact::redact(&resp.content_root_abs), + crate::openhuman::memory_tree::util::redact::redact(&resp.content_root_abs), ); Ok(RpcOutcome::single_log(resp, log)) } @@ -1430,8 +1430,8 @@ pub struct ResetTreeResponse { /// outside `spawn_blocking`) so the on-disk removal can use /// async retry without blocking the worker thread. pub async fn reset_tree_rpc(config: &Config) -> Result, String> { - use crate::openhuman::memory::tree::jobs::store as jobs_store; - use crate::openhuman::memory::tree::jobs::types::{ExtractChunkPayload, NewJob}; + use crate::openhuman::memory_tree::jobs::store as jobs_store; + use crate::openhuman::memory_tree::jobs::types::{ExtractChunkPayload, NewJob}; let cfg = config.clone(); let (tree_rows_deleted, chunks_requeued, jobs_enqueued) = @@ -1535,7 +1535,7 @@ pub async fn reset_tree_rpc(config: &Config) -> Result` is the current 3-hour UTC block (0..=7), so /// spamming the button within the same window doesn't queue duplicates. pub async fn flush_now_rpc(config: &Config) -> Result, String> { - use crate::openhuman::memory::tree::jobs::store as jobs_store; - use crate::openhuman::memory::tree::jobs::types::{FlushStalePayload, NewJob}; - use crate::openhuman::memory::tree::tree_source::store as tree_store; + use crate::openhuman::memory_tree::jobs::store as jobs_store; + use crate::openhuman::memory_tree::jobs::types::{FlushStalePayload, NewJob}; + use crate::openhuman::memory_tree::tree_source::store as tree_store; let cfg = config.clone(); let resp = tokio::task::spawn_blocking(move || -> Result { @@ -1827,8 +1827,8 @@ fn parse_source_kind_str(s: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::canonicalize::chat::{ChatBatch, ChatMessage}; - use crate::openhuman::memory::tree::ingest::ingest_chat; + use crate::openhuman::memory_tree::canonicalize::chat::{ChatBatch, ChatMessage}; + use crate::openhuman::memory_tree::ingest::ingest_chat; use chrono::{TimeZone, Utc}; use tempfile::TempDir; diff --git a/src/openhuman/memory/tree/retrieval/README.md b/src/openhuman/memory_tree/retrieval/README.md similarity index 100% rename from src/openhuman/memory/tree/retrieval/README.md rename to src/openhuman/memory_tree/retrieval/README.md diff --git a/src/openhuman/memory/tree/retrieval/benchmarks.rs b/src/openhuman/memory_tree/retrieval/benchmarks.rs similarity index 98% rename from src/openhuman/memory/tree/retrieval/benchmarks.rs rename to src/openhuman/memory_tree/retrieval/benchmarks.rs index 24605a3f9f9..ca2ec871566 100644 --- a/src/openhuman/memory/tree/retrieval/benchmarks.rs +++ b/src/openhuman/memory_tree/retrieval/benchmarks.rs @@ -21,13 +21,13 @@ use chrono::{TimeZone, Utc}; use tempfile::TempDir; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::canonicalize::chat::{ChatBatch, ChatMessage}; -use crate::openhuman::memory::tree::ingest::ingest_chat; -use crate::openhuman::memory::tree::jobs::testing::drain_until_idle; -use crate::openhuman::memory::tree::retrieval::{ +use crate::openhuman::memory_tree::canonicalize::chat::{ChatBatch, ChatMessage}; +use crate::openhuman::memory_tree::ingest::ingest_chat; +use crate::openhuman::memory_tree::jobs::testing::drain_until_idle; +use crate::openhuman::memory_tree::retrieval::{ fetch_leaves, query_source, query_topic, search_entities, }; -use crate::openhuman::memory::tree::types::SourceKind; +use crate::openhuman::memory_tree::types::SourceKind; /// Shared test config — disables embedding for deterministic inert behaviour. fn bench_config() -> (TempDir, Config) { diff --git a/src/openhuman/memory/tree/retrieval/drill_down.rs b/src/openhuman/memory_tree/retrieval/drill_down.rs similarity index 93% rename from src/openhuman/memory/tree/retrieval/drill_down.rs rename to src/openhuman/memory_tree/retrieval/drill_down.rs index c131f4c8751..0063cb30e55 100644 --- a/src/openhuman/memory/tree/retrieval/drill_down.rs +++ b/src/openhuman/memory_tree/retrieval/drill_down.rs @@ -23,13 +23,13 @@ use std::collections::VecDeque; use anyhow::Result; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::read as content_read; -use crate::openhuman::memory::tree::retrieval::types::{ +use crate::openhuman::memory_tree::content_store::read as content_read; +use crate::openhuman::memory_tree::retrieval::types::{ hit_from_chunk, hit_from_summary, RetrievalHit, }; -use crate::openhuman::memory::tree::score::embed::{build_embedder_from_config, cosine_similarity}; -use crate::openhuman::memory::tree::store::{get_chunk, get_chunk_embedding}; -use crate::openhuman::memory::tree::tree_source::store; +use crate::openhuman::memory_tree::score::embed::{build_embedder_from_config, cosine_similarity}; +use crate::openhuman::memory_tree::store::{get_chunk, get_chunk_embedding}; +use crate::openhuman::memory_tree::tree_source::store; /// Walk the summary hierarchy down one step (or more if `max_depth > 1`) /// and return the hydrated child hits. Children at level 1 are raw chunks; @@ -257,15 +257,15 @@ fn walk_with_embeddings( #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::content_store; - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_source::bucket_seal::{ + use crate::openhuman::memory_tree::content_store; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_source::bucket_seal::{ append_leaf, LabelStrategy, LeafRef, }; - use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree; - use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser; - use crate::openhuman::memory::tree::tree_source::types::TreeKind; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::tree_source::registry::get_or_create_source_tree; + use crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser; + use crate::openhuman::memory_tree::tree_source::types::TreeKind; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use chrono::Utc; use tempfile::TempDir; @@ -301,7 +301,7 @@ mod tests { tags: vec![], source_ref: Some(SourceRef::new("slack://x")), }, - token_count: crate::openhuman::memory::tree::tree_source::types::INPUT_TOKEN_BUDGET + token_count: crate::openhuman::memory_tree::tree_source::types::INPUT_TOKEN_BUDGET * 6 / 10, seq_in_source: seq, @@ -312,9 +312,9 @@ mod tests { // Stage to disk so `hydrate_leaf_inputs` can read the full body // via `read_chunk_body` during the seal triggered by `append_leaf`. let staged = content_store::stage_chunks(&content_root, &[c.clone()]).unwrap(); - crate::openhuman::memory::tree::store::with_connection(cfg, |conn| { + crate::openhuman::memory_tree::store::with_connection(cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) @@ -326,7 +326,7 @@ mod tests { &LeafRef { chunk_id: c.id.clone(), token_count: - crate::openhuman::memory::tree::tree_source::types::INPUT_TOKEN_BUDGET * 6 + crate::openhuman::memory_tree::tree_source::types::INPUT_TOKEN_BUDGET * 6 / 10, timestamp: ts, content: c.content.clone(), @@ -430,9 +430,9 @@ mod tests { // (or similar — the key invariant is that BFS returns all siblings at // one depth before any descendant at a deeper depth). - use crate::openhuman::memory::tree::store::with_connection; - use crate::openhuman::memory::tree::tree_source::store as tree_store; - use crate::openhuman::memory::tree::tree_source::types::{SummaryNode, Tree, TreeStatus}; + use crate::openhuman::memory_tree::store::with_connection; + use crate::openhuman::memory_tree::tree_source::store as tree_store; + use crate::openhuman::memory_tree::tree_source::types::{SummaryNode, Tree, TreeStatus}; /// Build a tiny 2-level tree directly via store inserts so we can /// assert BFS ordering without needing ~100 leaves to cascade L1→L2 @@ -499,9 +499,9 @@ mod tests { let content_root = cfg.memory_tree_content_root(); std::fs::create_dir_all(&content_root).unwrap(); let staged = content_store::stage_chunks(&content_root, &all_leaves).unwrap(); - crate::openhuman::memory::tree::store::with_connection(cfg, |conn| { + crate::openhuman::memory_tree::store::with_connection(cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) diff --git a/src/openhuman/memory/tree/retrieval/fetch.rs b/src/openhuman/memory_tree/retrieval/fetch.rs similarity index 92% rename from src/openhuman/memory/tree/retrieval/fetch.rs rename to src/openhuman/memory_tree/retrieval/fetch.rs index df952249376..96dd7a33e32 100644 --- a/src/openhuman/memory/tree/retrieval/fetch.rs +++ b/src/openhuman/memory_tree/retrieval/fetch.rs @@ -13,10 +13,10 @@ use anyhow::Result; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::read as content_read; -use crate::openhuman::memory::tree::retrieval::types::{hit_from_chunk, RetrievalHit}; -use crate::openhuman::memory::tree::score::store::get_score; -use crate::openhuman::memory::tree::store::get_chunk; +use crate::openhuman::memory_tree::content_store::read as content_read; +use crate::openhuman::memory_tree::retrieval::types::{hit_from_chunk, RetrievalHit}; +use crate::openhuman::memory_tree::score::store::get_score; +use crate::openhuman::memory_tree::store::get_chunk; /// Max batch size. Callers that pass more than this get truncated with a /// warn log — no error surface so the LLM sees a partial result. @@ -96,9 +96,9 @@ pub async fn fetch_leaves(config: &Config, chunk_ids: &[String]) -> Result Vec (TempDir, Config) { let tmp = TempDir::new().unwrap(); @@ -124,7 +124,7 @@ async fn end_to_end_three_chat_batches() { // ── fetch_leaves: find a guaranteed leaf hit from alice's topic results // and assert that fetch_leaves hydrates it correctly. - use crate::openhuman::memory::tree::retrieval::types::NodeKind; + use crate::openhuman::memory_tree::retrieval::types::NodeKind; let leaf_hit = by_email .hits .iter() @@ -164,9 +164,9 @@ async fn topic_entity_surfaces_after_ingest() { /// handler, so the test drains the queue before inspecting. #[tokio::test] async fn ingest_populates_chunk_embeddings() { - use crate::openhuman::memory::tree::jobs::drain_until_idle; - use crate::openhuman::memory::tree::score::embed::EMBEDDING_DIM; - use crate::openhuman::memory::tree::store::get_chunk_embedding; + use crate::openhuman::memory_tree::jobs::drain_until_idle; + use crate::openhuman::memory_tree::score::embed::EMBEDDING_DIM; + use crate::openhuman::memory_tree::store::get_chunk_embedding; let (_tmp, cfg) = test_config(); let out = ingest_chat(&cfg, "slack:#eng", "alice", vec![], chat_about_phoenix(0)) @@ -192,16 +192,16 @@ async fn ingest_populates_chunk_embeddings() { /// the seal from firing on short batches. #[tokio::test] async fn seal_populates_summary_embedding() { - use crate::openhuman::memory::tree::content_store; - use crate::openhuman::memory::tree::score::embed::EMBEDDING_DIM; - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_source::bucket_seal::{ + use crate::openhuman::memory_tree::content_store; + use crate::openhuman::memory_tree::score::embed::EMBEDDING_DIM; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_source::bucket_seal::{ append_leaf, LabelStrategy, LeafRef, }; - use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree; - use crate::openhuman::memory::tree::tree_source::store as src_store; - use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::tree_source::registry::get_or_create_source_tree; + use crate::openhuman::memory_tree::tree_source::store as src_store; + use crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; let (_tmp, cfg) = test_config(); let tree = get_or_create_source_tree(&cfg, "slack:#seal-test").unwrap(); @@ -233,9 +233,9 @@ async fn seal_populates_summary_embedding() { std::fs::create_dir_all(&content_root).expect("create content_root for test"); let staged = content_store::stage_chunks(&content_root, &[c1.clone(), c2.clone()]) .expect("stage_chunks for test chunks"); - crate::openhuman::memory::tree::store::with_connection(&cfg, |conn| { + crate::openhuman::memory_tree::store::with_connection(&cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) diff --git a/src/openhuman/memory/tree/retrieval/mod.rs b/src/openhuman/memory_tree/retrieval/mod.rs similarity index 100% rename from src/openhuman/memory/tree/retrieval/mod.rs rename to src/openhuman/memory_tree/retrieval/mod.rs diff --git a/src/openhuman/memory/tree/retrieval/rpc.rs b/src/openhuman/memory_tree/retrieval/rpc.rs similarity index 97% rename from src/openhuman/memory/tree/retrieval/rpc.rs rename to src/openhuman/memory_tree/retrieval/rpc.rs index 6a82d1a17db..24987bb2be6 100644 --- a/src/openhuman/memory/tree/retrieval/rpc.rs +++ b/src/openhuman/memory_tree/retrieval/rpc.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::retrieval::{ +use crate::openhuman::memory_tree::retrieval::{ drill_down::drill_down, fetch::fetch_leaves, global::query_global, @@ -17,8 +17,8 @@ use crate::openhuman::memory::tree::retrieval::{ topic::query_topic, types::{EntityMatch, QueryResponse, RetrievalHit}, }; -use crate::openhuman::memory::tree::score::extract::EntityKind; -use crate::openhuman::memory::tree::types::SourceKind; +use crate::openhuman::memory_tree::score::extract::EntityKind; +use crate::openhuman::memory_tree::types::SourceKind; use crate::rpc::RpcOutcome; // ── query_source ────────────────────────────────────────────────────── @@ -307,9 +307,9 @@ mod tests { //! initialises the schema idempotently on first access, so read-only //! calls return empty responses rather than erroring. use super::*; - use crate::openhuman::memory::tree::content_store; - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceRef}; + use crate::openhuman::memory_tree::content_store; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceRef}; use chrono::{TimeZone, Utc}; use tempfile::TempDir; @@ -318,9 +318,9 @@ mod tests { std::fs::create_dir_all(&content_root).expect("create content_root for test"); let staged = content_store::stage_chunks(&content_root, chunks) .expect("stage_chunks for test chunks"); - crate::openhuman::memory::tree::store::with_connection(cfg, |conn| { + crate::openhuman::memory_tree::store::with_connection(cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) diff --git a/src/openhuman/memory/tree/retrieval/schemas.rs b/src/openhuman/memory_tree/retrieval/schemas.rs similarity index 99% rename from src/openhuman/memory/tree/retrieval/schemas.rs rename to src/openhuman/memory_tree/retrieval/schemas.rs index 63f5a83c808..1503fe62b20 100644 --- a/src/openhuman/memory/tree/retrieval/schemas.rs +++ b/src/openhuman/memory_tree/retrieval/schemas.rs @@ -18,7 +18,7 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::memory::tree::retrieval::rpc as retrieval_rpc; +use crate::openhuman::memory_tree::retrieval::rpc as retrieval_rpc; use crate::rpc::RpcOutcome; const NAMESPACE: &str = "memory_tree"; diff --git a/src/openhuman/memory/tree/retrieval/search.rs b/src/openhuman/memory_tree/retrieval/search.rs similarity index 96% rename from src/openhuman/memory/tree/retrieval/search.rs rename to src/openhuman/memory_tree/retrieval/search.rs index 37b619bb68a..e86d28d3399 100644 --- a/src/openhuman/memory/tree/retrieval/search.rs +++ b/src/openhuman/memory_tree/retrieval/search.rs @@ -19,9 +19,9 @@ use anyhow::{Context, Result}; use rusqlite::params_from_iter; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::retrieval::types::EntityMatch; -use crate::openhuman::memory::tree::score::extract::EntityKind; -use crate::openhuman::memory::tree::store::with_connection; +use crate::openhuman::memory_tree::retrieval::types::EntityMatch; +use crate::openhuman::memory_tree::score::extract::EntityKind; +use crate::openhuman::memory_tree::store::with_connection; const DEFAULT_LIMIT: usize = 5; const MAX_LIMIT: usize = 100; @@ -156,8 +156,8 @@ fn row_to_match(row: &rusqlite::Row<'_>) -> rusqlite::Result { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::canonicalize::chat::{ChatBatch, ChatMessage}; - use crate::openhuman::memory::tree::ingest::ingest_chat; + use crate::openhuman::memory_tree::canonicalize::chat::{ChatBatch, ChatMessage}; + use crate::openhuman::memory_tree::ingest::ingest_chat; use chrono::{TimeZone, Utc}; use tempfile::TempDir; diff --git a/src/openhuman/memory/tree/retrieval/source.rs b/src/openhuman/memory_tree/retrieval/source.rs similarity index 92% rename from src/openhuman/memory/tree/retrieval/source.rs rename to src/openhuman/memory_tree/retrieval/source.rs index 85f6aac99ae..558e3632404 100644 --- a/src/openhuman/memory/tree/retrieval/source.rs +++ b/src/openhuman/memory_tree/retrieval/source.rs @@ -21,14 +21,14 @@ use anyhow::Result; use chrono::{Duration, Utc}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::read as content_read; -use crate::openhuman::memory::tree::retrieval::types::{ +use crate::openhuman::memory_tree::content_store::read as content_read; +use crate::openhuman::memory_tree::retrieval::types::{ hit_from_summary, QueryResponse, RetrievalHit, }; -use crate::openhuman::memory::tree::score::embed::{build_embedder_from_config, cosine_similarity}; -use crate::openhuman::memory::tree::tree_source::store; -use crate::openhuman::memory::tree::tree_source::types::{SummaryNode, Tree, TreeKind}; -use crate::openhuman::memory::tree::types::SourceKind; +use crate::openhuman::memory_tree::score::embed::{build_embedder_from_config, cosine_similarity}; +use crate::openhuman::memory_tree::tree_source::store; +use crate::openhuman::memory_tree::tree_source::types::{SummaryNode, Tree, TreeKind}; +use crate::openhuman::memory_tree::types::SourceKind; const DEFAULT_LIMIT: usize = 10; @@ -306,14 +306,14 @@ fn filter_by_window(hits: Vec, window_days: u32) -> Vec, ) -> Result> { - use crate::openhuman::memory::tree::retrieval::types::NodeKind; - use crate::openhuman::memory::tree::store::get_chunk_embedding; - use crate::openhuman::memory::tree::tree_source::store as src_store; + use crate::openhuman::memory_tree::retrieval::types::NodeKind; + use crate::openhuman::memory_tree::store::get_chunk_embedding; + use crate::openhuman::memory_tree::tree_source::store as src_store; let embedder = build_embedder_from_config(config)?; let query_vec = embedder.embed(query).await?; @@ -318,8 +318,8 @@ async fn entity_hit_to_retrieval_hit( return Ok(Some(h)); } // Leaf: fetch chunk and hydrate. - use crate::openhuman::memory::tree::retrieval::types::hit_from_chunk; - use crate::openhuman::memory::tree::store::get_chunk; + use crate::openhuman::memory_tree::retrieval::types::hit_from_chunk; + use crate::openhuman::memory_tree::store::get_chunk; let mut chunk = match get_chunk(&config_owned, &node_id)? { Some(c) => c, None => { @@ -368,8 +368,8 @@ fn filter_by_window(hits: Vec, window_days: u32) -> Vec Result, String> { - use crate::openhuman::memory::tree::jobs; + use crate::openhuman::memory_tree::jobs; use chrono::{Duration as ChronoDuration, NaiveDate, Utc}; let date = match req @@ -301,7 +301,7 @@ pub async fn backfill_status_rpc( msg })?; let in_progress = - crate::openhuman::memory::tree::jobs::backfill_in_progress() || pending_jobs > 0; + crate::openhuman::memory_tree::jobs::backfill_in_progress() || pending_jobs > 0; Ok(RpcOutcome::single_log( BackfillStatusResponse { in_progress, @@ -314,7 +314,7 @@ pub async fn backfill_status_rpc( #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::jobs::store::count_total; + use crate::openhuman::memory_tree::jobs::store::count_total; use chrono::{Duration as ChronoDuration, Utc}; use tempfile::TempDir; @@ -389,7 +389,7 @@ mod tests { /// underlying flag is a process-global shared across parallel tests. #[tokio::test] async fn backfill_status_reports_pending_jobs() { - use crate::openhuman::memory::tree::jobs; + use crate::openhuman::memory_tree::jobs; let (_tmp, cfg) = test_config(); let s0 = backfill_status_rpc(&cfg).await.unwrap().value; diff --git a/src/openhuman/memory/tree/schemas.rs b/src/openhuman/memory_tree/schemas.rs similarity index 99% rename from src/openhuman/memory/tree/schemas.rs rename to src/openhuman/memory_tree/schemas.rs index a3caa22a3a5..d18744a7247 100644 --- a/src/openhuman/memory/tree/schemas.rs +++ b/src/openhuman/memory_tree/schemas.rs @@ -16,8 +16,8 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::memory::tree::read_rpc; -use crate::openhuman::memory::tree::rpc as tree_rpc; +use crate::openhuman::memory_tree::read_rpc; +use crate::openhuman::memory_tree::rpc as tree_rpc; use crate::rpc::RpcOutcome; const NAMESPACE: &str = "memory_tree"; diff --git a/src/openhuman/memory/tree/score/README.md b/src/openhuman/memory_tree/score/README.md similarity index 100% rename from src/openhuman/memory/tree/score/README.md rename to src/openhuman/memory_tree/score/README.md diff --git a/src/openhuman/memory/tree/score/embed/README.md b/src/openhuman/memory_tree/score/embed/README.md similarity index 100% rename from src/openhuman/memory/tree/score/embed/README.md rename to src/openhuman/memory_tree/score/embed/README.md diff --git a/src/openhuman/memory/tree/score/embed/cloud.rs b/src/openhuman/memory_tree/score/embed/cloud.rs similarity index 100% rename from src/openhuman/memory/tree/score/embed/cloud.rs rename to src/openhuman/memory_tree/score/embed/cloud.rs diff --git a/src/openhuman/memory/tree/score/embed/factory.rs b/src/openhuman/memory_tree/score/embed/factory.rs similarity index 100% rename from src/openhuman/memory/tree/score/embed/factory.rs rename to src/openhuman/memory_tree/score/embed/factory.rs diff --git a/src/openhuman/memory/tree/score/embed/inert.rs b/src/openhuman/memory_tree/score/embed/inert.rs similarity index 100% rename from src/openhuman/memory/tree/score/embed/inert.rs rename to src/openhuman/memory_tree/score/embed/inert.rs diff --git a/src/openhuman/memory/tree/score/embed/mod.rs b/src/openhuman/memory_tree/score/embed/mod.rs similarity index 100% rename from src/openhuman/memory/tree/score/embed/mod.rs rename to src/openhuman/memory_tree/score/embed/mod.rs diff --git a/src/openhuman/memory/tree/score/embed/ollama.rs b/src/openhuman/memory_tree/score/embed/ollama.rs similarity index 100% rename from src/openhuman/memory/tree/score/embed/ollama.rs rename to src/openhuman/memory_tree/score/embed/ollama.rs diff --git a/src/openhuman/memory/tree/score/extract/README.md b/src/openhuman/memory_tree/score/extract/README.md similarity index 100% rename from src/openhuman/memory/tree/score/extract/README.md rename to src/openhuman/memory_tree/score/extract/README.md diff --git a/src/openhuman/memory/tree/score/extract/extractor.rs b/src/openhuman/memory_tree/score/extract/extractor.rs similarity index 98% rename from src/openhuman/memory/tree/score/extract/extractor.rs rename to src/openhuman/memory_tree/score/extract/extractor.rs index 6bcacc06dd0..2e05e6f3b82 100644 --- a/src/openhuman/memory/tree/score/extract/extractor.rs +++ b/src/openhuman/memory_tree/score/extract/extractor.rs @@ -77,7 +77,7 @@ impl EntityExtractor for CompositeExtractor { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::score::extract::EntityKind; + use crate::openhuman::memory_tree::score::extract::EntityKind; #[tokio::test] async fn regex_only_extractor_works() { diff --git a/src/openhuman/memory/tree/score/extract/llm.rs b/src/openhuman/memory_tree/score/extract/llm.rs similarity index 99% rename from src/openhuman/memory/tree/score/extract/llm.rs rename to src/openhuman/memory_tree/score/extract/llm.rs index 1caf08d1a3b..1442854ac86 100644 --- a/src/openhuman/memory/tree/score/extract/llm.rs +++ b/src/openhuman/memory_tree/score/extract/llm.rs @@ -35,7 +35,7 @@ use serde::Deserialize; use super::types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic}; use super::EntityExtractor; -use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider}; +use crate::openhuman::memory_tree::chat::{ChatPrompt, ChatProvider}; // ── Configuration ──────────────────────────────────────────────────────── @@ -101,7 +101,7 @@ impl Default for LlmExtractorConfig { /// Holds an `Arc` rather than a per-instance HTTP /// client. The provider abstraction lets a single workspace choose /// cloud vs local at runtime (see -/// [`crate::openhuman::memory::tree::chat::build_chat_provider`]). Tests +/// [`crate::openhuman::memory_tree::chat::build_chat_provider`]). Tests /// can mock the provider to assert the prompt / parse behaviour without /// a real Ollama or backend. pub struct LlmEntityExtractor { diff --git a/src/openhuman/memory/tree/score/extract/llm_tests.rs b/src/openhuman/memory_tree/score/extract/llm_tests.rs similarity index 97% rename from src/openhuman/memory/tree/score/extract/llm_tests.rs rename to src/openhuman/memory_tree/score/extract/llm_tests.rs index f5a18faf8a1..c6a7ae5d870 100644 --- a/src/openhuman/memory/tree/score/extract/llm_tests.rs +++ b/src/openhuman/memory_tree/score/extract/llm_tests.rs @@ -193,7 +193,7 @@ async fn extract_soft_fallback_on_provider_failure() { // Provider always errors. extract() must NOT return Err — it must // return an empty ExtractedEntities with a warn log after retry // exhaustion. - use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider}; + use crate::openhuman::memory_tree::chat::{ChatPrompt, ChatProvider}; use async_trait::async_trait; use std::sync::Arc; @@ -220,7 +220,7 @@ async fn extract_routes_through_chat_provider_and_parses_response() { // Mock provider returns canned NER+importance JSON. Verify the // extractor parses it, recovers spans by string search, and emits the // expected entities + importance signal. - use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider}; + use crate::openhuman::memory_tree::chat::{ChatPrompt, ChatProvider}; use async_trait::async_trait; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -266,7 +266,7 @@ async fn extract_returns_empty_on_malformed_provider_response() { // Provider returns garbage. Caller must NOT see an Err — the parse // failure path returns empty entities (retrying the same input would // yield the same garbage, so we don't burn retries). - use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider}; + use crate::openhuman::memory_tree::chat::{ChatPrompt, ChatProvider}; use async_trait::async_trait; use std::sync::Arc; @@ -386,7 +386,7 @@ fn into_extracted_entities_disallowed_known_kind_falls_back_to_misc() { #[test] fn build_prompt_carries_user_text_and_kind_tag() { - use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider}; + use crate::openhuman::memory_tree::chat::{ChatPrompt, ChatProvider}; use async_trait::async_trait; use std::sync::Arc; diff --git a/src/openhuman/memory/tree/score/extract/mod.rs b/src/openhuman/memory_tree/score/extract/mod.rs similarity index 98% rename from src/openhuman/memory/tree/score/extract/mod.rs rename to src/openhuman/memory_tree/score/extract/mod.rs index 750dca9ab24..fd3f588c58f 100644 --- a/src/openhuman/memory/tree/score/extract/mod.rs +++ b/src/openhuman/memory_tree/score/extract/mod.rs @@ -13,7 +13,7 @@ pub mod types; use std::sync::Arc; use crate::openhuman::config::{Config, DEFAULT_CLOUD_LLM_MODEL}; -use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer}; +use crate::openhuman::memory_tree::chat::{build_chat_provider, ChatConsumer}; pub use extractor::{CompositeExtractor, EntityExtractor, RegexEntityExtractor}; pub use llm::{LlmEntityExtractor, LlmExtractorConfig}; diff --git a/src/openhuman/memory/tree/score/extract/regex.rs b/src/openhuman/memory_tree/score/extract/regex.rs similarity index 100% rename from src/openhuman/memory/tree/score/extract/regex.rs rename to src/openhuman/memory_tree/score/extract/regex.rs diff --git a/src/openhuman/memory/tree/score/extract/types.rs b/src/openhuman/memory_tree/score/extract/types.rs similarity index 100% rename from src/openhuman/memory/tree/score/extract/types.rs rename to src/openhuman/memory_tree/score/extract/types.rs diff --git a/src/openhuman/memory/tree/score/mod.rs b/src/openhuman/memory_tree/score/mod.rs similarity index 98% rename from src/openhuman/memory/tree/score/mod.rs rename to src/openhuman/memory_tree/score/mod.rs index e845f0e12d8..8f296525286 100644 --- a/src/openhuman/memory/tree/score/mod.rs +++ b/src/openhuman/memory_tree/score/mod.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; use self::extract::{EntityExtractor, ExtractedEntities}; use self::resolver::{canonicalise, CanonicalEntity}; use self::signals::{ScoreSignals, SignalWeights}; -use crate::openhuman::memory::tree::types::{approx_token_count, Chunk, SourceKind}; +use crate::openhuman::memory_tree::types::{approx_token_count, Chunk, SourceKind}; /// Default drop threshold. Chunks with `total < DEFAULT_DROP_THRESHOLD` /// are tombstoned and never reach the chunk store. @@ -119,7 +119,7 @@ impl ScoringConfig { /// failures) fall back to regex-only with a warn log; scoring never /// blocks on LLM availability. pub fn from_config(config: &crate::openhuman::config::Config) -> Self { - use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer}; + use crate::openhuman::memory_tree::chat::{build_chat_provider, ChatConsumer}; let model = match extract::resolve_extractor_model(config) { Some(m) => m, diff --git a/src/openhuman/memory/tree/score/mod_tests.rs b/src/openhuman/memory_tree/score/mod_tests.rs similarity index 98% rename from src/openhuman/memory/tree/score/mod_tests.rs rename to src/openhuman/memory_tree/score/mod_tests.rs index 2ce45d981ad..c697c7fd888 100644 --- a/src/openhuman/memory/tree/score/mod_tests.rs +++ b/src/openhuman/memory_tree/score/mod_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind}; +use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind}; use chrono::Utc; fn test_chunk(content: &str) -> Chunk { @@ -7,7 +7,7 @@ fn test_chunk(content: &str) -> Chunk { Chunk { id: chunk_id(SourceKind::Email, "t1", 0, "test-content"), content: content.to_string(), - token_count: crate::openhuman::memory::tree::types::approx_token_count(content), + token_count: crate::openhuman::memory_tree::types::approx_token_count(content), metadata: meta, seq_in_source: 0, created_at: Utc::now(), diff --git a/src/openhuman/memory/tree/score/resolver.rs b/src/openhuman/memory_tree/score/resolver.rs similarity index 97% rename from src/openhuman/memory/tree/score/resolver.rs rename to src/openhuman/memory_tree/score/resolver.rs index cc5201b7b62..845ad0c31f7 100644 --- a/src/openhuman/memory/tree/score/resolver.rs +++ b/src/openhuman/memory_tree/score/resolver.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; -use crate::openhuman::memory::tree::score::extract::{EntityKind, ExtractedEntities}; +use crate::openhuman::memory_tree::score::extract::{EntityKind, ExtractedEntities}; /// Canonicalised entity — same shape as [`ExtractedEntity`] plus a stable /// `canonical_id` suitable for indexing. @@ -103,7 +103,7 @@ pub fn canonical_id_for(kind: EntityKind, surface: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::score::extract::ExtractedEntity; + use crate::openhuman::memory_tree::score::extract::ExtractedEntity; fn entity(kind: EntityKind, text: &str) -> ExtractedEntity { ExtractedEntity { @@ -173,7 +173,7 @@ mod tests { // ── Topic canonicalisation (#709 / Phase 3c topic-tree scope) ──── - use crate::openhuman::memory::tree::score::extract::ExtractedTopic; + use crate::openhuman::memory_tree::score::extract::ExtractedTopic; fn topic(label: &str, score: f32) -> ExtractedTopic { ExtractedTopic { diff --git a/src/openhuman/memory/tree/score/signals/README.md b/src/openhuman/memory_tree/score/signals/README.md similarity index 100% rename from src/openhuman/memory/tree/score/signals/README.md rename to src/openhuman/memory_tree/score/signals/README.md diff --git a/src/openhuman/memory/tree/score/signals/interaction.rs b/src/openhuman/memory_tree/score/signals/interaction.rs similarity index 96% rename from src/openhuman/memory/tree/score/signals/interaction.rs rename to src/openhuman/memory_tree/score/signals/interaction.rs index 58a5ac791fd..b254cdd8359 100644 --- a/src/openhuman/memory/tree/score/signals/interaction.rs +++ b/src/openhuman/memory_tree/score/signals/interaction.rs @@ -13,7 +13,7 @@ //! Ingest adapters can attach these tags during canonicalisation when the //! upstream source supports the distinction. Absent tags → neutral score. -use crate::openhuman::memory::tree::types::Metadata; +use crate::openhuman::memory_tree::types::Metadata; /// Tag set when the user replied to this message/thread. pub const TAG_REPLY: &str = "reply"; @@ -67,7 +67,7 @@ pub fn score(meta: &Metadata) -> f32 { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::types::SourceKind; + use crate::openhuman::memory_tree::types::SourceKind; use chrono::Utc; fn meta(tags: &[&str]) -> Metadata { diff --git a/src/openhuman/memory/tree/score/signals/metadata_weight.rs b/src/openhuman/memory_tree/score/signals/metadata_weight.rs similarity index 96% rename from src/openhuman/memory/tree/score/signals/metadata_weight.rs rename to src/openhuman/memory_tree/score/signals/metadata_weight.rs index 3b2a3026524..1c88a710d42 100644 --- a/src/openhuman/memory/tree/score/signals/metadata_weight.rs +++ b/src/openhuman/memory_tree/score/signals/metadata_weight.rs @@ -8,7 +8,7 @@ //! context (e.g., channel size, thread participant count) is a future //! refinement when we actually have that metadata at ingest. -use crate::openhuman::memory::tree::types::{Metadata, SourceKind}; +use crate::openhuman::memory_tree::types::{Metadata, SourceKind}; /// Base weight for each source kind. /// diff --git a/src/openhuman/memory/tree/score/signals/mod.rs b/src/openhuman/memory_tree/score/signals/mod.rs similarity index 100% rename from src/openhuman/memory/tree/score/signals/mod.rs rename to src/openhuman/memory_tree/score/signals/mod.rs diff --git a/src/openhuman/memory/tree/score/signals/ops.rs b/src/openhuman/memory_tree/score/signals/ops.rs similarity index 96% rename from src/openhuman/memory/tree/score/signals/ops.rs rename to src/openhuman/memory_tree/score/signals/ops.rs index 36831106933..76407b06346 100644 --- a/src/openhuman/memory/tree/score/signals/ops.rs +++ b/src/openhuman/memory_tree/score/signals/ops.rs @@ -3,8 +3,8 @@ use super::{interaction, metadata_weight, source_weight, token_count, unique_words}; use super::{ScoreSignals, SignalWeights}; -use crate::openhuman::memory::tree::score::extract::ExtractedEntities; -use crate::openhuman::memory::tree::types::Metadata; +use crate::openhuman::memory_tree::score::extract::ExtractedEntities; +use crate::openhuman::memory_tree::types::Metadata; /// Compute all signals for a chunk. /// @@ -95,10 +95,10 @@ pub fn combine_cheap_only(signals: &ScoreSignals, w: &SignalWeights) -> f32 { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::score::extract::{ + use crate::openhuman::memory_tree::score::extract::{ EntityKind, ExtractedEntities, ExtractedEntity, }; - use crate::openhuman::memory::tree::types::SourceKind; + use crate::openhuman::memory_tree::types::SourceKind; use chrono::Utc; fn meta(tags: &[&str], kind: SourceKind) -> Metadata { diff --git a/src/openhuman/memory/tree/score/signals/source_weight.rs b/src/openhuman/memory_tree/score/signals/source_weight.rs similarity index 97% rename from src/openhuman/memory/tree/score/signals/source_weight.rs rename to src/openhuman/memory_tree/score/signals/source_weight.rs index 2339e083675..b653907280a 100644 --- a/src/openhuman/memory/tree/score/signals/source_weight.rs +++ b/src/openhuman/memory_tree/score/signals/source_weight.rs @@ -10,7 +10,7 @@ //! Finer distinction (DM vs channel on Slack specifically) requires richer //! ingest-time metadata and is deferred. -use crate::openhuman::memory::tree::types::{DataSource, Metadata, SourceKind}; +use crate::openhuman::memory_tree::types::{DataSource, Metadata, SourceKind}; const PROVIDER_PREFIX: &str = "provider:"; diff --git a/src/openhuman/memory/tree/score/signals/token_count.rs b/src/openhuman/memory_tree/score/signals/token_count.rs similarity index 100% rename from src/openhuman/memory/tree/score/signals/token_count.rs rename to src/openhuman/memory_tree/score/signals/token_count.rs diff --git a/src/openhuman/memory/tree/score/signals/types.rs b/src/openhuman/memory_tree/score/signals/types.rs similarity index 100% rename from src/openhuman/memory/tree/score/signals/types.rs rename to src/openhuman/memory_tree/score/signals/types.rs diff --git a/src/openhuman/memory/tree/score/signals/unique_words.rs b/src/openhuman/memory_tree/score/signals/unique_words.rs similarity index 100% rename from src/openhuman/memory/tree/score/signals/unique_words.rs rename to src/openhuman/memory_tree/score/signals/unique_words.rs diff --git a/src/openhuman/memory/tree/score/store.rs b/src/openhuman/memory_tree/score/store.rs similarity index 98% rename from src/openhuman/memory/tree/score/store.rs rename to src/openhuman/memory_tree/score/store.rs index 4d982054a99..701635f1223 100644 --- a/src/openhuman/memory/tree/score/store.rs +++ b/src/openhuman/memory_tree/score/store.rs @@ -14,10 +14,10 @@ use serde::{Deserialize, Serialize}; use crate::openhuman::composio::providers::profile::{is_self_identity_any_toolkit, IdentityKind}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::score::extract::EntityKind; -use crate::openhuman::memory::tree::score::resolver::CanonicalEntity; -use crate::openhuman::memory::tree::score::signals::ScoreSignals; -use crate::openhuman::memory::tree::store::with_connection; +use crate::openhuman::memory_tree::score::extract::EntityKind; +use crate::openhuman::memory_tree::score::resolver::CanonicalEntity; +use crate::openhuman::memory_tree::score::signals::ScoreSignals; +use crate::openhuman::memory_tree::store::with_connection; /// Map a memory-tree `EntityKind` to the Composio identity-registry /// [`IdentityKind`] used for self-matching, or `None` for kinds that diff --git a/src/openhuman/memory/tree/score/store_tests.rs b/src/openhuman/memory_tree/score/store_tests.rs similarity index 99% rename from src/openhuman/memory/tree/score/store_tests.rs rename to src/openhuman/memory_tree/score/store_tests.rs index 139d47da75e..dc47eb79e95 100644 --- a/src/openhuman/memory/tree/score/store_tests.rs +++ b/src/openhuman/memory_tree/score/store_tests.rs @@ -159,7 +159,7 @@ fn lookup_limit_respected() { /// and summary hits. See PR #789 CodeRabbit review. #[test] fn summary_entity_index_kind_is_parseable() { - use crate::openhuman::memory::tree::store::with_connection; + use crate::openhuman::memory_tree::store::with_connection; let (_tmp, cfg) = test_config(); diff --git a/src/openhuman/memory/tree/store.rs b/src/openhuman/memory_tree/store.rs similarity index 99% rename from src/openhuman/memory/tree/store.rs rename to src/openhuman/memory_tree/store.rs index 3a672a8a478..1902b0b6d0b 100644 --- a/src/openhuman/memory/tree/store.rs +++ b/src/openhuman/memory_tree/store.rs @@ -35,8 +35,8 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::StagedChunk; -use crate::openhuman::memory::tree::types::{Chunk, Metadata, SourceKind, SourceRef}; +use crate::openhuman::memory_tree::content_store::StagedChunk; +use crate::openhuman::memory_tree::types::{Chunk, Metadata, SourceKind, SourceRef}; const DB_DIR: &str = "memory_tree"; const DB_FILE: &str = "chunks.db"; @@ -1334,7 +1334,7 @@ fn migrate_legacy_embeddings_to_sidecar(conn: &Connection, config: &Config) -> R set_chunk_embedding_for_signature_tx(&tx, &id, &sig, &vec)?; copied_chunks += 1; } else { - crate::openhuman::memory::tree::tree_source::store::set_summary_embedding_for_signature_tx( + crate::openhuman::memory_tree::tree_source::store::set_summary_embedding_for_signature_tx( &tx, &id, &sig, &vec, )?; copied_summaries += 1; @@ -1350,13 +1350,13 @@ fn migrate_legacy_embeddings_to_sidecar(conn: &Connection, config: &Config) -> R // migration; dedupe key = signature, so exactly one chain per space. let has_uncovered = has_uncovered_reembed_work(&*tx, &sig)?; if has_uncovered { - let backfill_job = crate::openhuman::memory::tree::jobs::types::NewJob::reembed_backfill( - &crate::openhuman::memory::tree::jobs::types::ReembedBackfillPayload { + let backfill_job = crate::openhuman::memory_tree::jobs::types::NewJob::reembed_backfill( + &crate::openhuman::memory_tree::jobs::types::ReembedBackfillPayload { signature: sig.clone(), }, )?; - crate::openhuman::memory::tree::jobs::enqueue_tx(&tx, &backfill_job)?; - crate::openhuman::memory::tree::jobs::set_backfill_in_progress(true); + crate::openhuman::memory_tree::jobs::enqueue_tx(&tx, &backfill_job)?; + crate::openhuman::memory_tree::jobs::set_backfill_in_progress(true); } tx.commit()?; diff --git a/src/openhuman/memory/tree/store_tests.rs b/src/openhuman/memory_tree/store_tests.rs similarity index 99% rename from src/openhuman/memory/tree/store_tests.rs rename to src/openhuman/memory_tree/store_tests.rs index 60430b6f5a5..f76419c92ff 100644 --- a/src/openhuman/memory/tree/store_tests.rs +++ b/src/openhuman/memory_tree/store_tests.rs @@ -11,7 +11,7 @@ //! that don't need it. use super::*; -use crate::openhuman::memory::tree::types::chunk_id; +use crate::openhuman::memory_tree::types::chunk_id; use chrono::TimeZone; use rusqlite::params; use tempfile::TempDir; @@ -714,7 +714,7 @@ fn clear_reembed_skipped_for_signature_removes_all_tombstones_for_sig() { Ok(()) }) .unwrap(); - crate::openhuman::memory::tree::tree_source::store::mark_summary_reembed_skipped( + crate::openhuman::memory_tree::tree_source::store::mark_summary_reembed_skipped( &cfg, summary_id, &sig, diff --git a/src/openhuman/tree_summarizer/bus.rs b/src/openhuman/memory_tree/summarizer/bus.rs similarity index 100% rename from src/openhuman/tree_summarizer/bus.rs rename to src/openhuman/memory_tree/summarizer/bus.rs diff --git a/src/openhuman/tree_summarizer/cli.rs b/src/openhuman/memory_tree/summarizer/cli.rs similarity index 95% rename from src/openhuman/tree_summarizer/cli.rs rename to src/openhuman/memory_tree/summarizer/cli.rs index 8e5a8a3c7a9..228999c39dd 100644 --- a/src/openhuman/tree_summarizer/cli.rs +++ b/src/openhuman/memory_tree/summarizer/cli.rs @@ -152,7 +152,7 @@ fn run_ingest(args: &[String]) -> Result<()> { let rt = build_runtime()?; rt.block_on(async { let config = load_config().await?; - let outcome = crate::openhuman::tree_summarizer::rpc::tree_summarizer_ingest( + let outcome = crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_ingest( &config, namespace, &content, None, None, ) .await @@ -189,7 +189,7 @@ fn run_summarize(args: &[String]) -> Result<()> { rt.block_on(async { let config = load_config().await?; let outcome = - crate::openhuman::tree_summarizer::rpc::tree_summarizer_run(&config, namespace) + crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_run(&config, namespace) .await .map_err(anyhow::Error::msg)?; @@ -238,7 +238,7 @@ fn run_query(args: &[String]) -> Result<()> { let rt = build_runtime()?; rt.block_on(async { let config = load_config().await?; - let outcome = crate::openhuman::tree_summarizer::rpc::tree_summarizer_query( + let outcome = crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_query( &config, namespace, node_id, ) .await @@ -273,10 +273,11 @@ fn run_status(args: &[String]) -> Result<()> { let rt = build_runtime()?; rt.block_on(async { let config = load_config().await?; - let outcome = - crate::openhuman::tree_summarizer::rpc::tree_summarizer_status(&config, namespace) - .await - .map_err(anyhow::Error::msg)?; + let outcome = crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_status( + &config, namespace, + ) + .await + .map_err(anyhow::Error::msg)?; println!( "{}", @@ -310,10 +311,11 @@ fn run_rebuild(args: &[String]) -> Result<()> { let rt = build_runtime()?; rt.block_on(async { let config = load_config().await?; - let outcome = - crate::openhuman::tree_summarizer::rpc::tree_summarizer_rebuild(&config, namespace) - .await - .map_err(anyhow::Error::msg)?; + let outcome = crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_rebuild( + &config, namespace, + ) + .await + .map_err(anyhow::Error::msg)?; println!( "{}", diff --git a/src/openhuman/tree_summarizer/engine.rs b/src/openhuman/memory_tree/summarizer/engine.rs similarity index 97% rename from src/openhuman/tree_summarizer/engine.rs rename to src/openhuman/memory_tree/summarizer/engine.rs index 83ca2425a9a..c20a5110d20 100644 --- a/src/openhuman/tree_summarizer/engine.rs +++ b/src/openhuman/memory_tree/summarizer/engine.rs @@ -8,8 +8,8 @@ use std::collections::BTreeMap; use crate::core::event_bus::{publish_global, DomainEvent}; use crate::openhuman::config::Config; use crate::openhuman::inference::provider::traits::Provider; -use crate::openhuman::tree_summarizer::store; -use crate::openhuman::tree_summarizer::types::{ +use crate::openhuman::memory_tree::summarizer::store; +use crate::openhuman::memory_tree::summarizer::types::{ derive_node_ids, derive_parent_id, estimate_tokens, level_from_node_id, NodeLevel, TreeNode, TreeStatus, }; @@ -278,7 +278,7 @@ pub async fn rebuild_tree( // ── Internal ─────────────────────────────────────────────────────────── /// Re-summarize a single non-leaf node from its children. -async fn propagate_node( +pub(crate) async fn propagate_node( config: &Config, provider: &dyn Provider, namespace: &str, @@ -430,7 +430,7 @@ async fn summarize_to_limit( /// /// Buffer filenames are `{timestamp_millis}_{uuid}.md`. We extract the timestamp /// and derive the hour ID for each entry. -fn group_by_hour(entries: &[(String, String)]) -> BTreeMap> { +pub(crate) fn group_by_hour(entries: &[(String, String)]) -> BTreeMap> { let mut groups: BTreeMap> = BTreeMap::new(); for (filename, content) in entries { @@ -511,10 +511,11 @@ fn collect_hour_leaves_recursive( let level = level_from_node_id(&node_id); if level == NodeLevel::Hour { let raw = std::fs::read_to_string(entry.path())?; - let node = crate::openhuman::tree_summarizer::store::parse_node_markdown_pub( - &raw, namespace, &node_id, - ) - .with_context(|| format!("failed to parse hour leaf '{node_id}'"))?; + let node = + crate::openhuman::memory_tree::summarizer::store::parse_node_markdown_pub( + &raw, namespace, &node_id, + ) + .with_context(|| format!("failed to parse hour leaf '{node_id}'"))?; leaves.push(node); } } @@ -608,3 +609,7 @@ fn discover_active_namespaces(config: &Config) -> Vec { } active } + +#[cfg(test)] +#[path = "engine_tests.rs"] +mod tests; diff --git a/src/openhuman/memory_tree/summarizer/engine_tests.rs b/src/openhuman/memory_tree/summarizer/engine_tests.rs new file mode 100644 index 00000000000..407c1de6a80 --- /dev/null +++ b/src/openhuman/memory_tree/summarizer/engine_tests.rs @@ -0,0 +1,448 @@ +use super::*; +use crate::openhuman::config::Config; +use crate::openhuman::inference::provider::traits::Provider; +use async_trait::async_trait; +use chrono::{TimeZone, Utc}; +use tempfile::TempDir; + +// ── Shared helpers ──────────────────────────────────────────────────────── + +fn test_config(tmp: &TempDir) -> Config { + Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + } +} + +/// A stub Provider whose `chat_with_system` returns a fixed string. +struct StubProvider { + reply: String, +} + +impl StubProvider { + fn with_reply(reply: impl Into) -> Self { + Self { + reply: reply.into(), + } + } +} + +#[async_trait] +impl Provider for StubProvider { + async fn chat_with_system( + &self, + _system: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + log::debug!("[memory_tree_test] StubProvider::chat_with_system called"); + Ok(self.reply.clone()) + } +} + +// ── group_by_hour ──────────────────────────────────────────────────────── + +#[test] +fn group_by_hour_empty_input_returns_empty_map() { + log::debug!("[memory_tree_test] group_by_hour: empty input"); + let groups = group_by_hour(&[]); + assert!(groups.is_empty()); +} + +#[test] +fn group_by_hour_single_entry_maps_to_correct_hour() { + // Timestamp 1_711_958_400_000 = 2024-04-01T08:00:00Z + let filename = "1711958400000_abc12345.md".to_string(); + let content = "hello".to_string(); + let entries = vec![(filename, content)]; + let groups = group_by_hour(&entries); + log::debug!("[memory_tree_test] group_by_hour single: groups={groups:?}"); + assert_eq!(groups.len(), 1); + let key = groups.keys().next().unwrap(); + // Hour node id should be "YYYY/MM/DD/HH" + assert_eq!(key.matches('/').count(), 3, "hour id must have 3 slashes"); + assert!( + key.starts_with("2024/04/01/"), + "expected 2024-04-01 hour key; got: {key}" + ); + assert!(key.ends_with("/08"), "expected hour /08; got: {key}"); +} + +#[test] +fn group_by_hour_same_hour_entries_are_merged() { + // Two timestamps within the same hour. + let ts_a = 1_711_958_400_000_i64; // 2024-04-01T08:00:00Z + let ts_b = ts_a + 1_800_000; // +30 min, still same hour + + let entries = vec![ + (format!("{ts_a}_uuid1.md"), "msg-a".to_string()), + (format!("{ts_b}_uuid2.md"), "msg-b".to_string()), + ]; + let groups = group_by_hour(&entries); + log::debug!("[memory_tree_test] group_by_hour same-hour: groups={groups:?}"); + assert_eq!( + groups.len(), + 1, + "same-hour entries must collapse into one group" + ); + let contents = groups.values().next().unwrap(); + assert_eq!(contents.len(), 2); + assert!(contents.contains(&"msg-a".to_string())); + assert!(contents.contains(&"msg-b".to_string())); +} + +#[test] +fn group_by_hour_different_hours_produce_separate_groups() { + // 2024-04-01T08:00:00Z and 2024-04-01T09:00:00Z + let ts_h8 = 1_711_958_400_000_i64; + let ts_h9 = 1_711_962_000_000_i64; + + let entries = vec![ + (format!("{ts_h8}_uuid1.md"), "hour-8-msg".to_string()), + (format!("{ts_h9}_uuid2.md"), "hour-9-msg".to_string()), + ]; + let groups = group_by_hour(&entries); + log::debug!("[memory_tree_test] group_by_hour diff-hours: groups={groups:?}"); + assert_eq!(groups.len(), 2); + let keys: Vec<&String> = groups.keys().collect(); + // BTreeMap returns sorted keys. + assert!( + keys[0].ends_with("/08"), + "first key should end in /08; got {}", + keys[0] + ); + assert!( + keys[1].ends_with("/09"), + "second key should end in /09; got {}", + keys[1] + ); +} + +#[test] +fn group_by_hour_unparseable_filename_falls_back_to_current_hour() { + // A filename with no timestamp prefix should fall back without panic. + let entries = vec![("bad-filename.md".to_string(), "content".to_string())]; + let groups = group_by_hour(&entries); + // Must produce exactly one group (fallback to now). + assert_eq!(groups.len(), 1); + let key = groups.keys().next().unwrap(); + assert_eq!( + key.matches('/').count(), + 3, + "fallback key must still be a valid hour id" + ); +} + +#[test] +fn group_by_hour_output_is_ordered_by_hour_id() { + // BTreeMap guarantees sorted iteration — verify the API honours that. + // Timestamps verified: 2024-04-01T{08,10,12}:00:00Z + let ts_h08 = 1_711_958_400_000_i64; // 2024-04-01T08:00:00Z + let ts_h10 = 1_711_965_600_000_i64; // 2024-04-01T10:00:00Z + let ts_h12 = 1_711_972_800_000_i64; // 2024-04-01T12:00:00Z + + let entries = vec![ + (format!("{ts_h10}_a.md"), "10h".to_string()), + (format!("{ts_h08}_b.md"), "8h".to_string()), + (format!("{ts_h12}_c.md"), "12h".to_string()), + ]; + let groups = group_by_hour(&entries); + let keys: Vec<&String> = groups.keys().collect(); + log::debug!("[memory_tree_test] group_by_hour ordering: keys={keys:?}"); + assert_eq!(keys.len(), 3); + // Sorted lexicographically: .../08 < .../10 < .../12 + assert!(keys[0] < keys[1]); + assert!(keys[1] < keys[2]); +} + +// ── propagate_node ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn propagate_node_with_no_children_is_noop() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + let provider = StubProvider::with_reply("should not be called"); + let model = "stub-model"; + // No children exist for "2024/03/15" — propagate must succeed silently. + let result = propagate_node( + &cfg, + &provider, + "test-ns", + "2024/03/15", + NodeLevel::Day, + model, + ) + .await; + assert!(result.is_ok(), "empty children must not error: {result:?}"); + // No node should have been written. + let node = store::read_node(&cfg, "test-ns", "2024/03/15").unwrap(); + assert!( + node.is_none(), + "propagate with no children must not write a node" + ); +} + +#[tokio::test] +async fn propagate_node_day_from_hour_children_fits_budget() { + // Write two small hour leaves; their combined tokens are well within + // Day::max_tokens() so the LLM is NOT called — combined text is used directly. + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + + let now = Utc.with_ymd_and_hms(2024, 3, 15, 8, 0, 0).unwrap(); + let make_hour_node = |ns: &str, hour_id: &str, summary: &str| TreeNode { + node_id: hour_id.to_string(), + namespace: ns.to_string(), + level: NodeLevel::Hour, + parent_id: derive_parent_id(hour_id), + summary: summary.to_string(), + token_count: estimate_tokens(summary), + child_count: 0, + created_at: now, + updated_at: now, + metadata: None, + }; + + let ns = "test-ns"; + store::write_node( + &cfg, + &make_hour_node(ns, "2024/03/15/08", "Meeting at 8am."), + ) + .unwrap(); + store::write_node( + &cfg, + &make_hour_node(ns, "2024/03/15/09", "Stand-up at 9am."), + ) + .unwrap(); + + // StubProvider reply should NOT be used when content fits budget. + let provider = StubProvider::with_reply("SHOULD_NOT_APPEAR"); + propagate_node(&cfg, &provider, ns, "2024/03/15", NodeLevel::Day, "m") + .await + .unwrap(); + + let day_node = store::read_node(&cfg, ns, "2024/03/15").unwrap().unwrap(); + log::debug!( + "[memory_tree_test] propagate day: summary={}", + day_node.summary + ); + assert_eq!(day_node.level, NodeLevel::Day); + assert!(day_node.summary.contains("Meeting at 8am.")); + assert!(day_node.summary.contains("Stand-up at 9am.")); + // LLM reply must not appear when content fits the budget. + assert!(!day_node.summary.contains("SHOULD_NOT_APPEAR")); + assert!(day_node.child_count >= 2); +} + +#[tokio::test] +async fn propagate_node_month_from_day_children() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + let now = Utc::now(); + let ns = "test-ns"; + + let make_day = |id: &str, text: &str| TreeNode { + node_id: id.to_string(), + namespace: ns.to_string(), + level: NodeLevel::Day, + parent_id: derive_parent_id(id), + summary: text.to_string(), + token_count: estimate_tokens(text), + child_count: 1, + created_at: now, + updated_at: now, + metadata: None, + }; + + store::write_node(&cfg, &make_day("2024/03/14", "Day 14 recap.")).unwrap(); + store::write_node(&cfg, &make_day("2024/03/15", "Day 15 recap.")).unwrap(); + + let provider = StubProvider::with_reply("Month summary from LLM."); + propagate_node(&cfg, &provider, ns, "2024/03", NodeLevel::Month, "m") + .await + .unwrap(); + + let month = store::read_node(&cfg, ns, "2024/03").unwrap().unwrap(); + assert_eq!(month.level, NodeLevel::Month); + // Either both day summaries appear (budget fit) or the stub reply is used. + let has_day_content = month.summary.contains("Day 14") || month.summary.contains("Day 15"); + let has_stub = month.summary.contains("Month summary from LLM."); + assert!( + has_day_content || has_stub, + "month summary must contain day content or stub reply; got: {}", + month.summary + ); +} + +#[tokio::test] +async fn propagate_node_preserves_created_at_on_update() { + // If a node already exists, propagate must NOT overwrite `created_at`. + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + let ns = "test-ns"; + let now = Utc::now(); + + // Write an existing day node. + let existing = TreeNode { + node_id: "2024/03/15".to_string(), + namespace: ns.to_string(), + level: NodeLevel::Day, + parent_id: Some("2024/03".to_string()), + summary: "old summary".to_string(), + token_count: 10, + child_count: 0, + created_at: now - chrono::Duration::hours(5), + updated_at: now - chrono::Duration::hours(5), + metadata: None, + }; + store::write_node(&cfg, &existing).unwrap(); + let original_created_at = existing.created_at; + + // Write a child so propagation has something to do. + let child = TreeNode { + node_id: "2024/03/15/10".to_string(), + namespace: ns.to_string(), + level: NodeLevel::Hour, + parent_id: Some("2024/03/15".to_string()), + summary: "hour content".to_string(), + token_count: 5, + child_count: 0, + created_at: now, + updated_at: now, + metadata: None, + }; + store::write_node(&cfg, &child).unwrap(); + + let provider = StubProvider::with_reply("updated summary"); + propagate_node(&cfg, &provider, ns, "2024/03/15", NodeLevel::Day, "m") + .await + .unwrap(); + + let updated = store::read_node(&cfg, ns, "2024/03/15").unwrap().unwrap(); + assert_eq!( + updated.created_at, original_created_at, + "created_at must be preserved across propagation updates" + ); + assert!( + updated.updated_at >= now, + "updated_at must be refreshed on re-propagation" + ); +} + +// ── run_summarization end-to-end ────────────────────────────────────────── + +#[tokio::test] +async fn run_summarization_empty_buffer_returns_none() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + let provider = StubProvider::with_reply("should not be called"); + let ts = Utc::now(); + let result = run_summarization(&cfg, &provider, "test-ns", ts) + .await + .unwrap(); + assert!(result.is_none(), "empty buffer must return None"); +} + +#[tokio::test] +async fn run_summarization_drains_buffer_and_writes_hour_node() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + let ns = "test-ns"; + + // Write two buffer entries at the same hour so they merge into one hour leaf. + let ts = Utc.with_ymd_and_hms(2024, 3, 15, 10, 0, 0).unwrap(); + store::buffer_write(&cfg, ns, "entry one", &ts, None).unwrap(); + store::buffer_write(&cfg, ns, "entry two", &ts, None).unwrap(); + + let provider = StubProvider::with_reply("hour leaf summary from LLM"); + let last_node = run_summarization(&cfg, &provider, ns, ts).await.unwrap(); + + let node = last_node.expect("non-empty buffer must return an hour node"); + log::debug!( + "[memory_tree_test] run_summarization: hour node_id={}", + node.node_id + ); + assert_eq!(node.level, NodeLevel::Hour); + assert_eq!(node.namespace, ns); + + // Buffer must be drained after successful run. + let remaining = store::buffer_read(&cfg, ns).unwrap(); + assert!( + remaining.is_empty(), + "buffer must be empty after summarization" + ); + + // The hour node must be persisted. + let stored = store::read_node(&cfg, ns, &node.node_id).unwrap(); + assert!(stored.is_some(), "hour node must be written to disk"); +} + +#[tokio::test] +async fn run_summarization_builds_ancestor_chain() { + // After a successful run the day/month/year/root ancestor chain must be written. + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + let ns = "ancestor-test"; + let ts = Utc.with_ymd_and_hms(2024, 3, 15, 10, 0, 0).unwrap(); + + store::buffer_write(&cfg, ns, "test content", &ts, None).unwrap(); + + let provider = StubProvider::with_reply("summary text"); + run_summarization(&cfg, &provider, ns, ts).await.unwrap(); + + // Day, month, year, and root must all be present. + assert!( + store::read_node(&cfg, ns, "2024/03/15").unwrap().is_some(), + "day node must be propagated" + ); + assert!( + store::read_node(&cfg, ns, "2024/03").unwrap().is_some(), + "month node must be propagated" + ); + assert!( + store::read_node(&cfg, ns, "2024").unwrap().is_some(), + "year node must be propagated" + ); + assert!( + store::read_node(&cfg, ns, "root").unwrap().is_some(), + "root node must be propagated" + ); +} + +#[tokio::test] +async fn run_summarization_multi_hour_groups_produce_multiple_hour_leaves() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + let ns = "multi-hour-test"; + + let ts_h08 = Utc.with_ymd_and_hms(2024, 3, 15, 8, 0, 0).unwrap(); + let ts_h14 = Utc.with_ymd_and_hms(2024, 3, 15, 14, 0, 0).unwrap(); + + // Write entries for two different hours. + store::buffer_write(&cfg, ns, "morning entry", &ts_h08, None).unwrap(); + store::buffer_write(&cfg, ns, "afternoon entry", &ts_h14, None).unwrap(); + + let provider = StubProvider::with_reply("grouped summary"); + run_summarization(&cfg, &provider, ns, ts_h14) + .await + .unwrap(); + + // Both hour leaves must be written. + let hour_08 = store::read_node(&cfg, ns, "2024/03/15/08").unwrap(); + let hour_14 = store::read_node(&cfg, ns, "2024/03/15/14").unwrap(); + assert!(hour_08.is_some(), "hour-08 leaf must be written"); + assert!(hour_14.is_some(), "hour-14 leaf must be written"); + + // Buffer must be empty. + assert!(store::buffer_read(&cfg, ns).unwrap().is_empty()); +} diff --git a/src/openhuman/tree_summarizer/mod.rs b/src/openhuman/memory_tree/summarizer/mod.rs similarity index 100% rename from src/openhuman/tree_summarizer/mod.rs rename to src/openhuman/memory_tree/summarizer/mod.rs diff --git a/src/openhuman/tree_summarizer/ops.rs b/src/openhuman/memory_tree/summarizer/ops.rs similarity index 98% rename from src/openhuman/tree_summarizer/ops.rs rename to src/openhuman/memory_tree/summarizer/ops.rs index 87ad921296b..14bd393bf23 100644 --- a/src/openhuman/tree_summarizer/ops.rs +++ b/src/openhuman/memory_tree/summarizer/ops.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; use serde_json::{json, Value}; use crate::openhuman::config::Config; -use crate::openhuman::tree_summarizer::{engine, store, types::*}; +use crate::openhuman::memory_tree::summarizer::{engine, store, types::*}; use crate::rpc::RpcOutcome; /// Append raw content to the ingestion buffer. diff --git a/src/openhuman/tree_summarizer/schemas.rs b/src/openhuman/memory_tree/summarizer/schemas.rs similarity index 95% rename from src/openhuman/tree_summarizer/schemas.rs rename to src/openhuman/memory_tree/summarizer/schemas.rs index 6c285af0017..8c22b72a68b 100644 --- a/src/openhuman/tree_summarizer/schemas.rs +++ b/src/openhuman/memory_tree/summarizer/schemas.rs @@ -176,7 +176,7 @@ fn handle_ingest(params: Map) -> ControllerFuture { let timestamp = read_optional_timestamp(¶ms, "timestamp")?; let metadata = read_optional::(¶ms, "metadata")?; to_json( - crate::openhuman::tree_summarizer::rpc::tree_summarizer_ingest( + crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_ingest( &config, &namespace, &content, @@ -193,8 +193,10 @@ fn handle_run(params: Map) -> ControllerFuture { let config = config_rpc::load_config_with_timeout().await?; let namespace = read_required::(¶ms, "namespace")?; to_json( - crate::openhuman::tree_summarizer::rpc::tree_summarizer_run(&config, &namespace) - .await?, + crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_run( + &config, &namespace, + ) + .await?, ) }) } @@ -205,7 +207,7 @@ fn handle_query(params: Map) -> ControllerFuture { let namespace = read_required::(¶ms, "namespace")?; let node_id = read_optional::(¶ms, "node_id")?; to_json( - crate::openhuman::tree_summarizer::rpc::tree_summarizer_query( + crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_query( &config, &namespace, node_id.as_deref(), @@ -220,8 +222,10 @@ fn handle_status(params: Map) -> ControllerFuture { let config = config_rpc::load_config_with_timeout().await?; let namespace = read_required::(¶ms, "namespace")?; to_json( - crate::openhuman::tree_summarizer::rpc::tree_summarizer_status(&config, &namespace) - .await?, + crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_status( + &config, &namespace, + ) + .await?, ) }) } @@ -231,8 +235,10 @@ fn handle_rebuild(params: Map) -> ControllerFuture { let config = config_rpc::load_config_with_timeout().await?; let namespace = read_required::(¶ms, "namespace")?; to_json( - crate::openhuman::tree_summarizer::rpc::tree_summarizer_rebuild(&config, &namespace) - .await?, + crate::openhuman::memory_tree::summarizer::rpc::tree_summarizer_rebuild( + &config, &namespace, + ) + .await?, ) }) } diff --git a/src/openhuman/tree_summarizer/store.rs b/src/openhuman/memory_tree/summarizer/store.rs similarity index 99% rename from src/openhuman/tree_summarizer/store.rs rename to src/openhuman/memory_tree/summarizer/store.rs index 4bc9cf61087..90a0c384d30 100644 --- a/src/openhuman/tree_summarizer/store.rs +++ b/src/openhuman/memory_tree/summarizer/store.rs @@ -13,7 +13,7 @@ use serde_json::Value; use std::path::{Path, PathBuf}; use crate::openhuman::config::Config; -use crate::openhuman::tree_summarizer::types::{ +use crate::openhuman::memory_tree::summarizer::types::{ derive_parent_id, estimate_tokens, level_from_node_id, node_id_to_path, NodeLevel, TreeNode, TreeStatus, }; diff --git a/src/openhuman/tree_summarizer/store_tests.rs b/src/openhuman/memory_tree/summarizer/store_tests.rs similarity index 100% rename from src/openhuman/tree_summarizer/store_tests.rs rename to src/openhuman/memory_tree/summarizer/store_tests.rs diff --git a/src/openhuman/tree_summarizer/types.rs b/src/openhuman/memory_tree/summarizer/types.rs similarity index 100% rename from src/openhuman/tree_summarizer/types.rs rename to src/openhuman/memory_tree/summarizer/types.rs diff --git a/src/openhuman/tools/impl/memory/tree/drill_down.rs b/src/openhuman/memory_tree/tools/drill_down.rs similarity index 95% rename from src/openhuman/tools/impl/memory/tree/drill_down.rs rename to src/openhuman/memory_tree/tools/drill_down.rs index 4825345a09c..04626d2e857 100644 --- a/src/openhuman/tools/impl/memory/tree/drill_down.rs +++ b/src/openhuman/memory_tree/tools/drill_down.rs @@ -1,6 +1,6 @@ use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::memory::tree::retrieval; -use crate::openhuman::memory::tree::retrieval::rpc::DrillDownRequest; +use crate::openhuman::memory_tree::retrieval; +use crate::openhuman::memory_tree::retrieval::rpc::DrillDownRequest; use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; diff --git a/src/openhuman/tools/impl/memory/tree/fetch_leaves.rs b/src/openhuman/memory_tree/tools/fetch_leaves.rs similarity index 95% rename from src/openhuman/tools/impl/memory/tree/fetch_leaves.rs rename to src/openhuman/memory_tree/tools/fetch_leaves.rs index 8d426206b6f..e87bc355f14 100644 --- a/src/openhuman/tools/impl/memory/tree/fetch_leaves.rs +++ b/src/openhuman/memory_tree/tools/fetch_leaves.rs @@ -1,6 +1,6 @@ use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::memory::tree::retrieval; -use crate::openhuman::memory::tree::retrieval::rpc::FetchLeavesRequest; +use crate::openhuman::memory_tree::retrieval; +use crate::openhuman::memory_tree::retrieval::rpc::FetchLeavesRequest; use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; diff --git a/src/openhuman/tools/impl/memory/tree/ingest_document.rs b/src/openhuman/memory_tree/tools/ingest_document.rs similarity index 96% rename from src/openhuman/tools/impl/memory/tree/ingest_document.rs rename to src/openhuman/memory_tree/tools/ingest_document.rs index a8cb4e62093..da41d641546 100644 --- a/src/openhuman/tools/impl/memory/tree/ingest_document.rs +++ b/src/openhuman/memory_tree/tools/ingest_document.rs @@ -1,7 +1,7 @@ use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::memory::tree::canonicalize::document::DocumentInput; -use crate::openhuman::memory::tree::rpc; -use crate::openhuman::memory::tree::types::SourceKind; +use crate::openhuman::memory_tree::canonicalize::document::DocumentInput; +use crate::openhuman::memory_tree::rpc; +use crate::openhuman::memory_tree::types::SourceKind; use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; use chrono::Utc; diff --git a/src/openhuman/tools/impl/memory/tree/mod.rs b/src/openhuman/memory_tree/tools/mod.rs similarity index 95% rename from src/openhuman/tools/impl/memory/tree/mod.rs rename to src/openhuman/memory_tree/tools/mod.rs index e503c66b7b9..9a1c08e598c 100644 --- a/src/openhuman/tools/impl/memory/tree/mod.rs +++ b/src/openhuman/memory_tree/tools/mod.rs @@ -14,6 +14,7 @@ mod query_global; mod query_source; mod query_topic; mod search_entities; +pub mod walk; // Re-export individual tool types for callers that need them directly // (e.g. tool registration in ops.rs). @@ -24,6 +25,7 @@ pub use query_global::MemoryTreeQueryGlobalTool; pub use query_source::MemoryTreeQuerySourceTool; pub use query_topic::MemoryTreeQueryTopicTool; pub use search_entities::MemoryTreeSearchEntitiesTool; +pub use walk::{run_walk, MemoryTreeWalkTool, WalkOptions, WalkOutcome, WalkStep, WalkStopReason}; use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; @@ -48,7 +50,8 @@ impl Tool for MemoryTreeTool { `query_source` (filter by source type + time window), \ `query_global` (cross-source daily digest), \ `drill_down` (expand a coarse summary one level), \ - `fetch_leaves` (pull raw chunks for citation), `ingest_document` (write a document into the tree for future retrieval)." + `fetch_leaves` (pull raw chunks for citation), `ingest_document` (write a document into the tree for future retrieval), \ + `walk` (agentic multi-turn walk — LLM navigates summaries and returns a synthesized answer for a natural-language query)." } fn parameters_schema(&self) -> serde_json::Value { @@ -58,13 +61,13 @@ impl Tool for MemoryTreeTool { "mode": { "type": "string", "enum": ["search_entities", "query_topic", "query_source", - "query_global", "drill_down", "fetch_leaves", "ingest_document"], + "query_global", "drill_down", "fetch_leaves", "ingest_document", "walk"], "description": "Which operation to run (retrieval or write)." }, // search_entities params "query": { "type": "string", - "description": "search_entities: substring to match. query_topic/query_source: semantic rerank query (optional)." + "description": "search_entities: substring to match. query_topic/query_source: semantic rerank query (optional). walk: natural-language question to answer by walking the memory tree." }, "kinds": { "type": "array", @@ -153,10 +156,11 @@ impl Tool for MemoryTreeTool { "drill_down" => MemoryTreeDrillDownTool.execute(args).await, "fetch_leaves" => MemoryTreeFetchLeavesTool.execute(args).await, "ingest_document" => MemoryTreeIngestDocumentTool.execute(args).await, + "walk" => MemoryTreeWalkTool.execute(args).await, other => { log::debug!("[tool][memory_tree] unknown_mode mode={other}"); Err(anyhow::anyhow!( - "memory_tree: unknown mode `{other}`. Valid: search_entities, query_topic, query_source, query_global, drill_down, fetch_leaves, ingest_document" + "memory_tree: unknown mode `{other}`. Valid: search_entities, query_topic, query_source, query_global, drill_down, fetch_leaves, ingest_document, walk" )) } } @@ -229,6 +233,7 @@ mod memory_tree_dispatcher_tests { assert!(modes.contains(&"drill_down")); assert!(modes.contains(&"fetch_leaves")); assert!(modes.contains(&"ingest_document")); + assert!(modes.contains(&"walk")); } #[test] diff --git a/src/openhuman/tools/impl/memory/tree/query_global.rs b/src/openhuman/memory_tree/tools/query_global.rs similarity index 94% rename from src/openhuman/tools/impl/memory/tree/query_global.rs rename to src/openhuman/memory_tree/tools/query_global.rs index 5f8b322c9d8..b85cc4f81ed 100644 --- a/src/openhuman/tools/impl/memory/tree/query_global.rs +++ b/src/openhuman/memory_tree/tools/query_global.rs @@ -1,6 +1,6 @@ use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::memory::tree::retrieval; -use crate::openhuman::memory::tree::retrieval::rpc::QueryGlobalRequest; +use crate::openhuman::memory_tree::retrieval; +use crate::openhuman::memory_tree::retrieval::rpc::QueryGlobalRequest; use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; diff --git a/src/openhuman/tools/impl/memory/tree/query_source.rs b/src/openhuman/memory_tree/tools/query_source.rs similarity index 94% rename from src/openhuman/tools/impl/memory/tree/query_source.rs rename to src/openhuman/memory_tree/tools/query_source.rs index c525295f030..6b433eccddb 100644 --- a/src/openhuman/tools/impl/memory/tree/query_source.rs +++ b/src/openhuman/memory_tree/tools/query_source.rs @@ -1,7 +1,7 @@ use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::memory::tree::retrieval; -use crate::openhuman::memory::tree::retrieval::rpc::QuerySourceRequest; -use crate::openhuman::memory::tree::types::SourceKind; +use crate::openhuman::memory_tree::retrieval; +use crate::openhuman::memory_tree::retrieval::rpc::QuerySourceRequest; +use crate::openhuman::memory_tree::types::SourceKind; use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; diff --git a/src/openhuman/tools/impl/memory/tree/query_topic.rs b/src/openhuman/memory_tree/tools/query_topic.rs similarity index 95% rename from src/openhuman/tools/impl/memory/tree/query_topic.rs rename to src/openhuman/memory_tree/tools/query_topic.rs index c0e0c8e6512..e45115d0cdb 100644 --- a/src/openhuman/tools/impl/memory/tree/query_topic.rs +++ b/src/openhuman/memory_tree/tools/query_topic.rs @@ -1,6 +1,6 @@ use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::memory::tree::retrieval; -use crate::openhuman::memory::tree::retrieval::rpc::QueryTopicRequest; +use crate::openhuman::memory_tree::retrieval; +use crate::openhuman::memory_tree::retrieval::rpc::QueryTopicRequest; use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; diff --git a/src/openhuman/tools/impl/memory/tree/search_entities.rs b/src/openhuman/memory_tree/tools/search_entities.rs similarity index 94% rename from src/openhuman/tools/impl/memory/tree/search_entities.rs rename to src/openhuman/memory_tree/tools/search_entities.rs index c6cba317dc7..e280d689ad6 100644 --- a/src/openhuman/tools/impl/memory/tree/search_entities.rs +++ b/src/openhuman/memory_tree/tools/search_entities.rs @@ -1,7 +1,7 @@ use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::memory::tree::retrieval; -use crate::openhuman::memory::tree::retrieval::rpc::SearchEntitiesRequest; -use crate::openhuman::memory::tree::score::extract::EntityKind; +use crate::openhuman::memory_tree::retrieval; +use crate::openhuman::memory_tree::retrieval::rpc::SearchEntitiesRequest; +use crate::openhuman::memory_tree::score::extract::EntityKind; use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; diff --git a/src/openhuman/memory_tree/tools/walk.rs b/src/openhuman/memory_tree/tools/walk.rs new file mode 100644 index 00000000000..cfe75eea605 --- /dev/null +++ b/src/openhuman/memory_tree/tools/walk.rs @@ -0,0 +1,962 @@ +//! Agentic memory-tree walk tool. +//! +//! Given a free-text query, a lightweight LLM navigates the summary tree in +//! a turn-based inner loop — calling `descend`, `peek`, `fetch_leaves`, or +//! `answer` each turn — and returns a synthesised answer with a trace. +//! +//! The inner loop uses `Provider::chat_with_history` (prompt-guided tool +//! calling via XML tags) because the `Provider::chat()` default does not +//! surface native `tool_calls` for prompt-guided backends. The response text +//! is parsed for `…` blocks, matching the harness +//! convention established in `agent/harness/parse.rs`. +//! +//! For the `Tool::execute` path, a thin `ChatProviderAdapter` wraps the +//! memory-tree's `ChatProvider` (available from `build_chat_provider`) to +//! satisfy the `Provider` trait — avoiding a dependency on the full routing +//! stack which requires a configured remote backend. + +use crate::openhuman::config::rpc as config_rpc; +use crate::openhuman::config::Config; +use crate::openhuman::inference::provider::traits::{ChatMessage, Provider}; +use crate::openhuman::memory_tree::chat::{build_chat_provider, ChatConsumer, ChatPrompt}; +use crate::openhuman::memory_tree::retrieval; +use crate::openhuman::memory_tree::retrieval::fetch::fetch_leaves as do_fetch_leaves; +use crate::openhuman::memory_tree::summarizer::store::{read_children, read_node}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +// ── Temperature (matches SUMMARIZATION_TEMP convention) ──────────────────── +const WALK_TEMP: f64 = 0.3; +/// Hard cap on LLM turns, even if the caller requests more. +const HARD_MAX_TURNS: usize = 20; + +// ── Public output types ───────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct WalkOptions { + /// Maximum number of LLM turns before giving up. Default: 6. + pub max_turns: usize, + /// Node id to start from. `None` → namespace root. + pub start_node_id: Option, + /// Memory namespace. Default: `"default"`. + pub namespace: String, + /// Model override. `None` → `config.local_ai.chat_model_id`. + pub model: Option, +} + +impl Default for WalkOptions { + fn default() -> Self { + Self { + max_turns: 6, + start_node_id: None, + namespace: "default".into(), + model: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WalkStopReason { + /// LLM called `answer { text }`. + Answered, + /// Loop exhausted `max_turns` without an answer action. + MaxTurnsReached, + /// LLM returned no tool call and no meaningful text — treated as giving up. + LlmGaveUp, + /// A hard error prevented the walk from completing. + Error(String), +} + +#[derive(Debug, Clone)] +pub struct WalkStep { + pub turn: usize, + pub action: String, + pub args_summary: String, + pub result_preview: String, +} + +#[derive(Debug, Clone)] +pub struct WalkOutcome { + pub answer: String, + pub trace: Vec, + pub turns_used: usize, + pub stopped_reason: WalkStopReason, +} + +// ── Public API ────────────────────────────────────────────────────────────── + +pub struct MemoryTreeWalkTool; + +#[async_trait] +impl Tool for MemoryTreeWalkTool { + fn name(&self) -> &str { + "memory_tree_walk" + } + + fn description(&self) -> &str { + "Agentically walk the memory tree to answer a query — a lightweight \ + LLM navigates summaries, drills into relevant branches, and returns \ + a synthesized answer with citations." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural-language question to answer by walking the memory tree." + }, + "namespace": { + "type": "string", + "description": "Memory namespace. Default: \"default\"." + }, + "start_node_id": { + "type": "string", + "description": "Optional starting node id. Default: namespace root." + }, + "max_turns": { + "type": "integer", + "description": "Max LLM turns. Default 6, hard cap 20." + } + }, + "required": ["query"] + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::System + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let query = args + .get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("memory_tree_walk: `query` is required"))? + .to_string(); + + let namespace = args + .get("namespace") + .and_then(|v| v.as_str()) + .unwrap_or("default") + .to_string(); + + let start_node_id = args + .get("start_node_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let max_turns = args + .get("max_turns") + .and_then(|v| v.as_u64()) + .map(|n| (n as usize).min(HARD_MAX_TURNS)) + .unwrap_or(6); + + let cfg = config_rpc::load_config_with_timeout() + .await + .map_err(|e| anyhow::anyhow!("memory_tree_walk: load config failed: {e}"))?; + + let model = args + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let opts = WalkOptions { + max_turns, + start_node_id, + namespace, + model, + }; + + // Build a chat provider from config (same path used by the summariser) + // and wrap it in the thin `ChatProviderAdapter` that satisfies `Provider`. + let chat_provider = build_chat_provider(&cfg, ChatConsumer::Summarise) + .map_err(|e| anyhow::anyhow!("memory_tree_walk: build chat provider failed: {e}"))?; + let adapter = ChatProviderAdapter { + inner: chat_provider, + }; + + let outcome = run_walk(&cfg, &adapter, &query, opts).await?; + + // Format output as markdown with trace. + let mut out = format!("{}\n\n## Trace\n", outcome.answer); + for step in &outcome.trace { + out.push_str(&format!( + "- **Turn {}** `{}` {}: {}\n", + step.turn, step.action, step.args_summary, step.result_preview + )); + } + out.push_str(&format!( + "\n*Stop reason: {:?}, turns used: {}*\n", + outcome.stopped_reason, outcome.turns_used + )); + + Ok(ToolResult::success(out)) + } +} + +/// Drive the walk without going through the Tool trait. +/// Useful for tests and callers that already hold a `Config` and `Provider`. +pub async fn run_walk( + config: &Config, + provider: &dyn Provider, + query: &str, + opts: WalkOptions, +) -> anyhow::Result { + let max_turns = opts.max_turns.min(HARD_MAX_TURNS); + let model = opts + .model + .clone() + .unwrap_or_else(|| config.local_ai.chat_model_id.clone()); + + log::debug!( + "[memory_tree_walk] starting walk query_len={} namespace={} max_turns={} model={}", + query.len(), + opts.namespace, + max_turns, + model + ); + + // Determine the starting node. + let start_id = opts + .start_node_id + .clone() + .unwrap_or_else(|| "root".to_string()); + + // Load the starting node summary + children to build the first context message. + let initial_context = build_node_context(config, &opts.namespace, &start_id).await; + log::debug!( + "[memory_tree_walk] initial_context node_id={} context_len={}", + start_id, + initial_context.len() + ); + + let system = build_system_prompt(); + let inner_tools_text = build_inner_tools_text(); + + // Chat history: system → tool instructions injected → user query → context. + let mut history: Vec = vec![ + ChatMessage::system(format!("{system}\n\n{inner_tools_text}")), + ChatMessage::user(format!( + "Query: {query}\n\nCurrent position in memory tree:\n{initial_context}" + )), + ]; + + let mut trace: Vec = Vec::new(); + let mut current_node_id = start_id.clone(); + + for turn in 1..=max_turns { + log::debug!("[memory_tree_walk] turn={turn} current_node={current_node_id}"); + + let response = match provider + .chat_with_history(&history, &model, WALK_TEMP) + .await + { + Ok(r) => r, + Err(e) => { + log::warn!("[memory_tree_walk] provider error on turn={turn}: {e:#}"); + let err_msg = format!("Provider error on turn {turn}: {e}"); + return Ok(WalkOutcome { + answer: format!( + "Walk failed: {err_msg}\n\nPartial trace from {} turn(s).", + trace.len() + ), + trace, + turns_used: turn, + stopped_reason: WalkStopReason::Error(err_msg), + }); + } + }; + + log::debug!( + "[memory_tree_walk] turn={turn} response_len={}", + response.len() + ); + + // Parse tool calls from the response text. + let (text_before, calls) = parse_walk_tool_calls(&response); + + if calls.is_empty() { + // No tool call — treat as final answer if there's meaningful text. + let trimmed = response.trim().to_string(); + if trimmed.is_empty() { + log::debug!("[memory_tree_walk] turn={turn} LLM gave up (empty response)"); + return Ok(WalkOutcome { + answer: synthesize_fallback_answer(&trace), + trace, + turns_used: turn, + stopped_reason: WalkStopReason::LlmGaveUp, + }); + } + log::debug!("[memory_tree_walk] turn={turn} no tool calls — treating as final answer"); + return Ok(WalkOutcome { + answer: trimmed, + trace, + turns_used: turn, + stopped_reason: WalkStopReason::Answered, + }); + } + + // Process the first tool call (walk is serial). + let call = &calls[0]; + log::debug!( + "[memory_tree_walk] turn={turn} action={} args={}", + call.name, + call.args + ); + + // Append assistant turn to history. + history.push(ChatMessage::assistant(response.clone())); + + // Dispatch inner walk primitive. + let (step_args_summary, tool_result, is_answer, answer_text) = + dispatch_inner_call(config, &opts.namespace, call, &mut current_node_id).await; + + let result_preview: String = tool_result.chars().take(200).collect(); + trace.push(WalkStep { + turn, + action: call.name.clone(), + args_summary: step_args_summary, + result_preview: result_preview.clone(), + }); + + if is_answer { + log::debug!("[memory_tree_walk] turn={turn} answer action — stopping"); + return Ok(WalkOutcome { + answer: answer_text, + trace, + turns_used: turn, + stopped_reason: WalkStopReason::Answered, + }); + } + + // Append tool result as user message (prompt-guided protocol). + let tool_msg = format!( + "{}\n\nCurrent position: {current_node_id}\n", + tool_result + ); + history.push(ChatMessage::user(tool_msg)); + + // Drop the preamble text if there was any (don't lose context). + if !text_before.trim().is_empty() { + log::debug!( + "[memory_tree_walk] turn={turn} text before tool call: {}", + &text_before[..text_before.len().min(80)] + ); + } + } + + // Max turns reached. + log::debug!("[memory_tree_walk] max_turns={max_turns} reached — synthesising fallback"); + Ok(WalkOutcome { + answer: synthesize_fallback_answer(&trace), + trace, + turns_used: max_turns, + stopped_reason: WalkStopReason::MaxTurnsReached, + }) +} + +// ── ChatProviderAdapter ───────────────────────────────────────────────────── +// +// Bridges the memory-tree's lightweight `ChatProvider` into the top-level +// `Provider` trait so `run_walk` can accept both production adapters and +// unit-test stubs that implement `Provider` directly. + +struct ChatProviderAdapter { + inner: std::sync::Arc, +} + +#[async_trait] +impl Provider for ChatProviderAdapter { + async fn chat_with_system( + &self, + system: Option<&str>, + message: &str, + _model: &str, + temperature: f64, + ) -> anyhow::Result { + let prompt = ChatPrompt { + system: system.unwrap_or("").to_string(), + user: message.to_string(), + temperature, + kind: "memory_tree_walk", + }; + self.inner.chat_for_text(&prompt).await + } + + async fn chat_with_history( + &self, + messages: &[ChatMessage], + model: &str, + temperature: f64, + ) -> anyhow::Result { + let system = messages + .iter() + .find(|m| m.role == "system") + .map(|m| m.content.as_str()); + // Combine all non-system messages into the user turn. + let user: String = messages + .iter() + .filter(|m| m.role != "system") + .map(|m| m.content.as_str()) + .collect::>() + .join("\n"); + self.chat_with_system(system, &user, model, temperature) + .await + } +} + +// ── Inner helpers ─────────────────────────────────────────────────────────── + +/// A parsed tool call from the inner walk loop. +struct InnerCall { + name: String, + args: serde_json::Value, +} + +/// Parse `…` blocks from a response string. +/// Returns `(text_before_first_call, calls)`. +fn parse_walk_tool_calls(response: &str) -> (String, Vec) { + let mut calls: Vec = Vec::new(); + let mut text_parts: Vec<&str> = Vec::new(); + let mut remaining: &str = response; + + const OPEN: &str = ""; + const CLOSE: &str = ""; + + loop { + match remaining.find(OPEN) { + None => { + // No more tags; collect trailing text. + if !remaining.trim().is_empty() && calls.is_empty() { + text_parts.push(remaining); + } + break; + } + Some(start) => { + let before = &remaining[..start]; + if !before.trim().is_empty() { + text_parts.push(before); + } + let after_open = &remaining[start + OPEN.len()..]; + match after_open.find(CLOSE) { + None => break, // malformed — stop + Some(close_idx) => { + let inner = &after_open[..close_idx]; + if let Ok(val) = serde_json::from_str::(inner.trim()) { + if let Some(name) = val.get("name").and_then(|v| v.as_str()) { + let args = val + .get("arguments") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + calls.push(InnerCall { + name: name.to_string(), + args, + }); + } + } + remaining = &after_open[close_idx + CLOSE.len()..]; + } + } + } + } + } + + let text_before = text_parts.concat(); + (text_before, calls) +} + +/// Dispatch an inner walk primitive and return +/// `(args_summary, result_text, is_final_answer, answer_text)`. +async fn dispatch_inner_call( + config: &Config, + namespace: &str, + call: &InnerCall, + current_node_id: &mut String, +) -> (String, String, bool, String) { + match call.name.as_str() { + "descend" => { + let node_id = call + .args + .get("node_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + log::debug!( + "[memory_tree_walk] descend node_id={node_id} from={current_node_id} namespace={namespace}" + ); + + if node_id.is_empty() { + return ( + "node_id=".into(), + "error: descend requires a non-empty node_id".into(), + false, + String::new(), + ); + } + + // Move to the target node. + let ctx = build_node_context(config, namespace, &node_id).await; + if ctx.starts_with("unknown node") { + ( + format!("node_id={node_id}"), + format!("unknown node: {node_id}"), + false, + String::new(), + ) + } else { + *current_node_id = node_id.clone(); + (format!("node_id={node_id}"), ctx, false, String::new()) + } + } + + "peek" => { + let node_ids: Vec = call + .args + .get("node_ids") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default(); + + log::debug!( + "[memory_tree_walk] peek node_ids={} namespace={namespace}", + node_ids.len() + ); + + let args_summary = format!("node_ids=[{}]", node_ids.join(", ")); + + let config_owned = config.clone(); + let ns_owned = namespace.to_string(); + let ids_owned = node_ids.clone(); + + let result = tokio::task::spawn_blocking(move || -> Vec { + ids_owned + .iter() + .map(|id| match read_node(&config_owned, &ns_owned, id) { + Ok(Some(node)) => { + format!( + "id={} level={:?} summary={}", + id, + node.level, + &node.summary[..node.summary.len().min(120)] + ) + } + Ok(None) => format!("id={id} unknown node"), + Err(e) => format!("id={id} error: {e}"), + }) + .collect() + }) + .await + .unwrap_or_default(); + + (args_summary, result.join("\n"), false, String::new()) + } + + "fetch_leaves" => { + let node_id = call + .args + .get("node_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + log::debug!("[memory_tree_walk] fetch_leaves node_id={node_id} namespace={namespace}"); + + if node_id.is_empty() { + return ( + "node_id=".into(), + "error: fetch_leaves requires a non-empty node_id".into(), + false, + String::new(), + ); + } + + // fetch_leaves in retrieval takes a list of chunk ids. Reuse + // drill_down to get the leaf hits under this node, then return content. + let hits = match retrieval::drill_down(config, &node_id, 1, None, Some(10)).await { + Ok(h) => h, + Err(e) => { + return ( + format!("node_id={node_id}"), + format!("error fetching leaves: {e}"), + false, + String::new(), + ); + } + }; + + let text = if hits.is_empty() { + // Try to fetch the node itself as a leaf (chunk). + let chunk_ids = vec![node_id.clone()]; + match do_fetch_leaves(config, &chunk_ids).await { + Ok(leaf_hits) if !leaf_hits.is_empty() => leaf_hits + .iter() + .map(|h| format!("[{}] {}", h.node_id, h.content)) + .collect::>() + .join("\n---\n"), + _ => format!("no leaves found under node_id={node_id}"), + } + } else { + hits.iter() + .map(|h| format!("[{}] {}", h.node_id, h.content)) + .collect::>() + .join("\n---\n") + }; + + (format!("node_id={node_id}"), text, false, String::new()) + } + + "answer" => { + let text = call + .args + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + log::debug!("[memory_tree_walk] answer action text_len={}", text.len()); + ("(final answer)".into(), text.clone(), true, text) + } + + other => { + log::warn!("[memory_tree_walk] unknown inner action: {other}"); + ( + format!("action={other}"), + format!( + "unknown walk action '{other}'. Valid: descend, peek, fetch_leaves, answer" + ), + false, + String::new(), + ) + } + } +} + +/// Build a human-readable context string for a node: its summary + children list. +async fn build_node_context(config: &Config, namespace: &str, node_id: &str) -> String { + let config_owned = config.clone(); + let ns_owned = namespace.to_string(); + let id_owned = node_id.to_string(); + + tokio::task::spawn_blocking(move || { + let node = match read_node(&config_owned, &ns_owned, &id_owned) { + Ok(Some(n)) => n, + Ok(None) => return format!("unknown node: {id_owned}"), + Err(e) => return format!("error reading node {id_owned}: {e}"), + }; + + let children = match read_children(&config_owned, &ns_owned, &id_owned) { + Ok(c) => c, + Err(_) => vec![], + }; + + let mut out = format!( + "Node: {} (level={:?})\nSummary: {}\n", + node.node_id, node.level, node.summary + ); + + if children.is_empty() { + out.push_str("Children: (none — this is a leaf)\n"); + } else { + out.push_str(&format!("Children ({}):\n", children.len())); + for c in &children { + out.push_str(&format!( + " - id={} level={:?} summary_preview={}\n", + c.node_id, + c.level, + &c.summary[..c.summary.len().min(80)] + )); + } + } + + out + }) + .await + .unwrap_or_else(|_| format!("error building context for node {node_id}")) +} + +fn build_system_prompt() -> String { + "You are a memory-tree navigator. Your job is to answer user queries \ + by walking a hierarchical summary tree.\n\ + Use the provided tools to navigate: `descend` to move into a child node, \ + `peek` to preview multiple children without descending, \ + `fetch_leaves` to retrieve raw content from a node, \ + and `answer` when you have enough information to respond.\n\ + Be efficient — prefer `peek` to survey options before `descend`.\n\ + Always end with `answer { \"text\": \"...\" }` when ready.\n\ + Use XML tool_call tags:\n\ + {\"name\": \"descend\", \"arguments\": {\"node_id\": \"some/id\"}}" + .into() +} + +fn build_inner_tools_text() -> String { + "## Inner walk tools\n\n\ + **descend** `{\"node_id\": \"\"}` — move to a child node and see its summary and children.\n\ + **peek** `{\"node_ids\": [\"\", \"\"]}` — preview summaries for a list of nodes without descending.\n\ + **fetch_leaves** `{\"node_id\": \"\"}` — retrieve raw chunk text under a node for citation.\n\ + **answer** `{\"text\": \"\"}` — stop and return your synthesised answer." + .into() +} + +fn synthesize_fallback_answer(trace: &[WalkStep]) -> String { + if trace.is_empty() { + return "Could not converge on an answer — no steps taken.".into(); + } + let preview: Vec = trace + .iter() + .map(|s| { + format!( + "Turn {}: {} → {}", + s.turn, + s.action, + &s.result_preview[..s.result_preview.len().min(100)] + ) + }) + .collect(); + format!( + "Could not converge on an answer within the turn limit. Here is what I saw:\n\n{}", + preview.join("\n") + ) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::Config; + use crate::openhuman::inference::provider::traits::ChatMessage; + use crate::openhuman::memory_tree::summarizer::store::write_node; + use crate::openhuman::memory_tree::summarizer::types::{NodeLevel, TreeNode}; + use async_trait::async_trait; + use chrono::Utc; + use std::sync::Mutex; + use tempfile::TempDir; + + // ── Stub provider ────────────────────────────────────────────────── + + /// A scripted stub provider that returns predefined responses in sequence. + /// Each `chat_with_history` call pops the next response from the queue. + struct StubProvider { + responses: Mutex>, + } + + impl StubProvider { + fn new(responses: Vec<&str>) -> Self { + Self { + responses: Mutex::new(responses.into_iter().map(|s| s.to_string()).collect()), + } + } + } + + #[async_trait] + impl Provider for StubProvider { + async fn chat_with_system( + &self, + _system: Option<&str>, + _message: &str, + _model: &str, + _temp: f64, + ) -> anyhow::Result { + let mut responses = self.responses.lock().unwrap(); + if responses.is_empty() { + return Err(anyhow::anyhow!("StubProvider: no more scripted responses")); + } + Ok(responses.remove(0)) + } + + async fn chat_with_history( + &self, + _messages: &[ChatMessage], + _model: &str, + _temp: f64, + ) -> anyhow::Result { + let mut responses = self.responses.lock().unwrap(); + if responses.is_empty() { + return Err(anyhow::anyhow!("StubProvider: no more scripted responses")); + } + Ok(responses.remove(0)) + } + } + + // ── Tree helpers ─────────────────────────────────────────────────── + + fn test_config(tmp: &TempDir) -> Config { + let mut cfg = Config::default(); + cfg.workspace_dir = tmp.path().join("workspace"); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + cfg + } + + fn make_node(namespace: &str, node_id: &str, summary: &str, child_count: u32) -> TreeNode { + let level = crate::openhuman::memory_tree::summarizer::types::level_from_node_id(node_id); + let parent_id = crate::openhuman::memory_tree::summarizer::types::derive_parent_id(node_id); + let ts = Utc::now(); + TreeNode { + node_id: node_id.to_string(), + namespace: namespace.to_string(), + level, + parent_id, + summary: summary.to_string(), + token_count: crate::openhuman::memory_tree::summarizer::types::estimate_tokens(summary), + child_count, + created_at: ts, + updated_at: ts, + metadata: None, + } + } + + /// Seed: root → 2024 (child of root) → 2024/01 (leaf). + fn seed_tree(cfg: &Config, ns: &str) { + write_node( + cfg, + &make_node(ns, "root", "All-time summary: project logs 2024", 1), + ) + .unwrap(); + write_node( + cfg, + &make_node(ns, "2024", "Year 2024: shipped v1, v2, v3", 1), + ) + .unwrap(); + write_node( + cfg, + &make_node(ns, "2024/01", "January 2024: initial project launch", 0), + ) + .unwrap(); + } + + // ── Test 1: walks_and_answers ────────────────────────────────────── + + /// Script: turn1=descend(2024), turn2=fetch_leaves(2024/01), turn3=answer. + #[tokio::test] + async fn walks_and_answers() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let ns = "default"; + seed_tree(&cfg, ns); + + let provider = StubProvider::new(vec![ + // Turn 1: descend into the year node. + r#"{"name":"descend","arguments":{"node_id":"2024"}}"#, + // Turn 2: fetch leaves under the month node. + r#"{"name":"fetch_leaves","arguments":{"node_id":"2024/01"}}"#, + // Turn 3: answer. + r#"{"name":"answer","arguments":{"text":"The project launched in January 2024."}}"#, + ]); + + let opts = WalkOptions { + max_turns: 6, + start_node_id: None, + namespace: ns.to_string(), + model: Some("test-model".into()), + }; + + let outcome = run_walk(&cfg, &provider, "When did the project launch?", opts) + .await + .unwrap(); + + assert_eq!(outcome.stopped_reason, WalkStopReason::Answered); + assert!( + outcome.answer.contains("January 2024"), + "answer should mention January 2024, got: {}", + outcome.answer + ); + assert_eq!(outcome.trace.len(), 3, "expected 3 steps"); + assert_eq!(outcome.trace[0].action, "descend"); + assert_eq!(outcome.trace[1].action, "fetch_leaves"); + assert_eq!(outcome.trace[2].action, "answer"); + assert_eq!(outcome.turns_used, 3); + } + + // ── Test 2: max_turns_cap ───────────────────────────────────────── + + /// Script: always `descend` in a loop — should stop at max_turns. + #[tokio::test] + async fn max_turns_cap() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let ns = "default"; + seed_tree(&cfg, ns); + + // Provide more `descend` responses than max_turns (3) so the cap fires. + let provider = StubProvider::new(vec![ + r#"{"name":"descend","arguments":{"node_id":"2024"}}"#, + r#"{"name":"descend","arguments":{"node_id":"2024"}}"#, + r#"{"name":"descend","arguments":{"node_id":"2024"}}"#, + r#"{"name":"descend","arguments":{"node_id":"2024"}}"#, + ]); + + let opts = WalkOptions { + max_turns: 3, + start_node_id: None, + namespace: ns.to_string(), + model: Some("test-model".into()), + }; + + let outcome = run_walk(&cfg, &provider, "infinite loop query", opts) + .await + .unwrap(); + + assert_eq!(outcome.stopped_reason, WalkStopReason::MaxTurnsReached); + assert_eq!(outcome.turns_used, 3); + assert!( + outcome.answer.contains("Could not converge"), + "expected fallback answer, got: {}", + outcome.answer + ); + } + + // ── Test 3: unknown_node_recovers ───────────────────────────────── + + /// Script: descend into a non-existent node → result says "unknown node" → loop continues. + #[tokio::test] + async fn unknown_node_recovers() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let ns = "default"; + seed_tree(&cfg, ns); + + let provider = StubProvider::new(vec![ + // Turn 1: descend into a node that does not exist. + r#"{"name":"descend","arguments":{"node_id":"does_not_exist"}}"#, + // Turn 2: answer (loop continues after bad descend). + r#"{"name":"answer","arguments":{"text":"I could not find that node."}}"#, + ]); + + let opts = WalkOptions { + max_turns: 6, + start_node_id: None, + namespace: ns.to_string(), + model: Some("test-model".into()), + }; + + let outcome = run_walk(&cfg, &provider, "find nonexistent data", opts) + .await + .unwrap(); + + // The first trace step should indicate "unknown node". + assert_eq!(outcome.trace.len(), 2); + assert!( + outcome.trace[0].result_preview.contains("unknown node"), + "expected 'unknown node' in preview, got: {}", + outcome.trace[0].result_preview + ); + // The walk should continue and eventually answer. + assert_eq!(outcome.stopped_reason, WalkStopReason::Answered); + assert!(outcome.answer.contains("could not find that node")); + } +} diff --git a/src/openhuman/memory/tree/tree_global/README.md b/src/openhuman/memory_tree/tree_global/README.md similarity index 100% rename from src/openhuman/memory/tree/tree_global/README.md rename to src/openhuman/memory_tree/tree_global/README.md diff --git a/src/openhuman/memory/tree/tree_global/digest.rs b/src/openhuman/memory_tree/tree_global/digest.rs similarity index 94% rename from src/openhuman/memory/tree/tree_global/digest.rs rename to src/openhuman/memory_tree/tree_global/digest.rs index 36c8666d5af..02eda1579dd 100644 --- a/src/openhuman/memory/tree/tree_global/digest.rs +++ b/src/openhuman/memory_tree/tree_global/digest.rs @@ -26,21 +26,21 @@ use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc}; use rusqlite::OptionalExtension; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::{ +use crate::openhuman::memory_tree::content_store::{ atomic::stage_summary, paths::slugify_source_id, read as content_read, SummaryComposeInput, SummaryTreeKind, }; -use crate::openhuman::memory::tree::score::embed::build_embedder_from_config; -use crate::openhuman::memory::tree::store::with_connection; -use crate::openhuman::memory::tree::tree_global::registry::get_or_create_global_tree; -use crate::openhuman::memory::tree::tree_global::seal::append_daily_and_cascade; -use crate::openhuman::memory::tree::tree_global::GLOBAL_TOKEN_BUDGET; -use crate::openhuman::memory::tree::tree_source::registry::new_summary_id; -use crate::openhuman::memory::tree::tree_source::store; -use crate::openhuman::memory::tree::tree_source::summariser::{ +use crate::openhuman::memory_tree::score::embed::build_embedder_from_config; +use crate::openhuman::memory_tree::store::with_connection; +use crate::openhuman::memory_tree::tree_global::registry::get_or_create_global_tree; +use crate::openhuman::memory_tree::tree_global::seal::append_daily_and_cascade; +use crate::openhuman::memory_tree::tree_global::GLOBAL_TOKEN_BUDGET; +use crate::openhuman::memory_tree::tree_source::registry::new_summary_id; +use crate::openhuman::memory_tree::tree_source::store; +use crate::openhuman::memory_tree::tree_source::summariser::{ Summariser, SummaryContext, SummaryInput, }; -use crate::openhuman::memory::tree::tree_source::types::{SummaryNode, Tree, TreeKind}; +use crate::openhuman::memory_tree::tree_source::types::{SummaryNode, Tree, TreeKind}; /// Outcome of a single `end_of_day_digest` call — lets the caller decide /// whether to log skip details or propagate seal counts to telemetry. @@ -251,10 +251,10 @@ pub async fn end_of_day_digest( &tx, &daily_clone, Some(&staged_daily), - &crate::openhuman::memory::tree::store::tree_active_signature(config), + &crate::openhuman::memory_tree::store::tree_active_signature(config), )?; // Index any entities the summariser emitted (no-op under inert). - crate::openhuman::memory::tree::score::store::index_summary_entity_ids_tx( + crate::openhuman::memory_tree::score::store::index_summary_entity_ids_tx( &tx, &daily_clone.entities, &daily_clone.id, diff --git a/src/openhuman/memory/tree/tree_global/digest_tests.rs b/src/openhuman/memory_tree/tree_global/digest_tests.rs similarity index 94% rename from src/openhuman/memory/tree/tree_global/digest_tests.rs rename to src/openhuman/memory_tree/tree_global/digest_tests.rs index 691c9711a03..b990fb26dca 100644 --- a/src/openhuman/memory/tree/tree_global/digest_tests.rs +++ b/src/openhuman/memory_tree/tree_global/digest_tests.rs @@ -3,15 +3,15 @@ //! cascade-seal trigger for weekly/monthly/yearly levels. use super::*; -use crate::openhuman::memory::tree::content_store; -use crate::openhuman::memory::tree::store::upsert_chunks; -use crate::openhuman::memory::tree::tree_source::bucket_seal::{ +use crate::openhuman::memory_tree::content_store; +use crate::openhuman::memory_tree::store::upsert_chunks; +use crate::openhuman::memory_tree::tree_source::bucket_seal::{ append_leaf, LabelStrategy, LeafRef, }; -use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree; -use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser; -use crate::openhuman::memory::tree::tree_source::types::TreeStatus; -use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; +use crate::openhuman::memory_tree::tree_source::registry::get_or_create_source_tree; +use crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser; +use crate::openhuman::memory_tree::tree_source::types::TreeStatus; +use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use tempfile::TempDir; /// Stage a batch of chunks to the content store so that `read_chunk_body` @@ -23,9 +23,9 @@ fn stage_test_chunks(cfg: &Config, chunks: &[Chunk]) { std::fs::create_dir_all(&content_root).expect("create content_root for test"); let staged = content_store::stage_chunks(&content_root, chunks).expect("stage_chunks for test chunks"); - crate::openhuman::memory::tree::store::with_connection(cfg, |conn| { + crate::openhuman::memory_tree::store::with_connection(cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) @@ -260,9 +260,9 @@ async fn seed_source_tree_with_labeled_l1( entities: Vec, topics: Vec, ) { - use crate::openhuman::memory::tree::score::extract::EntityKind; - use crate::openhuman::memory::tree::score::resolver::CanonicalEntity; - use crate::openhuman::memory::tree::score::store::index_entity; + use crate::openhuman::memory_tree::score::extract::EntityKind; + use crate::openhuman::memory_tree::score::resolver::CanonicalEntity; + use crate::openhuman::memory_tree::score::store::index_entity; let tree = get_or_create_source_tree(cfg, scope).unwrap(); let summariser = InertSummariser::new(); diff --git a/src/openhuman/memory/tree/tree_global/mod.rs b/src/openhuman/memory_tree/tree_global/mod.rs similarity index 100% rename from src/openhuman/memory/tree/tree_global/mod.rs rename to src/openhuman/memory_tree/tree_global/mod.rs diff --git a/src/openhuman/memory/tree/tree_global/recap.rs b/src/openhuman/memory_tree/tree_global/recap.rs similarity index 93% rename from src/openhuman/memory/tree/tree_global/recap.rs rename to src/openhuman/memory_tree/tree_global/recap.rs index 611f08f7170..000bb8a7115 100644 --- a/src/openhuman/memory/tree/tree_global/recap.rs +++ b/src/openhuman/memory_tree/tree_global/recap.rs @@ -22,9 +22,9 @@ use anyhow::Result; use chrono::{DateTime, Duration, Utc}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::tree_global::registry::get_or_create_global_tree; -use crate::openhuman::memory::tree::tree_source::store; -use crate::openhuman::memory::tree::tree_source::types::SummaryNode; +use crate::openhuman::memory_tree::tree_global::registry::get_or_create_global_tree; +use crate::openhuman::memory_tree::tree_source::store; +use crate::openhuman::memory_tree::tree_source::types::SummaryNode; /// Aggregated recap returned to the caller. #[derive(Debug, Clone)] @@ -158,15 +158,15 @@ fn assemble_recap(covering: &[&SummaryNode], level: u32) -> RecapOutput { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::content_store; - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_global::digest::{end_of_day_digest, DigestOutcome}; - use crate::openhuman::memory::tree::tree_source::bucket_seal::{ + use crate::openhuman::memory_tree::content_store; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_global::digest::{end_of_day_digest, DigestOutcome}; + use crate::openhuman::memory_tree::tree_source::bucket_seal::{ append_leaf, LabelStrategy, LeafRef, }; - use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree; - use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::tree_source::registry::get_or_create_source_tree; + use crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use tempfile::TempDir; fn stage_test_chunks(cfg: &Config, chunks: &[Chunk]) { @@ -174,9 +174,9 @@ mod tests { std::fs::create_dir_all(&content_root).expect("create content_root for test"); let staged = content_store::stage_chunks(&content_root, chunks) .expect("stage_chunks for test chunks"); - crate::openhuman::memory::tree::store::with_connection(cfg, |conn| { + crate::openhuman::memory_tree::store::with_connection(cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) diff --git a/src/openhuman/memory/tree/tree_global/registry.rs b/src/openhuman/memory_tree/tree_global/registry.rs similarity index 95% rename from src/openhuman/memory/tree/tree_global/registry.rs rename to src/openhuman/memory_tree/tree_global/registry.rs index fbf3453e6c0..ad91013e347 100644 --- a/src/openhuman/memory/tree/tree_global/registry.rs +++ b/src/openhuman/memory_tree/tree_global/registry.rs @@ -10,9 +10,9 @@ use chrono::Utc; use uuid::Uuid; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::tree_global::GLOBAL_SCOPE; -use crate::openhuman::memory::tree::tree_source::store; -use crate::openhuman::memory::tree::tree_source::types::{Tree, TreeKind, TreeStatus}; +use crate::openhuman::memory_tree::tree_global::GLOBAL_SCOPE; +use crate::openhuman::memory_tree::tree_source::store; +use crate::openhuman::memory_tree::tree_source::types::{Tree, TreeKind, TreeStatus}; /// Return the workspace's singleton global tree, creating it lazily on /// first call. Safe to call on every ingest; subsequent calls short-circuit diff --git a/src/openhuman/memory/tree/tree_global/seal.rs b/src/openhuman/memory_tree/tree_global/seal.rs similarity index 94% rename from src/openhuman/memory/tree/tree_global/seal.rs rename to src/openhuman/memory_tree/tree_global/seal.rs index 4364d10b190..c83e5aeb820 100644 --- a/src/openhuman/memory/tree/tree_global/seal.rs +++ b/src/openhuman/memory_tree/tree_global/seal.rs @@ -17,20 +17,20 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::{ +use crate::openhuman::memory_tree::content_store::{ atomic::stage_summary, SummaryComposeInput, SummaryTreeKind, }; -use crate::openhuman::memory::tree::score::embed::build_embedder_from_config; -use crate::openhuman::memory::tree::store::with_connection; -use crate::openhuman::memory::tree::tree_global::{ +use crate::openhuman::memory_tree::score::embed::build_embedder_from_config; +use crate::openhuman::memory_tree::store::with_connection; +use crate::openhuman::memory_tree::tree_global::{ GLOBAL_TOKEN_BUDGET, MONTHLY_SEAL_THRESHOLD, WEEKLY_SEAL_THRESHOLD, YEARLY_SEAL_THRESHOLD, }; -use crate::openhuman::memory::tree::tree_source::registry::new_summary_id; -use crate::openhuman::memory::tree::tree_source::store; -use crate::openhuman::memory::tree::tree_source::summariser::{ +use crate::openhuman::memory_tree::tree_source::registry::new_summary_id; +use crate::openhuman::memory_tree::tree_source::store; +use crate::openhuman::memory_tree::tree_source::summariser::{ Summariser, SummaryContext, SummaryInput, }; -use crate::openhuman::memory::tree::tree_source::types::{Buffer, SummaryNode, Tree, TreeKind}; +use crate::openhuman::memory_tree::tree_source::types::{Buffer, SummaryNode, Tree, TreeKind}; /// Hard cap on cascade depth — mirrors the source-tree constant. L0→L1→L2→L3 /// is only 3 hops so we have ample slack. @@ -268,7 +268,7 @@ async fn seal_one_level( // Global tree scope is typically the literal "global" string. // Use it as-is for the path (slugify passes through short ascii strings unchanged). let global_scope_slug = - crate::openhuman::memory::tree::content_store::paths::slugify_source_id(&tree.scope); + crate::openhuman::memory_tree::content_store::paths::slugify_source_id(&tree.scope); let staged_global = stage_summary( &content_root_global, &compose_input_global, @@ -311,13 +311,13 @@ async fn seal_one_level( &tx, &node, Some(&staged_global), - &crate::openhuman::memory::tree::store::tree_active_signature(config), + &crate::openhuman::memory_tree::store::tree_active_signature(config), )?; // Index any entities the summariser emitted. No-op under // InertSummariser (entities stays empty by design — see // summariser/inert.rs). Becomes active when the Ollama summariser // lands and emits curated canonical ids. - crate::openhuman::memory::tree::score::store::index_summary_entity_ids_tx( + crate::openhuman::memory_tree::score::store::index_summary_entity_ids_tx( &tx, &node.entities, &node.id, @@ -415,8 +415,8 @@ fn hydrate_summary_inputs(config: &Config, summary_ids: &[String]) -> Result { // RawRef::path is a forward-slash relative path // under content_root, e.g. @@ -600,7 +600,7 @@ pub(crate) async fn seal_one_level( // without manual configuration. Best-effort and idempotent — never // overwrites an existing file. if let Err(err) = - crate::openhuman::memory::tree::content_store::obsidian::ensure_obsidian_defaults( + crate::openhuman::memory_tree::content_store::obsidian::ensure_obsidian_defaults( &content_root, ) { @@ -648,7 +648,7 @@ pub(crate) async fn seal_one_level( &tx, &node, Some(&staged), - &crate::openhuman::memory::tree::store::tree_active_signature(config), + &crate::openhuman::memory_tree::store::tree_active_signature(config), )?; // Forward-compat: index any entities the summariser emitted into // `mem_tree_entity_index` so Phase 4 retrieval can resolve @@ -656,7 +656,7 @@ pub(crate) async fn seal_one_level( // leaves. No-op under InertSummariser (entities is empty by // design — see summariser/inert.rs doc); becomes active once the // Ollama summariser lands and emits curated canonical ids. - crate::openhuman::memory::tree::score::store::index_summary_entity_ids_tx( + crate::openhuman::memory_tree::score::store::index_summary_entity_ids_tx( &tx, &node.entities, &node.id, @@ -709,8 +709,8 @@ pub(crate) async fn seal_one_level( // `seal:{tree_id}:{parent_level}` prevents duplicates if a // parallel path already queued it. if should_seal(&parent) { - use crate::openhuman::memory::tree::jobs::store::enqueue_tx as enqueue_job_tx; - use crate::openhuman::memory::tree::jobs::types::{NewJob, SealPayload}; + use crate::openhuman::memory_tree::jobs::store::enqueue_tx as enqueue_job_tx; + use crate::openhuman::memory_tree::jobs::types::{NewJob, SealPayload}; let parent_seal = SealPayload { tree_id: tree_id.clone(), level: target_level_for_closure, @@ -722,8 +722,8 @@ pub(crate) async fn seal_one_level( // entities back into the topic-tree spawn pipeline. Topic // and global trees are sinks — no fan-out from their seals. if matches!(tree_kind, TreeKind::Source) { - use crate::openhuman::memory::tree::jobs::store::enqueue_tx as enqueue_job_tx; - use crate::openhuman::memory::tree::jobs::types::{ + use crate::openhuman::memory_tree::jobs::store::enqueue_tx as enqueue_job_tx; + use crate::openhuman::memory_tree::jobs::types::{ NewJob, NodeRef, TopicRoutePayload, }; let route = TopicRoutePayload { @@ -774,7 +774,7 @@ pub(crate) async fn seal_one_level( /// HTTP 500 from Ollama rather than auto-truncating, which would /// abort the seal transaction. fn truncate_for_embed(text: &str, max_tokens: u32) -> String { - let approx = crate::openhuman::memory::tree::types::approx_token_count(text); + let approx = crate::openhuman::memory_tree::types::approx_token_count(text); if approx <= max_tokens { return text.to_string(); } @@ -807,9 +807,9 @@ fn hydrate_inputs(config: &Config, level: u32, item_ids: &[String]) -> Result Result> { - use crate::openhuman::memory::tree::content_store::read as content_read; - use crate::openhuman::memory::tree::score::store::{get_score, list_entity_ids_for_node}; - use crate::openhuman::memory::tree::store::get_chunk; + use crate::openhuman::memory_tree::content_store::read as content_read; + use crate::openhuman::memory_tree::score::store::{get_score, list_entity_ids_for_node}; + use crate::openhuman::memory_tree::store::get_chunk; let mut out: Vec = Vec::with_capacity(chunk_ids.len()); for id in chunk_ids { @@ -855,7 +855,7 @@ fn hydrate_leaf_inputs(config: &Config, chunk_ids: &[String]) -> Result Result> { - use crate::openhuman::memory::tree::content_store::read as content_read; + use crate::openhuman::memory_tree::content_store::read as content_read; let mut out: Vec = Vec::with_capacity(summary_ids.len()); for id in summary_ids { diff --git a/src/openhuman/memory/tree/tree_source/bucket_seal_tests.rs b/src/openhuman/memory_tree/tree_source/bucket_seal_tests.rs similarity index 93% rename from src/openhuman/memory/tree/tree_source/bucket_seal_tests.rs rename to src/openhuman/memory_tree/tree_source/bucket_seal_tests.rs index 24fe942a9ae..56596847ba6 100644 --- a/src/openhuman/memory/tree/tree_source/bucket_seal_tests.rs +++ b/src/openhuman/memory_tree/tree_source/bucket_seal_tests.rs @@ -3,25 +3,25 @@ //! cascade depth bounds, idempotency on retry, and label-strategy resolution. use super::*; -use crate::openhuman::memory::tree::content_store; -use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree; -use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser; +use crate::openhuman::memory_tree::content_store; +use crate::openhuman::memory_tree::tree_source::registry::get_or_create_source_tree; +use crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser; use tempfile::TempDir; /// Stage a batch of chunks to the content store so that `read_chunk_body` /// can find the on-disk file during seals. Tests that call `upsert_chunks` /// and then trigger a seal MUST also call this helper; otherwise /// `hydrate_leaf_inputs` will fail with "no content_path for chunk_id". -fn stage_test_chunks(cfg: &Config, chunks: &[crate::openhuman::memory::tree::types::Chunk]) { +fn stage_test_chunks(cfg: &Config, chunks: &[crate::openhuman::memory_tree::types::Chunk]) { let content_root = cfg.memory_tree_content_root(); std::fs::create_dir_all(&content_root).expect("create content_root for test"); let staged = content_store::stage_chunks(&content_root, chunks).expect("stage_chunks for test chunks"); // Record the content_path + content_sha256 pointers in SQLite so the // store's `get_chunk_content_pointers` can resolve them later. - crate::openhuman::memory::tree::store::with_connection(cfg, |conn| { + crate::openhuman::memory_tree::store::with_connection(cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) @@ -74,8 +74,8 @@ async fn append_below_budget_does_not_seal() { #[tokio::test] async fn crossing_budget_triggers_seal() { - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use chrono::TimeZone; let (_tmp, cfg) = test_config(); @@ -159,7 +159,7 @@ async fn crossing_budget_triggers_seal() { assert!(t.last_sealed_at.is_some()); // Leaf → parent backlink populated for both children. - use crate::openhuman::memory::tree::store::with_connection; + use crate::openhuman::memory_tree::store::with_connection; let parent: Option = with_connection(&cfg, |conn| { let p: Option = conn .query_row( @@ -176,9 +176,9 @@ async fn crossing_budget_triggers_seal() { #[tokio::test] async fn fanout_at_l1_triggers_l2_seal() { - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_source::types::SUMMARY_FANOUT; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_source::types::SUMMARY_FANOUT; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use chrono::TimeZone; let (_tmp, cfg) = test_config(); @@ -265,9 +265,9 @@ async fn fanout_at_l1_triggers_l2_seal() { #[tokio::test] async fn upper_level_does_not_seal_below_fanout() { - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_source::types::SUMMARY_FANOUT; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_source::types::SUMMARY_FANOUT; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use chrono::TimeZone; let (_tmp, cfg) = test_config(); @@ -348,11 +348,11 @@ fn seed_leaf( entities: Vec, topics: Vec, ) -> LeafRef { - use crate::openhuman::memory::tree::score::extract::EntityKind; - use crate::openhuman::memory::tree::score::resolver::CanonicalEntity; - use crate::openhuman::memory::tree::score::store::index_entity; - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::score::extract::EntityKind; + use crate::openhuman::memory_tree::score::resolver::CanonicalEntity; + use crate::openhuman::memory_tree::score::store::index_entity; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use chrono::TimeZone; let ts = Utc .timestamp_millis_opt(1_700_000_000_000 + seq as i64) @@ -413,7 +413,7 @@ fn seed_leaf( #[tokio::test] async fn seal_with_extract_strategy_populates_entities_and_topics() { - use crate::openhuman::memory::tree::score::extract::{CompositeExtractor, EntityExtractor}; + use crate::openhuman::memory_tree::score::extract::{CompositeExtractor, EntityExtractor}; use std::sync::Arc; let (_tmp, cfg) = test_config(); @@ -577,7 +577,7 @@ async fn seal_with_empty_strategy_leaves_labels_empty() { #[tokio::test] async fn topic_tree_seal_persists_topic_kind_not_source() { - use crate::openhuman::memory::tree::tree_source::types::TreeStatus; + use crate::openhuman::memory_tree::tree_source::types::TreeStatus; let (_tmp, cfg) = test_config(); // Build a topic tree directly — `seal_one_level` runs for both @@ -616,7 +616,7 @@ fn scope_slug_non_gmail_uses_full_scope() { // slack:#eng and discord:#eng must NOT produce the same scope slug. // Previously, stripping everything before ':' made both → "eng". // After Fix K, only gmail: strips the prefix — others use the full string. - use crate::openhuman::memory::tree::content_store::paths::slugify_source_id; + use crate::openhuman::memory_tree::content_store::paths::slugify_source_id; // Verify that the slug logic produces distinct values for different platforms. let slack_slug = slugify_source_id("slack:#eng"); diff --git a/src/openhuman/memory/tree/tree_source/flush.rs b/src/openhuman/memory_tree/tree_source/flush.rs similarity index 88% rename from src/openhuman/memory/tree/tree_source/flush.rs rename to src/openhuman/memory_tree/tree_source/flush.rs index 4bfc7e7bc48..a6470c2799a 100644 --- a/src/openhuman/memory/tree/tree_source/flush.rs +++ b/src/openhuman/memory_tree/tree_source/flush.rs @@ -15,10 +15,10 @@ use anyhow::Result; use chrono::{DateTime, Duration, Utc}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::tree_source::bucket_seal::{cascade_all_from, LabelStrategy}; -use crate::openhuman::memory::tree::tree_source::store; -use crate::openhuman::memory::tree::tree_source::summariser::Summariser; -use crate::openhuman::memory::tree::tree_source::types::DEFAULT_FLUSH_AGE_SECS; +use crate::openhuman::memory_tree::tree_source::bucket_seal::{cascade_all_from, LabelStrategy}; +use crate::openhuman::memory_tree::tree_source::store; +use crate::openhuman::memory_tree::tree_source::summariser::Summariser; +use crate::openhuman::memory_tree::tree_source::types::DEFAULT_FLUSH_AGE_SECS; /// Seal every buffer whose oldest item is older than `max_age`. Returns /// the number of individual seal calls (not trees) that fired. When the @@ -90,12 +90,12 @@ pub async fn force_flush_tree( #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::content_store; - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_source::bucket_seal::{append_leaf, LeafRef}; - use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree; - use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::content_store; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_source::bucket_seal::{append_leaf, LeafRef}; + use crate::openhuman::memory_tree::tree_source::registry::get_or_create_source_tree; + use crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use tempfile::TempDir; fn stage_test_chunks(cfg: &Config, chunks: &[Chunk]) { @@ -103,9 +103,9 @@ mod tests { std::fs::create_dir_all(&content_root).expect("create content_root for test"); let staged = content_store::stage_chunks(&content_root, chunks) .expect("stage_chunks for test chunks"); - crate::openhuman::memory::tree::store::with_connection(cfg, |conn| { + crate::openhuman::memory_tree::store::with_connection(cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::store::upsert_staged_chunks_tx(&tx, &staged)?; + crate::openhuman::memory_tree::store::upsert_staged_chunks_tx(&tx, &staged)?; tx.commit()?; Ok(()) }) @@ -192,11 +192,11 @@ mod tests { // Plant a stale L1 buffer holding a single (synthetic) child id. // No L0 chunks — the only thing flush could touch is the L1 buffer. let old_ts = Utc::now() - Duration::days(10); - crate::openhuman::memory::tree::store::with_connection(&cfg, |conn| { + crate::openhuman::memory_tree::store::with_connection(&cfg, |conn| { let tx = conn.unchecked_transaction()?; - crate::openhuman::memory::tree::tree_source::store::upsert_buffer_tx( + crate::openhuman::memory_tree::tree_source::store::upsert_buffer_tx( &tx, - &crate::openhuman::memory::tree::tree_source::types::Buffer { + &crate::openhuman::memory_tree::tree_source::types::Buffer { tree_id: tree.id.clone(), level: 1, item_ids: vec!["fake-l1-child".into()], diff --git a/src/openhuman/memory/tree/tree_source/mod.rs b/src/openhuman/memory_tree/tree_source/mod.rs similarity index 100% rename from src/openhuman/memory/tree/tree_source/mod.rs rename to src/openhuman/memory_tree/tree_source/mod.rs diff --git a/src/openhuman/memory/tree/tree_source/registry.rs b/src/openhuman/memory_tree/tree_source/registry.rs similarity index 97% rename from src/openhuman/memory/tree/tree_source/registry.rs rename to src/openhuman/memory_tree/tree_source/registry.rs index 9cfb9801639..f507a523d13 100644 --- a/src/openhuman/memory/tree/tree_source/registry.rs +++ b/src/openhuman/memory_tree/tree_source/registry.rs @@ -10,9 +10,9 @@ use chrono::Utc; use uuid::Uuid; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::tree_source::source_file::write_source_file; -use crate::openhuman::memory::tree::tree_source::store; -use crate::openhuman::memory::tree::tree_source::types::{Tree, TreeKind, TreeStatus}; +use crate::openhuman::memory_tree::tree_source::source_file::write_source_file; +use crate::openhuman::memory_tree::tree_source::store; +use crate::openhuman::memory_tree::tree_source::types::{Tree, TreeKind, TreeStatus}; /// Look up the source tree for `scope`, or create a new one. /// diff --git a/src/openhuman/memory/tree/tree_source/source_file.rs b/src/openhuman/memory_tree/tree_source/source_file.rs similarity index 98% rename from src/openhuman/memory/tree/tree_source/source_file.rs rename to src/openhuman/memory_tree/tree_source/source_file.rs index 72f91aee4cf..a4acfc82c9a 100644 --- a/src/openhuman/memory/tree/tree_source/source_file.rs +++ b/src/openhuman/memory_tree/tree_source/source_file.rs @@ -31,8 +31,8 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::raw::raw_source_dir; -use crate::openhuman::memory::tree::tree_source::types::{Tree, TreeKind, TreeStatus}; +use crate::openhuman::memory_tree::content_store::raw::raw_source_dir; +use crate::openhuman::memory_tree::tree_source::types::{Tree, TreeKind, TreeStatus}; /// Filename of the per-source registry mirror inside `raw//`. pub const SOURCE_FILE_NAME: &str = "_source.md"; diff --git a/src/openhuman/memory/tree/tree_source/store.rs b/src/openhuman/memory_tree/tree_source/store.rs similarity index 96% rename from src/openhuman/memory/tree/tree_source/store.rs rename to src/openhuman/memory_tree/tree_source/store.rs index a48b8a13e07..b801721fd50 100644 --- a/src/openhuman/memory/tree/tree_source/store.rs +++ b/src/openhuman/memory_tree/tree_source/store.rs @@ -12,7 +12,7 @@ //! //! Phase 4 (#710) adds a nullable `embedding` blob on //! `mem_tree_summaries` — packed little-endian `f32` vectors via -//! [`crate::openhuman::memory::tree::score::embed::pack_embedding`]. New +//! [`crate::openhuman::memory_tree::score::embed::pack_embedding`]. New //! writes populate it via [`insert_summary_tx`]; reads decode it when //! present. @@ -21,10 +21,10 @@ use chrono::{DateTime, TimeZone, Utc}; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::content_store::StagedSummary; -use crate::openhuman::memory::tree::score::embed::{decode_optional_blob, pack_checked}; -use crate::openhuman::memory::tree::store::with_connection; -use crate::openhuman::memory::tree::tree_source::types::{ +use crate::openhuman::memory_tree::content_store::StagedSummary; +use crate::openhuman::memory_tree::score::embed::{decode_optional_blob, pack_checked}; +use crate::openhuman::memory_tree::store::with_connection; +use crate::openhuman::memory_tree::tree_source::types::{ Buffer, SummaryNode, Tree, TreeKind, TreeStatus, }; @@ -263,7 +263,7 @@ pub(crate) fn insert_summary_tx( /// at the active signature (via [`set_summary_embedding_for_signature`]) /// instead of the legacy `mem_tree_summaries.embedding` column. The signature /// is resolved internally from `config` via the shared -/// [`crate::openhuman::memory::tree::store::tree_active_signature`] — same +/// [`crate::openhuman::memory_tree::store::tree_active_signature`] — same /// resolution as the chunk path. Returns `1` on success (one sidecar row /// written/updated); the legacy "0 if id unknown" count no longer applies /// since the sidecar upsert does not join the parent summary row. @@ -272,7 +272,7 @@ pub fn set_summary_embedding( summary_id: &str, embedding: &[f32], ) -> Result { - let signature = crate::openhuman::memory::tree::store::tree_active_signature(config); + let signature = crate::openhuman::memory_tree::store::tree_active_signature(config); log::debug!( "[tree_source::store] set_summary_embedding: summary_id={summary_id} sig={signature} dims={}", embedding.len() @@ -289,7 +289,7 @@ pub fn set_summary_embedding( /// vector exists under the active signature — graceful absence during the §7 /// backfill window, never a cross-space read. pub fn get_summary_embedding(config: &Config, summary_id: &str) -> Result>> { - let signature = crate::openhuman::memory::tree::store::tree_active_signature(config); + let signature = crate::openhuman::memory_tree::store::tree_active_signature(config); get_summary_embedding_for_signature(config, summary_id, &signature) } @@ -349,8 +349,8 @@ pub fn mark_summary_reembed_skipped( reason: &str, ) -> Result<()> { let summary_id = - crate::openhuman::memory::tree::store::validate_reembed_skip_key("summary_id", summary_id)?; - let model_signature = crate::openhuman::memory::tree::store::validate_reembed_skip_key( + crate::openhuman::memory_tree::store::validate_reembed_skip_key("summary_id", summary_id)?; + let model_signature = crate::openhuman::memory_tree::store::validate_reembed_skip_key( "model_signature", model_signature, )?; @@ -374,15 +374,15 @@ pub fn mark_summary_reembed_skipped( /// Remove a single summary tombstone so re-embed backfill can retry the row. /// -/// Idempotent — see [`crate::openhuman::memory::tree::store::clear_chunk_reembed_skipped`]. +/// Idempotent — see [`crate::openhuman::memory_tree::store::clear_chunk_reembed_skipped`]. pub fn clear_summary_reembed_skipped( config: &Config, summary_id: &str, model_signature: &str, ) -> Result<()> { let summary_id = - crate::openhuman::memory::tree::store::validate_reembed_skip_key("summary_id", summary_id)?; - let model_signature = crate::openhuman::memory::tree::store::validate_reembed_skip_key( + crate::openhuman::memory_tree::store::validate_reembed_skip_key("summary_id", summary_id)?; + let model_signature = crate::openhuman::memory_tree::store::validate_reembed_skip_key( "model_signature", model_signature, )?; diff --git a/src/openhuman/memory/tree/tree_source/store_tests.rs b/src/openhuman/memory_tree/tree_source/store_tests.rs similarity index 100% rename from src/openhuman/memory/tree/tree_source/store_tests.rs rename to src/openhuman/memory_tree/tree_source/store_tests.rs diff --git a/src/openhuman/memory/tree/tree_source/summariser/README.md b/src/openhuman/memory_tree/tree_source/summariser/README.md similarity index 100% rename from src/openhuman/memory/tree/tree_source/summariser/README.md rename to src/openhuman/memory_tree/tree_source/summariser/README.md diff --git a/src/openhuman/memory/tree/tree_source/summariser/inert.rs b/src/openhuman/memory_tree/tree_source/summariser/inert.rs similarity index 97% rename from src/openhuman/memory/tree/tree_source/summariser/inert.rs rename to src/openhuman/memory_tree/tree_source/summariser/inert.rs index 5d1f056080d..edf06a65b08 100644 --- a/src/openhuman/memory/tree/tree_source/summariser/inert.rs +++ b/src/openhuman/memory_tree/tree_source/summariser/inert.rs @@ -13,10 +13,10 @@ use anyhow::Result; use async_trait::async_trait; -use crate::openhuman::memory::tree::tree_source::summariser::{ +use crate::openhuman::memory_tree::tree_source::summariser::{ Summariser, SummaryContext, SummaryInput, SummaryOutput, }; -use crate::openhuman::memory::tree::types::approx_token_count; +use crate::openhuman::memory_tree::types::approx_token_count; /// Default prefix applied to each contribution in the joined body. Keeps /// provenance visible to a human reading the raw summary. @@ -97,7 +97,7 @@ fn truncate_to_budget(text: &str, budget: u32) -> (String, u32) { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::tree_source::types::TreeKind; + use crate::openhuman::memory_tree::tree_source::types::TreeKind; use chrono::Utc; fn sample_input(id: &str, content: &str, entities: &[&str]) -> SummaryInput { diff --git a/src/openhuman/memory/tree/tree_source/summariser/llm.rs b/src/openhuman/memory_tree/tree_source/summariser/llm.rs similarity index 98% rename from src/openhuman/memory/tree/tree_source/summariser/llm.rs rename to src/openhuman/memory_tree/tree_source/summariser/llm.rs index 440c88d09d7..ff2cbc76608 100644 --- a/src/openhuman/memory/tree/tree_source/summariser/llm.rs +++ b/src/openhuman/memory_tree/tree_source/summariser/llm.rs @@ -1,5 +1,5 @@ //! LLM-backed summariser — peer of -//! [`crate::openhuman::memory::tree::score::extract::llm::LlmEntityExtractor`]. +//! [`crate::openhuman::memory_tree::score::extract::llm::LlmEntityExtractor`]. //! //! ## Responsibility //! @@ -43,8 +43,8 @@ use std::sync::Arc; use super::inert::InertSummariser; use super::{Summariser, SummaryContext, SummaryInput, SummaryOutput}; use crate::openhuman::learning::extract::summary_facets::{self, StructuredSummary}; -use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider}; -use crate::openhuman::memory::tree::types::approx_token_count; +use crate::openhuman::memory_tree::chat::{ChatPrompt, ChatProvider}; +use crate::openhuman::memory_tree::types::approx_token_count; /// Hard cap on summariser output length (in approximate tokens). /// @@ -402,7 +402,7 @@ fn clamp_to_budget(text: &str, budget: u32) -> (String, u32) { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::tree_source::types::TreeKind; + use crate::openhuman::memory_tree::tree_source::types::TreeKind; use chrono::Utc; fn sample_input(id: &str, content: &str) -> SummaryInput { diff --git a/src/openhuman/memory/tree/tree_source/summariser/mod.rs b/src/openhuman/memory_tree/tree_source/summariser/mod.rs similarity index 97% rename from src/openhuman/memory/tree/tree_source/summariser/mod.rs rename to src/openhuman/memory_tree/tree_source/summariser/mod.rs index 223dd30c4d1..c324dffb167 100644 --- a/src/openhuman/memory/tree/tree_source/summariser/mod.rs +++ b/src/openhuman/memory_tree/tree_source/summariser/mod.rs @@ -13,7 +13,7 @@ use chrono::{DateTime, Utc}; use std::sync::Arc; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::tree_source::types::TreeKind; +use crate::openhuman::memory_tree::tree_source::types::TreeKind; pub mod inert; pub mod llm; @@ -83,7 +83,7 @@ pub trait Summariser: Send + Sync { /// without threading a generic type parameter through every caller. pub fn build_summariser(config: &Config) -> Arc { use crate::openhuman::config::DEFAULT_CLOUD_LLM_MODEL; - use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer}; + use crate::openhuman::memory_tree::chat::{build_chat_provider, ChatConsumer}; // Resolve the model identifier to log alongside the provider name. // When memory_provider is local (ollama:*), prefer the legacy diff --git a/src/openhuman/memory/tree/tree_source/types.rs b/src/openhuman/memory_tree/tree_source/types.rs similarity index 100% rename from src/openhuman/memory/tree/tree_source/types.rs rename to src/openhuman/memory_tree/tree_source/types.rs diff --git a/src/openhuman/memory/tree/tree_topic/README.md b/src/openhuman/memory_tree/tree_topic/README.md similarity index 100% rename from src/openhuman/memory/tree/tree_topic/README.md rename to src/openhuman/memory_tree/tree_topic/README.md diff --git a/src/openhuman/memory/tree/tree_topic/backfill.rs b/src/openhuman/memory_tree/tree_topic/backfill.rs similarity index 93% rename from src/openhuman/memory/tree/tree_topic/backfill.rs rename to src/openhuman/memory_tree/tree_topic/backfill.rs index ac1e9a43d43..9172aea47ad 100644 --- a/src/openhuman/memory/tree/tree_topic/backfill.rs +++ b/src/openhuman/memory_tree/tree_topic/backfill.rs @@ -27,14 +27,14 @@ use anyhow::{Context, Result}; use chrono::Utc; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::score::store::lookup_entity; -use crate::openhuman::memory::tree::store::get_chunk; -use crate::openhuman::memory::tree::tree_source::bucket_seal::{ +use crate::openhuman::memory_tree::score::store::lookup_entity; +use crate::openhuman::memory_tree::store::get_chunk; +use crate::openhuman::memory_tree::tree_source::bucket_seal::{ append_leaf, LabelStrategy, LeafRef, }; -use crate::openhuman::memory::tree::tree_source::summariser::Summariser; -use crate::openhuman::memory::tree::tree_source::types::Tree; -use crate::openhuman::memory::tree::util::redact::redact; +use crate::openhuman::memory_tree::tree_source::summariser::Summariser; +use crate::openhuman::memory_tree::tree_source::types::Tree; +use crate::openhuman::memory_tree::util::redact::redact; /// Max leaves to pull from the entity index during backfill. A hard cap /// keeps initial spawn latency bounded even for very active entities. @@ -191,14 +191,14 @@ pub async fn backfill_topic_tree_at( #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::score::extract::EntityKind; - use crate::openhuman::memory::tree::score::resolver::CanonicalEntity; - use crate::openhuman::memory::tree::score::store::index_entity; - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_source::store as src_store; - use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser; - use crate::openhuman::memory::tree::tree_topic::registry::get_or_create_topic_tree; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::score::extract::EntityKind; + use crate::openhuman::memory_tree::score::resolver::CanonicalEntity; + use crate::openhuman::memory_tree::score::store::index_entity; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_source::store as src_store; + use crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser; + use crate::openhuman::memory_tree::tree_topic::registry::get_or_create_topic_tree; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use chrono::{TimeZone, Utc}; use tempfile::TempDir; diff --git a/src/openhuman/memory/tree/tree_topic/curator.rs b/src/openhuman/memory_tree/tree_topic/curator.rs similarity index 92% rename from src/openhuman/memory/tree/tree_topic/curator.rs rename to src/openhuman/memory_tree/tree_topic/curator.rs index 255a1c4007f..45acac45c74 100644 --- a/src/openhuman/memory/tree/tree_topic/curator.rs +++ b/src/openhuman/memory_tree/tree_topic/curator.rs @@ -19,16 +19,16 @@ use anyhow::Result; use chrono::Utc; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::tree_source::store as src_store; -use crate::openhuman::memory::tree::tree_source::summariser::Summariser; -use crate::openhuman::memory::tree::tree_source::types::{Tree, TreeKind}; -use crate::openhuman::memory::tree::tree_topic::backfill::backfill_topic_tree; -use crate::openhuman::memory::tree::tree_topic::hotness::hotness_at; -use crate::openhuman::memory::tree::tree_topic::registry::get_or_create_topic_tree; -use crate::openhuman::memory::tree::tree_topic::store::{ +use crate::openhuman::memory_tree::tree_source::store as src_store; +use crate::openhuman::memory_tree::tree_source::summariser::Summariser; +use crate::openhuman::memory_tree::tree_source::types::{Tree, TreeKind}; +use crate::openhuman::memory_tree::tree_topic::backfill::backfill_topic_tree; +use crate::openhuman::memory_tree::tree_topic::hotness::hotness_at; +use crate::openhuman::memory_tree::tree_topic::registry::get_or_create_topic_tree; +use crate::openhuman::memory_tree::tree_topic::store::{ distinct_sources_for, get_or_fresh, upsert, }; -use crate::openhuman::memory::tree::tree_topic::types::{ +use crate::openhuman::memory_tree::tree_topic::types::{ HotnessCounters, TOPIC_CREATION_THRESHOLD, TOPIC_RECHECK_EVERY, }; @@ -54,7 +54,7 @@ pub enum SpawnOutcome { /// fires, consider spawning a topic tree. /// /// `summariser` is used only when a spawn + backfill happens; passing an -/// [`InertSummariser`](crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser) +/// [`InertSummariser`](crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser) /// is fine for Phase 3c. pub async fn maybe_spawn_topic_tree( config: &Config, @@ -168,13 +168,13 @@ fn existing_topic_tree(config: &Config, entity_id: &str) -> Result> #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::score::extract::EntityKind; - use crate::openhuman::memory::tree::score::resolver::CanonicalEntity; - use crate::openhuman::memory::tree::score::store::index_entity; - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser; - use crate::openhuman::memory::tree::tree_topic::store::get; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::score::extract::EntityKind; + use crate::openhuman::memory_tree::score::resolver::CanonicalEntity; + use crate::openhuman::memory_tree::score::store::index_entity; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser; + use crate::openhuman::memory_tree::tree_topic::store::get; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use chrono::{TimeZone, Utc}; use tempfile::TempDir; diff --git a/src/openhuman/memory/tree/tree_topic/hotness.rs b/src/openhuman/memory_tree/tree_topic/hotness.rs similarity index 97% rename from src/openhuman/memory/tree/tree_topic/hotness.rs rename to src/openhuman/memory_tree/tree_topic/hotness.rs index c10a7fbb2f9..43241f12407 100644 --- a/src/openhuman/memory/tree/tree_topic/hotness.rs +++ b/src/openhuman/memory_tree/tree_topic/hotness.rs @@ -23,7 +23,7 @@ use chrono::Utc; -use crate::openhuman::memory::tree::tree_topic::types::EntityIndexStats; +use crate::openhuman::memory_tree::tree_topic::types::EntityIndexStats; /// Pure hotness function — no I/O, no clocks unless the caller passes one. /// @@ -110,7 +110,7 @@ mod tests { #[test] fn spike_of_mentions_pushes_over_creation_threshold() { - use crate::openhuman::memory::tree::tree_topic::types::TOPIC_CREATION_THRESHOLD; + use crate::openhuman::memory_tree::tree_topic::types::TOPIC_CREATION_THRESHOLD; let now_ms = 1_700_000_000_000; // 100 mentions across 5 sources, 3 recent query hits, seen today. let s = EntityIndexStats { diff --git a/src/openhuman/memory/tree/tree_topic/mod.rs b/src/openhuman/memory_tree/tree_topic/mod.rs similarity index 100% rename from src/openhuman/memory/tree/tree_topic/mod.rs rename to src/openhuman/memory_tree/tree_topic/mod.rs diff --git a/src/openhuman/memory/tree/tree_topic/registry.rs b/src/openhuman/memory_tree/tree_topic/registry.rs similarity index 95% rename from src/openhuman/memory/tree/tree_topic/registry.rs rename to src/openhuman/memory_tree/tree_topic/registry.rs index 21a59dc9fd4..23aa30c15df 100644 --- a/src/openhuman/memory/tree/tree_topic/registry.rs +++ b/src/openhuman/memory_tree/tree_topic/registry.rs @@ -12,8 +12,8 @@ use chrono::Utc; use uuid::Uuid; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::tree_source::store; -use crate::openhuman::memory::tree::tree_source::types::{Tree, TreeKind, TreeStatus}; +use crate::openhuman::memory_tree::tree_source::store; +use crate::openhuman::memory_tree::tree_source::types::{Tree, TreeKind, TreeStatus}; /// Look up the topic tree for `entity_id`, or create a new one. /// @@ -47,7 +47,7 @@ pub fn force_create_topic_tree(config: &Config, entity_id: &str) -> Result /// ascending for stable output. pub fn list_topic_trees(config: &Config) -> Result> { use rusqlite::params; - crate::openhuman::memory::tree::store::with_connection(config, |conn| { + crate::openhuman::memory_tree::store::with_connection(config, |conn| { let mut stmt = conn.prepare( "SELECT id, kind, scope, root_id, max_level, status, created_at_ms, last_sealed_at_ms @@ -68,7 +68,7 @@ pub fn list_topic_trees(config: &Config) -> Result> { /// (unarchive is not a Phase 3c primitive — Phase 3c just stops routing). pub fn archive_topic_tree(config: &Config, tree_id: &str) -> Result<()> { use rusqlite::params; - crate::openhuman::memory::tree::store::with_connection(config, |conn| { + crate::openhuman::memory_tree::store::with_connection(config, |conn| { let n = conn .execute( "UPDATE mem_tree_trees @@ -241,7 +241,7 @@ mod tests { // the UNIQUE constraint is on (kind, scope), not scope alone. let (_tmp, cfg) = test_config(); let source = - crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree( + crate::openhuman::memory_tree::tree_source::registry::get_or_create_source_tree( &cfg, "shared:slack:#eng", ) @@ -283,7 +283,7 @@ mod tests { fn list_topic_trees_returns_only_topics() { let (_tmp, cfg) = test_config(); // Mix of source + topic trees. - crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree( + crate::openhuman::memory_tree::tree_source::registry::get_or_create_source_tree( &cfg, "slack:#eng", ) diff --git a/src/openhuman/memory/tree/tree_topic/routing.rs b/src/openhuman/memory_tree/tree_topic/routing.rs similarity index 90% rename from src/openhuman/memory/tree/tree_topic/routing.rs rename to src/openhuman/memory_tree/tree_topic/routing.rs index 6f5e5b129ef..3591e3f111f 100644 --- a/src/openhuman/memory/tree/tree_topic/routing.rs +++ b/src/openhuman/memory_tree/tree_topic/routing.rs @@ -21,13 +21,13 @@ use anyhow::Result; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::tree_source::bucket_seal::{ +use crate::openhuman::memory_tree::tree_source::bucket_seal::{ append_leaf, LabelStrategy, LeafRef, }; -use crate::openhuman::memory::tree::tree_source::store as src_store; -use crate::openhuman::memory::tree::tree_source::summariser::Summariser; -use crate::openhuman::memory::tree::tree_source::types::{TreeKind, TreeStatus}; -use crate::openhuman::memory::tree::tree_topic::curator::maybe_spawn_topic_tree; +use crate::openhuman::memory_tree::tree_source::store as src_store; +use crate::openhuman::memory_tree::tree_source::summariser::Summariser; +use crate::openhuman::memory_tree::tree_source::types::{TreeKind, TreeStatus}; +use crate::openhuman::memory_tree::tree_topic::curator::maybe_spawn_topic_tree; /// Route `leaf` into every active topic tree matching one of /// `canonical_entities`. Also ticks the curator for each entity so the @@ -128,16 +128,16 @@ async fn route_one_entity( #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::tree::score::extract::EntityKind; - use crate::openhuman::memory::tree::score::resolver::CanonicalEntity; - use crate::openhuman::memory::tree::score::store::index_entity; - use crate::openhuman::memory::tree::store::upsert_chunks; - use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser; - use crate::openhuman::memory::tree::tree_topic::registry::{ + use crate::openhuman::memory_tree::score::extract::EntityKind; + use crate::openhuman::memory_tree::score::resolver::CanonicalEntity; + use crate::openhuman::memory_tree::score::store::index_entity; + use crate::openhuman::memory_tree::store::upsert_chunks; + use crate::openhuman::memory_tree::tree_source::summariser::inert::InertSummariser; + use crate::openhuman::memory_tree::tree_topic::registry::{ archive_topic_tree, get_or_create_topic_tree, }; - use crate::openhuman::memory::tree::tree_topic::store::get as get_hotness; - use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; + use crate::openhuman::memory_tree::tree_topic::store::get as get_hotness; + use crate::openhuman::memory_tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef}; use chrono::{TimeZone, Utc}; use tempfile::TempDir; @@ -198,7 +198,7 @@ mod tests { .unwrap(); // No hotness rows were created. assert_eq!( - crate::openhuman::memory::tree::tree_topic::store::count(&cfg).unwrap(), + crate::openhuman::memory_tree::tree_topic::store::count(&cfg).unwrap(), 0 ); } @@ -306,14 +306,14 @@ mod tests { // to keep hotness above `TOPIC_CREATION_THRESHOLD` once the index // is queried (two indexed sources below → distinct_sources → 2). let mut counters = - crate::openhuman::memory::tree::tree_topic::types::HotnessCounters::fresh(entity_id, 0); + crate::openhuman::memory_tree::tree_topic::types::HotnessCounters::fresh(entity_id, 0); counters.mention_count_30d = 1_000; counters.distinct_sources = 2; counters.last_seen_ms = Some(Utc::now().timestamp_millis()); counters.query_hits_30d = 5; counters.ingests_since_check = - crate::openhuman::memory::tree::tree_topic::types::TOPIC_RECHECK_EVERY - 1; - crate::openhuman::memory::tree::tree_topic::store::upsert(&cfg, &counters).unwrap(); + crate::openhuman::memory_tree::tree_topic::types::TOPIC_RECHECK_EVERY - 1; + crate::openhuman::memory_tree::tree_topic::store::upsert(&cfg, &counters).unwrap(); // Seed leaves in slack and gmail referencing Alice. Anchor the // timestamps to "now" so the 30-day backfill window diff --git a/src/openhuman/memory/tree/tree_topic/store.rs b/src/openhuman/memory_tree/tree_topic/store.rs similarity index 94% rename from src/openhuman/memory/tree/tree_topic/store.rs rename to src/openhuman/memory_tree/tree_topic/store.rs index 64d518af3a7..1eab937481d 100644 --- a/src/openhuman/memory/tree/tree_topic/store.rs +++ b/src/openhuman/memory_tree/tree_topic/store.rs @@ -17,8 +17,8 @@ use chrono::Utc; use rusqlite::{params, OptionalExtension}; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::store::with_connection; -use crate::openhuman::memory::tree::tree_topic::types::HotnessCounters; +use crate::openhuman::memory_tree::store::with_connection; +use crate::openhuman::memory_tree::tree_topic::types::HotnessCounters; /// Fetch the hotness row for `entity_id`, or `None` if the entity has /// never been seen. Callers usually want [`get_or_fresh`] instead. @@ -202,9 +202,9 @@ mod tests { #[test] fn distinct_sources_counts_trees() { - use crate::openhuman::memory::tree::score::extract::EntityKind; - use crate::openhuman::memory::tree::score::resolver::CanonicalEntity; - use crate::openhuman::memory::tree::score::store::index_entity; + use crate::openhuman::memory_tree::score::extract::EntityKind; + use crate::openhuman::memory_tree::score::resolver::CanonicalEntity; + use crate::openhuman::memory_tree::score::store::index_entity; let (_tmp, cfg) = test_config(); let e = CanonicalEntity { canonical_id: "email:alice@example.com".into(), @@ -224,9 +224,9 @@ mod tests { #[test] fn distinct_sources_ignores_null_tree_id() { - use crate::openhuman::memory::tree::score::extract::EntityKind; - use crate::openhuman::memory::tree::score::resolver::CanonicalEntity; - use crate::openhuman::memory::tree::score::store::index_entity; + use crate::openhuman::memory_tree::score::extract::EntityKind; + use crate::openhuman::memory_tree::score::resolver::CanonicalEntity; + use crate::openhuman::memory_tree::score::store::index_entity; let (_tmp, cfg) = test_config(); let e = CanonicalEntity { canonical_id: "email:alice@example.com".into(), diff --git a/src/openhuman/memory/tree/tree_topic/types.rs b/src/openhuman/memory_tree/tree_topic/types.rs similarity index 100% rename from src/openhuman/memory/tree/tree_topic/types.rs rename to src/openhuman/memory_tree/tree_topic/types.rs diff --git a/src/openhuman/memory/tree/types.rs b/src/openhuman/memory_tree/types.rs similarity index 100% rename from src/openhuman/memory/tree/types.rs rename to src/openhuman/memory_tree/types.rs diff --git a/src/openhuman/memory/tree/util/README.md b/src/openhuman/memory_tree/util/README.md similarity index 100% rename from src/openhuman/memory/tree/util/README.md rename to src/openhuman/memory_tree/util/README.md diff --git a/src/openhuman/memory/tree/util/mod.rs b/src/openhuman/memory_tree/util/mod.rs similarity index 100% rename from src/openhuman/memory/tree/util/mod.rs rename to src/openhuman/memory_tree/util/mod.rs diff --git a/src/openhuman/memory/tree/util/redact.rs b/src/openhuman/memory_tree/util/redact.rs similarity index 100% rename from src/openhuman/memory/tree/util/redact.rs rename to src/openhuman/memory_tree/util/redact.rs diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index cdafe89ae96..48d027e68d8 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -52,6 +52,7 @@ pub mod mcp_server; pub mod meet; pub mod meet_agent; pub mod memory; +pub mod memory_tree; pub mod migration; pub mod migrations; pub mod notifications; @@ -83,7 +84,6 @@ pub mod tokenjuice; pub mod tool_registry; pub mod tool_timeout; pub mod tools; -pub mod tree_summarizer; pub mod update; pub mod util; pub mod vault; diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs index ed0469cdcc2..49c11156f24 100644 --- a/src/openhuman/subconscious/engine.rs +++ b/src/openhuman/subconscious/engine.rs @@ -21,10 +21,10 @@ use super::types::{ }; use crate::openhuman::config::Config; use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER}; -use crate::openhuman::memory::tree::chat::{ +use crate::openhuman::memory::MemoryClientRef; +use crate::openhuman::memory_tree::chat::{ build_chat_provider, ChatConsumer, ChatPrompt, ChatProvider, }; -use crate::openhuman::memory::MemoryClientRef; use anyhow::Result; use executor::ExecutionOutcome; use std::collections::HashMap; diff --git a/src/openhuman/subconscious/situation_report/digest.rs b/src/openhuman/subconscious/situation_report/digest.rs index 1c1d82acf29..0e0ce94cdd3 100644 --- a/src/openhuman/subconscious/situation_report/digest.rs +++ b/src/openhuman/subconscious/situation_report/digest.rs @@ -12,7 +12,7 @@ //! exactly what was happening before this section was gated. use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::tree_source::types::TreeKind; +use crate::openhuman::memory_tree::tree_source::types::TreeKind; /// Truncate point for the digest body in the situation report. const DIGEST_BODY_PREVIEW: usize = 1200; @@ -63,7 +63,7 @@ struct DigestRow { } fn read_latest_global_l0(config: &Config, cutoff_ms: i64) -> anyhow::Result> { - crate::openhuman::memory::tree::store::with_connection(config, |conn| { + crate::openhuman::memory_tree::store::with_connection(config, |conn| { let row = conn .query_row( "SELECT s.id, s.content, s.sealed_at_ms diff --git a/src/openhuman/subconscious/situation_report/hotness.rs b/src/openhuman/subconscious/situation_report/hotness.rs index 90046a0c71d..6999a165551 100644 --- a/src/openhuman/subconscious/situation_report/hotness.rs +++ b/src/openhuman/subconscious/situation_report/hotness.rs @@ -145,7 +145,7 @@ struct HotnessDelta { /// subquery over `mem_tree_entity_index` (#1365): true iff any indexed /// row for this entity has `is_user = 1`. fn read_current_hotness(config: &Config) -> anyhow::Result> { - crate::openhuman::memory::tree::store::with_connection(config, |conn| { + crate::openhuman::memory_tree::store::with_connection(config, |conn| { let mut stmt = conn.prepare( "SELECT h.entity_id, h.last_hotness, diff --git a/src/openhuman/subconscious/situation_report/query_window.rs b/src/openhuman/subconscious/situation_report/query_window.rs index 049a0e76b2c..d167239a940 100644 --- a/src/openhuman/subconscious/situation_report/query_window.rs +++ b/src/openhuman/subconscious/situation_report/query_window.rs @@ -11,7 +11,7 @@ use std::fmt::Write; use crate::openhuman::config::Config; -use crate::openhuman::memory::tree::retrieval::global::query_global; +use crate::openhuman::memory_tree::retrieval::global::query_global; /// Cold-start fallback window when `last_tick_at` is unset. const COLD_START_DAYS: u32 = 7; diff --git a/src/openhuman/subconscious/situation_report/summaries.rs b/src/openhuman/subconscious/situation_report/summaries.rs index 43848a21617..253e792624c 100644 --- a/src/openhuman/subconscious/situation_report/summaries.rs +++ b/src/openhuman/subconscious/situation_report/summaries.rs @@ -65,7 +65,7 @@ struct SummaryRow { } fn read_recent_summaries(config: &Config, cutoff_ms: i64) -> anyhow::Result> { - crate::openhuman::memory::tree::store::with_connection(config, |conn| { + crate::openhuman::memory_tree::store::with_connection(config, |conn| { let mut stmt = conn.prepare( "SELECT s.id, s.level, s.content, t.scope FROM mem_tree_summaries s diff --git a/src/openhuman/subconscious/source_chunk.rs b/src/openhuman/subconscious/source_chunk.rs index 9789b3228e7..f9870e6c1c6 100644 --- a/src/openhuman/subconscious/source_chunk.rs +++ b/src/openhuman/subconscious/source_chunk.rs @@ -150,7 +150,7 @@ fn resolve_summary(config: &crate::openhuman::config::Config, raw: &str) -> Sour // `L:` token, which left no row matching anything in the // table. let lookup: anyhow::Result> = - crate::openhuman::memory::tree::store::with_connection(config, |conn| { + crate::openhuman::memory_tree::store::with_connection(config, |conn| { let mut stmt = conn.prepare( "SELECT s.content, s.level, t.scope FROM mem_tree_summaries s @@ -219,7 +219,7 @@ fn resolve_entity(config: &crate::openhuman::config::Config, raw: &str) -> Sourc let original_kind = parse_ref(raw).0.to_string(); type EntityLookup = anyhow::Result)>>; let lookup: EntityLookup = - crate::openhuman::memory::tree::store::with_connection(config, |conn| { + crate::openhuman::memory_tree::store::with_connection(config, |conn| { // Top-scoring surface form for this entity. let mut stmt = conn.prepare( "SELECT entity_kind, surface, score diff --git a/src/openhuman/test_support/rpc.rs b/src/openhuman/test_support/rpc.rs index acd84f33964..25ed256107b 100644 --- a/src/openhuman/test_support/rpc.rs +++ b/src/openhuman/test_support/rpc.rs @@ -15,7 +15,7 @@ use serde_json::json; use crate::openhuman::config::Config; use crate::openhuman::config::{clear_active_user, default_root_openhuman_dir}; use crate::openhuman::cron; -use crate::openhuman::memory::tree::read_rpc; +use crate::openhuman::memory_tree::read_rpc; use crate::rpc::RpcOutcome; const E2E_MODE_ENV_VAR: &str = "OPENHUMAN_E2E_MODE"; diff --git a/src/openhuman/tools/impl/memory/mod.rs b/src/openhuman/tools/impl/memory/mod.rs index c523665b686..30748d807dc 100644 --- a/src/openhuman/tools/impl/memory/mod.rs +++ b/src/openhuman/tools/impl/memory/mod.rs @@ -1,9 +1,8 @@ mod forget; mod recall; mod store; -mod tree; +pub use crate::openhuman::memory_tree::tools::*; pub use forget::MemoryForgetTool; pub use recall::MemoryRecallTool; pub use store::MemoryStoreTool; -pub use tree::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 6336f8a2598..089c4e9316c 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -153,6 +153,7 @@ pub fn all_tools_with_runtime( Box::new(MemoryRecallTool::new(memory.clone())), Box::new(MemoryForgetTool::new(memory.clone(), security.clone())), Box::new(MemoryTreeTool), + Box::new(MemoryTreeWalkTool), // Explicit user-preference pinning — always registered so the model // can save user-stated preferences regardless of whether the full // inference-based learning subsystem is enabled. The preference diff --git a/src/openhuman/whatsapp_data/sqlite_retry.rs b/src/openhuman/whatsapp_data/sqlite_retry.rs index dbaf91a815e..f4066c11f92 100644 --- a/src/openhuman/whatsapp_data/sqlite_retry.rs +++ b/src/openhuman/whatsapp_data/sqlite_retry.rs @@ -1,6 +1,6 @@ //! SQLite busy/locked detection and retry-with-backoff for WhatsApp data writes. //! -//! Modelled on [`crate::openhuman::memory::tree::jobs::worker::is_sqlite_busy`] — +//! Modelled on [`crate::openhuman::memory_tree::jobs::worker::is_sqlite_busy`] — //! the configured `busy_timeout` absorbs short waits inside rusqlite; this layer //! catches residual `SQLITE_BUSY` / `SQLITE_LOCKED` after that window. diff --git a/tests/agent_retrieval_e2e.rs b/tests/agent_retrieval_e2e.rs index b5e6c8c06c3..1efc7fdba2e 100644 --- a/tests/agent_retrieval_e2e.rs +++ b/tests/agent_retrieval_e2e.rs @@ -21,10 +21,10 @@ use chrono::{TimeZone, Utc}; use openhuman_core::openhuman::config::Config; -use openhuman_core::openhuman::memory::tree::canonicalize::chat::{ChatBatch, ChatMessage}; -use openhuman_core::openhuman::memory::tree::canonicalize::email::{EmailMessage, EmailThread}; -use openhuman_core::openhuman::memory::tree::ingest::{ingest_chat, ingest_email}; -use openhuman_core::openhuman::memory::tree::jobs::drain_until_idle; +use openhuman_core::openhuman::memory_tree::canonicalize::chat::{ChatBatch, ChatMessage}; +use openhuman_core::openhuman::memory_tree::canonicalize::email::{EmailMessage, EmailThread}; +use openhuman_core::openhuman::memory_tree::ingest::{ingest_chat, ingest_email}; +use openhuman_core::openhuman::memory_tree::jobs::drain_until_idle; use openhuman_core::openhuman::tools::{ MemoryTreeFetchLeavesTool, MemoryTreeQueryTopicTool, MemoryTreeSearchEntitiesTool, Tool, }; diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 504587c0831..0527f9b17cb 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -18,7 +18,7 @@ use tempfile::tempdir; use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR}; use openhuman_core::core::jsonrpc::build_core_http_router; -use openhuman_core::openhuman::memory::all_memory_tree_registered_controllers; +use openhuman_core::openhuman::memory_tree::all_memory_tree_registered_controllers; const TEST_RPC_TOKEN: &str = "json-rpc-e2e-local-token"; static JSON_RPC_AUTH_INIT: OnceLock<()> = OnceLock::new(); diff --git a/tests/memory_tree_summarizer_e2e.rs b/tests/memory_tree_summarizer_e2e.rs new file mode 100644 index 00000000000..ac387a19a33 --- /dev/null +++ b/tests/memory_tree_summarizer_e2e.rs @@ -0,0 +1,578 @@ +//! E2E tests for the tree summarizer engine. +//! +//! Calls `engine::run_summarization` directly with a mock LLM provider so the +//! full ingest → summarize → propagate chain is exercised without needing a +//! running Ollama process. Three scenarios are covered: +//! +//! 1. `builds_hour_day_month_year_chain` — ingest chunks across two distinct +//! hours, run the summarizer, and assert the full hour→day→month→year→root +//! node chain is written. +//! +//! 2. `merges_into_existing_hour_node` — run the summarizer twice for the +//! same hour and confirm `created_at` is preserved while `updated_at` +//! advances and the summary reflects both passes. +//! +//! 3. `survives_llm_error_with_partial_progress` — program the mock so the +//! second LLM call returns an error; assert the first hour node was +//! written, the second was not, and the engine surfaces the error without +//! panicking. +//! +//! Run with: `bash scripts/test-rust-with-mock.sh --test memory_tree_summarizer_e2e` +//! +//! The mock HTTP server is started by `scripts/test-rust-with-mock.sh` and its +//! URL is available in `BACKEND_URL` / `MOCK_API_PORT`. + +use std::path::Path; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::{DateTime, TimeZone, Utc}; +use tempfile::tempdir; + +use openhuman_core::openhuman::config::Config; +use openhuman_core::openhuman::inference::provider::traits::Provider; +use openhuman_core::openhuman::memory_tree::summarizer::{engine, store}; + +// ── Env isolation ───────────────────────────────────────────────────────── + +struct EnvVarGuard { + key: &'static str, + old: Option, +} + +impl EnvVarGuard { + fn set_to_path(key: &'static str, path: &Path) -> Self { + let old = std::env::var(key).ok(); + // SAFETY: guarded by ENV_LOCK which serialises process-global env mutations. + unsafe { std::env::set_var(key, path.as_os_str()) }; + Self { key, old } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.old { + // SAFETY: symmetric teardown under the same ENV_LOCK guard. + Some(v) => unsafe { std::env::set_var(self.key, v) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } +} + +/// Serialise tests: `HOME` and `OPENHUMAN_WORKSPACE` are process-global. +static ENV_LOCK: OnceLock> = OnceLock::new(); + +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + let m = ENV_LOCK.get_or_init(|| Mutex::new(())); + match m.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + } +} + +// ── Mock provider helpers ───────────────────────────────────────────────── + +/// A provider whose `chat_with_system` returns scripted responses in order. +/// Thread-safe via a `Mutex`. Each pop returns the next scripted +/// response; once the queue is exhausted, every subsequent call returns an +/// error so missing a setup step is caught immediately. +struct ScriptedProvider { + responses: Arc>>>, + call_count: Arc>, +} + +impl ScriptedProvider { + fn new(responses: Vec>) -> Self { + log::debug!( + "[memory_tree_summarizer_e2e] ScriptedProvider created with {} responses", + responses.len() + ); + Self { + responses: Arc::new(Mutex::new(responses.into())), + call_count: Arc::new(Mutex::new(0)), + } + } + + fn call_count(&self) -> usize { + *self.call_count.lock().expect("call_count lock") + } +} + +#[async_trait] +impl Provider for ScriptedProvider { + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + model: &str, + _temperature: f64, + ) -> anyhow::Result { + let mut count = self.call_count.lock().expect("call_count lock"); + *count += 1; + let call_n = *count; + drop(count); + + log::debug!( + "[memory_tree_summarizer_e2e] ScriptedProvider.chat_with_system call #{call_n}: \ + model={model} system_prompt_len={} msg_len={}", + system_prompt.map(|s| s.len()).unwrap_or(0), + message.len() + ); + + let mut q = self.responses.lock().expect("responses lock"); + match q.pop_front() { + Some(Ok(text)) => { + log::debug!( + "[memory_tree_summarizer_e2e] call #{call_n} → scripted Ok ({} chars)", + text.len() + ); + Ok(text) + } + Some(Err(msg)) => { + log::debug!("[memory_tree_summarizer_e2e] call #{call_n} → scripted Err: {msg}"); + Err(anyhow::anyhow!("{msg}")) + } + None => { + log::debug!( + "[memory_tree_summarizer_e2e] call #{call_n} → queue exhausted (fallback error)" + ); + Err(anyhow::anyhow!( + "ScriptedProvider queue exhausted at call #{call_n}" + )) + } + } + } +} + +// ── Config builder ──────────────────────────────────────────────────────── + +/// Build a minimal `Config` rooted at `workspace_path`. +/// `local_ai.runtime_enabled` is irrelevant because we bypass `create_provider` +/// and pass our own `ScriptedProvider` directly. +fn build_config(workspace_path: &Path) -> Config { + Config { + workspace_dir: workspace_path.to_path_buf(), + ..Config::default() + } +} + +/// Return a fixed test timestamp anchored to 2026-03-15T14:xx UTC. +/// We use explicit timestamps so buffer filenames are deterministic and +/// the hour_id derived from them matches our assertions. +fn ts_hour14() -> DateTime { + Utc.with_ymd_and_hms(2026, 3, 15, 14, 5, 0) + .single() + .expect("valid ts_hour14") +} + +fn ts_hour15() -> DateTime { + Utc.with_ymd_and_hms(2026, 3, 15, 15, 10, 0) + .single() + .expect("valid ts_hour15") +} + +const NS: &str = "e2e-summarizer-test"; + +// ── Tests ───────────────────────────────────────────────────────────────── + +/// Ingest content for two distinct hours, run the summarizer, and assert the +/// full chain of nodes (hour × 2, day, month, year, root) is written. +/// The mock provider returns per-hour summaries short enough that upper levels +/// fit within their token budgets without an additional LLM call — only the +/// two hour-leaf summarizations trigger LLM calls. +#[tokio::test] +async fn builds_hour_day_month_year_chain() { + let _lock = env_lock(); + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("ws"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); + let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace); + + log::debug!("[memory_tree_summarizer_e2e] builds_hour_day_month_year_chain: start"); + + let config = build_config(&workspace); + + // Ingest 3 chunks: 2 for hour-14, 1 for hour-15. + store::buffer_write( + &config, + NS, + "Slack: discussed deployment timeline with team", + &ts_hour14(), + None, + ) + .expect("buffer_write hour14 chunk1"); + + store::buffer_write( + &config, + NS, + "Slack: follow-up on deployment blockers", + &ts_hour14(), + None, + ) + .expect("buffer_write hour14 chunk2"); + + store::buffer_write( + &config, + NS, + "Reviewed PR for infrastructure changes", + &ts_hour15(), + None, + ) + .expect("buffer_write hour15 chunk1"); + + // Provider: 2 LLM calls expected — one per hour leaf. + // The hour summaries are short enough that day/month/year/root fit within + // token budget and do NOT trigger additional LLM calls (propagate_node + // short-circuits when combined children text fits the level budget). + let provider = ScriptedProvider::new(vec![ + Ok("User discussed deployment timeline".to_string()), + Ok("Reviewed infrastructure PR".to_string()), + ]); + + log::debug!("[memory_tree_summarizer_e2e] running summarization"); + let result = engine::run_summarization(&config, &provider, NS, Utc::now()).await; + + log::debug!( + "[memory_tree_summarizer_e2e] run_summarization returned: {:?}", + result + .as_ref() + .map(|n| n.as_ref().map(|node| &node.node_id)) + ); + assert!( + result.is_ok(), + "run_summarization should succeed: {:?}", + result + ); + let last_node = result.unwrap(); + assert!(last_node.is_some(), "should return a last hour node"); + let last_node = last_node.unwrap(); + log::debug!( + "[memory_tree_summarizer_e2e] last hour node: {} level={:?}", + last_node.node_id, + last_node.level + ); + + // Assert both hour leaves exist. + let hour14_id = "2026/03/15/14"; + let hour15_id = "2026/03/15/15"; + + let node14 = store::read_node(&config, NS, hour14_id) + .expect("read_node hour14") + .expect("hour14 node must exist"); + log::debug!( + "[memory_tree_summarizer_e2e] hour14 summary: {}", + node14.summary + ); + assert!( + node14.summary.contains("deployment"), + "hour14 summary should contain 'deployment', got: {}", + node14.summary + ); + + let node15 = store::read_node(&config, NS, hour15_id) + .expect("read_node hour15") + .expect("hour15 node must exist"); + log::debug!( + "[memory_tree_summarizer_e2e] hour15 summary: {}", + node15.summary + ); + assert!( + node15.summary.contains("infrastructure") || node15.summary.contains("PR"), + "hour15 summary should contain 'infrastructure' or 'PR', got: {}", + node15.summary + ); + + // Assert day node was propagated. + let day_id = "2026/03/15"; + let day_node = store::read_node(&config, NS, day_id) + .expect("read_node day") + .expect("day node must exist after propagation"); + log::debug!( + "[memory_tree_summarizer_e2e] day node summary len={}", + day_node.summary.len() + ); + assert!( + !day_node.summary.is_empty(), + "day summary should not be empty" + ); + + // Assert month node. + let month_id = "2026/03"; + let month_node = store::read_node(&config, NS, month_id) + .expect("read_node month") + .expect("month node must exist after propagation"); + assert!( + !month_node.summary.is_empty(), + "month summary should not be empty" + ); + + // Assert year node. + let year_id = "2026"; + let year_node = store::read_node(&config, NS, year_id) + .expect("read_node year") + .expect("year node must exist after propagation"); + assert!( + !year_node.summary.is_empty(), + "year summary should not be empty" + ); + + // Assert root node. + let root_node = store::read_node(&config, NS, "root") + .expect("read_node root") + .expect("root node must exist after propagation"); + assert!( + !root_node.summary.is_empty(), + "root summary should not be empty" + ); + + // Exactly 2 LLM calls: one per hour leaf. + assert_eq!( + provider.call_count(), + 2, + "expected exactly 2 LLM calls (one per hour leaf)" + ); + + // Buffer should be drained after successful summarization. + let remaining = store::buffer_read(&config, NS).expect("buffer_read post-run"); + assert!( + remaining.is_empty(), + "buffer should be empty after successful run, got {} entries", + remaining.len() + ); + + log::debug!("[memory_tree_summarizer_e2e] builds_hour_day_month_year_chain: PASS"); +} + +/// Run the summarizer twice for the same hour. Verify: +/// - `created_at` is preserved from the first run. +/// - `updated_at` is strictly greater after the second run. +/// - The merged summary contains keywords from both passes. +#[tokio::test] +async fn merges_into_existing_hour_node() { + let _lock = env_lock(); + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("ws"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); + let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace); + + log::debug!("[memory_tree_summarizer_e2e] merges_into_existing_hour_node: start"); + + let config = build_config(&workspace); + + // --- First run: ingest and summarize hour-14. --- + store::buffer_write( + &config, + NS, + "Discussed deployment timeline on slack", + &ts_hour14(), + None, + ) + .expect("buffer_write pass1"); + + let provider1 = ScriptedProvider::new(vec![Ok( + "First-run summary: deployment timeline discussed".to_string(), + )]); + + log::debug!("[memory_tree_summarizer_e2e] first run"); + let r1 = engine::run_summarization(&config, &provider1, NS, Utc::now()) + .await + .expect("first run_summarization"); + assert!(r1.is_some(), "first run should yield a node"); + + let hour14_id = "2026/03/15/14"; + let node_after_first = store::read_node(&config, NS, hour14_id) + .expect("read node after first run") + .expect("hour14 must exist after first run"); + + let created_at_first = node_after_first.created_at; + let updated_at_first = node_after_first.updated_at; + log::debug!( + "[memory_tree_summarizer_e2e] after first run: created_at={} updated_at={}", + created_at_first, + updated_at_first + ); + + // Small sleep so the second updated_at is strictly greater. + tokio::time::sleep(Duration::from_millis(50)).await; + + // --- Second run: ingest more content for the same hour-14. --- + store::buffer_write( + &config, + NS, + "Follow-up on deployment blockers", + &ts_hour14(), + None, + ) + .expect("buffer_write pass2"); + + let provider2 = ScriptedProvider::new(vec![Ok( + "Merged summary: deployment timeline and blockers".to_string(), + )]); + + log::debug!("[memory_tree_summarizer_e2e] second run (same hour)"); + let r2 = engine::run_summarization(&config, &provider2, NS, Utc::now()) + .await + .expect("second run_summarization"); + assert!(r2.is_some(), "second run should yield a node"); + + let node_after_second = store::read_node(&config, NS, hour14_id) + .expect("read node after second run") + .expect("hour14 must exist after second run"); + + let created_at_second = node_after_second.created_at; + let updated_at_second = node_after_second.updated_at; + log::debug!( + "[memory_tree_summarizer_e2e] after second run: created_at={} updated_at={}", + created_at_second, + updated_at_second + ); + + // `created_at` must be preserved. + assert_eq!( + created_at_first, created_at_second, + "created_at must be preserved across merges" + ); + + // `updated_at` must advance (or at worst stay equal if clocks are too coarse). + assert!( + updated_at_second >= updated_at_first, + "updated_at must not go backward: first={updated_at_first} second={updated_at_second}" + ); + + // Summary must reflect the merge (the scripted response contains "blockers"). + assert!( + node_after_second.summary.contains("blockers") + || node_after_second.summary.contains("Merged"), + "merged summary should reflect second pass content, got: {}", + node_after_second.summary + ); + + log::debug!("[memory_tree_summarizer_e2e] merges_into_existing_hour_node: PASS"); +} + +/// Program the provider so the SECOND LLM call returns an error. +/// Ingest two hours' worth of content and run the summarizer. +/// +/// Expected behaviour: +/// - The first hour leaf is successfully written before the error. +/// - The error propagates out of `run_summarization` as an `Err`. +/// - The process does NOT panic. +/// - Because the buffer is only deleted after ALL hour leaves are written, +/// the buffer entries are NOT deleted (the second hour's content persists). +#[tokio::test] +async fn survives_llm_error_with_partial_progress() { + let _lock = env_lock(); + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("ws"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); + let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace); + + log::debug!("[memory_tree_summarizer_e2e] survives_llm_error_with_partial_progress: start"); + + let config = build_config(&workspace); + + // Ingest content for two distinct hours so two LLM calls are required. + store::buffer_write( + &config, + NS, + "Hour-14 content: deployment planning", + &ts_hour14(), + None, + ) + .expect("buffer_write hour14"); + + store::buffer_write( + &config, + NS, + "Hour-15 content: infrastructure review", + &ts_hour15(), + None, + ) + .expect("buffer_write hour15"); + + // Provider: call 1 succeeds, call 2 returns an error. + let provider = ScriptedProvider::new(vec![ + Ok("Hour-14 summary: deployment planning in progress".to_string()), + Err("boom: simulated LLM failure on second call".to_string()), + ]); + + log::debug!("[memory_tree_summarizer_e2e] running summarization expecting partial failure"); + let result = engine::run_summarization(&config, &provider, NS, Utc::now()).await; + + log::debug!( + "[memory_tree_summarizer_e2e] run_summarization result: is_ok={}", + result.is_ok() + ); + + // The engine must return an error — not panic. + assert!( + result.is_err(), + "expected Err from run_summarization when second LLM call fails, got: {:?}", + result + ); + let err = result.unwrap_err(); + log::debug!( + "[memory_tree_summarizer_e2e] propagated error (as expected): {:#}", + err + ); + // Use the full anyhow error chain (alternating display) so nested context + // layers — e.g. "summarize hour leaf: LLM summarization failed: boom: …" — + // are all visible in the assertion. + let err_chain = format!("{err:#}"); + assert!( + err_chain.contains("boom") || err_chain.contains("LLM summarization failed"), + "error message should mention the LLM failure, got: {err_chain}" + ); + + // Exactly 2 LLM calls were made. + assert_eq!( + provider.call_count(), + 2, + "expected 2 LLM calls (1 success + 1 error)" + ); + + // The first hour leaf (hour-14) was written before the error. + // The engine writes hour leaves as it goes; the second fails before writing. + // Note: the exact behaviour depends on which hour is processed first + // (BTreeMap ordering: "2026/03/15/14" < "2026/03/15/15"), so hour-14 is first. + let hour14_id = "2026/03/15/14"; + let node14 = store::read_node(&config, NS, hour14_id).expect("read_node hour14"); + assert!( + node14.is_some(), + "hour-14 leaf must be written before the error on hour-15" + ); + log::debug!( + "[memory_tree_summarizer_e2e] hour14 node present: summary={}", + node14.unwrap().summary + ); + + // The second hour node (hour-15) was NOT written because the LLM call failed. + let hour15_id = "2026/03/15/15"; + let node15 = store::read_node(&config, NS, hour15_id).expect("read_node hour15"); + assert!( + node15.is_none(), + "hour-15 leaf must NOT exist when its LLM call failed" + ); + + // The buffer must NOT be drained — the engine only deletes buffer entries + // after all hour leaves are successfully written. A partial failure means + // the buffer retains its entries so the next run can retry. + let remaining = store::buffer_read(&config, NS).expect("buffer_read post-error"); + assert!( + !remaining.is_empty(), + "buffer must not be drained after a partial failure, but it is empty" + ); + log::debug!( + "[memory_tree_summarizer_e2e] {} buffer entries remain (expected > 0)", + remaining.len() + ); + + log::debug!("[memory_tree_summarizer_e2e] survives_llm_error_with_partial_progress: PASS"); +} diff --git a/tests/memory_tree_walk_e2e.rs b/tests/memory_tree_walk_e2e.rs new file mode 100644 index 00000000000..f3b15ac0147 --- /dev/null +++ b/tests/memory_tree_walk_e2e.rs @@ -0,0 +1,535 @@ +//! E2E tests for the `memory_tree_walk` agentic tool. +//! +//! These tests exercise `run_walk` end-to-end against a real HTTP mock +//! LLM server (wiremock) to prove that: +//! - The `OpenAiCompatibleProvider` → `run_walk` chain works over HTTP. +//! - Tool-call XML parsing, trace assembly, and stop-reason detection +//! all behave correctly with per-turn scripted LLM responses. +//! - Turn-cap enforcement fires correctly. +//! - Unknown node descends are handled gracefully. +//! +//! Tree nodes are seeded directly via `store::write_node` so the tests +//! are isolated from the summariser pipeline and focus purely on walk. +//! +//! Run with: +//! cargo test --test memory_tree_walk_e2e +//! or via the project wrapper: +//! bash scripts/test-rust-with-mock.sh --test memory_tree_walk_e2e + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, OnceLock}; + +use chrono::Utc; +use serde_json::json; +use tempfile::TempDir; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +use openhuman_core::openhuman::config::Config; +use openhuman_core::openhuman::inference::provider::compatible::{ + AuthStyle, OpenAiCompatibleProvider, +}; +use openhuman_core::openhuman::memory_tree::summarizer::store::write_node; +use openhuman_core::openhuman::memory_tree::summarizer::types::{ + derive_parent_id, estimate_tokens, level_from_node_id, TreeNode, +}; +use openhuman_core::openhuman::memory_tree::tools::walk::{run_walk, WalkOptions, WalkStopReason}; + +// ── Environment serialisation lock ────────────────────────────────────────── +// +// `OPENHUMAN_WORKSPACE` is a process-global env var. Tests that set it +// must serialise with this lock. + +static ENV_LOCK: OnceLock> = OnceLock::new(); + +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + let m = ENV_LOCK.get_or_init(|| Mutex::new(())); + match m.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + } +} + +// ── Per-turn sequential mock responder ────────────────────────────────────── +// +// `ScriptedResponder` pops one canned OpenAI-compatible JSON response from its +// queue on each `respond` call. This allows a single wiremock `Mock` to hand +// back turn-1, turn-2, turn-3 responses in sequence. +// +// The queue is `Arc>>` so it can be shared between the +// `Mock` registration (which clones the arc) and the test body for inspection. + +#[derive(Clone)] +struct ScriptedResponder { + queue: Arc>>, + call_count: Arc>, +} + +impl ScriptedResponder { + /// Create a new responder pre-loaded with `content_strings`. + /// + /// Each string becomes the `choices[0].message.content` of an + /// OpenAI-compatible non-streaming response. + fn new(content_strings: Vec<&str>) -> Self { + log::debug!( + "[memory_tree_walk_e2e] ScriptedResponder created with {} turns", + content_strings.len() + ); + let queue: VecDeque = content_strings.iter().map(|s| s.to_string()).collect(); + Self { + queue: Arc::new(Mutex::new(queue)), + call_count: Arc::new(Mutex::new(0)), + } + } + + fn call_count(&self) -> usize { + *self.call_count.lock().unwrap() + } +} + +impl Respond for ScriptedResponder { + fn respond(&self, _request: &Request) -> ResponseTemplate { + let mut queue = self.queue.lock().unwrap(); + let mut count = self.call_count.lock().unwrap(); + *count += 1; + let current_turn = *count; + + let content = queue + .pop_front() + .unwrap_or_else(|| "ScriptedResponder: queue exhausted".to_string()); + + log::debug!( + "[memory_tree_walk_e2e] ScriptedResponder turn={current_turn} content_preview={}", + &content[..content.len().min(80)] + ); + + let body = json!({ + "id": format!("chatcmpl-walk-e2e-{current_turn}"), + "object": "chat.completion", + "created": 1_700_000_000_u64, + "model": "e2e-walk-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": content + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 10, + "total_tokens": 30 + } + }); + + ResponseTemplate::new(200).set_body_json(body) + } +} + +// ── Tree helpers ───────────────────────────────────────────────────────────── + +/// Build a minimal `Config` pointing at a temp workspace. +fn test_config(tmp: &TempDir) -> Config { + let mut cfg = Config::default(); + cfg.workspace_dir = tmp.path().join("workspace"); + std::fs::create_dir_all(&cfg.workspace_dir).expect("create workspace dir"); + cfg +} + +/// Create a `TreeNode` with the correct level and parent derived from its id. +fn make_node(namespace: &str, node_id: &str, summary: &str, child_count: u32) -> TreeNode { + let level = level_from_node_id(node_id); + let parent_id = derive_parent_id(node_id); + let ts = Utc::now(); + TreeNode { + node_id: node_id.to_string(), + namespace: namespace.to_string(), + level, + parent_id, + summary: summary.to_string(), + token_count: estimate_tokens(summary), + child_count, + created_at: ts, + updated_at: ts, + metadata: None, + } +} + +/// Seed a 3-level tree: +/// root → 2024 (year) → 2024/01 (month leaf) +/// +/// Returns the node ids for easy reference in tests. +fn seed_tree(cfg: &Config, ns: &str) -> (&'static str, &'static str, &'static str) { + let root_id = "root"; + let year_id = "2024"; + let month_id = "2024/01"; + + write_node( + cfg, + &make_node(ns, root_id, "All-time summary: project activity 2024.", 1), + ) + .expect("write root node"); + + write_node( + cfg, + &make_node( + ns, + year_id, + "Year 2024: major deployment cycle, shipped v1 release.", + 1, + ), + ) + .expect("write year node"); + + write_node( + cfg, + &make_node( + ns, + month_id, + "January 2024: user discussed deployment X on the Slack channel.", + 0, + ), + ) + .expect("write month node"); + + log::debug!( + "[memory_tree_walk_e2e] seeded tree namespace={ns} root={root_id} year={year_id} month={month_id}" + ); + + (root_id, year_id, month_id) +} + +// ── Provider helper ─────────────────────────────────────────────────────────── + +/// Build an `OpenAiCompatibleProvider` pointing at `wiremock_uri/v1`. +fn make_provider(wiremock_uri: &str) -> OpenAiCompatibleProvider { + log::debug!( + "[memory_tree_walk_e2e] building provider base_url={}/v1", + wiremock_uri + ); + OpenAiCompatibleProvider::new( + "e2e-walk-test", + &format!("{}/v1", wiremock_uri), + Some("test-key"), + AuthStyle::Bearer, + ) +} + +// ── Test 1: walks_descend_fetch_answer ─────────────────────────────────────── + +/// Happy-path walk: descend → fetch_leaves → answer in 3 turns. +/// +/// Validates: +/// - `WalkStopReason::Answered` +/// - `answer` contains expected text +/// - trace has 3 steps with correct action names +/// - `turns_used == 3` +/// - mock LLM received exactly 3 HTTP calls +#[tokio::test] +async fn walks_descend_fetch_answer() { + let _lock = env_lock(); + + log::debug!("[memory_tree_walk_e2e] test=walks_descend_fetch_answer starting"); + + // ── Start wiremock ── + let server = MockServer::start().await; + log::debug!( + "[memory_tree_walk_e2e] wiremock listening at {}", + server.uri() + ); + + // ── Seed tree ── + let tmp = TempDir::new().expect("tempdir"); + let cfg = test_config(&tmp); + let ns = "e2e-walk-test"; + let (_, year_id, month_id) = seed_tree(&cfg, ns); + + // ── Script 3 turns ── + // Turn 1: descend into the year node. + // Turn 2: fetch_leaves on the month node. + // Turn 3: answer. + let turn1 = format!( + r#"{{"name":"descend","arguments":{{"node_id":"{year_id}"}}}}"# + ); + let turn2 = format!( + r#"{{"name":"fetch_leaves","arguments":{{"node_id":"{month_id}"}}}}"# + ); + let turn3 = + r#"{"name":"answer","arguments":{"text":"The user discussed deployment X"}}"#.to_string(); + + let responder = ScriptedResponder::new(vec![&turn1, &turn2, &turn3]); + let responder_clone = responder.clone(); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(responder) + .mount(&server) + .await; + + // ── Run walk ── + let provider = make_provider(&server.uri()); + let opts = WalkOptions { + max_turns: 6, + start_node_id: None, + namespace: ns.to_string(), + model: Some("e2e-walk-model".into()), + }; + + log::debug!("[memory_tree_walk_e2e] calling run_walk query='deployment query'"); + let outcome = run_walk( + &cfg, + &provider, + "What was discussed about deployment?", + opts, + ) + .await + .expect("run_walk should succeed"); + + log::debug!( + "[memory_tree_walk_e2e] outcome stopped_reason={:?} turns_used={} trace_len={}", + outcome.stopped_reason, + outcome.turns_used, + outcome.trace.len() + ); + + // ── Assertions ── + assert_eq!( + outcome.stopped_reason, + WalkStopReason::Answered, + "walk should stop with Answered, got {:?}", + outcome.stopped_reason + ); + assert!( + outcome.answer.contains("The user discussed deployment X"), + "answer should contain expected text, got: {}", + outcome.answer + ); + assert_eq!( + outcome.turns_used, 3, + "expected 3 turns, got {}", + outcome.turns_used + ); + assert_eq!( + outcome.trace.len(), + 3, + "expected trace of 3 steps, got {}", + outcome.trace.len() + ); + assert_eq!( + outcome.trace[0].action, "descend", + "step 0 should be descend" + ); + assert_eq!( + outcome.trace[1].action, "fetch_leaves", + "step 1 should be fetch_leaves" + ); + assert_eq!(outcome.trace[2].action, "answer", "step 2 should be answer"); + + // Verify LLM was called exactly 3 times over HTTP. + let llm_calls = responder_clone.call_count(); + assert_eq!( + llm_calls, 3, + "LLM mock should have received exactly 3 HTTP requests, got {llm_calls}" + ); + + log::debug!("[memory_tree_walk_e2e] test=walks_descend_fetch_answer PASSED"); +} + +// ── Test 2: respects_max_turns_cap_with_mock ───────────────────────────────── + +/// Turn-cap enforcement: the mock always returns `descend` (never `answer`), +/// so the walk must stop at `max_turns` with `MaxTurnsReached`. +/// +/// Validates: +/// - `WalkStopReason::MaxTurnsReached` +/// - `turns_used == max_turns` (3) +/// - fallback answer contains "converge" or similar marker +/// - mock received exactly 3 HTTP calls (== max_turns) +#[tokio::test] +async fn respects_max_turns_cap_with_mock() { + let _lock = env_lock(); + + log::debug!("[memory_tree_walk_e2e] test=respects_max_turns_cap_with_mock starting"); + + let server = MockServer::start().await; + log::debug!( + "[memory_tree_walk_e2e] wiremock listening at {}", + server.uri() + ); + + let tmp = TempDir::new().expect("tempdir"); + let cfg = test_config(&tmp); + let ns = "e2e-walk-cap-test"; + let (_, year_id, _) = seed_tree(&cfg, ns); + + // Always descend (never answer) — more entries than max_turns so the cap fires. + let forever_descend = format!( + r#"{{"name":"descend","arguments":{{"node_id":"{year_id}"}}}}"# + ); + + let responder = ScriptedResponder::new(vec![ + &forever_descend, + &forever_descend, + &forever_descend, + &forever_descend, + &forever_descend, + ]); + let responder_clone = responder.clone(); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(responder) + .mount(&server) + .await; + + let provider = make_provider(&server.uri()); + let opts = WalkOptions { + max_turns: 3, + start_node_id: None, + namespace: ns.to_string(), + model: Some("e2e-walk-model".into()), + }; + + log::debug!("[memory_tree_walk_e2e] calling run_walk with max_turns=3"); + let outcome = run_walk(&cfg, &provider, "infinite loop query", opts) + .await + .expect("run_walk should succeed (not error)"); + + log::debug!( + "[memory_tree_walk_e2e] outcome stopped_reason={:?} turns_used={} trace_len={}", + outcome.stopped_reason, + outcome.turns_used, + outcome.trace.len() + ); + + assert_eq!( + outcome.stopped_reason, + WalkStopReason::MaxTurnsReached, + "walk should stop with MaxTurnsReached, got {:?}", + outcome.stopped_reason + ); + assert_eq!( + outcome.turns_used, 3, + "turns_used should be max_turns=3, got {}", + outcome.turns_used + ); + assert!( + outcome.answer.to_lowercase().contains("converge") + || outcome.answer.to_lowercase().contains("turn limit") + || outcome.answer.to_lowercase().contains("could not"), + "fallback answer should indicate failure to converge, got: {}", + outcome.answer + ); + + // Mock should have been called exactly max_turns (3) times. + let llm_calls = responder_clone.call_count(); + assert_eq!( + llm_calls, 3, + "LLM mock should have received exactly 3 HTTP requests (max_turns), got {llm_calls}" + ); + + log::debug!("[memory_tree_walk_e2e] test=respects_max_turns_cap_with_mock PASSED"); +} + +// ── Test 3: handles_unknown_node_gracefully ─────────────────────────────────── + +/// Unknown-node recovery: turn 1 descends into a non-existent node; +/// the walk reports "unknown node" in the trace but continues. +/// Turn 2 answers, so the walk completes with `Answered`. +/// +/// Validates: +/// - `WalkStopReason::Answered` +/// - `trace[0].result_preview` contains "unknown node" +/// - `trace.len() == 2` +/// - answer from turn 2 is preserved +#[tokio::test] +async fn handles_unknown_node_gracefully() { + let _lock = env_lock(); + + log::debug!("[memory_tree_walk_e2e] test=handles_unknown_node_gracefully starting"); + + let server = MockServer::start().await; + log::debug!( + "[memory_tree_walk_e2e] wiremock listening at {}", + server.uri() + ); + + let tmp = TempDir::new().expect("tempdir"); + let cfg = test_config(&tmp); + let ns = "e2e-walk-unknown-test"; + seed_tree(&cfg, ns); + + // Turn 1: descend into a node that does not exist. + let turn1 = + r#"{"name":"descend","arguments":{"node_id":"does_not_exist"}}"#; + // Turn 2: answer (walk continues after bad descend). + let turn2 = r#"{"name":"answer","arguments":{"text":"I gave up: node not found"}}"#; + + let responder = ScriptedResponder::new(vec![turn1, turn2]); + let responder_clone = responder.clone(); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(responder) + .mount(&server) + .await; + + let provider = make_provider(&server.uri()); + let opts = WalkOptions { + max_turns: 6, + start_node_id: None, + namespace: ns.to_string(), + model: Some("e2e-walk-model".into()), + }; + + log::debug!("[memory_tree_walk_e2e] calling run_walk with unknown-node script"); + let outcome = run_walk(&cfg, &provider, "find nonexistent data", opts) + .await + .expect("run_walk should succeed"); + + log::debug!( + "[memory_tree_walk_e2e] outcome stopped_reason={:?} turns_used={} trace_len={}", + outcome.stopped_reason, + outcome.turns_used, + outcome.trace.len() + ); + + // Walk should complete despite the bad descend. + assert_eq!( + outcome.stopped_reason, + WalkStopReason::Answered, + "walk should eventually answer, got {:?}", + outcome.stopped_reason + ); + + assert_eq!( + outcome.trace.len(), + 2, + "expected 2 trace steps, got {}", + outcome.trace.len() + ); + + // The first step's result_preview should indicate the unknown node. + let step0_preview = &outcome.trace[0].result_preview; + assert!( + step0_preview.contains("unknown node"), + "step 0 result_preview should contain 'unknown node', got: {step0_preview}" + ); + + // The final answer should be from turn 2. + assert!( + outcome.answer.contains("I gave up"), + "answer should contain turn-2 text, got: {}", + outcome.answer + ); + + // 2 HTTP calls made. + let llm_calls = responder_clone.call_count(); + assert_eq!( + llm_calls, 2, + "LLM mock should have received exactly 2 HTTP requests, got {llm_calls}" + ); + + log::debug!("[memory_tree_walk_e2e] test=handles_unknown_node_gracefully PASSED"); +}