Skip to content
Closed
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
104 changes: 104 additions & 0 deletions crates/uteke-core/src/memory/crud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,110 @@ impl super::Store {
Ok(())
}

/// Partial update of specific memory fields (#659).
///
/// Only modifies fields that are `Some(...)` in the parameters.
/// Returns true if a row was found and updated, false if the memory ID
/// does not exist. Always sets `updated_at` to the provided timestamp.
///
/// When `tags` is `Some`, performs dual-write: replaces junction table.
/// When `content` is `Some`, caller must handle embedding regeneration
/// separately (via `VectorIndex::insert` with the new embedding).
pub fn update_fields(
&self,
id: &str,
content: Option<&str>,
tags: Option<&[String]>,
metadata: Option<&serde_json::Value>,
importance: Option<f64>,
pinned: Option<bool>,
memory_type: Option<&str>,
updated_at: chrono::DateTime<chrono::Utc>,
) -> Result<bool, Error> {
let mut set_clauses: Vec<String> = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();

// updated_at is always set
set_clauses.push("updated_at = ?".to_string());
params_vec.push(Box::new(updated_at.to_rfc3339()));

if let Some(c) = content {
set_clauses.push("content = ?".to_string());
params_vec.push(Box::new(c.to_string()));
// Detect content_type for JSON content
let ct = detect_content_type(c);
set_clauses.push("content_type = ?".to_string());
params_vec.push(Box::new(ct.to_string()));
}
if let Some(t) = tags {
let tags_json =
serde_json::to_string(t).map_err(|e| Error::db("database operation", e))?;
set_clauses.push("tags = ?".to_string());
params_vec.push(Box::new(tags_json));
}
if let Some(m) = metadata {
let meta_json =
serde_json::to_string(m).map_err(|e| Error::db("database operation", e))?;
set_clauses.push("metadata = ?".to_string());
params_vec.push(Box::new(meta_json));
}
if let Some(imp) = importance {
set_clauses.push("importance = ?".to_string());
params_vec.push(Box::new(imp));
}
if let Some(p) = pinned {
set_clauses.push("pinned = ?".to_string());
params_vec.push(Box::new(p as i32));
}
if let Some(mt) = memory_type {
set_clauses.push("memory_type = ?".to_string());
params_vec.push(Box::new(mt.to_string()));
}

if set_clauses.is_empty() {
// Nothing to update — check existence
let exists: bool = self
.conn
.query_row("SELECT EXISTS(SELECT 1 FROM memories WHERE id = ?1)", params![id], |row| row.get(0))
.map_err(|e| Error::db("database operation", e))?;
return Ok(exists);
}

let sql = format!(
"UPDATE memories SET {} WHERE id = ?",
set_clauses.join(", ")
);
params_vec.push(Box::new(id.to_string()));

let param_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
let rows = self
.conn
.execute(&sql, param_refs.as_slice())
.map_err(|e| Error::db("update memory fields", e))?;

// Dual-write: sync junction table tags if tags were provided
if tags.is_some() {
self.conn
.execute(
"DELETE FROM memory_tags WHERE memory_id = ?1",
params![id],
)
.map_err(|e| Error::db("delete old tags", e))?;
if let Some(t) = tags {
for tag in t {
self.conn
.execute(
"INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?1, ?2)",
params![id, tag],
)
.map_err(|e| Error::db("insert tag", e))?;
}
}
}

Ok(rows > 0)
}

/// List memories with optional tag filter, namespace filter, and pagination.
///
/// When `namespace` is `None`, returns memories from ALL namespaces (#526).
Expand Down
134 changes: 134 additions & 0 deletions crates/uteke-core/src/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,140 @@ impl crate::Uteke {
self.store.get_by_id(id)
}

