Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 6 additions & 0 deletions memoria/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

### Added

**Filtered Full-text Search** (`POST /v1/memories/fulltext-search`)
- Pure MatrixOne lexical full-text search without embedding, vector, graph, or hybrid retrieval
- Exact metadata, subject, memory type, session, trust tier, user/group, active-memory, and branch SQL pre-filters
- Strict session equality (unscoped memories are excluded when `session_id` is supplied)
- REST and sync/async Python SDK only; intentionally not added to the MCP tool surface

**Admin API** (`GET/DELETE /admin/*`, `POST /admin/governance/:id/trigger`)
- User listing, per-user stats, user deletion
- Trigger governance/consolidate per user on demand
Expand Down
4 changes: 4 additions & 0 deletions memoria/crates/memoria-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/metrics", get(routes::metrics::prometheus_metrics))
// Memory reads
.route("/v1/memories", get(routes::memory::list_memories))
.route(
"/v1/memories/fulltext-search",
post(routes::memory::fulltext_search_memories),
)
.route("/v1/memories/retrieve", post(routes::memory::retrieve))
.route("/v1/memories/search", post(routes::memory::search))
.route("/v1/memories/:id", get(routes::memory::get_memory))
Expand Down
99 changes: 93 additions & 6 deletions memoria/crates/memoria-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use memoria_core::{Memory, MemoryType, TrustTier};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;

// ── Memory ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -64,10 +65,8 @@ fn parse_session_scope(
.transpose()
}

fn parse_memory_types_opt(
types: Option<&Vec<String>>,
) -> Result<Option<Vec<MemoryType>>, String> {
types
fn parse_memory_types_opt(types: Option<&Vec<String>>) -> Result<Option<Vec<MemoryType>>, String> {
let mut parsed = types
.map(|ts| {
ts.iter()
.map(|s| s.trim())
Expand All @@ -76,7 +75,12 @@ fn parse_memory_types_opt(
.collect::<Result<Vec<_>, _>>()
})
.transpose()
.map(|v| v.filter(|t| !t.is_empty()))
.map(|v| v.filter(|t| !t.is_empty()))?;
if let Some(types) = parsed.as_mut() {
let mut seen = std::collections::HashSet::new();
types.retain(|memory_type| seen.insert(memory_type.clone()));
}
Ok(parsed)
}

impl RetrieveRequest {
Expand Down Expand Up @@ -143,6 +147,73 @@ impl SearchRequest {
}
}

fn default_fulltext_search_limit() -> i64 {
memoria_storage::FULLTEXT_SEARCH_DEFAULT_LIMIT
}

/// Pure MatrixOne full-text search with exact structured SQL pre-filters.
/// Session filtering is strict and does not include unscoped memories.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FulltextSearchRequest {
pub query: String,
#[serde(default)]
pub extra_metadata_filter: HashMap<String, serde_json::Value>,
pub subject_id: Option<String>,
pub memory_types: Option<Vec<String>>,
pub session_id: Option<String>,
pub trust_tier: Option<String>,
pub branch: Option<String>,
#[serde(default = "default_fulltext_search_limit")]
pub limit: i64,
}

impl FulltextSearchRequest {
pub fn fulltext_options(&self) -> Result<memoria_service::FulltextSearchOptions, String> {
if !(1..=memoria_storage::FULLTEXT_SEARCH_MAX_LIMIT).contains(&self.limit) {
return Err(format!(
"limit must be between 1 and {}",
memoria_storage::FULLTEXT_SEARCH_MAX_LIMIT
));
}
memoria_storage::validate_fulltext_query(&self.query).map_err(|error| error.to_string())?;
memoria_storage::validate_extra_metadata_filter(&self.extra_metadata_filter)
.map_err(|error| error.to_string())?;

let session_id = normalized_filter("session_id", self.session_id.as_deref())?;
let subject_id = normalized_filter("subject_id", self.subject_id.as_deref())?;
let trust_tier = normalized_filter("trust_tier", self.trust_tier.as_deref())?
.as_deref()
.map(parse_trust_tier)
.transpose()?;
Ok(memoria_service::FulltextSearchOptions {
limit: self.limit,
memory_types: parse_memory_types_opt(self.memory_types.as_ref())?,
session_id,
trust_tier,
subject_id,
extra_metadata_filter: self.extra_metadata_filter.clone(),
})
}

pub fn fulltext_branch(&self) -> Result<Option<String>, String> {
normalized_filter("branch", self.branch.as_deref())
}
}

fn normalized_filter(name: &str, value: Option<&str>) -> Result<Option<String>, String> {
value
.map(|value| {
let value = value.trim();
if value.is_empty() {
Err(format!("{name} must not be empty when provided"))
} else {
Ok(value.to_string())
}
})
.transpose()
}

fn deserialize_explain<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
use serde::Deserialize;
#[derive(Deserialize)]
Expand Down Expand Up @@ -498,7 +569,23 @@ pub fn parse_trust_tier(s: &str) -> Result<TrustTier, String> {

#[cfg(test)]
mod tests {
use super::{PurgeRequest, PurgeSelector};
use super::{parse_memory_types_opt, PurgeRequest, PurgeSelector};
use memoria_core::MemoryType;

#[test]
fn memory_type_parser_deduplicates_before_sql_option_construction() {
let raw = vec![
"semantic".to_string(),
" semantic ".to_string(),
"profile".to_string(),
"semantic".to_string(),
];

assert_eq!(
parse_memory_types_opt(Some(&raw)).unwrap(),
Some(vec![MemoryType::Semantic, MemoryType::Profile])
);
}

#[test]
fn purge_selector_ignores_empty_arrays() {
Expand Down
24 changes: 24 additions & 0 deletions memoria/crates/memoria-api/src/routes/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,30 @@ pub async fn list_memories(
Ok(Json(ListResponse { items, next_cursor }))
}

pub async fn fulltext_search_memories(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<FulltextSearchRequest>,
) -> ApiResult<Vec<MemoryResponse>> {
let branch = req
.fulltext_branch()
.map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error))?;
let options = req
.fulltext_options()
.map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error))?;
let memories = state
.service
.search_fulltext_structured_on_branch(
auth.scope_id(),
branch.as_deref(),
&req.query,
&options,
)
.await
.map_err(api_err_typed)?;
Ok(Json(memories.into_iter().map(Into::into).collect()))
}

pub async fn store_memory(
State(state): State<AppState>,
auth: AuthUser,
Expand Down
Loading
Loading