diff --git a/crates/uteke-core/src/memory/crud.rs b/crates/uteke-core/src/memory/crud.rs index 619d34e7..d50429dc 100644 --- a/crates/uteke-core/src/memory/crud.rs +++ b/crates/uteke-core/src/memory/crud.rs @@ -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, + pinned: Option, + memory_type: Option<&str>, + updated_at: chrono::DateTime, + ) -> Result { + let mut set_clauses: Vec = Vec::new(); + let mut params_vec: Vec> = 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). diff --git a/crates/uteke-core/src/operations.rs b/crates/uteke-core/src/operations.rs index 91f29c86..d8a1d17b 100644 --- a/crates/uteke-core/src/operations.rs +++ b/crates/uteke-core/src/operations.rs @@ -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, + pinned: Option, + memory_type: Option<&str>, + ) -> Result { + // 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 diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index e8480624..0ab544df 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -699,6 +699,65 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< } } + // ── Update memory by ID (PUT /memory, #659) ────────────────── + (Method::Put, "/memory") => match read_body::(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> = 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?"); diff --git a/crates/uteke-server/src/types.rs b/crates/uteke-server/src/types.rs index d0a88b5e..6c7a4a65 100644 --- a/crates/uteke-server/src/types.rs +++ b/crates/uteke-server/src/types.rs @@ -323,6 +323,32 @@ pub struct TagDeleteRequest { pub namespace: Option, } +// ── 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, + /// Replace tags entirely with this list. + #[serde(default)] + pub tags: Option>, + /// Replace metadata entirely with this object. + #[serde(default)] + pub metadata: Option, + /// Set importance score (0.0–1.0). + #[serde(default)] + pub importance: Option, + /// Set pinned state. + #[serde(default)] + pub pinned: Option, + /// Set memory type (fact, procedure, preference, decision, context, note, insight, reference, event). + #[serde(default)] + pub memory_type: Option, +} + // ── Pin Types ───────────────────────────────────────────────────────────── #[derive(Deserialize)]