diff --git a/crates/core/src/layers.rs b/crates/core/src/layers.rs index 28059da..da9c1ef 100644 --- a/crates/core/src/layers.rs +++ b/crates/core/src/layers.rs @@ -649,7 +649,12 @@ mod tests { for (i, id) in r.ids.iter().enumerate() { let content = r.documents.get(i).cloned().unwrap_or_default(); let metadata = r.metadatas.get(i).cloned().unwrap_or_default(); - drawers.push(Drawer { + // mp-migration 24/8: auto-migrate legacy drawers + // on every read so callers (Layer 1 wake-up, + // status, etc.) see the v1 (typed-field) shape + // regardless of which Palace version wrote the + // data. + let mut drawer = Drawer { id: Some(DrawerId(id.clone())), content, kind: DrawerKind::default(), @@ -664,7 +669,16 @@ mod tests { .map(String::from), metadata, derived_from: Vec::new(), - }); + tags: Vec::new(), + trust: None, + access_count: 0, + last_accessed: None, + reinforcements: Vec::new(), + superseded_by: None, + active: true, + }; + drawer.migrate_metadata(); + drawers.push(drawer); } } drawers.truncate(limit); diff --git a/crates/core/src/palace.rs b/crates/core/src/palace.rs index 0f31326..001584b 100644 --- a/crates/core/src/palace.rs +++ b/crates/core/src/palace.rs @@ -39,6 +39,7 @@ use async_trait::async_trait; use std::sync::Arc; +use tracing::warn; pub mod builder; pub mod store; @@ -393,6 +394,11 @@ pub struct Drawer { /// Arbitrary key-value metadata carried through to search results. /// Built-in keys: `source_file`, `created_at`, `filed_at`, `added_by`. /// Custom keys are allowed and forwarded to the vector store. + /// + /// New first-class fields ([`Drawer::tags`], [`Drawer::trust`], + /// [`Drawer::access_count`], etc.) are kept in sync with this map + /// by [`Drawer::migrate_metadata`] so callers that pre-date those + /// fields keep working. #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] pub metadata: std::collections::HashMap, /// IDs of drawers this drawer was derived from (mp-052 / ADR-10 / ADR-13). @@ -401,6 +407,79 @@ pub struct Drawer { /// citation chains: "I used drawer #42 which came from session #abc-123". #[serde(default, skip_serializing_if = "Vec::is_empty")] pub derived_from: Vec, + + // ---- First-class fields added in mp-migration 7/8 ---- + // + // These fields were previously stored only in `metadata` under the + // keys "tags" / "trust" / "access_count" / "last_accessed" / + // "reinforcements" / "superseded_by" / "active". They are now + // promoted to typed fields for type-safe access from the + // `MemoryProvider` trait (boost/decay/reinforce/supersede/tag/link). + // + // `#[serde(default)]` keeps backwards compatibility — drawers + // serialised before this change still load cleanly. The reverse + // direction (writing the typed field) is handled by + // `migrate_metadata` which is called by `add_drawer` on the + // embedvec path. + /// First-class tags. Mirrors `metadata["tags"]` (Vec). + /// Promoted from metadata in mp-migration 7/8. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + + /// Trust level. `"high" | "medium" | "low"`. Mirrors + /// `metadata["trust"]` (String). Promoted from metadata in + /// mp-migration 7/8. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trust: Option, + + /// How many times this drawer has been retrieved. Mirrors + /// `metadata["access_count"]` (u64). Promoted from metadata in + /// mp-migration 7/8. Updated by [`crate::retention::record_access`]. + #[serde(default)] + pub access_count: u64, + + /// Last time this drawer was retrieved. Mirrors + /// `metadata["last_accessed"]` (RFC 3339 string). Promoted from + /// metadata in mp-migration 7/8. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_accessed: Option>, + + /// Reinforcement history. Mirrors `metadata["reinforcements"]` + /// (Vec). Promoted from metadata in mp-migration 7/8. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reinforcements: Vec, + + /// If `Some`, this drawer has been superseded by the drawer with + /// this id. Mirrors `metadata["superseded_by"]` (String). + /// Promoted from metadata in mp-migration 7/8. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub superseded_by: Option, + + /// Whether this drawer is "active" (i.e. not superseded and not + /// deleted). Mirrors `metadata["active"]` (bool). Defaults to + /// `true`. Promoted from metadata in mp-migration 7/8. + #[serde(default = "default_active")] + pub active: bool, +} + +fn default_active() -> bool { + true +} + +/// A reinforcement breadcrumb — a record of when/where a drawer was +/// reinforced (the same fact re-encountered in a new session). +/// +/// Mirrors the `Reinforcement` struct in jcode's `memory_types::Reinforcement`. +/// Promoted to a first-class type in mp-migration 7/8 so it can be +/// referenced from the `MemoryProvider::reinforce` trait method. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct Reinforcement { + /// Session that reinforced the drawer. + pub session_id: String, + /// Message index within that session. + pub message_index: usize, + /// When the reinforcement happened. + pub timestamp: chrono::DateTime, } impl Drawer { @@ -414,6 +493,13 @@ impl Drawer { room: None, metadata: std::collections::HashMap::new(), derived_from: Vec::new(), + tags: Vec::new(), + trust: None, + access_count: 0, + last_accessed: None, + reinforcements: Vec::new(), + superseded_by: None, + active: true, } } @@ -447,6 +533,112 @@ impl Drawer { self.derived_from = ids; self } + + /// Builder methods for the new first-class fields (mp-migration 7/8). + /// These mirror `metadata()` but write to the typed field. + pub fn tags(mut self, tags: Vec) -> Self { + self.tags = tags; + self + } + + pub fn trust(mut self, trust: impl Into) -> Self { + self.trust = Some(trust.into()); + self + } + + /// One-shot migration: if the typed fields are empty but the legacy + /// `metadata` keys are populated, lift them up. Idempotent — safe + /// to call repeatedly. Called by `add_drawer` on the embedvec path + /// before upsert so the typed fields always reflect the source of + /// truth on disk. + pub fn migrate_metadata(&mut self) { + if self.tags.is_empty() { + if let Some(v) = self.metadata.remove("tags") { + match serde_json::from_value::>(v) { + Ok(arr) => self.tags = arr, + Err(e) => warn!("Drawer::migrate_metadata: failed to parse metadata['tags'] as Vec: {}", e), + } + } + } else { + // Keep typed and metadata in sync. + self.metadata.remove("tags"); + } + if self.trust.is_none() { + if let Some(v) = self.metadata.remove("trust") { + match v.as_str() { + Some(s) => self.trust = Some(s.to_string()), + None => warn!( + "Drawer::migrate_metadata: metadata['trust'] is not a string: {}", + v + ), + } + } + } else { + self.metadata.remove("trust"); + } + if self.access_count == 0 { + if let Some(v) = self.metadata.remove("access_count") { + match v.as_u64() { + Some(n) => self.access_count = n, + None => warn!( + "Drawer::migrate_metadata: metadata['access_count'] is not a u64: {}", + v + ), + } + } + } else { + self.metadata.remove("access_count"); + } + if self.last_accessed.is_none() { + if let Some(v) = self.metadata.remove("last_accessed") { + match v.as_str() { + Some(s) => match chrono::DateTime::parse_from_rfc3339(s) { + Ok(dt) => self.last_accessed = Some(dt.with_timezone(&chrono::Utc)), + Err(e) => warn!("Drawer::migrate_metadata: failed to parse metadata['last_accessed'] as RFC 3339: {}", e), + }, + None => warn!("Drawer::migrate_metadata: metadata['last_accessed'] is not a string: {}", v), + } + } + } else { + self.metadata.remove("last_accessed"); + } + if self.reinforcements.is_empty() { + if let Some(v) = self.metadata.remove("reinforcements") { + match serde_json::from_value::>(v) { + Ok(arr) => self.reinforcements = arr, + Err(e) => warn!("Drawer::migrate_metadata: failed to parse metadata['reinforcements'] as Vec: {}", e), + } + } + } else { + self.metadata.remove("reinforcements"); + } + if self.superseded_by.is_none() { + if let Some(v) = self.metadata.remove("superseded_by") { + match v.as_str() { + Some(s) => self.superseded_by = Some(DrawerId::new(s)), + None => warn!( + "Drawer::migrate_metadata: metadata['superseded_by'] is not a string: {}", + v + ), + } + } + } else { + self.metadata.remove("superseded_by"); + } + if self.active == default_active() { + if let Some(v) = self.metadata.remove("active") { + match v.as_bool() { + Some(b) => self.active = b, + None => warn!( + "Drawer::migrate_metadata: metadata['active'] is not a bool: {}", + v + ), + } + } + } else { + self.metadata.remove("active"); + } + } } // --------------------------------------------------------------------------- @@ -623,6 +815,12 @@ impl Palace { #[async_trait] impl MemoryProvider for Palace { async fn add_drawer(&self, drawer: Drawer) -> anyhow::Result { + // mp-migration 24/8: auto-migrate legacy metadata on every + // write so this drawer is persisted in the new (v1) shape. + // Idempotent — repeated calls are no-ops once migrated. + let mut drawer = drawer; + drawer.migrate_metadata(); + let content = drawer.content.clone(); let kind = drawer.kind; let id = Self::derive_drawer_id(&content); @@ -712,6 +910,9 @@ impl MemoryProvider for Palace { scope: Option<&SearchScope>, limit: Option, ) -> anyhow::Result> { + // mp-migration 24/8: store-level reads (usearch_sqlite + // get_drawer_by_id/all_drawers, layers test adapter) already + // migrate. Palace delegates without duplicating work. self.store.get_drawers(scope, limit).await } @@ -774,4 +975,98 @@ mod tests { assert_eq!(d.room, Some("auth-migration".into())); assert!(d.metadata.contains_key("source")); } + + // mp-migration 7/8: new Drawer fields default correctly. + #[test] + fn drawer_new_field_defaults() { + let d = Drawer::new("hello"); + assert!(d.tags.is_empty()); + assert!(d.trust.is_none()); + assert_eq!(d.access_count, 0); + assert!(d.last_accessed.is_none()); + assert!(d.reinforcements.is_empty()); + assert!(d.superseded_by.is_none()); + assert!(d.active); + } + + // mp-migration 7/8: typed builder methods. + #[test] + fn drawer_typed_builders() { + let d = Drawer::new("use Clerk") + .tags(vec!["auth".into(), "decision".into()]) + .trust("high"); + assert_eq!(d.tags, vec!["auth", "decision"]); + assert_eq!(d.trust.as_deref(), Some("high")); + } + + // mp-migration 7/8: backwards-compat serde load (old format). + #[test] + fn drawer_legacy_serde_load() { + // Simulates a JSON file written by the previous version where + // tags/trust/active were stored only in `metadata`. + let json = r#"{ + "content": "legacy drawer", + "kind": "fact", + "tier": "working", + "metadata": { + "tags": ["a", "b"], + "trust": "low", + "access_count": 7, + "active": false + } + }"#; + let d: Drawer = serde_json::from_str(json).unwrap(); + // Defaults applied to missing typed fields. + assert_eq!(d.tags, Vec::::new()); + assert_eq!(d.trust, None); + assert_eq!(d.access_count, 0); + assert!(d.active); + // Legacy data still in metadata. + assert_eq!( + d.metadata.get("tags").unwrap(), + &serde_json::json!(["a", "b"]) + ); + } + + // mp-migration 7/8: migrate_metadata lifts legacy keys to typed fields. + #[test] + fn drawer_migrate_metadata() { + let mut d = Drawer::new("legacy"); + d.metadata + .insert("tags".into(), serde_json::json!(["x", "y"])); + d.metadata + .insert("trust".into(), serde_json::json!("medium")); + d.metadata + .insert("access_count".into(), serde_json::json!(3)); + d.metadata.insert("active".into(), serde_json::json!(true)); + d.migrate_metadata(); + assert_eq!(d.tags, vec!["x", "y"]); + assert_eq!(d.trust.as_deref(), Some("medium")); + assert_eq!(d.access_count, 3); + // Metadata cleaned of lifted keys. + assert!(!d.metadata.contains_key("tags")); + assert!(!d.metadata.contains_key("trust")); + assert!(!d.metadata.contains_key("access_count")); + assert!(!d.metadata.contains_key("active")); + } + + // mp-migration 7/8: migrate_metadata is idempotent. + #[test] + fn drawer_migrate_metadata_idempotent() { + let mut d = Drawer::new("legacy"); + d.metadata.insert("tags".into(), serde_json::json!(["x"])); + d.migrate_metadata(); + d.migrate_metadata(); + assert_eq!(d.tags, vec!["x"]); + } + + // mp-migration 7/8: round-trip serde preserves new fields. + #[test] + fn drawer_new_field_serde_roundtrip() { + let d = Drawer::new("hi").tags(vec!["a".into()]).trust("high"); + let json = serde_json::to_string(&d).unwrap(); + let back: Drawer = serde_json::from_str(&json).unwrap(); + assert_eq!(back.tags, vec!["a"]); + assert_eq!(back.trust.as_deref(), Some("high")); + } } diff --git a/crates/core/src/palace/store/usearch_sqlite.rs b/crates/core/src/palace/store/usearch_sqlite.rs index f50c9a2..8b68766 100644 --- a/crates/core/src/palace/store/usearch_sqlite.rs +++ b/crates/core/src/palace/store/usearch_sqlite.rs @@ -98,7 +98,10 @@ impl Inner { let kind_str: String = row.get(2)?; let tier_str: String = row.get(3)?; let metadata_str: String = row.get(6)?; - Ok(Some(Drawer { + // mp-migration 24/8: auto-migrate legacy drawers on + // every read so callers see the v1 (typed-field) shape + // even if the data was written by a pre-PR #7 Palace. + let mut drawer = Drawer { id: Some(DrawerId(id_str)), content: row.get(1)?, kind: serde_json::from_str(&kind_str).unwrap_or_default(), @@ -107,7 +110,16 @@ impl Inner { room: row.get(5)?, metadata: serde_json::from_str(&metadata_str).unwrap_or_default(), derived_from: vec![], - })) + tags: Vec::new(), + trust: None, + access_count: 0, + last_accessed: None, + reinforcements: Vec::new(), + superseded_by: None, + active: true, + }; + drawer.migrate_metadata(); + Ok(Some(drawer)) } else { Ok(None) } @@ -132,7 +144,9 @@ impl Inner { let kind_str: String = row.get(2)?; let tier_str: String = row.get(3)?; let metadata_str: String = row.get(6)?; - drawers.push(Drawer { + // mp-migration 24/8: auto-migrate legacy drawers on + // every read (see comment in get_drawer_by_id above). + let mut drawer = Drawer { id: Some(DrawerId(id_str)), content: row.get(1)?, kind: serde_json::from_str(&kind_str).unwrap_or_default(), @@ -141,7 +155,16 @@ impl Inner { room: row.get(5)?, metadata: serde_json::from_str(&metadata_str).unwrap_or_default(), derived_from: vec![], - }); + tags: Vec::new(), + trust: None, + access_count: 0, + last_accessed: None, + reinforcements: Vec::new(), + superseded_by: None, + active: true, + }; + drawer.migrate_metadata(); + drawers.push(drawer); } Ok(drawers) }