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 @@ -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
119 changes: 113 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,21 @@ 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)
}

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 {
Expand Down Expand Up @@ -143,6 +156,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_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| {
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 +578,34 @@ pub fn parse_trust_tier(s: &str) -> Result<TrustTier, String> {

#[cfg(test)]
mod tests {
use super::{PurgeRequest, PurgeSelector};
use super::{
parse_fulltext_memory_types_opt, 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 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 @@ -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