use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use uuid::Uuid;
use super::record::SharedMemoryRecord;
use crate::memory_core::content_hash::ContentHash;
use crate::memory_core::embed::Embedder;
use crate::memory_core::palace::{Drawer, RoomType};
use crate::memory_core::retrieval::{PalaceHandle, shared_embedder};
use crate::memory_core::room_identity::DEFAULT_WING_ID;
use crate::memory_core::store::l1_cache::L1Cache;
use crate::memory_core::store::rooms::resolve_or_create_room_in_wing;
use crate::memory_core::store::vector::VectorStore;
use crate::memory_core::timeouts;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportOutcome {
Inserted,
Merged,
Unchanged,
Skipped,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ImportSummary {
pub inserted: usize,
pub merged: usize,
pub unchanged: usize,
pub skipped: usize,
}
impl ImportSummary {
fn record(&mut self, outcome: ImportOutcome) {
match outcome {
ImportOutcome::Inserted => self.inserted += 1,
ImportOutcome::Merged => self.merged += 1,
ImportOutcome::Unchanged => self.unchanged += 1,
ImportOutcome::Skipped => self.skipped += 1,
}
}
pub fn changed_anything(&self) -> bool {
self.inserted > 0 || self.merged > 0
}
}
pub async fn import_palace_jsonl(handle: &PalaceHandle, path: &Path) -> Result<ImportSummary> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read the export at {}", path.display()))?;
let mut records = Vec::new();
let mut summary = ImportSummary::default();
for (i, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<SharedMemoryRecord>(line) {
Ok(r) => records.push(r),
Err(e) => {
tracing::warn!(
line = i + 1,
file = %path.display(),
"#5902 import: skipping unparseable JSONL line: {e}"
);
summary.skipped += 1;
}
}
}
let applied = import_palace_records(handle, &records).await?;
summary.inserted += applied.inserted;
summary.merged += applied.merged;
summary.unchanged += applied.unchanged;
summary.skipped += applied.skipped;
Ok(summary)
}
pub async fn import_palace_records(
handle: &PalaceHandle,
records: &[SharedMemoryRecord],
) -> Result<ImportSummary> {
if records.is_empty() {
return Ok(ImportSummary::default());
}
let embedder = shared_embedder()
.await
.context("acquire the shared embedder for import")?;
import_palace_records_with_embedder(handle, records, &embedder).await
}
pub(crate) async fn import_palace_records_with_embedder(
handle: &PalaceHandle,
records: &[SharedMemoryRecord],
embedder: &Arc<dyn Embedder + Send + Sync>,
) -> Result<ImportSummary> {
let mut summary = ImportSummary::default();
if records.is_empty() {
return Ok(summary);
}
handle.touch();
if handle.is_read_only() {
return Err(anyhow::anyhow!(
"palace '{}' is read-only: the HTTP daemon holds the write lock, so an \
import cannot proceed — route it through the daemon or stop it first",
handle.id
));
}
let _write_guard = timeouts::lock_with_timeout(
&handle.write_mutex,
timeouts::write_lock_timeout(),
handle.id.as_str(),
)
.await?;
let mut changed = false;
for rec in records {
match apply_one(handle, rec, embedder).await {
Ok(outcome) => {
if matches!(outcome, ImportOutcome::Inserted | ImportOutcome::Merged) {
changed = true;
}
summary.record(outcome);
}
Err(e) => {
tracing::warn!(
palace = %handle.id,
hash = %rec.content_hash,
"#5902 import: skipping a record: {e:#}"
);
summary.record(ImportOutcome::Skipped);
}
}
}
if changed {
if let Some(data_dir) = handle.data_dir.as_ref() {
let snap = handle.drawers.read().clone();
L1Cache::save_l1_cache(&snap, data_dir).context("save the L1 snapshot after import")?;
}
handle.rebuild_closets();
}
Ok(summary)
}
async fn apply_one(
handle: &PalaceHandle,
rec: &SharedMemoryRecord,
embedder: &Arc<dyn Embedder + Send + Sync>,
) -> Result<ImportOutcome> {
let hash = rec.verify().context("verify the record")?;
let existing = find_by_hash(handle, hash);
match existing {
Some((id, local_created_at, local_tags, local_importance)) => {
merge_into_existing(
handle,
rec,
id,
local_created_at,
local_tags,
local_importance,
)
.await
}
None => insert_new(handle, rec, embedder)
.await
.map(|_| ImportOutcome::Inserted),
}
}
fn find_by_hash(
handle: &PalaceHandle,
hash: ContentHash,
) -> Option<(Uuid, DateTime<Utc>, Vec<String>, f32)> {
handle
.drawers
.read()
.iter()
.filter(|d| d.content_hash() == hash)
.min_by_key(|d| (d.created_at, d.id))
.map(|d| (d.id, d.created_at, d.tags.clone(), d.importance))
}
async fn merge_into_existing(
handle: &PalaceHandle,
rec: &SharedMemoryRecord,
id: Uuid,
local_created_at: DateTime<Utc>,
local_tags: Vec<String>,
local_importance: f32,
) -> Result<ImportOutcome> {
let earlier = local_created_at.min(rec.created_at);
let mut tags = local_tags.clone();
for t in &rec.tags {
if !tags.contains(t) {
tags.push(t.clone());
}
}
let importance = local_importance.max(rec.importance.clamp(0.0, 1.0));
let unchanged = earlier == local_created_at
&& tags.len() == local_tags.len()
&& (importance - local_importance).abs() < f32::EPSILON;
if unchanged {
return Ok(ImportOutcome::Unchanged);
}
let Some(mut updated) = handle.drawers.read().iter().find(|d| d.id == id).cloned() else {
return Ok(ImportOutcome::Skipped);
};
updated.created_at = earlier;
updated.tags = tags;
updated.importance = importance;
handle
.kg
.upsert_drawer(&updated)
.await
.context("persist the merged drawer")?;
{
let mut drawers = handle.drawers.write();
for d in drawers.iter_mut().filter(|d| d.id == id) {
*d = updated.clone();
}
}
Ok(ImportOutcome::Merged)
}
async fn insert_new(
handle: &PalaceHandle,
rec: &SharedMemoryRecord,
embedder: &Arc<dyn Embedder + Send + Sync>,
) -> Result<Uuid> {
let room = RoomType::parse(&rec.room);
let room_id = resolve_or_create_room_in_wing(&handle.kg, &room, DEFAULT_WING_ID).await;
let mut drawer = Drawer::new(room_id, rec.content.clone());
drawer.created_at = rec.created_at;
drawer.tags = rec.tags.clone();
drawer.importance = rec.importance.clamp(0.0, 1.0);
drawer.drawer_type = rec.parsed_drawer_type();
drawer.expires_at = None;
drawer.fact_key = None;
let id = drawer.id;
let embed_timeout = timeouts::embed_batch_timeout();
let vecs = tokio::time::timeout(
embed_timeout,
embedder.embed_batch(std::slice::from_ref(&rec.content)),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"embed_batch timed out after {embed_timeout:?} while importing a shared \
memory; raise TRUSTY_EMBED_BATCH_TIMEOUT_SECS if batches legitimately \
take longer on this host"
)
})?
.context("embed the imported content")?;
if let Some(v) = vecs.into_iter().next() {
handle
.vector_store
.upsert(id, v)
.await
.context("upsert the imported drawer's vector")?;
}
handle
.kg
.upsert_drawer(&drawer)
.await
.context("persist the imported drawer")?;
handle.drawers.write().push(drawer);
Ok(id)
}
pub fn merge_records(sets: &[&[SharedMemoryRecord]]) -> Vec<SharedMemoryRecord> {
let mut by_hash: HashMap<ContentHash, SharedMemoryRecord> = HashMap::new();
for set in sets {
for rec in set.iter() {
if rec.verify().is_err() {
continue;
}
match by_hash.get_mut(&rec.content_hash) {
None => {
by_hash.insert(rec.content_hash, rec.clone());
}
Some(kept) => {
kept.created_at = kept.created_at.min(rec.created_at);
for t in &rec.tags {
if !kept.tags.contains(t) {
kept.tags.push(t.clone());
}
}
kept.importance = kept.importance.max(rec.importance);
}
}
}
}
let mut out: Vec<SharedMemoryRecord> = by_hash.into_values().collect();
out.sort_by(|a, b| {
a.content_hash
.cmp(&b.content_hash)
.then_with(|| a.created_at.cmp(&b.created_at))
});
out
}