diff --git a/docs/specs/memory-guard-allowlist.md b/docs/specs/memory-guard-allowlist.md index 3e253d1d07..a6fee3fdcf 100644 --- a/docs/specs/memory-guard-allowlist.md +++ b/docs/specs/memory-guard-allowlist.md @@ -17,7 +17,7 @@ dead-string rot the ratchet exists to prevent. ## Scope -The lint scans `src/` for twelve patterns, keyed on `(file, pattern)` so the +The lint scans `src/` for thirteen patterns, keyed on `(file, pattern)` so the failure message names the needle that tripped: | Pattern | What it hands out | @@ -25,7 +25,8 @@ failure message names the needle that tripped: | `active_memory_client(` | `MemoryClientRef` | | `global::client_if_ready(` / `global::client(` | `MemoryClientRef` | | `.memory_handle(` | raw `Arc` | -| `.profile_conn(` | raw `Arc>` | +| `.profile_conn(` | raw `Arc>` (one in-family site) | +| `.profile_store(` | a typed `ProfileStore` — confined, but still unguarded | | `.get_document(` | `pub(crate)` read-one escape hatch | | `EmbeddedMemoryProvider::new(` / `NullMemoryProvider::new(` | a driver, built outside `binding::for_workspace` | | `MemoryClient::from_workspace_dir(` | a second engine on the same store | @@ -62,6 +63,17 @@ store: | `tool_memory::tool_rule_list` | `MemoryToolMemory::tool_rules` | `tool_memory_store(memory).list_rules(tool)` | | `tool_memory::tool_rule_delete` | `MemoryToolMemory::delete_tool_rule` | `tool_memory_store(memory).delete_rule(tool, id)` | +Two **agent tools** followed the same route: + +| Tool | Contract method | Note | +| --- | --- | --- | +| `memory_tools_list` | `MemoryToolMemory::tool_rules` | 1:1 — same rules, same order, same serialization. | +| `memory_tools_put` | `MemoryToolMemory::put_tool_rule` + `tool_rules` | The contract method returns unit while the tool answers with the *stored* rule, so the write is followed by a read-back on the id `ToolMemoryRule::new` generated before the write. Exact, not lossy: there is no server-assigned identity, and `tool_memory_namespace` normalises the caller's raw `tool_name` the same way the write did. A concurrent delete in that window errors rather than fabricating a rule. | + +`memory_tools_put` therefore now refuses under the `readonly` autonomy tier +with `"memory guard: "`-prefixed text, and store-level validation errors arrive +as `MemoryError::Invalid` rather than as a raw string. Both are intended. + **Three deltas ride along, and they are the point of the milestone, not accidents:** @@ -133,7 +145,6 @@ are recorded here so M4c starts from the real set. | `agent/harness/session/builder/factory.rs` | `.memory_handle()` → `Arc`. | | `flows/tinyflows/memory_adapter.rs` | Returns `Arc` to satisfy a tinyflows engine trait. The contract has no `Arc` door. | | `flows/bus.rs` | `resolve_memory() -> Option>`, and carries a `#[cfg(test)] memory_override` seam a guard would bypass. | -| `memory/tool_memory/tools/list.rs`, `tools/put.rs` | Agent tools building `ToolMemoryStore` from `memory_handle()`. Re-pointable in principle via `as_tool_memory()` — **deferred to M5**, which filters the tool surface by capability and would collide with a re-point made now. | | `memory/ops/tool_memory.rs` (`open_store`) | Still needed by the four handlers left on the client. Shrank; did not disappear. | ### D. No contract method exists, or the wire shape would change @@ -157,14 +168,17 @@ module). ## Honest scorecard -Four of the twenty-eight `active_memory_client()` call sites now route through -the guard. Eleven non-test `profile_conn()` sites and twelve non-test -`memory_handle()` sites still hand out raw handles. The defensible claim for M4 -is therefore: +Six of the twenty-eight `active_memory_client()` call sites now route through +the guard — four RPC handlers plus the `memory_tools_list` / `memory_tools_put` +agent tools. Raw `profile_conn()` no longer leaves the memory family — but the ten +profile/facet call sites it fed are still unguarded, now through a typed +`ProfileStore`, and twelve non-test `memory_handle()` sites still hand out raw +handles. The defensible claim is therefore: > Every memory RPC handler whose contract twin is a literal delegation now > routes through the guard, and every remaining bypass is enumerated here with > a reason and pinned by a drift guard. "Impossible to skip by construction" is **not** true until `memory_handle()` -and `profile_conn()` are gone. +is gone and the profile/facet tables have a capability family to be guarded +against. diff --git a/src/openhuman/agent/harness/subagent_runner/ops/graph.rs b/src/openhuman/agent/harness/subagent_runner/ops/graph.rs index eddf2ca853..12505f649f 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/graph.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/graph.rs @@ -734,7 +734,7 @@ fn persist_failed_run( } } -/// Append a worker-thread [`StoredMessage`](crate::openhuman::memory::conversations::ConversationMessage) +/// Append a worker-thread [`StoredMessage`](tinycortex::memory::conversations::ConversationMessage) /// with the restored legacy [`SubagentObserver`] metadata (#4466): `scope`, /// `agent_id`, `task_id`, plus the per-message `iteration`, `final`, `mode`, and /// (for assistant tool rounds / tool results) `tool_calls` / `tool_call_id` / @@ -750,9 +750,7 @@ fn append_worker_message( sender: &str, metadata: serde_json::Value, ) { - use crate::openhuman::memory::conversations::{ - append_message, ConversationMessage as StoredMessage, - }; + use tinycortex::memory::conversations::{append_message, ConversationMessage as StoredMessage}; let mut extra = serde_json::json!({ "scope": "worker_thread", "agent_id": agent_id, diff --git a/src/openhuman/agent/learning/README.md b/src/openhuman/agent/learning/README.md index a0b8cce346..795d8edacf 100644 --- a/src/openhuman/agent/learning/README.md +++ b/src/openhuman/agent/learning/README.md @@ -68,7 +68,7 @@ Namespace `learning` (wired into `src/core/all.rs`; 11 controllers). Methods: | `learning.forget_facet` | Mark `Dropped` + `user_state = Forgotten` (blocks re-promotion). | | `learning.reset_cache` | Delete all `Auto` rows, preserve `Pinned`. | -All handlers go through the memory client's `profile_conn()` and a `FacetCache`; `linkedin_enrichment` / `save_profile` load config via `config::rpc::load_config_with_timeout`. +All handlers go through the memory client's `profile_store()` and a `FacetCache`; `linkedin_enrichment` / `save_profile` load config via `config::rpc::load_config_with_timeout`. ## Agent tools diff --git a/src/openhuman/agent/learning/cache.rs b/src/openhuman/agent/learning/cache.rs index 06ba93b4a7..0169accc8c 100644 --- a/src/openhuman/agent/learning/cache.rs +++ b/src/openhuman/agent/learning/cache.rs @@ -4,36 +4,33 @@ //! The stability detector uses this to persist the result of each rebuild cycle. //! Prompt sections use [`FacetCache::list_active`] to read the ambient cache. -use parking_lot::Mutex; -use rusqlite::Connection; -use std::sync::Arc; - use crate::openhuman::agent::learning::candidate::FacetClass; -use crate::openhuman::memory::store::profile::{self, ProfileFacet, UserState}; +use crate::openhuman::memory::store::profile::{ProfileFacet, UserState}; +use crate::openhuman::memory::store::ProfileStore; /// Thin wrapper around the `user_profile` table. /// -/// All methods delegate to the standalone helpers in -/// `memory_store::namespace_store::profile`. This type exists so callers -/// (stability detector, prompt sections, RPCs) share a single typed -/// entry-point that can be constructed from any `Arc>`. +/// A learning-side newtype over [`ProfileStore`], which owns the SQL. This +/// type exists because the class↔key vocabulary below (`FacetClass`) is agent +/// domain knowledge that must not move into the memory family; everything +/// else forwards straight to the store. pub struct FacetCache { - conn: Arc>, + store: ProfileStore, } impl FacetCache { - pub fn new(conn: Arc>) -> Self { - Self { conn } + pub fn new(store: ProfileStore) -> Self { + Self { store } } /// List all facets with `state = 'active'`, ordered by stability descending. pub fn list_active(&self) -> anyhow::Result> { - profile::profile_select_active(&self.conn) + self.store.list_active() } /// List all facets (all states), ordered by stability descending. pub fn list_all(&self) -> anyhow::Result> { - profile::profile_select_all(&self.conn) + self.store.list_all() } /// List active facets belonging to a specific class. @@ -50,31 +47,31 @@ impl FacetCache { /// Fetch a single facet by its full key (e.g. `"style/verbosity"`). pub fn get(&self, key: &str) -> anyhow::Result> { - profile::profile_get_by_key(&self.conn, key) + self.store.get(key) } /// Upsert a fully-formed facet row (rebuild path). pub fn upsert(&self, facet: &ProfileFacet) -> anyhow::Result<()> { - profile::profile_upsert_full(&self.conn, facet) + self.store.upsert_full(facet) } /// Override the `user_state` of a facet. /// /// Returns `Ok(true)` if a row was found and updated. pub fn set_user_state(&self, key: &str, user_state: UserState) -> anyhow::Result { - profile::profile_set_user_state(&self.conn, key, user_state) + self.store.set_user_state(key, user_state) } /// Delete a facet by key. Returns `true` if a row was removed. pub fn delete(&self, key: &str) -> anyhow::Result { - profile::profile_delete_by_key(&self.conn, key) + self.store.delete(key) } /// Delete all `Dropped`-state facets whose stability is below `threshold`. /// /// Pinned facets are never deleted. Returns the number of rows removed. pub fn drop_below_threshold(&self, threshold: f64) -> anyhow::Result { - profile::profile_delete_below_threshold(&self.conn, threshold) + self.store.drop_below_threshold(threshold) } } diff --git a/src/openhuman/agent/learning/cache_tests.rs b/src/openhuman/agent/learning/cache_tests.rs index 1301dfb3d6..04df23c020 100644 --- a/src/openhuman/agent/learning/cache_tests.rs +++ b/src/openhuman/agent/learning/cache_tests.rs @@ -13,7 +13,9 @@ use crate::openhuman::memory::store::profile::{ fn make_cache() -> FacetCache { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(PROFILE_INIT_SQL).unwrap(); - FacetCache::new(Arc::new(Mutex::new(conn))) + FacetCache::new(crate::openhuman::memory::store::ProfileStore::for_tests( + Arc::new(Mutex::new(conn)), + )) } fn stub_facet(id: &str, key: &str, value: &str, state: FacetState, stability: f64) -> ProfileFacet { diff --git a/src/openhuman/agent/learning/profile_md_renderer.rs b/src/openhuman/agent/learning/profile_md_renderer.rs index a6cb02f796..67a3b0518e 100644 --- a/src/openhuman/agent/learning/profile_md_renderer.rs +++ b/src/openhuman/agent/learning/profile_md_renderer.rs @@ -227,7 +227,9 @@ mod tests { use tempfile::TempDir; fn make_cache(conn: Arc>) -> Arc { - Arc::new(FacetCache::new(conn)) + Arc::new(FacetCache::new( + crate::openhuman::memory::store::ProfileStore::for_tests(conn), + )) } fn insert_facet( diff --git a/src/openhuman/agent/learning/prompt_sections.rs b/src/openhuman/agent/learning/prompt_sections.rs index e6d01d03da..725fa8d3dd 100644 --- a/src/openhuman/agent/learning/prompt_sections.rs +++ b/src/openhuman/agent/learning/prompt_sections.rs @@ -389,7 +389,9 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(PROFILE_INIT_SQL).unwrap(); - let cache = FacetCache::new(Arc::new(Mutex::new(conn))); + let cache = FacetCache::new(crate::openhuman::memory::store::ProfileStore::for_tests( + Arc::new(Mutex::new(conn)), + )); let make_facet = |id: &str, key: &str, value: &str, stab: f64| ProfileFacet { facet_id: id.into(), @@ -467,7 +469,9 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(PROFILE_INIT_SQL).unwrap(); - let cache = FacetCache::new(Arc::new(Mutex::new(conn))); + let cache = FacetCache::new(crate::openhuman::memory::store::ProfileStore::for_tests( + Arc::new(Mutex::new(conn)), + )); let result = load_learned_from_cache(&cache); assert!(result.is_empty()); diff --git a/src/openhuman/agent/learning/prompt_sections_tests.rs b/src/openhuman/agent/learning/prompt_sections_tests.rs index 17018d10ed..8aaf578400 100644 --- a/src/openhuman/agent/learning/prompt_sections_tests.rs +++ b/src/openhuman/agent/learning/prompt_sections_tests.rs @@ -15,7 +15,9 @@ use crate::openhuman::memory::store::profile::{ fn open_cache() -> FacetCache { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(PROFILE_INIT_SQL).unwrap(); - FacetCache::new(Arc::new(Mutex::new(conn))) + FacetCache::new(crate::openhuman::memory::store::ProfileStore::for_tests( + Arc::new(Mutex::new(conn)), + )) } fn make_active(id: &str, key: &str, value: &str, stability: f64) -> ProfileFacet { diff --git a/src/openhuman/agent/learning/schemas.rs b/src/openhuman/agent/learning/schemas.rs index 0c7ebb289f..354e91edea 100644 --- a/src/openhuman/agent/learning/schemas.rs +++ b/src/openhuman/agent/learning/schemas.rs @@ -660,8 +660,7 @@ fn handle_rebuild_cache(_params: Map) -> ControllerFuture { let client = crate::openhuman::memory::global::client_if_ready() .ok_or_else(|| "memory client not ready".to_string())?; - let conn = client.profile_conn(); - let cache = FacetCache::new(conn); + let cache = FacetCache::new(client.profile_store()); let detector = StabilityDetector::new(cache); let now = SystemTime::now() @@ -698,8 +697,7 @@ fn handle_cache_stats(_params: Map) -> ControllerFuture { let client = crate::openhuman::memory::global::client_if_ready() .ok_or_else(|| "memory client not ready".to_string())?; - let conn = client.profile_conn(); - let cache = FacetCache::new(conn); + let cache = FacetCache::new(client.profile_store()); let all_facets = cache .list_all() @@ -760,7 +758,7 @@ fn get_cache() -> Result StabilityDetector { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(PROFILE_INIT_SQL).unwrap(); - let cache = FacetCache::new(Arc::new(Mutex::new(conn))); + let cache = FacetCache::new(crate::openhuman::memory::store::ProfileStore::for_tests( + Arc::new(Mutex::new(conn)), + )); // Use a private buffer so tests don't interfere with the global singleton. let buffer: &'static Buffer = Box::leak(Box::new(Buffer::new(256))); StabilityDetector { cache, buffer } diff --git a/src/openhuman/agent/learning/startup.rs b/src/openhuman/agent/learning/startup.rs index b1988fe835..c5ad88361b 100644 --- a/src/openhuman/agent/learning/startup.rs +++ b/src/openhuman/agent/learning/startup.rs @@ -118,7 +118,7 @@ fn register_with_client( use crate::openhuman::agent::learning::scheduler::register_event_trigger; use crate::openhuman::agent::learning::StabilityDetector; use std::sync::Arc; - let cache = FacetCache::new(client.profile_conn()); + let cache = FacetCache::new(client.profile_store()); let detector = Arc::new(StabilityDetector::new(cache)); // Also spawn the periodic rebuild loop (30-minute cadence). let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); @@ -148,7 +148,7 @@ fn register_with_client( use crate::openhuman::agent::learning::cache::FacetCache; use crate::openhuman::agent::learning::ProfileMdRenderer; use std::sync::Arc; - let cache = Arc::new(FacetCache::new(client.profile_conn())); + let cache = Arc::new(FacetCache::new(client.profile_store())); let renderer = Arc::new(ProfileMdRenderer::new(cache, workspace_dir.to_path_buf())); let handle = ProfileMdRenderer::subscribe(renderer); if handle.is_some() { @@ -175,6 +175,7 @@ mod tests { use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; + use tinybus::EventBus; /// Build a real `MemoryClient` against a fresh temp workspace. The temp dir /// is returned so callers keep it alive for the client's lifetime. diff --git a/src/openhuman/agent/learning/tools.rs b/src/openhuman/agent/learning/tools.rs index 0afa14aeb9..106ebad87b 100644 --- a/src/openhuman/agent/learning/tools.rs +++ b/src/openhuman/agent/learning/tools.rs @@ -30,7 +30,7 @@ use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; fn get_cache() -> anyhow::Result { let client = crate::openhuman::memory::global::client_if_ready() .ok_or_else(|| anyhow::anyhow!("memory client not ready"))?; - Ok(FacetCache::new(client.profile_conn())) + Ok(FacetCache::new(client.profile_store())) } /// Compose the full facet key from a class string + key suffix. diff --git a/src/openhuman/agent/orchestration/tools/spawn_async_subagent.rs b/src/openhuman/agent/orchestration/tools/spawn_async_subagent.rs index c5a32fc7e0..939616335a 100644 --- a/src/openhuman/agent/orchestration/tools/spawn_async_subagent.rs +++ b/src/openhuman/agent/orchestration/tools/spawn_async_subagent.rs @@ -17,11 +17,11 @@ use crate::openhuman::agent::orchestration::subagent_sessions::{ SubagentSessionUpsert, }; use crate::openhuman::agent::progress::AgentProgress; -use crate::openhuman::memory::conversations::{self as conversations, ConversationMessage}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; use async_trait::async_trait; use serde_json::json; use tinyagents::harness::tool::ToolExecutionContext; +use tinycortex::memory::conversations::{self as conversations, ConversationMessage}; pub struct SpawnAsyncSubagentTool; @@ -1253,7 +1253,7 @@ mod tests { #[test] fn attach_workflow_proposal_persists_thread_message_and_extends_summary() { - use crate::openhuman::memory::conversations::CreateConversationThread; + use tinycortex::memory::conversations::CreateConversationThread; let temp = tempfile::tempdir().expect("tempdir"); conversations::ensure_thread( temp.path().to_path_buf(), diff --git a/src/openhuman/agent/orchestration/tools/spawn_subagent.rs b/src/openhuman/agent/orchestration/tools/spawn_subagent.rs index 7815889b63..b03b86bf15 100644 --- a/src/openhuman/agent/orchestration/tools/spawn_subagent.rs +++ b/src/openhuman/agent/orchestration/tools/spawn_subagent.rs @@ -18,14 +18,14 @@ use crate::openhuman::agent::harness::subagent_runner::{ run_subagent, SubagentRunOptions, SubagentRunOutcome, SubagentRunStatus, }; use crate::openhuman::agent::progress::AgentProgress; -use crate::openhuman::memory::conversations::{ - self as conversations, ConversationMessage, CreateConversationThread, -}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; use async_trait::async_trait; use serde_json::json; use std::path::PathBuf; use tinyagents::harness::tool::ToolExecutionContext; +use tinycortex::memory::conversations::{ + self as conversations, ConversationMessage, CreateConversationThread, +}; /// Spawns a sub-agent of the requested type to handle a delegated task. /// diff --git a/src/openhuman/agent/orchestration/tools/spawn_worker_thread.rs b/src/openhuman/agent/orchestration/tools/spawn_worker_thread.rs index 504ae7e3de..572b921683 100644 --- a/src/openhuman/agent/orchestration/tools/spawn_worker_thread.rs +++ b/src/openhuman/agent/orchestration/tools/spawn_worker_thread.rs @@ -12,11 +12,11 @@ use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::harness::fork_context::current_parent; use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions}; -use crate::openhuman::memory::conversations::{self as conversations}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; use async_trait::async_trait; use serde_json::json; use tinyagents::harness::tool::ToolExecutionContext; +use tinycortex::memory::conversations; /// Spawns a sub-agent in a dedicated worker thread. pub struct SpawnWorkerThreadTool; @@ -307,10 +307,10 @@ mod tests { use super::*; use crate::openhuman::agent::harness::fork_context::with_parent_context; use crate::openhuman::agent::harness::ParentExecutionContext; - use crate::openhuman::memory::conversations::CreateConversationThread; use std::path::PathBuf; use std::sync::Arc; use tempfile::TempDir; + use tinycortex::memory::conversations::CreateConversationThread; struct MockMemory; #[async_trait] diff --git a/src/openhuman/agent/orchestration/tools/tools_e2e_tests.rs b/src/openhuman/agent/orchestration/tools/tools_e2e_tests.rs index 9bf552e940..75270fb3dc 100644 --- a/src/openhuman/agent/orchestration/tools/tools_e2e_tests.rs +++ b/src/openhuman/agent/orchestration/tools/tools_e2e_tests.rs @@ -5,7 +5,6 @@ use crate::openhuman::agent::context::prompt::{ConnectedIntegration, ToolCallFor use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::harness::{with_parent_context, ParentExecutionContext}; use crate::openhuman::agent::messages::ChatMessage; -use crate::openhuman::memory::conversations; use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}; use crate::openhuman::tools::Tool; use async_trait::async_trait; @@ -15,6 +14,7 @@ use std::path::Path; use std::sync::Arc; use tinyagents::harness::message::Message; use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse}; +use tinycortex::memory::conversations; const SPAWN_SUBAGENT_CANARY: &str = "tool-e2e-spawn-subagent-canary"; const ARCHETYPE_DELEGATION_CANARY: &str = "tool-e2e-archetype-delegation-canary"; diff --git a/src/openhuman/agent/orchestration/tools/worker_thread.rs b/src/openhuman/agent/orchestration/tools/worker_thread.rs index 80204b8a94..dcf219766b 100644 --- a/src/openhuman/agent/orchestration/tools/worker_thread.rs +++ b/src/openhuman/agent/orchestration/tools/worker_thread.rs @@ -14,7 +14,7 @@ use std::path::PathBuf; use serde_json::json; -use crate::openhuman::memory::conversations::{ +use tinycortex::memory::conversations::{ self as conversations, ConversationMessage, CreateConversationThread, }; diff --git a/src/openhuman/agent/task_session.rs b/src/openhuman/agent/task_session.rs index 8d3d3ceec3..21ca1cb879 100644 --- a/src/openhuman/agent/task_session.rs +++ b/src/openhuman/agent/task_session.rs @@ -29,7 +29,7 @@ use std::path::PathBuf; use serde_json::json; use crate::openhuman::agent::task_board::TaskBoardCard; -use crate::openhuman::memory::conversations::{ +use tinycortex::memory::conversations::{ self as conversations, ConversationMessage, CreateConversationThread, }; diff --git a/src/openhuman/channels/host/adapters.rs b/src/openhuman/channels/host/adapters.rs index 4b5b7f5600..35e9a6a524 100644 --- a/src/openhuman/channels/host/adapters.rs +++ b/src/openhuman/channels/host/adapters.rs @@ -217,7 +217,7 @@ impl ConversationStore for ConversationHistoryStore { session_key: &str, limit: usize, ) -> anyhow::Result> { - let messages = crate::openhuman::memory::conversations::get_messages( + let messages = tinycortex::memory::conversations::get_messages( self.workspace_dir.clone(), session_key, ) @@ -236,9 +236,9 @@ impl ConversationStore for ConversationHistoryStore { async fn append(&self, session_key: &str, message: ConversationMessage) -> anyhow::Result<()> { let now = chrono::Utc::now().to_rfc3339(); // `append_message` requires the thread to exist; create-or-noop first. - crate::openhuman::memory::conversations::ensure_thread( + tinycortex::memory::conversations::ensure_thread( self.workspace_dir.clone(), - crate::openhuman::memory::conversations::CreateConversationThread { + tinycortex::memory::conversations::CreateConversationThread { id: session_key.to_string(), title: session_key.to_string(), created_at: now.clone(), @@ -248,7 +248,7 @@ impl ConversationStore for ConversationHistoryStore { }, ) .map_err(|e| anyhow::anyhow!(e))?; - let stored = crate::openhuman::memory::conversations::ConversationMessage { + let stored = tinycortex::memory::conversations::ConversationMessage { id: uuid::Uuid::new_v4().to_string(), content: message.content, message_type: message.role.clone(), @@ -256,7 +256,7 @@ impl ConversationStore for ConversationHistoryStore { sender: message.role, created_at: now, }; - crate::openhuman::memory::conversations::append_message( + tinycortex::memory::conversations::append_message( self.workspace_dir.clone(), session_key, stored, diff --git a/src/openhuman/channels/providers/telegram/remote_control.rs b/src/openhuman/channels/providers/telegram/remote_control.rs index 0a6265f0fb..2bd811b904 100644 --- a/src/openhuman/channels/providers/telegram/remote_control.rs +++ b/src/openhuman/channels/providers/telegram/remote_control.rs @@ -5,7 +5,7 @@ use crate::openhuman::channels::context::{ clear_sender_history, conversation_history_key, ChannelRouteSelection, ChannelRuntimeContext, }; use crate::openhuman::channels::traits::ChannelMessage; -use crate::openhuman::memory::conversations::{ +use tinycortex::memory::conversations::{ self as conversations, ConversationThread, CreateConversationThread, }; diff --git a/src/openhuman/desktop/app_state/ops.rs b/src/openhuman/desktop/app_state/ops.rs index 2f144d86f0..51ca528c23 100644 --- a/src/openhuman/desktop/app_state/ops.rs +++ b/src/openhuman/desktop/app_state/ops.rs @@ -511,7 +511,7 @@ async fn activate_revalidated_user_dir(user_id: &str) -> Result ); if previous_active.is_none() { let pre_ws = crate::openhuman::config::pre_login_user_dir(&root_dir).join("workspace"); - if let Err(error) = crate::openhuman::memory::conversations::purge_threads(pre_ws) { + if let Err(error) = tinycortex::memory::conversations::purge_threads(pre_ws) { debug!( "{LOG_PREFIX} pre-login conversation purge skipped after pending session revalidation: {error}" ); diff --git a/src/openhuman/memory/agent/memory_loader.rs b/src/openhuman/memory/agent/memory_loader.rs index d38a6592b9..18d9259127 100644 --- a/src/openhuman/memory/agent/memory_loader.rs +++ b/src/openhuman/memory/agent/memory_loader.rs @@ -929,7 +929,7 @@ mod tests { /// actually run. #[tokio::test] async fn loader_surfaces_jsonl_primary_path_with_workspace_dir() { - use crate::openhuman::memory::conversations::{ + use tinycortex::memory::conversations::{ ConversationMessage, ConversationStore, CreateConversationThread, }; diff --git a/src/openhuman/memory/bypass_allowlist_tests.rs b/src/openhuman/memory/bypass_allowlist_tests.rs index cc86fe55d6..9d3fcfbc3c 100644 --- a/src/openhuman/memory/bypass_allowlist_tests.rs +++ b/src/openhuman/memory/bypass_allowlist_tests.rs @@ -7,12 +7,16 @@ //! pretend otherwise. `MemoryClient` still hands out raw, undecoratable //! handles, all `pub(crate)` and all with live production callers: //! -//! - `profile_conn()` (`memory/store/client.rs`) — an -//! `Arc>`. **No decorator can wrap a raw SQLite -//! connection**, so the eleven non-test call sites beneath -//! `agent/learning/*` and `memory/sync/composio/providers/profile.rs` reach -//! the profile/facet tables under all of the guard's policy steps. Closing -//! this is explicitly out of scope for M4. +//! - `profile_store()` (`memory/store/client.rs`) — a typed `ProfileStore` +//! over the profile/facet tables. `profile_conn()`, the raw +//! `Arc>` it used to hand out, is now +//! `pub(in crate::openhuman::memory)` with a single in-family caller, so +//! every SQL statement against `user_profile` lives inside the memory +//! family and the compiler keeps it there. **This did not put the profile +//! tables under the guard**: the contract has no profile/facet capability +//! family, so these reads and writes still run beneath all seven policy +//! steps. The `.profile_store(` needle exists to keep that fact counted +//! rather than renamed away. //! - `memory_handle()` (`memory/store/client.rs`) — a raw `Arc`. //! The contract has no `Arc` door, so consumers that must satisfy //! a foreign trait (tinyflows, the agent-experience store) still take it. @@ -89,6 +93,10 @@ const BYPASS_PATTERNS: &[(&str, &str)] = &[ ".profile_conn(", "raw rusqlite connection — undecoratable by construction", ), + ( + ".profile_store(", + "typed profile store — the profile/facet tables have no capability family, so these reads and writes still skip the guard's seven steps", + ), ( ".memory_handle(", "raw Arc — bypasses the MemoryClient API surface", @@ -179,16 +187,16 @@ const ALLOWED: &[(&str, &str, &str)] = &[ ".memory_handle(", "session builder needs Arc; no contract door for it", ), - // ── Unguardable raw SQLite (profile_conn) — the known hole ── + // ── Unguarded (but no longer raw) profile/facet access ── ( "src/openhuman/agent/learning/schemas.rs", - ".profile_conn(", - "raw SQLite profile/facet reads; undecoratable, out of scope for M4", + ".profile_store(", + "typed profile/facet reads; the contract has no profile family, so still unguarded", ), ( "src/openhuman/agent/learning/schemas.rs", "global::client_if_ready(", - "resolved only to reach profile_conn() on the line below", + "resolved only to reach profile_store() on the line below", ), ( "src/openhuman/agent/learning/startup.rs", @@ -197,18 +205,18 @@ const ALLOWED: &[(&str, &str, &str)] = &[ ), ( "src/openhuman/agent/learning/startup.rs", - ".profile_conn(", - "raw SQLite facet bootstrap; undecoratable, out of scope for M4", + ".profile_store(", + "typed facet bootstrap; the contract has no profile family, so still unguarded", ), ( "src/openhuman/agent/learning/tools.rs", - ".profile_conn(", - "raw SQLite facet read from an agent tool; undecoratable, out of scope for M4", + ".profile_store(", + "typed facet read from an agent tool; the contract has no profile family", ), ( "src/openhuman/agent/learning/tools.rs", "global::client_if_ready(", - "resolved only to reach profile_conn() on the line below", + "resolved only to reach profile_store() on the line below", ), // ── Flows: foreign trait shapes and a test-override seam ── ( @@ -354,16 +362,21 @@ const ALLOWED: &[(&str, &str, &str)] = &[ "active_memory_client(", "tool_rule_put/get/*_json/*_for_prompt have no contract equivalent", ), - // ── Composio memory sync: profile_conn + &MemoryClientRef ── ( - "src/openhuman/memory/sync/composio/providers/profile.rs", + "src/openhuman/memory/store/client.rs", ".profile_conn(", - "raw SQLite profile writes; undecoratable, out of scope for M4", + "sole in-family call; wraps the raw handle in ProfileStore. profile_conn is pub(in crate::openhuman::memory), so the compiler — not this lint — is the primary enforcement", + ), + // ── Composio memory sync: profile_store + &MemoryClientRef ── + ( + "src/openhuman/memory/sync/composio/providers/profile.rs", + ".profile_store(", + "typed profile writes; the contract has no profile family, so still unguarded", ), ( "src/openhuman/memory/sync/composio/providers/profile.rs", "global::client_if_ready(", - "resolved only to reach profile_conn()", + "resolved only to reach profile_store()", ), ( "src/openhuman/memory/sync/composio/providers/types.rs", @@ -404,27 +417,6 @@ const ALLOWED: &[(&str, &str, &str)] = &[ "global::client_if_ready(", "the TinyCortex engine seam; it sits beneath the contract, not above it", ), - // ── Agent tools — deferred to M5's capability filter ── - ( - "src/openhuman/memory/tool_memory/tools/list.rs", - ".memory_handle(", - "builds ToolMemoryStore; re-pointable via as_tool_memory(), deferred to M5", - ), - ( - "src/openhuman/memory/tool_memory/tools/list.rs", - "active_memory_client(", - "same tool; M5 filters the tool surface by capability and would collide", - ), - ( - "src/openhuman/memory/tool_memory/tools/put.rs", - ".memory_handle(", - "builds ToolMemoryStore; re-pointable via as_tool_memory(), deferred to M5", - ), - ( - "src/openhuman/memory/tool_memory/tools/put.rs", - "active_memory_client(", - "same tool; M5 filters the tool surface by capability and would collide", - ), ]; /// True for source files the lint deliberately does not scan. @@ -505,9 +497,10 @@ fn render(pairs: impl IntoIterator) -> String { /// A parser that silently found nothing would turn every other test here into a /// rubber stamp, so refuse to pass vacuously. /// -/// The literal pinned below is the densest known bypass in the tree: five -/// `profile_conn()` calls reaching raw SQLite. If the scanner ever stops seeing -/// it, the scanner is broken — fix it, do not relax this assertion. +/// The literal pinned below is `profile_store()`'s own construction site — a +/// call inside the module that defines the method, so it is the most stable +/// pair available. If the scanner ever stops seeing it, the scanner is broken — +/// fix it, do not relax this assertion. #[test] fn bypass_scanner_finds_the_known_bypasses() { let found = scan(); @@ -517,7 +510,7 @@ fn bypass_scanner_finds_the_known_bypasses() { module would pass vacuously. Fix the scanner, not the assertion." ); let canary = ( - "src/openhuman/memory/sync/composio/providers/profile.rs".to_string(), + "src/openhuman/memory/store/client.rs".to_string(), ".profile_conn(".to_string(), ); assert!( diff --git a/src/openhuman/memory/conversations/blocking.rs b/src/openhuman/memory/conversations/blocking.rs index 9cd13a83c3..eda10aaa8b 100644 --- a/src/openhuman/memory/conversations/blocking.rs +++ b/src/openhuman/memory/conversations/blocking.rs @@ -37,7 +37,7 @@ use std::path::PathBuf; use tinycortex::memory::conversations as store; -use super::{ +use tinycortex::memory::conversations::{ ConversationMessage, ConversationMessagePatch, ConversationPurgeStats, ConversationStore, ConversationThread, CreateConversationThread, CrossThreadHit, }; diff --git a/src/openhuman/memory/conversations/bus.rs b/src/openhuman/memory/conversations/bus.rs index 864246869f..890ec1d789 100644 --- a/src/openhuman/memory/conversations/bus.rs +++ b/src/openhuman/memory/conversations/bus.rs @@ -15,7 +15,7 @@ use tinybus::SubscriptionHandle; use tinychannels::context::conversation_history_key; use tinychannels::ChannelMessage; -use super::{ +use tinycortex::memory::conversations::{ append_message, ensure_thread, get_messages, ConversationMessage, CreateConversationThread, }; @@ -416,12 +416,16 @@ mod tests { }) .await; - let threads = super::super::list_threads(temp.path().to_path_buf()).expect("threads"); + let threads = tinycortex::memory::conversations::list_threads(temp.path().to_path_buf()) + .expect("threads"); assert_eq!(threads.len(), 1); assert_eq!(threads[0].id, "channel:slack_alice_general_thread:thread-1"); - let messages = super::super::get_messages(temp.path().to_path_buf(), &threads[0].id) - .expect("messages"); + let messages = tinycortex::memory::conversations::get_messages( + temp.path().to_path_buf(), + &threads[0].id, + ) + .expect("messages"); assert_eq!(messages.len(), 2); assert_eq!(messages[0].id, "user:m1"); assert_eq!(messages[0].sender, "user"); @@ -467,7 +471,8 @@ mod tests { }) .await; - let threads = super::super::list_threads(temp.path().to_path_buf()).expect("threads"); + let threads = tinycortex::memory::conversations::list_threads(temp.path().to_path_buf()) + .expect("threads"); assert_eq!(threads.len(), 1); assert_eq!(threads[0].id, "channel:telegram_alice_chat-1"); } @@ -491,9 +496,11 @@ mod tests { subscriber.handle(&event).await; subscriber.handle(&event).await; - let messages = - super::super::get_messages(temp.path().to_path_buf(), "channel:discord_alice_room-1") - .expect("messages"); + let messages = tinycortex::memory::conversations::get_messages( + temp.path().to_path_buf(), + "channel:discord_alice_room-1", + ) + .expect("messages"); assert_eq!(messages.len(), 1); assert_eq!(messages[0].id, "user:m1"); } @@ -546,9 +553,11 @@ mod tests { }) .await; - let messages = - super::super::get_messages(temp.path().to_path_buf(), "channel:slack_bob_dev") - .expect("messages"); + let messages = tinycortex::memory::conversations::get_messages( + temp.path().to_path_buf(), + "channel:slack_bob_dev", + ) + .expect("messages"); assert_eq!(messages.len(), 1); assert_eq!(messages[0].id, "user:m1"); } @@ -575,7 +584,8 @@ mod tests { .await; // No thread should have been created in temp (the subscriber's workspace). - let threads = super::super::list_threads(temp.path().to_path_buf()).expect("threads"); + let threads = tinycortex::memory::conversations::list_threads(temp.path().to_path_buf()) + .expect("threads"); assert!( threads.is_empty(), "stale-workspace event must not create a thread" @@ -620,9 +630,11 @@ mod tests { }) .await; - let messages = - super::super::get_messages(temp.path().to_path_buf(), "channel:slack_alice_general") - .expect("messages"); + let messages = tinycortex::memory::conversations::get_messages( + temp.path().to_path_buf(), + "channel:slack_alice_general", + ) + .expect("messages"); assert_eq!(messages.len(), 2); assert_eq!(messages[1].id, "assistant:m1"); } @@ -668,9 +680,11 @@ mod tests { }) .await; - let messages = - super::super::get_messages(temp.path().to_path_buf(), "channel:slack_alice_general") - .expect("messages"); + let messages = tinycortex::memory::conversations::get_messages( + temp.path().to_path_buf(), + "channel:slack_alice_general", + ) + .expect("messages"); // Only the user turn should be present; the stale processed event must be dropped. assert_eq!(messages.len(), 1); assert_eq!(messages[0].id, "user:m1"); @@ -738,7 +752,7 @@ mod tests { }) .await; - let messages = super::super::get_messages( + let messages = tinycortex::memory::conversations::get_messages( workspace_a.path().to_path_buf(), "channel:telegram_alice_chat-1", ) @@ -778,7 +792,8 @@ mod tests { .await; } - let threads = super::super::list_threads(temp.path().to_path_buf()).expect("threads"); + let threads = tinycortex::memory::conversations::list_threads(temp.path().to_path_buf()) + .expect("threads"); assert!( threads.is_empty(), "no events from wrong workspaces should create a thread" @@ -821,9 +836,11 @@ mod tests { }) .await; - let messages = - super::super::get_messages(temp.path().to_path_buf(), "channel:slack_alice_general") - .expect("messages"); + let messages = tinycortex::memory::conversations::get_messages( + temp.path().to_path_buf(), + "channel:slack_alice_general", + ) + .expect("messages"); assert_eq!( messages.len(), 1, diff --git a/src/openhuman/memory/conversations/mod.rs b/src/openhuman/memory/conversations/mod.rs index f1b3cb29a6..c293ea8d93 100644 --- a/src/openhuman/memory/conversations/mod.rs +++ b/src/openhuman/memory/conversations/mod.rs @@ -1,26 +1,20 @@ -//! Workspace-backed conversation thread/message storage for the desktop UI — -//! thin host shim over `tinycortex::memory::conversations` (W7). +//! Host-side wiring for workspace-backed conversation thread/message storage. //! //! Conversations are stored as JSONL files under the workspace (thread metadata //! append-only in `threads.jsonl`; each thread's messages in a dedicated JSONL //! file). The store / inverted-index / tokenizer / types engine is the crate's -//! (a byte-identical port, incl. the D1 rank-before-materialize fix); this -//! module re-exports that surface so the ~30 host consumers -//! (`openhuman::memory` re-exports it as `memory::conversations`, plus jsonrpc, -//! agent orchestration, agent_memory, threads, channels) keep their import paths -//! and identical `Result<_, String>` / on-disk behaviour unchanged. +//! (a byte-identical port, incl. the D1 rank-before-materialize fix), and +//! consumers name `tinycortex::memory::conversations` directly — this module no +//! longer re-exports that surface under a second path. //! -//! Host-retained: [`bus`] — the `core::bus` persistence subscriber that -//! bridges typed channel events onto the crate store (the crate abstracts the -//! bus behind its own `ConversationEventBus` trait; the host wires the real one). +//! Host-retained: +//! - [`bus`] — the `core::bus` persistence subscriber that bridges typed channel +//! events onto the crate store (the crate abstracts the bus behind its own +//! `ConversationEventBus` trait; the host wires the real one). +//! - [`blocking`] — `spawn_blocking` wrappers around the store's synchronous +//! entry points. Request paths must use these, never the sync API (#5156). pub mod blocking; mod bus; pub use bus::register_conversation_persistence_subscriber; -pub use tinycortex::memory::conversations::{ - append_message, delete_thread, ensure_thread, get_messages, list_threads, purge_threads, - update_message, update_thread_labels, update_thread_title, ConversationMessage, - ConversationMessagePatch, ConversationPurgeStats, ConversationStore, ConversationThread, - CreateConversationThread, CrossThreadHit, -}; diff --git a/src/openhuman/memory/diff/mod.rs b/src/openhuman/memory/diff/mod.rs index a99565ad97..30e8357766 100644 --- a/src/openhuman/memory/diff/mod.rs +++ b/src/openhuman/memory/diff/mod.rs @@ -17,8 +17,10 @@ //! `tinycortex::memory::diff::DiffEngine` (a byte-identical port over the same //! `/memory_diff/repo` git layout). This module is a thin host shim: //! [`ops`] async-wraps the engine, [`source`] supplies the chunk-store item -//! seam (`DiffEngine`'s `SnapshotItemSource`), [`types`] re-exports the crate -//! wire types, and [`rpc`]/[`schemas`]/[`tools`] keep the RPC + agent surface. +//! seam (`DiffEngine`'s `SnapshotItemSource`), and [`rpc`]/[`schemas`]/[`tools`] +//! keep the RPC + agent surface. The wire types are the crate's, named directly +//! (`tinycortex::memory::diff::types`) rather than through a host re-export +//! module. //! //! Features: //! - Per-source snapshots (auto after sync, or manual via RPC) @@ -59,7 +61,6 @@ pub mod schemas; pub mod source; #[cfg(feature = "memory-git")] pub mod tools; -pub mod types; #[cfg(not(feature = "memory-git"))] mod stub; @@ -71,9 +72,8 @@ pub use schemas::{ all_controller_schemas as all_memory_diff_controller_schemas, all_registered_controllers as all_memory_diff_registered_controllers, }; -#[cfg(feature = "memory-git")] -pub use tools::MemoryDiffTool; -pub use types::{ +pub use tinycortex::memory::diff::types::{ ChangeKind, Checkpoint, CrossSourceDiff, DiffResult, DiffSummary, ItemChange, Snapshot, SnapshotTrigger, }; +pub use tools::MemoryDiffTool; diff --git a/src/openhuman/memory/diff/ops.rs b/src/openhuman/memory/diff/ops.rs index 3236e38c5b..9e9ed5aebc 100644 --- a/src/openhuman/memory/diff/ops.rs +++ b/src/openhuman/memory/diff/ops.rs @@ -16,7 +16,7 @@ use crate::openhuman::memory::sources::types::MemorySourceEntry; use tinycortex::memory::diff::{DiffEngine, SourceDescriptor}; use super::source::ChunkStoreItemSource; -use super::types::*; +use tinycortex::memory::diff::types::*; /// A crate [`SourceDescriptor`] from a host source entry. fn descriptor(source: &MemorySourceEntry) -> SourceDescriptor { diff --git a/src/openhuman/memory/diff/rpc.rs b/src/openhuman/memory/diff/rpc.rs index 88ab475083..3fdf97b27f 100644 --- a/src/openhuman/memory/diff/rpc.rs +++ b/src/openhuman/memory/diff/rpc.rs @@ -9,7 +9,7 @@ use crate::rpc::RpcOutcome; use tinycortex::memory::diff::Ledger; use super::ops; -use super::types::*; +use tinycortex::memory::diff::types::*; // ── Request / Response types ────────────────────────────────────────── diff --git a/src/openhuman/memory/diff/tools.rs b/src/openhuman/memory/diff/tools.rs index b3c2d2d4d0..1c785873c9 100644 --- a/src/openhuman/memory/diff/tools.rs +++ b/src/openhuman/memory/diff/tools.rs @@ -11,7 +11,7 @@ use crate::openhuman::config::rpc as config_rpc; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use super::ops; -use super::types::*; +use tinycortex::memory::diff::types::*; pub struct MemoryDiffTool; diff --git a/src/openhuman/memory/diff/types.rs b/src/openhuman/memory/diff/types.rs deleted file mode 100644 index 6459298b80..0000000000 --- a/src/openhuman/memory/diff/types.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Domain types for snapshot-based memory-source change tracking — thin host -//! re-export of `tinycortex::memory::diff` types (W7). -//! -//! These are the published RPC/tool wire contract (serde `snake_case` enums + -//! stable field names). The crate port preserves them byte-for-byte, so the -//! host simply re-exports the crate types; the external consumers -//! (`memory_diff::rpc`/`tools`, `subconscious::profiles::memory`, and the RPC -//! controller schemas in `schemas.rs` which reference them by name) keep their -//! `memory_diff::types::*` import paths unchanged. -//! -//! Note: the host types formerly derived `schemars::JsonSchema`, but the RPC -//! surface is described by hand-written [`super::schemas`] (`TypeSchema::Ref` -//! strings), not derived schemas — so the derive was vestigial and its loss is -//! immaterial. - -pub use tinycortex::memory::diff::{ - ChangeKind, Checkpoint, CrossSourceDiff, DiffResult, DiffSummary, ItemChange, Snapshot, - SnapshotTrigger, -}; diff --git a/src/openhuman/memory/driver/embedded/diff.rs b/src/openhuman/memory/driver/embedded/diff.rs index 2d009170a6..5962b8e99b 100644 --- a/src/openhuman/memory/driver/embedded/diff.rs +++ b/src/openhuman/memory/driver/embedded/diff.rs @@ -48,10 +48,10 @@ use tinycortex_api::provider::types::{ChangeKind, DiffReport, SnapshotRef, Sourc use tinycortex_api::provider::MemoryDiff; use crate::openhuman::memory::diff::ops; -use crate::openhuman::memory::diff::types::{ +use crate::openhuman::memory::sources::registry; +use tinycortex::memory::diff::types::{ ChangeKind as EngineChangeKind, DiffResult, ItemChange, Snapshot, SnapshotTrigger, }; -use crate::openhuman::memory::sources::registry; use super::{host_error, EmbeddedMemoryProvider}; diff --git a/src/openhuman/memory/driver/embedded/goals.rs b/src/openhuman/memory/driver/embedded/goals.rs index 6175c42c91..ca5cfa6a8b 100644 --- a/src/openhuman/memory/driver/embedded/goals.rs +++ b/src/openhuman/memory/driver/embedded/goals.rs @@ -9,12 +9,12 @@ //! ever forks the type, this file should stop compiling here rather than //! somewhere confusing. //! -//! ## Both directions go through the host store, not the engine +//! ## Both directions go through the engine store //! -//! `store::load` / `store::save` are the host's own thin wrappers over the -//! engine's goals store. They own the on-disk location -//! (`/MEMORY_GOALS.md`) and the item/character caps, and going -//! through them keeps this driver from being a second place that knows either. +//! `store::load` / `store::save` are `tinycortex::memory::goals::store`. They +//! own the on-disk location (`/MEMORY_GOALS.md`) and the +//! item/character caps, and going through them keeps this driver from being a +//! second place that knows either. //! //! ## `set_goals` takes ownership; `save` needs `&mut` //! @@ -27,20 +27,19 @@ //! ## Why nothing maps to [`MemoryError::Invalid`] //! //! The contract reserves `Invalid` for "a document the driver refuses (e.g. -//! over its own item cap)". The engine *does* have those rejections, but -//! `goals::store` flattens every engine error to `String` via `to_string()`, so -//! by the time it reaches this file nothing is machine-readable. String-matching -//! the message to recover the class would be worse than the honest -//! [`MemoryError::Other`]: it would silently reclassify on any wording change. -//! Making this typed needs `goals/store.rs` to stop flattening, which is a host -//! change outside this step. +//! over its own item cap)". The engine *does* have those rejections, and now +//! that the host shim is gone they arrive here as a typed +//! `tinycortex::memory::error::MemoryError`. This file still flattens them with +//! `to_string()` into [`MemoryError::Other`], because the facade collapse is a +//! pure relocation; mapping engine `Invalid`/`NotFound` onto the contract's +//! variants is a behaviour change and is tracked separately. use async_trait::async_trait; use tinycortex_api::error::MemoryError; use tinycortex_api::goals::GoalsDoc; use tinycortex_api::provider::MemoryGoals; -use crate::openhuman::memory::goals::store; +use tinycortex::memory::goals::store; use super::{host_error, EmbeddedMemoryProvider}; @@ -54,9 +53,7 @@ impl MemoryGoals for EmbeddedMemoryProvider { // A missing `MEMORY_GOALS.md` maps to an empty document inside // `store::load`, so the contract's "no goals is not NotFound" rule // holds without anything here. - store::load(self.workspace_dir()) - .await - .map_err(|error| host_error("goals", error)) + store::load(self.workspace_dir()).map_err(|error| host_error("goals", error.to_string())) } async fn set_goals(&self, goals: GoalsDoc) -> Result<(), MemoryError> { @@ -67,8 +64,7 @@ impl MemoryGoals for EmbeddedMemoryProvider { doc.items.len() ); store::save(self.workspace_dir(), &mut doc) - .await - .map_err(|error| host_error("set_goals", error)) + .map_err(|error| host_error("set_goals", error.to_string())) } } diff --git a/src/openhuman/memory/goals/enrich.rs b/src/openhuman/memory/goals/enrich.rs index 38f7fd780f..0d1fa319d0 100644 --- a/src/openhuman/memory/goals/enrich.rs +++ b/src/openhuman/memory/goals/enrich.rs @@ -15,11 +15,11 @@ use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; -use super::store; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin, TrustedAutomationSource}; use crate::openhuman::agent::Agent; use crate::openhuman::config::Config; +use tinycortex::memory::goals::store; /// Registry id of the bundled goals enrichment agent definition. pub const GOALS_AGENT_ID: &str = "goals_agent"; @@ -66,9 +66,7 @@ pub async fn enrich_goals( ) -> Result { // Surface real storage failures instead of masking them as an empty // first-run doc — `load` already maps a missing file to an empty doc. - let doc = store::load(workspace_dir) - .await - .map_err(|e| format!("goals load failed: {e}"))?; + let doc = store::load(workspace_dir).map_err(|e| format!("goals load failed: {e}"))?; let first_run = doc.is_empty(); log::info!( "[memory_goals] enrich start (first_run={first_run}, existing_items={})", diff --git a/src/openhuman/memory/goals/mod.rs b/src/openhuman/memory/goals/mod.rs index 84ecab9fa0..2322b2c96a 100644 --- a/src/openhuman/memory/goals/mod.rs +++ b/src/openhuman/memory/goals/mod.rs @@ -14,20 +14,15 @@ //! - **Automatically** — the reflection agent is fired (best-effort) when the //! conversation context is summarized; see the archivist segment-close hook. //! -//! Persistence + cap enforcement live in [`store`]; the file is stored state, +//! Persistence + cap enforcement live in `tinycortex::memory::goals::store`; +//! the file is stored state, //! not injected into the main system prompt. pub mod enrich; pub mod ops; mod schemas; -pub mod store; pub mod tools; pub use enrich::{enrich_goals, spawn_enrich_goals, GOALS_AGENT_ID}; pub use schemas::{all_memory_goals_controller_schemas, all_memory_goals_registered_controllers}; pub use tools::{GoalsAddTool, GoalsDeleteTool, GoalsEditTool, GoalsListTool}; -// W7: goal item/doc types are the crate's (byte-identical `MEMORY_GOALS.md` -// render/parse); the host `types.rs` engine was deleted. Consumers use only -// `.items` / `.render()` / `.is_empty()` / `.len()`, all present on the crate -// type, so re-exporting is transparent. -pub use tinycortex::memory::goals::types::{GoalItem, GoalsDoc}; diff --git a/src/openhuman/memory/goals/ops.rs b/src/openhuman/memory/goals/ops.rs index 9a4c52ff19..354934506e 100644 --- a/src/openhuman/memory/goals/ops.rs +++ b/src/openhuman/memory/goals/ops.rs @@ -6,10 +6,10 @@ use std::path::Path; use serde::Serialize; -use super::store; -use super::GoalsDoc; use crate::openhuman::config::Config; use crate::rpc::RpcOutcome; +use tinycortex::memory::goals::store; +use tinycortex_api::goals::GoalsDoc; /// Result of an add operation: the new id plus the full updated list. #[derive(Debug, Serialize)] @@ -32,14 +32,14 @@ pub struct ReflectResult { /// List the current goals. pub async fn list(workspace_dir: &Path) -> Result, String> { log::debug!("[memory_goals] rpc=list"); - let doc = store::load(workspace_dir).await?; + let doc = store::load(workspace_dir).map_err(|e| e.to_string())?; Ok(RpcOutcome::new(doc, vec![])) } /// Add a goal and return the new id + updated list. pub async fn add(workspace_dir: &Path, text: &str) -> Result, String> { log::debug!("[memory_goals] rpc=add"); - let (id, goals) = store::add(workspace_dir, text).await?; + let (id, goals) = store::add(workspace_dir, text).map_err(|e| e.to_string())?; Ok(RpcOutcome::single_log( AddResult { id: id.clone(), @@ -56,14 +56,14 @@ pub async fn edit( text: &str, ) -> Result, String> { log::debug!("[memory_goals] rpc=edit id={id}"); - let goals = store::edit(workspace_dir, id, text).await?; + let goals = store::edit(workspace_dir, id, text).map_err(|e| e.to_string())?; Ok(RpcOutcome::single_log(goals, format!("edited goal {id}"))) } /// Delete a goal and return the updated list. pub async fn delete(workspace_dir: &Path, id: &str) -> Result, String> { log::debug!("[memory_goals] rpc=delete id={id}"); - let goals = store::delete(workspace_dir, id).await?; + let goals = store::delete(workspace_dir, id).map_err(|e| e.to_string())?; Ok(RpcOutcome::single_log(goals, format!("deleted goal {id}"))) } @@ -89,7 +89,7 @@ pub async fn reflect_now( Ok(s) => s, Err(e) => { log::warn!("[memory_goals] reflect failed: {e}"); - let goals = store::load(&workspace_dir).await.unwrap_or_default(); + let goals = store::load(&workspace_dir).unwrap_or_default(); return Ok(RpcOutcome::single_log( ReflectResult { ran: false, @@ -101,7 +101,7 @@ pub async fn reflect_now( } }; - let goals = store::load(&workspace_dir).await.unwrap_or_default(); + let goals = store::load(&workspace_dir).unwrap_or_default(); Ok(RpcOutcome::single_log( ReflectResult { ran: true, diff --git a/src/openhuman/memory/goals/store.rs b/src/openhuman/memory/goals/store.rs deleted file mode 100644 index e1d8dd3942..0000000000 --- a/src/openhuman/memory/goals/store.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Persistence for the long-term goals list — thin host shim over -//! `tinycortex::memory::goals::store` (W7). -//! -//! The engine (read / write / mutate / cap of `MEMORY_GOALS.md`) is the crate's. -//! These wrappers keep the host's `async` + `Result<_, String>` signatures so -//! the RPC ops, agent tools, and the reflection (`enrich`) caller are unchanged. -//! On-disk layout is identical: `/MEMORY_GOALS.md` in the -//! workspace root (`GOALS_FILE`), with the same render/parse format. - -use std::path::{Path, PathBuf}; - -use tinycortex::memory::goals::store as engine; -use tinycortex::memory::goals::types::GoalsDoc; - -pub use engine::{GOALS_FILE, GOALS_FILE_MAX_CHARS, GOALS_MAX_ITEMS}; - -/// Absolute path to `MEMORY_GOALS.md` within `workspace_dir`. -pub fn goals_path(workspace_dir: &Path) -> PathBuf { - engine::goals_path(workspace_dir) -} - -/// Load the goals document (a missing file maps to an empty doc). -pub async fn load(workspace_dir: &Path) -> Result { - engine::load(workspace_dir).map_err(|e| e.to_string()) -} - -/// Persist the goals document, enforcing the item/char caps. -pub async fn save(workspace_dir: &Path, doc: &mut GoalsDoc) -> Result<(), String> { - engine::save(workspace_dir, doc).map_err(|e| e.to_string()) -} - -/// Append a goal; returns the new item's id and the updated doc. -pub async fn add(workspace_dir: &Path, text: &str) -> Result<(String, GoalsDoc), String> { - engine::add(workspace_dir, text).map_err(|e| e.to_string()) -} - -/// Edit an existing goal by id. -pub async fn edit(workspace_dir: &Path, id: &str, text: &str) -> Result { - engine::edit(workspace_dir, id, text).map_err(|e| e.to_string()) -} - -/// Delete a goal by id. -pub async fn delete(workspace_dir: &Path, id: &str) -> Result { - engine::delete(workspace_dir, id).map_err(|e| e.to_string()) -} diff --git a/src/openhuman/memory/goals/tools.rs b/src/openhuman/memory/goals/tools.rs index aefd816803..1dc56e4694 100644 --- a/src/openhuman/memory/goals/tools.rs +++ b/src/openhuman/memory/goals/tools.rs @@ -11,8 +11,8 @@ use std::path::PathBuf; use async_trait::async_trait; use serde_json::json; -use super::store; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use tinycortex::memory::goals::store; /// `goals_list` — read the current long-term goals list. pub struct GoalsListTool { @@ -47,7 +47,7 @@ impl Tool for GoalsListTool { async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { log::debug!("[memory_goals] tool=goals_list"); - let doc = match store::load(&self.workspace_dir).await { + let doc = match store::load(&self.workspace_dir).map_err(|e| e.to_string()) { Ok(doc) => doc, Err(e) => return Ok(ToolResult::error(e)), }; @@ -96,7 +96,7 @@ impl Tool for GoalsAddTool { return Ok(ToolResult::error("Missing 'text' parameter")); }; log::debug!("[memory_goals] tool=goals_add"); - match store::add(&self.workspace_dir, text).await { + match store::add(&self.workspace_dir, text).map_err(|e| e.to_string()) { Ok((id, _)) => Ok(ToolResult::success(format!("Added goal '{id}'."))), Err(e) => Ok(ToolResult::error(e)), } @@ -148,7 +148,7 @@ impl Tool for GoalsEditTool { return Ok(ToolResult::error("Missing 'text' parameter")); }; log::debug!("[memory_goals] tool=goals_edit id={id}"); - match store::edit(&self.workspace_dir, id, text).await { + match store::edit(&self.workspace_dir, id, text).map_err(|e| e.to_string()) { Ok(_) => Ok(ToolResult::success(format!("Edited goal '{id}'."))), Err(e) => Ok(ToolResult::error(e)), } @@ -196,7 +196,7 @@ impl Tool for GoalsDeleteTool { return Ok(ToolResult::error("Missing 'id' parameter")); }; log::debug!("[memory_goals] tool=goals_delete id={id}"); - match store::delete(&self.workspace_dir, id).await { + match store::delete(&self.workspace_dir, id).map_err(|e| e.to_string()) { Ok(_) => Ok(ToolResult::success(format!("Deleted goal '{id}'."))), Err(e) => Ok(ToolResult::error(e)), } diff --git a/src/openhuman/memory/guard/mod.rs b/src/openhuman/memory/guard/mod.rs index 40ff0aea8d..ff5b36e8da 100644 --- a/src/openhuman/memory/guard/mod.rs +++ b/src/openhuman/memory/guard/mod.rs @@ -58,16 +58,31 @@ //! //! ## Honesty clause: "the guard is the only path" is NOT yet true //! -//! [`MemoryClient::profile_conn`](crate::openhuman::memory::store::MemoryClient::profile_conn) -//! hands out a raw `Arc>`. No decorator can wrap a -//! SQLite connection, so those callers reach the profile/facet tables beneath -//! every one of the seven steps above. It is explicitly out of scope for M4a -//! and must be closed before the invariant may be claimed. Current production -//! callers: +//! `MemoryClient::profile_conn` no longer leaves the memory family: it is +//! `pub(in crate::openhuman::memory)` with one caller, +//! [`MemoryClient::profile_store`](crate::openhuman::memory::store::MemoryClient::profile_store), +//! which wraps it in a typed +//! [`ProfileStore`](crate::openhuman::memory::store::ProfileStore). Every SQL +//! statement against `user_profile` is now inside the family, and the compiler +//! enforces that. +//! +//! **That is confinement, not policy.** The contract has no profile/facet +//! capability family, so `ProfileStore`'s reads and writes still run beneath +//! every one of the seven steps above — no tier check, no source scope, no +//! taint, no redaction, no budget, no audit. Closing *that* needs a fourteenth +//! family in `tinycortex_api`, or a host-side half-measure where `ProfileStore` +//! consults [`policy::GuardPolicy`] directly (which would make a `readonly` +//! tier start rejecting learning-cache rebuilds — a behaviour change, not a +//! refactor). Current unguarded profile callers: //! //! - `memory/sync/composio/providers/profile.rs` //! - `agent/learning/{tools,startup,schemas}.rs` //! +//! A second, independent write path into `user_profile` exists and is *not* +//! covered by the `.profile_store(` needle: `agent/harness/archivist/lifecycle.rs` +//! calls `profile::profile_upsert` on a connection injected at construction. +//! It has no production construction site today. +//! //! `MemoryClient::memory_handle()` is already `pub(crate)`; do not widen it. pub mod audit; diff --git a/src/openhuman/memory/ops/tool_memory.rs b/src/openhuman/memory/ops/tool_memory.rs index a87845872e..eab5145b38 100644 --- a/src/openhuman/memory/ops/tool_memory.rs +++ b/src/openhuman/memory/ops/tool_memory.rs @@ -116,7 +116,10 @@ pub async fn tool_rule_get( /// A driver that does not advertise `Capability::ToolMemory` returns `None` /// from `as_tool_memory()`; the embedded driver always advertises it, so this /// is reachable only under a null / fallback binding. -const NO_TOOL_MEMORY: &str = "memory driver does not support the tool_memory family"; +/// +/// Shared with the `memory_tools_list` / `memory_tools_put` agent tools, which +/// route through the same family. +pub(crate) const NO_TOOL_MEMORY: &str = "memory driver does not support the tool_memory family"; /// List every tool-scoped rule for a tool. /// diff --git a/src/openhuman/memory/store/client.rs b/src/openhuman/memory/store/client.rs index f0eb043603..f663bc164c 100644 --- a/src/openhuman/memory/store/client.rs +++ b/src/openhuman/memory/store/client.rs @@ -55,19 +55,32 @@ pub struct MemoryClient { } impl MemoryClient { - /// Returns a handle to the underlying SQLite connection for direct - /// profile-facet writes via - /// [`crate::openhuman::memory::store::namespace_store::profile::profile_upsert`]. + /// Returns a handle to the underlying SQLite connection backing the + /// profile/facet tables. /// - /// Intentionally `pub(crate)` — external consumers should use the - /// higher-level `MemoryClient` API; this escape hatch exists so - /// in-crate subsystems (composio providers, archivist, learning - /// hooks) can write structured profile facets without an additional - /// round-trip through the ingestion queue. - pub(crate) fn profile_conn(&self) -> std::sync::Arc> { + /// Narrowed from `pub(crate)` to `pub(in crate::openhuman::memory)`: a raw + /// `Arc>` cannot be wrapped by any decorator, so no + /// caller outside the memory family may hold one. [`Self::profile_store`] + /// is the only door out, and every SQL statement against `user_profile` + /// now lives inside this family. + pub(in crate::openhuman::memory) fn profile_conn( + &self, + ) -> std::sync::Arc> { std::sync::Arc::clone(&self.inner.conn) } + /// Typed access to the profile/facet tables. + /// + /// **Not guarded.** The profile tables have no capability family in the + /// thirteen-family `tinycortex_api` contract, so these reads and writes + /// still run beneath [`crate::openhuman::memory::guard::MemoryGuard`]'s + /// seven steps. What this buys is confinement, not policy: the SQL is in + /// the memory family and the compiler keeps it there. + pub(crate) fn profile_store(&self) -> crate::openhuman::memory::store::ProfileStore { + tracing::debug!("[memory::profile_store] handing out typed profile store"); + crate::openhuman::memory::store::ProfileStore::from_conn(self.profile_conn()) + } + /// Returns an `Arc` handle backed by the same /// [`UnifiedMemory`] this client wraps. Used by sub-systems that /// want to build on top of the `Memory` trait (e.g. the diff --git a/src/openhuman/memory/store/client_tests.rs b/src/openhuman/memory/store/client_tests.rs index 4696d3003a..f9917b35c4 100644 --- a/src/openhuman/memory/store/client_tests.rs +++ b/src/openhuman/memory/store/client_tests.rs @@ -291,6 +291,61 @@ async fn profile_conn_returns_arc_shared_connection() { assert!(Arc::ptr_eq(&a, &b)); } +/// `profile_conn()` hands out a raw `Arc>` that no decorator +/// can wrap. It is `pub(in crate::openhuman::memory)`, so the compiler already +/// refuses a call from outside the family — this test states the rule in a form +/// that *names the offending file*, because a visibility error at a call site +/// reads as "private method", not as "you are reaching around the guard". +/// +/// Before the typed-store change this reported +/// `agent/learning/{schemas,startup,tools}.rs` (six call sites). +#[test] +fn profile_conn_is_confined_to_the_memory_family() { + fn rs_files_under(dir: &std::path::Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + rs_files_under(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let family = root.join("openhuman").join("memory"); + let mut files = Vec::new(); + rs_files_under(&root, &mut files); + + let mut outside = Vec::new(); + for path in files { + if path.starts_with(&family) { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + for line in text.lines() { + if line.trim_start().starts_with("//") { + continue; + } + if line.contains(".profile_conn(") { + outside.push(path.display().to_string()); + break; + } + } + } + assert!( + outside.is_empty(), + "raw profile connections reached from outside the memory family: {outside:?}\n\ + Use `MemoryClient::profile_store()`; every SQL statement against \ + user_profile belongs inside `crate::openhuman::memory`." + ); +} + #[tokio::test] async fn put_doc_full_pipeline_completes() { // Exercise the full `put_doc` path (vs `put_doc_light`) — the diff --git a/src/openhuman/memory/store/mod.rs b/src/openhuman/memory/store/mod.rs index f96f1c49ae..39fca3cfa1 100644 --- a/src/openhuman/memory/store/mod.rs +++ b/src/openhuman/memory/store/mod.rs @@ -30,6 +30,7 @@ pub mod entities; pub mod kinds; pub mod kv; pub mod namespace_store; +pub mod profile_store; pub mod retrieval; pub mod safety; pub mod tools; @@ -63,6 +64,7 @@ pub use namespace_store::fts5; pub use namespace_store::profile; pub use namespace_store::segments; pub use namespace_store::UnifiedMemory; +pub use profile_store::ProfileStore; pub use types::{ GraphRelationRecord, MemoryItemKind, MemoryKvRecord, NamespaceDocumentInput, NamespaceMemoryHit, NamespaceQueryResult, NamespaceRetrievalContext, RetrievalScoreBreakdown, diff --git a/src/openhuman/memory/store/profile_store.rs b/src/openhuman/memory/store/profile_store.rs new file mode 100644 index 0000000000..612a318a52 --- /dev/null +++ b/src/openhuman/memory/store/profile_store.rs @@ -0,0 +1,168 @@ +//! `ProfileStore` — the only typed door onto the `user_profile` table. +//! +//! Before this type existed, `MemoryClient::profile_conn()` handed a raw +//! `Arc>` to three domains outside the memory +//! family (`agent/learning/*`, `memory/sync/composio/providers/profile.rs`), +//! two of which wrote SQL inline at the call site. Every SQL statement against +//! profile/facet rows now lives either here or in +//! [`super::namespace_store::profile`], both inside `crate::openhuman::memory`; +//! callers outside the family hold this handle and never a `Connection`. +//! +//! **This is not a guard win.** The profile/facet tables have no capability +//! family in the `tinycortex_api` contract, so reads and writes through this +//! type still run beneath [`crate::openhuman::memory::guard::MemoryGuard`]'s +//! seven policy steps: no tier check, no source-scope predicate, no taint +//! stamping, no redaction, no budget, no audit event. What changed is the shape +//! of the door — raw SQLite reachable from three domains became one typed store +//! whose confinement the compiler enforces. + +use parking_lot::Mutex; +use rusqlite::{params, Connection}; +use std::sync::Arc; + +use super::namespace_store::profile::{self, FacetType, ProfileFacet, UserState}; + +/// Typed access to the `user_profile` table. +/// +/// Cheap to clone — it is an `Arc` over the same connection `MemoryClient` +/// owns, so clones share one lock. +#[derive(Clone)] +pub struct ProfileStore { + conn: Arc>, +} + +impl ProfileStore { + /// The single production construction site is + /// [`super::MemoryClient::profile_store`]. + pub(in crate::openhuman::memory) fn from_conn(conn: Arc>) -> Self { + Self { conn } + } + + /// Test-only: build a store over a caller-owned in-memory database. + /// + /// Not a hole — the caller already holds the `Connection`, so this hands + /// out nothing a [`super::MemoryClient`] owns. Confinement is about not + /// *extracting* the client's connection, and `profile_conn()` stays + /// `pub(in crate::openhuman::memory)`. + /// + /// Deliberately **not** `#[cfg(test)]`: integration tests under `tests/` + /// link the lib compiled without `cfg(test)`, so a test-gated constructor + /// is invisible to them — which is exactly how + /// `tests/learning_phase4_integration_test.rs` was left uncompilable when + /// `FacetCache::new` changed shape. `#[doc(hidden)]` keeps it off the + /// public docs without hiding it from the linker. + #[doc(hidden)] + pub fn for_tests(conn: Arc>) -> Self { + Self { conn } + } + + // ── Facet-cache surface ─────────────────────────────────────────────── + + /// List all facets with `state = 'active'`, ordered by stability descending. + pub fn list_active(&self) -> anyhow::Result> { + profile::profile_select_active(&self.conn) + } + + /// List all facets (all states), ordered by stability descending. + pub fn list_all(&self) -> anyhow::Result> { + profile::profile_select_all(&self.conn) + } + + /// Fetch a single facet by its full key (e.g. `"style/verbosity"`). + pub fn get(&self, key: &str) -> anyhow::Result> { + profile::profile_get_by_key(&self.conn, key) + } + + /// Upsert a fully-formed facet row (rebuild path). + pub fn upsert_full(&self, facet: &ProfileFacet) -> anyhow::Result<()> { + profile::profile_upsert_full(&self.conn, facet) + } + + /// Override the `user_state` of a facet. `Ok(true)` if a row was updated. + pub fn set_user_state(&self, key: &str, user_state: UserState) -> anyhow::Result { + profile::profile_set_user_state(&self.conn, key, user_state) + } + + /// Delete a facet by key. Returns `true` if a row was removed. + pub fn delete(&self, key: &str) -> anyhow::Result { + profile::profile_delete_by_key(&self.conn, key) + } + + /// Delete all `Dropped`-state facets whose stability is below `threshold`. + pub fn drop_below_threshold(&self, threshold: f64) -> anyhow::Result { + profile::profile_delete_below_threshold(&self.conn, threshold) + } + + // ── Provider-identity surface ───────────────────────────────────────── + + /// Confidence-aware upsert of one provider-sourced facet row. + #[allow(clippy::too_many_arguments)] + pub fn upsert_provider_facet( + &self, + facet_id: &str, + facet_type: &FacetType, + key: &str, + value: &str, + confidence: f64, + segment_id: Option<&str>, + now: f64, + ) -> anyhow::Result<()> { + profile::profile_upsert( + &self.conn, facet_id, facet_type, key, value, confidence, segment_id, now, + ) + } + + /// Load every facet of `facet_type`, ordered by evidence count descending. + pub fn facets_by_type(&self, facet_type: &FacetType) -> anyhow::Result> { + profile::profile_facets_by_type(&self.conn, facet_type) + } + + /// True if any [`FacetType::Workflow`] (`"skill"`) row's key matches + /// `key_pattern` (a SQL `LIKE` pattern) with exactly `canonical_value`. + /// + /// Encapsulates the two hand-rolled `SELECT 1 … LIKE` queries the composio + /// provider used to write inline. Deliberately infallible: the callers are + /// "is this row the user?" predicates whose only sane answer on a database + /// error is "no", which is what the raw `.is_ok()` gave before. + pub fn skill_identity_matches(&self, key_pattern: &str, canonical_value: &str) -> bool { + let conn = self.conn.lock(); + let matched = conn + .query_row( + "SELECT 1 FROM user_profile + WHERE facet_type = ?1 + AND key LIKE ?2 + AND value = ?3 + LIMIT 1", + params![FacetType::Workflow.as_str(), key_pattern, canonical_value], + |_| Ok(()), + ) + .is_ok(); + // Facet values are user PII (emails, phone numbers, handles) — log the + // pattern and the verdict, never the value. + tracing::debug!( + pattern = %key_pattern, + matched, + "[memory::profile_store] skill_identity_matches" + ); + matched + } + + /// Delete exactly one row by `facet_id`. `Ok(true)` if a row was removed. + pub fn delete_by_facet_id(&self, facet_id: &str) -> anyhow::Result { + let conn = self.conn.lock(); + let removed = conn.execute( + "DELETE FROM user_profile WHERE facet_id = ?1", + params![facet_id], + )?; + tracing::debug!( + facet_id = %facet_id, + removed, + "[memory::profile_store] delete_by_facet_id" + ); + Ok(removed > 0) + } +} + +#[cfg(test)] +#[path = "profile_store_tests.rs"] +mod tests; diff --git a/src/openhuman/memory/store/profile_store_tests.rs b/src/openhuman/memory/store/profile_store_tests.rs new file mode 100644 index 0000000000..567cb141f2 --- /dev/null +++ b/src/openhuman/memory/store/profile_store_tests.rs @@ -0,0 +1,120 @@ +//! Tests for [`ProfileStore`]. +//! +//! The two interesting methods are the ones that replaced hand-rolled SQL in +//! `memory/sync/composio/providers/profile.rs`. A subtly wrong reimplementation +//! of `skill_identity_matches` makes the entity matcher stop recognising the +//! user, which degrades silently rather than erroring — so the oracle here is +//! the literal SQL that was replaced, executed against the same connection, +//! rather than my reading of it. + +use super::*; +use crate::openhuman::memory::store::profile::PROFILE_INIT_SQL; + +fn seeded_store() -> ProfileStore { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(PROFILE_INIT_SQL).unwrap(); + let store = ProfileStore::for_tests(Arc::new(Mutex::new(conn))); + + let rows = [ + ( + "skill-gmail-default-email", + "skill:gmail:default:email", + "user@example.com", + ), + ( + "skill-slack-c123-handle", + "skill:slack:c123:handle", + "userhandle", + ), + ( + "skill-slack-c123-email", + "skill:slack:c123:email", + "work@example.com", + ), + ]; + for (facet_id, key, value) in rows { + store + .upsert_provider_facet( + facet_id, + &FacetType::Workflow, + key, + value, + 0.9, + None, + 1000.0, + ) + .unwrap(); + } + store +} + +/// The exact query string from the pre-refactor +/// `is_self_identity` / `is_self_identity_any_toolkit`, run here so the +/// assertion compares against the code that was replaced. +fn legacy_like_query(store: &ProfileStore, key_pattern: &str, canonical: &str) -> bool { + let conn = store.conn.lock(); + conn.query_row( + "SELECT 1 FROM user_profile + WHERE facet_type = 'skill' + AND key LIKE ?1 + AND value = ?2 + LIMIT 1", + params![key_pattern, canonical], + |_| Ok(()), + ) + .is_ok() +} + +#[test] +fn skill_identity_matches_agrees_with_the_legacy_like_query() { + let store = seeded_store(); + let cases = [ + ("skill:gmail:%:email", "user@example.com"), // exact toolkit hit + ("skill:slack:%:email", "user@example.com"), // wrong toolkit + ("skill:%:%:email", "user@example.com"), // cross-toolkit hit + ("skill:%:%:email", "other@example.com"), // value miss + ("skill:%:%:phone", "user@example.com"), // kind miss + ("skill:gmail:%:handle", ""), // empty value + ("skill:slack:%:handle", "userhandle"), // second toolkit hit + ]; + for (pattern, value) in cases { + let legacy = legacy_like_query(&store, pattern, value); + assert_eq!( + store.skill_identity_matches(pattern, value), + legacy, + "divergence for pattern={pattern:?} value={value:?}" + ); + } + // Non-vacuity: at least one case must actually be a hit, or the loop above + // would pass with a method that always returns false. + assert!(store.skill_identity_matches("skill:%:%:email", "user@example.com")); +} + +#[test] +fn delete_by_facet_id_removes_exactly_one_row() { + let store = seeded_store(); + assert_eq!(store.facets_by_type(&FacetType::Workflow).unwrap().len(), 3); + + assert!(store.delete_by_facet_id("skill-slack-c123-email").unwrap()); + + let survivors = store.facets_by_type(&FacetType::Workflow).unwrap(); + let ids: Vec<&str> = survivors.iter().map(|f| f.facet_id.as_str()).collect(); + assert_eq!(survivors.len(), 2, "deleted more than one row: {ids:?}"); + assert!(ids.contains(&"skill-gmail-default-email"), "{ids:?}"); + assert!(ids.contains(&"skill-slack-c123-handle"), "{ids:?}"); + + assert!( + !store.delete_by_facet_id("skill-does-not-exist").unwrap(), + "deleting an unknown facet_id must report false" + ); +} + +#[test] +fn facet_cache_surface_round_trips_through_the_store() { + let store = seeded_store(); + let facet = store.get("skill:gmail:default:email").unwrap(); + assert_eq!(facet.map(|f| f.value).as_deref(), Some("user@example.com")); + assert_eq!(store.list_all().unwrap().len(), 3); + assert!(store.delete("skill:gmail:default:email").unwrap()); + assert_eq!(store.list_all().unwrap().len(), 2); +} diff --git a/src/openhuman/memory/sync/composio/providers/profile.rs b/src/openhuman/memory/sync/composio/providers/profile.rs index 5fe0768847..06d7e2d880 100644 --- a/src/openhuman/memory/sync/composio/providers/profile.rs +++ b/src/openhuman/memory/sync/composio/providers/profile.rs @@ -21,8 +21,7 @@ use super::ProviderUserProfile; use crate::openhuman::agent::learning::candidate::{ self as learning_candidate, CueFamily, EvidenceRef, FacetClass, LearningCandidate, }; -use crate::openhuman::memory::store::profile::{self, FacetType}; -use rusqlite::params; +use crate::openhuman::memory::store::profile::FacetType; use serde_json::Value; use std::collections::BTreeMap; @@ -136,7 +135,7 @@ pub fn persist_provider_profile(profile: &ProviderUserProfile) -> usize { ); return 0; }; - let conn = client.profile_conn(); + let store = client.profile_store(); let now = now_secs(); let toolkit = normalize_token(&profile.toolkit); @@ -154,8 +153,7 @@ pub fn persist_provider_profile(profile: &ProviderUserProfile) -> usize { let key = format!("skill:{toolkit}:{identifier}:{}", kind.as_str()); let facet_id = format!("skill-{toolkit}-{identifier}-{}", kind.as_str()); - if let Err(e) = profile::profile_upsert( - &conn, + if let Err(e) = store.upsert_provider_facet( &facet_id, &FacetType::Workflow, &key, @@ -284,8 +282,7 @@ pub fn load_connected_identities() -> Vec { tracing::debug!("[composio:profile] load_connected_identities: memory client not ready"); return Vec::new(); }; - let conn = client.profile_conn(); - let facets = match profile::profile_facets_by_type(&conn, &FacetType::Workflow) { + let facets = match client.profile_store().facets_by_type(&FacetType::Workflow) { Ok(f) => f, Err(error) => { tracing::warn!( @@ -338,20 +335,10 @@ pub fn is_self_identity(toolkit: &str, kind: IdentityKind, raw_value: &str) -> b let Some(client) = crate::openhuman::memory::global::client_if_ready() else { return false; }; - let conn = client.profile_conn(); - let conn = conn.lock(); - let key_pattern = format!("skill:{}:%:{}", normalize_token(toolkit), kind.as_str()); - conn.query_row( - "SELECT 1 FROM user_profile - WHERE facet_type = 'skill' - AND key LIKE ?1 - AND value = ?2 - LIMIT 1", - params![key_pattern, canonical], - |_| Ok(()), - ) - .is_ok() + client + .profile_store() + .skill_identity_matches(&key_pattern, &canonical) } /// Cross-toolkit variant — matches against every connected provider's @@ -368,20 +355,10 @@ pub fn is_self_identity_any_toolkit(kind: IdentityKind, raw_value: &str) -> bool let Some(client) = crate::openhuman::memory::global::client_if_ready() else { return false; }; - let conn = client.profile_conn(); - let conn = conn.lock(); - let key_pattern = format!("skill:%:%:{}", kind.as_str()); - conn.query_row( - "SELECT 1 FROM user_profile - WHERE facet_type = 'skill' - AND key LIKE ?1 - AND value = ?2 - LIMIT 1", - params![key_pattern, canonical], - |_| Ok(()), - ) - .is_ok() + client + .profile_store() + .skill_identity_matches(&key_pattern, &canonical) } /// Render a compact section for prompt injection. Skips `user_id` (not @@ -451,8 +428,8 @@ pub fn delete_connected_identity_facets(source: &str, identifier: &str) -> usize ); return 0; }; - let conn = client.profile_conn(); - let Ok(facets) = profile::profile_facets_by_type(&conn, &FacetType::Workflow) else { + let store = client.profile_store(); + let Ok(facets) = store.facets_by_type(&FacetType::Workflow) else { return 0; }; let mut deleted = 0usize; @@ -461,15 +438,9 @@ pub fn delete_connected_identity_facets(source: &str, identifier: &str) -> usize continue; }; if s == source && i == identifier { - let conn_guard = conn.lock(); - if conn_guard - .execute( - "DELETE FROM user_profile WHERE facet_id = ?1", - params![facet.facet_id], - ) - .unwrap_or(0) - > 0 - { + // Same swallow as before: a disconnect must not fail because one + // row was already gone. + if store.delete_by_facet_id(&facet.facet_id).unwrap_or(false) { deleted += 1; } } @@ -538,7 +509,7 @@ fn now_secs() -> f64 { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::store::profile::{profile_load_all, PROFILE_INIT_SQL}; + use crate::openhuman::memory::store::profile::{self, profile_load_all, PROFILE_INIT_SQL}; use parking_lot::Mutex; use rusqlite::Connection; use serde_json::json; diff --git a/src/openhuman/memory/tool_memory/tools/list.rs b/src/openhuman/memory/tool_memory/tools/list.rs index ef3b996f57..65cda588a3 100644 --- a/src/openhuman/memory/tool_memory/tools/list.rs +++ b/src/openhuman/memory/tool_memory/tools/list.rs @@ -1,11 +1,21 @@ //! `memory_tools_list` — list every stored rule for a given tool. +//! +//! Routed through [`MemoryGuard`](crate::openhuman::memory::guard::MemoryGuard) +//! rather than a raw `ToolMemoryStore`. `MemoryToolMemory::tool_rules` on the +//! embedded driver is literally `tool_memory_store(self.memory()).list_rules(…)`, +//! and the wire type matches by identity, not conversion: +//! `memory::tool_memory::ToolMemoryRule` **is** +//! `tinycortex_api::tool_memory::ToolMemoryRule`. So the re-point is exact — +//! same rules, same order, same serialization — with `Capability::ToolMemory` +//! admitted first. use async_trait::async_trait; use serde::Deserialize; use serde_json::json; +use tinycortex_api::provider::MemoryProvider; -use crate::openhuman::memory::ops::helpers::active_memory_client; -use crate::openhuman::memory::tool_memory::tool_memory_store; +use crate::openhuman::memory::ops::guard::active_memory_guard; +use crate::openhuman::memory::ops::tool_memory::NO_TOOL_MEMORY; use crate::openhuman::tools::traits::{Tool, ToolResult}; pub struct MemoryToolsListTool; @@ -45,14 +55,20 @@ impl Tool for MemoryToolsListTool { let parsed: Args = serde_json::from_value(args) .map_err(|e| anyhow::anyhow!("invalid arguments for memory_tools_list: {e}"))?; log::debug!("[tool][memory_tools] list tool_name={}", parsed.tool_name); - let client = active_memory_client() + let guard = active_memory_guard() .await .map_err(|e| anyhow::anyhow!("memory_tools_list: {e}"))?; - let store = tool_memory_store(client.memory_handle()); - let rules = store - .list_rules(&parsed.tool_name) + let rules = guard + .as_tool_memory() + .ok_or_else(|| anyhow::anyhow!("memory_tools_list: {NO_TOOL_MEMORY}"))? + .tool_rules(&parsed.tool_name) .await .map_err(|e| anyhow::anyhow!("memory_tools_list: {e}"))?; + log::debug!( + "[tool][memory_tools] list via guard tool_name={} rules={}", + parsed.tool_name, + rules.len() + ); let json = serde_json::to_string(&rules)?; Ok(ToolResult::success(json)) } diff --git a/src/openhuman/memory/tool_memory/tools/put.rs b/src/openhuman/memory/tool_memory/tools/put.rs index a807c58b26..267b525447 100644 --- a/src/openhuman/memory/tool_memory/tools/put.rs +++ b/src/openhuman/memory/tool_memory/tools/put.rs @@ -1,13 +1,34 @@ //! `memory_tools_put` — upsert a tool-scoped memory rule. +//! +//! Routed through [`MemoryGuard`](crate::openhuman::memory::guard::MemoryGuard). +//! `MemoryToolMemory::put_tool_rule` delegates to the same +//! `ToolMemoryStore::put_rule` this tool used to build by hand, with one +//! asymmetry: the contract method returns unit while the store returns the +//! *stored* rule (trim/lower-cased `tool_name`, `created_at` preserved on +//! upsert, `updated_at` refreshed) — which is what this tool answers with. The +//! asymmetry is recovered exactly by reading the rule back: +//! `ToolMemoryRule::new` always generates the id before the write, so there is +//! no server-assigned identity to lose, and `tool_memory_namespace` applies the +//! same `trim().to_lowercase()` the write normalised into, so reading back with +//! the caller's raw `tool_name` hits the same namespace. +//! +//! A concurrent delete between the write and the read-back yields no rule. That +//! answers with an error, never a fabricated rule — absence, not a lie. +//! +//! **Behaviour change, deliberate:** the write now takes +//! `SecurityPolicy::enforce_write_tier`, so the tool is refused under the +//! `readonly` autonomy tier with `"memory guard: "`-prefixed text, and +//! store-level validation errors arrive as `MemoryError::Invalid` rather than as +//! a raw string. use async_trait::async_trait; use serde::Deserialize; use serde_json::json; +use tinycortex_api::provider::MemoryProvider; -use crate::openhuman::memory::ops::helpers::active_memory_client; -use crate::openhuman::memory::tool_memory::{ - tool_memory_store, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, -}; +use crate::openhuman::memory::ops::guard::active_memory_guard; +use crate::openhuman::memory::ops::tool_memory::NO_TOOL_MEMORY; +use crate::openhuman::memory::tool_memory::{ToolMemoryPriority, ToolMemoryRule, ToolMemorySource}; use crate::openhuman::tools::traits::{Tool, ToolResult}; pub struct MemoryToolsPutTool; @@ -79,10 +100,12 @@ impl Tool for MemoryToolsPutTool { parsed.priority, parsed.tags.len() ); - let client = active_memory_client() + let guard = active_memory_guard() .await .map_err(|e| anyhow::anyhow!("memory_tools_put: {e}"))?; - let store = tool_memory_store(client.memory_handle()); + let family = guard + .as_tool_memory() + .ok_or_else(|| anyhow::anyhow!("memory_tools_put: {NO_TOOL_MEMORY}"))?; let mut rule = ToolMemoryRule::new( &parsed.tool_name, &parsed.rule, @@ -90,10 +113,29 @@ impl Tool for MemoryToolsPutTool { ToolMemorySource::UserExplicit, ); rule.tags = parsed.tags; - let stored = store - .put_rule(rule) + let rule_id = rule.id.clone(); + let tool_name = rule.tool_name.clone(); + family + .put_tool_rule(rule) .await .map_err(|e| anyhow::anyhow!("memory_tools_put: {e}"))?; + // `put_tool_rule` answers with unit; the tool's contract is the stored + // rule (normalised tool_name, preserved created_at, refreshed + // updated_at), so read it back by the id generated above. + let stored = family + .tool_rules(&tool_name) + .await + .map_err(|e| anyhow::anyhow!("memory_tools_put: {e}"))? + .into_iter() + .find(|r| r.id == rule_id) + .ok_or_else(|| { + anyhow::anyhow!("memory_tools_put: stored rule {rule_id} not found on read-back") + })?; + log::debug!( + "[tool][memory_tools] put via guard tool_name={} id={} read_back=ok", + stored.tool_name, + stored.id + ); let json = serde_json::to_string(&stored)?; Ok(ToolResult::success(json)) } @@ -107,9 +149,27 @@ mod tests { use tempfile::TempDir; use crate::openhuman::config::{Config, TEST_ENV_LOCK}; - use crate::openhuman::memory::tool_memory::tool_memory_store; + use crate::openhuman::memory::guard::policy::GUARD_DENIED_PREFIX; + use crate::openhuman::security::live_policy; + use crate::openhuman::security::policy::{AutonomyLevel, SecurityPolicy}; use crate::openhuman::tools::traits::Tool; use serde_json::json; + use std::sync::Arc; + + /// Install `autonomy` as the live policy for this test thread only. Same + /// shape `memory/guard/policy_tests.rs` uses; `#[tokio::test]`'s + /// current-thread runtime keeps the future on the installing thread. + fn scoped_tier(autonomy: AutonomyLevel) -> live_policy::TestPolicyGuard { + let dir = std::env::temp_dir(); + live_policy::install_scoped( + Arc::new(SecurityPolicy { + autonomy, + ..SecurityPolicy::default() + }), + dir.clone(), + dir, + ) + } struct WorkspaceEnvGuard { _lock: std::sync::MutexGuard<'static, ()>, @@ -237,11 +297,15 @@ mod tests { assert_eq!(parsed["tags"], json!(["safety", "shell"])); assert!(parsed["id"].as_str().is_some()); - let client = crate::openhuman::memory::ops::helpers::active_memory_client() + let guard = crate::openhuman::memory::ops::guard::active_memory_guard() + .await + .expect("active memory guard"); + let rules = guard + .as_tool_memory() + .expect("embedded driver advertises the tool_memory family") + .tool_rules("bash") .await - .expect("active memory client"); - let store = tool_memory_store(client.memory_handle()); - let rules = store.list_rules("bash").await.expect("list stored rules"); + .expect("list stored rules"); let stored = rules .iter() .find(|rule| rule.rule == "Always dry-run dangerous commands first") @@ -273,4 +337,94 @@ mod tests { serde_json::from_str(&result.text()).expect("tool result should be json"); assert_eq!(parsed["priority"], "normal"); } + + /// The behavioural discriminator for the re-point: before it, the tool + /// wrote through an undecorated `MemoryClientRef` and no tier check ran, so + /// a `readonly` agent could still pin rules. Through the guard, + /// `admit_write` calls `enforce_write_tier` first. + #[tokio::test] + async fn execute_is_refused_under_the_readonly_tier() { + let _serial = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK + .lock() + .await; + let tmp = TempDir::new().expect("tempdir"); + let (_workspace, _cfg) = isolated_config(&tmp).await; + let _tier = scoped_tier(AutonomyLevel::ReadOnly); + let tool = MemoryToolsPutTool; + let err = tool + .execute(json!({ + "tool_name": "bash", + "rule": "readonly agents must not pin rules" + })) + .await + .expect_err("the readonly tier must refuse a tool-memory write"); + let message = err.to_string(); + assert!( + message.contains(GUARD_DENIED_PREFIX), + "refusal must be attributable to the guard: {message}" + ); + } + + /// The paired positive case: the same call under `full` succeeds, so the + /// test above is proving the tier gate rather than a broken write path. + #[tokio::test] + async fn execute_succeeds_under_the_full_tier() { + let _serial = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK + .lock() + .await; + let tmp = TempDir::new().expect("tempdir"); + let (_workspace, _cfg) = isolated_config(&tmp).await; + let _tier = scoped_tier(AutonomyLevel::Full); + let tool = MemoryToolsPutTool; + let result = tool + .execute(json!({ + "tool_name": "bash", + "rule": "full-tier agents may pin rules" + })) + .await + .expect("the full tier must admit a tool-memory write"); + assert!(!result.is_error); + } + + /// `memory_tools_put` and `memory_tools_list` must observe each other now + /// that both resolve through the guard rather than through their own + /// `ToolMemoryStore` handles. + #[tokio::test] + async fn guarded_put_and_guarded_list_share_the_store() { + let _serial = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK + .lock() + .await; + let tmp = TempDir::new().expect("tempdir"); + let (_workspace, _cfg) = isolated_config(&tmp).await; + let put = MemoryToolsPutTool; + let stored = put + .execute(json!({ + "tool_name": "web_search", + "rule": "prefer primary sources", + "priority": "critical" + })) + .await + .expect("put should succeed"); + let stored: serde_json::Value = + serde_json::from_str(&stored.text()).expect("put result should be json"); + let stored_id = stored["id"].as_str().expect("stored id").to_string(); + + let list = super::super::list::MemoryToolsListTool; + let listed = list + .execute(json!({ "tool_name": "web_search" })) + .await + .expect("list should succeed"); + let listed: serde_json::Value = + serde_json::from_str(&listed.text()).expect("list result should be json"); + let ids: Vec<&str> = listed + .as_array() + .expect("list returns an array") + .iter() + .filter_map(|r| r["id"].as_str()) + .collect(); + assert!( + ids.contains(&stored_id.as_str()), + "the guarded list must observe the guarded put: {ids:?}" + ); + } } diff --git a/src/openhuman/security/credentials/ops.rs b/src/openhuman/security/credentials/ops.rs index 47e83f9105..120164d029 100644 --- a/src/openhuman/security/credentials/ops.rs +++ b/src/openhuman/security/credentials/ops.rs @@ -19,7 +19,7 @@ use crate::openhuman::config::{ default_root_openhuman_dir, pre_login_user_dir, read_active_user_id, user_openhuman_dir, write_active_user_id, }; -use crate::openhuman::memory::conversations; +use tinycortex::memory::conversations; const AUTH_ME_STORE_RETRY_DELAY: Duration = Duration::from_millis(150); const AUTH_ME_STORE_TRANSIENT_STATUSES: &[u16] = &[408, 429, 500, 502, 503, 504, 520]; diff --git a/src/openhuman/subconscious/profiles/memory.rs b/src/openhuman/subconscious/profiles/memory.rs index b11ca29047..96745e5492 100644 --- a/src/openhuman/subconscious/profiles/memory.rs +++ b/src/openhuman/subconscious/profiles/memory.rs @@ -25,7 +25,7 @@ use crate::openhuman::agent::orchestration::parent_context::with_root_parent; use crate::openhuman::agent::turn_origin::TrustedAutomationSource; use crate::openhuman::config::schema::SubconsciousMode; use crate::openhuman::config::Config; -use crate::openhuman::memory::diff::types::CrossSourceDiff; +use tinycortex::memory::diff::types::CrossSourceDiff; /// Per-tool-call timeout injected into the decision agent config. const TOOL_CALL_TIMEOUT_SECS: u64 = 5 * 60; @@ -459,9 +459,9 @@ pub(crate) fn render_world_diff(diff: &CrossSourceDiff) -> String { )); for change in source.changes.iter().take(MAX_ITEMS_PER_SOURCE) { let verb = match change.kind { - crate::openhuman::memory::diff::types::ChangeKind::Added => "added", - crate::openhuman::memory::diff::types::ChangeKind::Removed => "removed", - crate::openhuman::memory::diff::types::ChangeKind::Modified => "modified", + tinycortex::memory::diff::types::ChangeKind::Added => "added", + tinycortex::memory::diff::types::ChangeKind::Removed => "removed", + tinycortex::memory::diff::types::ChangeKind::Modified => "modified", }; let label = if change.title.trim().is_empty() { change.item_id.as_str() diff --git a/src/openhuman/subconscious/profiles/memory_tests.rs b/src/openhuman/subconscious/profiles/memory_tests.rs index b8c3c96897..53e8ec8334 100644 --- a/src/openhuman/subconscious/profiles/memory_tests.rs +++ b/src/openhuman/subconscious/profiles/memory_tests.rs @@ -21,7 +21,7 @@ fn tick_origin_with_external_sync_chunk_uses_tainted_source() { // ── World-diff rendering (Stage 1) ────────────────────────────────────── -use crate::openhuman::memory::diff::types::{ +use tinycortex::memory::diff::types::{ ChangeKind, CrossSourceDiff, DiffResult, DiffSummary, ItemChange, }; diff --git a/src/openhuman/subconscious/session.rs b/src/openhuman/subconscious/session.rs index 8c33f62e52..b9d1664f4c 100644 --- a/src/openhuman/subconscious/session.rs +++ b/src/openhuman/subconscious/session.rs @@ -26,8 +26,8 @@ use tracing::{debug, info, warn}; use crate::openhuman::agent::Agent; use crate::openhuman::config::schema::SubconsciousMode; use crate::openhuman::config::Config; -use crate::openhuman::memory::conversations::ConversationMessage; use crate::openhuman::security::AutonomyLevel; +use tinycortex::memory::conversations::ConversationMessage; use super::profiles::memory::tick_origin_source; @@ -196,7 +196,7 @@ impl LongLivedSession { agent.set_event_context(self.thread_id.clone(), "subconscious"); // Cold-boot resume: prime history from the reserved thread. - match crate::openhuman::memory::conversations::get_messages( + match tinycortex::memory::conversations::get_messages( self.workspace_dir.clone(), &self.thread_id, ) { @@ -246,7 +246,7 @@ impl LongLivedSession { "Subconscious Orchestrator", ); let message = new_message(sender, content, tainted); - if let Err(err) = crate::openhuman::memory::conversations::append_message( + if let Err(err) = tinycortex::memory::conversations::append_message( self.workspace_dir.clone(), &self.thread_id, message, @@ -287,7 +287,7 @@ pub(crate) fn ensure_reserved_thread( thread_id: &str, title: &str, ) { - use crate::openhuman::memory::conversations::CreateConversationThread; + use tinycortex::memory::conversations::CreateConversationThread; let req = CreateConversationThread { id: thread_id.to_string(), title: title.to_string(), @@ -297,7 +297,7 @@ pub(crate) fn ensure_reserved_thread( personality_id: None, }; if let Err(err) = - crate::openhuman::memory::conversations::ensure_thread(workspace_dir.to_path_buf(), req) + tinycortex::memory::conversations::ensure_thread(workspace_dir.to_path_buf(), req) { warn!( "[subconscious::session] ensure reserved thread failed thread={} err={}", diff --git a/src/openhuman/subconscious/user_thread.rs b/src/openhuman/subconscious/user_thread.rs index a82a452bf2..3d6e0cbc32 100644 --- a/src/openhuman/subconscious/user_thread.rs +++ b/src/openhuman/subconscious/user_thread.rs @@ -20,8 +20,8 @@ use tracing::{info, warn}; use crate::core::bus::BUS; use crate::core::events::DomainEvent; -use crate::openhuman::memory::conversations::ConversationMessage; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult, ToolScope}; +use tinycortex::memory::conversations::ConversationMessage; /// Reserved conversation thread for agent↔user communication, distinct from /// the orchestrator's internal reasoning thread. @@ -48,11 +48,9 @@ pub fn notify_user(workspace_dir: std::path::PathBuf, message: &str, subject: Op // `append_message` requires the thread to exist; create the reserved // user-facing thread lazily (idempotent). super::session::ensure_reserved_thread(&workspace_dir, USER_THREAD_ID, "Subconscious → You"); - if let Err(err) = crate::openhuman::memory::conversations::append_message( - workspace_dir, - USER_THREAD_ID, - record, - ) { + if let Err(err) = + tinycortex::memory::conversations::append_message(workspace_dir, USER_THREAD_ID, record) + { warn!("[subconscious::user_thread] persist notify_user message failed: {err}"); } diff --git a/src/openhuman/threads/ops.rs b/src/openhuman/threads/ops.rs index f9141f4fac..bb99749b5c 100644 --- a/src/openhuman/threads/ops.rs +++ b/src/openhuman/threads/ops.rs @@ -18,10 +18,7 @@ use crate::openhuman::memory::{ // sync entry points directly from these handlers parked async worker threads on // the store's `parking_lot` mutex, which starved the runtime and made // `threads_create_new` blow the frontend's 30 s RPC budget (#5156). -use crate::openhuman::memory::conversations::{ - self as conversations, ConversationMessage, ConversationMessagePatch, ConversationThread, - CreateConversationThread, CrossThreadHit, -}; +use crate::openhuman::memory::conversations; use crate::openhuman::threads::title::{ build_title_prompt, is_auto_generated_thread_title, sanitize_generated_title, title_from_user_message, title_log_fingerprint, THREAD_TITLE_LOG_PREFIX, @@ -39,6 +36,10 @@ use std::collections::BTreeMap; use std::path::PathBuf; use tinyagents::harness::message::Message; use tinyagents::harness::model::ModelRequest; +use tinycortex::memory::conversations::{ + ConversationMessage, ConversationMessagePatch, ConversationThread, CreateConversationThread, + CrossThreadHit, +}; fn request_id() -> String { uuid::Uuid::new_v4().to_string() diff --git a/src/openhuman/threads/ops_tests.rs b/src/openhuman/threads/ops_tests.rs index 2198bc3197..839048cdde 100644 --- a/src/openhuman/threads/ops_tests.rs +++ b/src/openhuman/threads/ops_tests.rs @@ -10,6 +10,7 @@ use crate::openhuman::threads::ThreadsError; use serde_json::{json, Value}; use std::ffi::OsString; use std::path::Path; +use tinycortex::memory::conversations as conversations_store; struct EnvVarGuard { key: &'static str, @@ -360,7 +361,7 @@ async fn create_thread_with_title(_workspace: &tempfile::TempDir, thread_id: &st .await .expect("load config") .workspace_dir; - conversations::ensure_thread( + conversations_store::ensure_thread( dir, CreateConversationThread { id: thread_id.to_string(), @@ -387,7 +388,7 @@ async fn generate_title_leaves_custom_title_unchanged() { .await .expect("load config") .workspace_dir; - conversations::append_message( + conversations_store::append_message( dir, thread_id, ConversationMessage { @@ -452,7 +453,7 @@ async fn generate_title_falls_back_to_first_user_message_when_assistant_missing( .expect("load config") .workspace_dir; let user_message = "Please summarize the latest five email threads for me."; - conversations::append_message( + conversations_store::append_message( dir, thread_id, ConversationMessage { diff --git a/src/openhuman/threads/welcome_migration.rs b/src/openhuman/threads/welcome_migration.rs index 6ad88960c7..54e2e40b3d 100644 --- a/src/openhuman/threads/welcome_migration.rs +++ b/src/openhuman/threads/welcome_migration.rs @@ -18,8 +18,8 @@ use std::fs; use std::path::Path; -use crate::openhuman::memory::conversations; use serde_json::{json, Value}; +use tinycortex::memory::conversations; const MIGRATION_MARKER: &str = "state/migrations/welcome_to_orchestrator_v1.done"; const WELCOME_THREAD_LABEL: &str = "onboarding"; @@ -398,10 +398,10 @@ fn write_marker(marker: &Path) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::memory::conversations::{ + use tempfile::TempDir; + use tinycortex::memory::conversations::{ ensure_thread, list_threads, CreateConversationThread, }; - use tempfile::TempDir; fn make_thread(id: &str, labels: Vec) -> CreateConversationThread { CreateConversationThread { diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 542b8708be..8ca4d0c003 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -1560,7 +1560,7 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { /// /// ## Honesty clause — three assignments run ahead of the plumbing /// -/// `goals_*` is filesystem-backed today (`memory::goals::store`), not +/// `goals_*` is filesystem-backed today (`tinycortex::memory::goals::store`), not /// `MemoryGoals`; `tool_stats` reads the legacy `Arc` plus /// `agent::learning::tool_tracker`, not `MemoryToolMemory`; `memory_diff` reads /// `memory::diff::ops`, not `MemoryDiff`. Filtering them on the driver's diff --git a/tests/learning_phase4_integration_test.rs b/tests/learning_phase4_integration_test.rs index f04211109b..f24a9d0609 100644 --- a/tests/learning_phase4_integration_test.rs +++ b/tests/learning_phase4_integration_test.rs @@ -25,6 +25,7 @@ use openhuman_core::openhuman::agent::learning::stability_detector::StabilityDet use openhuman_core::openhuman::memory::store::profile::{ FacetState, FacetType, ProfileFacet, UserState, PROFILE_INIT_SQL, }; +use openhuman_core::openhuman::memory::store::ProfileStore; use parking_lot::Mutex; use rusqlite::Connection; use tempfile::TempDir; @@ -72,7 +73,7 @@ impl TestHarness { conn.execute_batch(PROFILE_INIT_SQL).unwrap(); let conn = Arc::new(Mutex::new(conn)); - let cache = Arc::new(FacetCache::new(Arc::clone(&conn))); + let cache = Arc::new(FacetCache::new(ProfileStore::for_tests(Arc::clone(&conn)))); let workspace = TempDir::new().unwrap(); let renderer = Arc::new(ProfileMdRenderer::new( @@ -84,7 +85,7 @@ impl TestHarness { // this test's results. let _ = candidate::global().drain(); - let detector = StabilityDetector::new(FacetCache::new(conn)); + let detector = StabilityDetector::new(FacetCache::new(ProfileStore::for_tests(conn))); TestHarness { cache, @@ -266,7 +267,7 @@ fn phase4_end_to_end_pin_forget_profile_md_list() { fn list_facets_cache_direct_active_vs_all() { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(PROFILE_INIT_SQL).unwrap(); - let cache = FacetCache::new(Arc::new(Mutex::new(conn))); + let cache = FacetCache::new(ProfileStore::for_tests(Arc::new(Mutex::new(conn)))); let make = |id: &str, key: &str, state: FacetState| ProfileFacet { facet_id: id.into(), diff --git a/tests/personality_e2e.rs b/tests/personality_e2e.rs index 9911e07f5c..255d1e9507 100644 --- a/tests/personality_e2e.rs +++ b/tests/personality_e2e.rs @@ -32,10 +32,10 @@ use openhuman_core::openhuman::agent::prompts::{ PromptSection, ToolCallFormat, UserFilesSection, }; use openhuman_core::openhuman::inference::embeddings::NoopEmbedding; -use openhuman_core::openhuman::memory::conversations::{ +use openhuman_core::openhuman::memory::{NamespaceDocumentInput, UnifiedMemory}; +use tinycortex::memory::conversations::{ ensure_thread, list_threads, update_thread_title, ConversationStore, CreateConversationThread, }; -use openhuman_core::openhuman::memory::{NamespaceDocumentInput, UnifiedMemory}; // ───────────────────────────────────────────────────────────────────────────── // Test helpers diff --git a/tests/raw_coverage/memory_core_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_core_threads_raw_coverage_e2e.rs index 5dc4fea84c..9041275b06 100644 --- a/tests/raw_coverage/memory_core_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_core_threads_raw_coverage_e2e.rs @@ -23,9 +23,7 @@ use openhuman_core::openhuman::memory::{ GenerateConversationThreadTitleRequest, UpdateConversationMessageRequest, UpdateConversationThreadLabelsRequest, UpdateConversationThreadTitleRequest, }; -use openhuman_core::openhuman::memory::conversations::{ - ensure_thread, list_threads, CreateConversationThread, -}; +use tinycortex::memory::conversations::{ensure_thread, list_threads, CreateConversationThread}; use openhuman_core::openhuman::memory::store::chunks::store::{upsert_chunks, with_connection}; use openhuman_core::openhuman::memory::store::chunks::types::{ approx_token_count, chunk_id, Chunk, Metadata, SourceKind, SourceRef, diff --git a/tests/subconscious_fullstack_e2e.rs b/tests/subconscious_fullstack_e2e.rs index e54a71f017..257bdf04ac 100644 --- a/tests/subconscious_fullstack_e2e.rs +++ b/tests/subconscious_fullstack_e2e.rs @@ -366,7 +366,7 @@ async fn fullstack_session_runs_real_agent_and_persists() { ); // Real reserved-thread persistence: the user turn + agent reply landed. - let msgs = openhuman_core::openhuman::memory::conversations::get_messages( + let msgs = tinycortex::memory::conversations::get_messages( h.workspace.clone(), "subconscious:orchestrator", ) diff --git a/tests/subconscious_triggers_e2e.rs b/tests/subconscious_triggers_e2e.rs index 45729eb7c9..ed696f6852 100644 --- a/tests/subconscious_triggers_e2e.rs +++ b/tests/subconscious_triggers_e2e.rs @@ -522,9 +522,8 @@ async fn scenario_notify_user_delivers_and_persists() { ); // 2) The message landed in the reserved user-facing thread. - let persisted = - openhuman_core::openhuman::memory::conversations::get_messages(workspace, USER_THREAD_ID) - .expect("read user thread"); + let persisted = tinycortex::memory::conversations::get_messages(workspace, USER_THREAD_ID) + .expect("read user thread"); assert!( persisted .iter() @@ -539,7 +538,7 @@ async fn scenario_notify_user_delivers_and_persists() { #[test] fn scenario_reserved_threads_are_distinct_and_persist() { - use openhuman_core::openhuman::memory::conversations::{ + use tinycortex::memory::conversations::{ append_message, ensure_thread, get_messages, ConversationMessage, CreateConversationThread, }; diff --git a/tests/transcript_search_e2e.rs b/tests/transcript_search_e2e.rs index 33d808ccc5..399c2c4462 100644 --- a/tests/transcript_search_e2e.rs +++ b/tests/transcript_search_e2e.rs @@ -18,12 +18,12 @@ use std::sync::OnceLock; use serde_json::json; use tempfile::tempdir; -use openhuman_core::openhuman::memory::conversations::{ - ConversationMessage, ConversationStore, CreateConversationThread, -}; use openhuman_core::openhuman::threads::ops::transcript_search; use openhuman_core::openhuman::threads::tools::ThreadTranscriptSearchTool; use openhuman_core::openhuman::tools::traits::Tool; +use tinycortex::memory::conversations::{ + ConversationMessage, ConversationStore, CreateConversationThread, +}; // ── Env isolation (mirrors tests/memory_roundtrip_e2e.rs) ────────────────────