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
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 @@ -239,6 +239,10 @@ pub fn build_router(state: AppState) -> Router {
// Memory reads
.route("/v1/memories", get(routes::memory::list_memories))
.route("/v1/memories/query", post(routes::memory::query_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
75 changes: 74 additions & 1 deletion memoria/crates/memoria-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ fn parse_memory_types_opt(
Ok(parsed)
}

fn parse_fulltext_memory_types_opt(
types: Option<&Vec<String>>,
) -> Result<Option<Vec<MemoryType>>, String> {
if types.is_some_and(|types| types.iter().any(|value| value.trim().is_empty())) {
return Err("memory_types entries must not be empty when provided".to_string());
}
parse_memory_types_opt(types)
}

impl RetrieveRequest {
pub fn session_scope(&self) -> Result<Option<memoria_service::SessionScope>, String> {
parse_session_scope(self.session_scope.as_deref())
Expand Down Expand Up @@ -232,6 +241,60 @@ fn normalized(value: Option<&str>) -> Option<String> {
.map(str::to_string)
}

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_fulltext_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| {
Expand Down Expand Up @@ -600,7 +663,9 @@ pub fn parse_trust_tier(s: &str) -> Result<TrustTier, String> {

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

#[test]
Expand All @@ -617,6 +682,14 @@ mod tests {
Some(vec![MemoryType::Semantic, MemoryType::Profile])
);
}
#[test]
fn fulltext_memory_type_parser_rejects_blank_entries_but_allows_empty_arrays() {
let blank = vec!["semantic".to_string(), " ".to_string()];
assert!(parse_fulltext_memory_types_opt(Some(&blank)).is_err());

let empty = vec![];
assert_eq!(parse_fulltext_memory_types_opt(Some(&empty)).unwrap(), None);
}

#[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 @@ -208,6 +208,30 @@ pub async fn query_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