Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/src/host/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
//! rather than growing a subscribe method here.

/// Why an embedding model was reported unhealthy, and what took over.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EmbeddingHealthReason {
/// The provider that failed.
pub provider: String,
Expand All @@ -46,7 +46,7 @@ pub type SyncTrigger = String;
/// Deliberately **not** `#[non_exhaustive]`: the host's mapping impl matches
/// exhaustively on purpose, so adding a variant here is a compile error at the
/// mapping site rather than an event that silently never reaches the bus.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum MemoryEvent {
/// A sync run moved to a new stage.
SyncStageChanged {
Expand Down
37 changes: 21 additions & 16 deletions core/src/store/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,30 @@ impl MemoryClient {
/// tool-scoped memory layer) without depending on the concrete
/// `MemoryClient` type or holding a reference to it.
///
/// Intentionally `pub(crate)` — handing a raw `Arc<dyn Memory>` to an
/// external consumer bypasses any policy decorator wrapped around the
/// `MemoryClient` API, so the escape hatch stays in-crate. Mirrors
/// [`Self::profile_conn`].
/// This is public for the `tinymemory-module` provider, which implements
/// the TinyMemory contract over this exact client. Product hosts must use
/// the guarded provider and must not retain this raw engine handle.
pub fn memory_handle(&self) -> Arc<dyn crate::Memory> {
Arc::clone(&self.inner) as Arc<dyn crate::Memory>
}

/// Wrap an already-configured unified store and start its ingestion worker.
///
/// This is the constructor used by the compiled module: the module first
/// resolves the host-supplied embedding route and storage configuration,
/// then gives the resulting store to the high-level client without opening
/// a second database or creating a second ingestion queue.
#[must_use]
pub fn from_unified_memory(inner: UnifiedMemory) -> Self {
let inner = Arc::new(inner);
let ingestion_queue =
ingestion_queue::start_worker_with_state(Arc::clone(&inner), IngestionState::new());
Self {
inner,
ingestion_queue,
}
}

/// Create a new local memory client using the default `.openhuman` directory.
///
/// # Errors
Expand Down Expand Up @@ -141,18 +157,7 @@ impl MemoryClient {
// Create the underlying UnifiedMemory instance.
let memory =
UnifiedMemory::new(&workspace_dir, embedder, None).map_err(|e| format!("{e}"))?;
let inner = Arc::new(memory);

// Start the background worker for document ingestion and graph extraction.
// The worker shares its IngestionState with the synchronous ingest path
// below so all ingestion is singleton-serialised.
let ingestion_queue =
ingestion_queue::start_worker_with_state(Arc::clone(&inner), IngestionState::new());

Ok(Self {
inner,
ingestion_queue,
})
Ok(Self::from_unified_memory(memory))
}

/// Store a document in a specific namespace.
Expand Down
162 changes: 162 additions & 0 deletions core/src/store/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,92 @@ use crate::sync::composio::providers::profile::{is_self_identity_any_toolkit, Id
use crate::tinycortex::memory_config_from;
use crate::Config;

/// Aggregate entity-index row for capability providers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopEntity {
/// Canonical entity id.
pub id: String,
/// Stable entity kind string.
pub kind: String,
/// Representative observed surface form.
pub name: String,
/// Number of indexed observations.
pub mentions: u32,
}

/// Entity row scoped to one memory-tree namespace.
pub fn namespace_entities(
config: &Config,
namespace: &str,
query: Option<&str>,
limit: usize,
) -> Result<Vec<TopEntity>> {
let memory = memory_config_from(config, config.workspace_dir().clone());
let connection = tinycortex::memory::chunks::shared_connection(&memory)?;
let guard = connection.lock();
let pattern = query.map(|value| format!("%{}%", value.to_ascii_lowercase()));
let mut statement = guard.prepare(
"SELECT entity_id, entity_kind, MAX(surface), COUNT(*)
FROM mem_tree_entity_index
WHERE tree_id = ?1
AND (?2 IS NULL OR LOWER(entity_id) LIKE ?2 OR LOWER(surface) LIKE ?2)
GROUP BY entity_id, entity_kind
ORDER BY COUNT(*) DESC, MAX(timestamp_ms) DESC
LIMIT ?3",
)?;
let rows = statement
.query_map(
rusqlite::params![namespace, pattern, i64::try_from(limit).unwrap_or(i64::MAX)],
|row| {
let mentions: i64 = row.get(3)?;
Ok(TopEntity {
id: row.get(0)?,
kind: row.get(1)?,
name: row.get(2)?,
mentions: u32::try_from(mentions.max(0)).unwrap_or(u32::MAX),
})
},
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}

/// Co-occurrence edges scoped to one memory-tree namespace.
pub fn namespace_entity_edges(
config: &Config,
namespace: &str,
entity_id: &str,
limit: usize,
) -> Result<Vec<(String, u32)>> {
let memory = memory_config_from(config, config.workspace_dir().clone());
let connection = tinycortex::memory::chunks::shared_connection(&memory)?;
let guard = connection.lock();
let mut statement = guard.prepare(
"SELECT b.entity_id, COUNT(*)
FROM mem_tree_entity_index a
JOIN mem_tree_entity_index b
ON a.node_id = b.node_id AND a.tree_id = b.tree_id
WHERE a.tree_id = ?1 AND a.entity_id = ?2 AND b.entity_id <> a.entity_id
GROUP BY b.entity_id
ORDER BY COUNT(*) DESC
LIMIT ?3",
)?;
let rows = statement
.query_map(
rusqlite::params![
namespace,
entity_id,
i64::try_from(limit).unwrap_or(i64::MAX)
],
|row| {
let count: i64 = row.get(1)?;
Ok((row.get(0)?, u32::try_from(count.max(0)).unwrap_or(u32::MAX)))
},
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}

pub use tinycortex::memory::store::entity_index::EntityHit;

#[derive(Debug)]
Expand Down Expand Up @@ -80,10 +166,61 @@ pub fn count_entity_index(config: &Config) -> Result<u64> {
index(config)?.count_entity_index()
}

/// Most frequently observed entities, with recency as the tie-breaker.
pub fn top_entities(config: &Config, limit: usize) -> Result<Vec<TopEntity>> {
let memory = memory_config_from(config, config.workspace_dir().clone());
let connection = tinycortex::memory::chunks::shared_connection(&memory)?;
let guard = connection.lock();
let mut statement = guard.prepare(
"SELECT entity_id, entity_kind, MAX(surface), COUNT(*)
FROM mem_tree_entity_index
GROUP BY entity_id, entity_kind
ORDER BY COUNT(*) DESC, MAX(timestamp_ms) DESC
LIMIT ?1",
)?;
let rows = statement
.query_map([i64::try_from(limit).unwrap_or(i64::MAX)], |row| {
let mentions: i64 = row.get(3)?;
Ok(TopEntity {
id: row.get(0)?,
kind: row.get(1)?,
name: row.get(2)?,
mentions: u32::try_from(mentions.max(0)).unwrap_or(u32::MAX),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}

#[cfg(test)]
mod tests {
use super::*;

fn scoped_config() -> (
tempfile::TempDir,
tinymemory_api::host::test_support::TestHostConfig,
) {
crate::test_seams::init();
let temp = tempfile::tempdir().expect("tempdir");
let mut config = tinymemory_api::host::test_support::TestHostConfig::default();
config.workspace_dir = temp.path().to_path_buf();
(temp, config)
}

fn insert_entity(config: &Config, tree: &str, entity: &str, node: &str, surface: &str) {
let memory = memory_config_from(config, config.workspace_dir().clone());
let connection = tinycortex::memory::chunks::shared_connection(&memory).expect("db");
connection
.lock()
.execute(
"INSERT INTO mem_tree_entity_index
(entity_id,node_id,node_kind,entity_kind,surface,score,timestamp_ms,tree_id)
VALUES (?1,?2,'chunk','person',?3,1.0,1,?4)",
rusqlite::params![entity, node, surface, tree],
)
.expect("insert");
}

#[test]
fn crate_entity_hit_is_the_host_facade_type() {
let hit = EntityHit {
Expand All @@ -99,4 +236,29 @@ mod tests {
};
assert_eq!(hit.entity_id, "person:alice");
}

#[test]
fn namespace_entity_reads_do_not_cross_tree_ids() {
let (_temp, config) = scoped_config();
insert_entity(&config, "team-a", "person:alice", "a1", "Alice");
insert_entity(&config, "team-b", "person:bob", "b1", "Bob");

let rows = namespace_entities(&config, "team-a", None, 10).expect("entities");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "person:alice");
assert!(namespace_entities(&config, "team-a", Some("bob"), 10)
.expect("search")
.is_empty());
}

#[test]
fn namespace_edges_join_only_rows_in_the_same_tree() {
let (_temp, config) = scoped_config();
insert_entity(&config, "team-a", "person:alice", "shared", "Alice");
insert_entity(&config, "team-a", "person:bob", "shared", "Bob");
insert_entity(&config, "team-b", "person:mallory", "shared", "Mallory");

let rows = namespace_entity_edges(&config, "team-a", "person:alice", 10).expect("edges");
assert_eq!(rows, vec![("person:bob".to_string(), 1)]);
}
}
26 changes: 26 additions & 0 deletions core/src/store/factories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,32 @@ fn create_unified_memory_full(
)
}

/// Create the high-level memory client used by the compiled TinyMemory module.
///
/// Unlike [`crate::store::MemoryClient::from_workspace_dir`], this preserves
/// the caller's resolved embedding and storage configuration. The returned
/// client and its raw [`Memory`] handle share one `UnifiedMemory` instance and
/// one ingestion worker.
pub fn create_memory_client_with_local_ai(
memory: &MemoryConfig,
local_embedding_model: Option<&str>,
embedding_api_key: &str,
embedding_routes: &[EmbeddingRouteConfig],
storage_provider: Option<&StorageProviderConfig>,
workspace_dir: &Path,
) -> anyhow::Result<crate::store::MemoryClient> {
let store = create_unified_memory_full(
memory,
embedding_routes,
storage_provider,
local_embedding_model,
embedding_api_key,
workspace_dir,
"memory",
)?;
Ok(crate::store::MemoryClient::from_unified_memory(store))
}

/// Create a memory instance specifically for migration purposes.
///
/// The unified namespace memory core has a single workspace-scoped
Expand Down
Loading