/// Update an existing memory with partial fields (#659).
///
/// Only provided fields are changed. If `content` is changed, the
/// embedding is regenerated and the vector index is updated.
/// Returns `Ok(true)` if the memory was found and updated,
/// `Ok(false)` if the memory ID doesn't exist.
///
/// Acceptance criteria:
/// - Partial update semantics (only provided fields changed)
/// - Content update regenerates embedding
/// - 404 if not found (caller checks return value)
pub fn update_memory(
&self,
id: &str,
content: Option<&str>,
tags: Option<&[String]>,
metadata: Option<&serde_json::Value>,
importance: Option<f64>,
pinned: Option<bool>,
memory_type: Option<&str>,
) -> Result<bool, Error> {
// Validate memory exists
let existing = self
.store
.get_by_id(id)?
.ok_or_else(|| Error::Validation(format!("Memory not found: {id}")))?;

// Validate new content if provided
if let Some(c) = content {
crate::validate_input(c, &[])?;
}

// Validate new tags if provided
if let Some(t) = tags {
let tag_refs: Vec<&str> = t.iter().map(|s| s.as_str()).collect();
crate::validate_input(content.unwrap_or(&existing.content), &tag_refs)?;
}

// Validate new memory_type if provided
if let Some(mt) = memory_type {
crate::memory::types::MemoryType::from_str_opt(mt).ok_or_else(|| {
Error::Validation(format!(
"Unknown memory type '{mt}'. Valid types: fact, procedure, preference, decision, context, note, insight, reference, event"
))
})?;
}

// Validate importance range
if let Some(imp) = importance {
if !(0.0..=1.0).contains(&imp) {
return Err(Error::Validation(format!(
"Importance must be between 0.0 and 1.0, got {imp}"
)));
}
}

let now = chrono::Utc::now();

// Re-embed if content changed
if let Some(c) = content {
let content_type = crate::memory::crud::detect_content_type(c);
let embed_text = if content_type == "json" {
crate::memory::crud::flatten_json_for_embedding(c)
} else {
c.to_string()
};
self.ensure_embedder()?;
let new_embedding = retry_embed(&self.embedder, &embed_text)?;

// Update SQLite first, then vector index
let updated = self.store.update_fields(
id,
content,
tags,
metadata,
importance,
pinned,
memory_type,
now,
)?;

if !updated {
return Ok(false);
}

// Update vector index (insert handles dedup by removing old entry)
let mut index = self
.index
.write()
.map_err(|_| Error::lock("index write lock during update_memory"))?;
index.insert(id, &new_embedding)?;

// Persist index with retry
for attempt in 0..3 {
match index.save() {
Ok(()) => break,
Err(e) => {
if attempt < 2 {
tracing::warn!(
"Index save attempt {}/3 failed after update_memory for id={id}: {e}. Retrying...",
attempt + 1
);
std::thread::sleep(std::time::Duration::from_millis(200));
} else {
tracing::error!(
"Index save failed after 3 attempts for id={id}: {e}. Index may be stale on next launch."
);
}
}
}
}
} else {
// No content change — just update SQLite fields
let updated = self.store.update_fields(
id,
None,
tags,
metadata,
importance,
pinned,
memory_type,
now,
)?;
if !updated {
return Ok(false);
}
}

// Invalidate recall cache for the memory's namespace
self.recall_cache.invalidate_namespace(&existing.namespace);

Ok(true)
}

/// Recall memories that existed at a specific point in time.
///
/// Runs a semantic recall to gather candidates, then post-filters by
Expand Down
59 changes: 59 additions & 0 deletions crates/uteke-server/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,65 @@ pub fn route(uteke: &Mutex<Uteke>, ctx: &ReqCtx, req: &mut Request) -> Response<
}
}

// ── Update memory by ID (PUT /memory, #659) ──────────────────
(Method::Put, "/memory") => match read_body::<MemoryUpdateRequest>(req.as_reader()) {
Ok(req_data) => {
// Validate UUID format
if uuid::Uuid::parse_str(&req_data.id).is_err() {
return ctx.error_response_for(
req,
400,
format!("Invalid UUID format: {}", req_data.id),
);
}
// Check that at least one field is provided
if req_data.content.is_none()
&& req_data.tags.is_none()
&& req_data.metadata.is_none()
&& req_data.importance.is_none()
&& req_data.pinned.is_none()
&& req_data.memory_type.is_none()
{
return ctx.error_response_for(
req,
400,
"No fields to update. Provide at least one of: content, tags, metadata, importance, pinned, memory_type",
);
}
let tag_refs: Option<Vec<String>> = req_data.tags;
let tag_slice: Option<&[String]> = tag_refs.as_deref();
match uteke.update_memory(
&req_data.id,
req_data.content.as_deref(),
tag_slice,
req_data.metadata.as_ref(),
req_data.importance,
req_data.pinned,
req_data.memory_type.as_deref(),
) {
Ok(true) => ctx.ok_response_for(
req,
&serde_json::json!({"updated": req_data.id}),
),
Ok(false) => ctx.error_response_for(
req,
404,
format!("Memory not found: {}", req_data.id),
),
Err(e) => {
error!("Update memory error: {e}");
// Propagate validation errors with proper status codes
let status = match e {
crate::Error::Validation(_) => 400,
_ => 500,
};
ctx.error_response_for(req, status, e.to_string())
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},

// ── Room Memories (chronological listing — GET /room/memories) ────
(Method::Get, p) if p == "/room/memories" || p.starts_with("/room/memories?") => {
let query_str = p.strip_prefix("/room/memories?");
Expand Down
26 changes: 26 additions & 0 deletions crates/uteke-server/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,32 @@ pub struct TagDeleteRequest {
pub namespace: Option<String>,
}

// ── Memory Update Types (#659) ─────────────────────────────────────────────

#[derive(Deserialize)]
pub struct MemoryUpdateRequest {
/// UUID of the memory to update (required).
pub id: String,
/// New content. Triggers embedding regeneration.
#[serde(default)]
pub content: Option<String>,
/// Replace tags entirely with this list.
#[serde(default)]
pub tags: Option<Vec<String>>,
/// Replace metadata entirely with this object.
#[serde(default)]
pub metadata: Option<serde_json::Value>,
/// Set importance score (0.0–1.0).
#[serde(default)]
pub importance: Option<f64>,
/// Set pinned state.
#[serde(default)]
pub pinned: Option<bool>,
/// Set memory type (fact, procedure, preference, decision, context, note, insight, reference, event).
#[serde(default)]
pub memory_type: Option<String>,
}

// ── Pin Types ─────────────────────────────────────────────────────────────

#[derive(Deserialize)]
Expand Down
Loading