memorph 0.1.12

Convert, import, and export AI coding sessions between Claude Code, Codex, and OpenCode
Documentation
use crate::canonical::{
    CanonicalSchema, CanonicalSession, EventBlock, EventLinks, EventMetadata, EventRole,
    EventSource, ImportedSession, MappingDirection, MappingDisposition, MappingReport,
    ProviderSessionRef, SessionContext, SessionEvent, SessionEventKind, SessionIdentity,
    SessionProvenance,
};
use crate::providers::cursor::db::{list_bubbles, list_composers, BubbleData, ComposerData};
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::collections::BTreeMap;

pub fn import_session(composer_id: &str) -> Result<ImportedSession> {
    let composer = list_composers()?
        .into_iter()
        .find(|composer| composer.composer_id == composer_id);
    let bubbles = list_bubbles(composer_id)?;
    Ok(imported_session_from_cursor(composer_id, composer, bubbles))
}

fn imported_session_from_cursor(
    composer_id: &str,
    composer: Option<ComposerData>,
    mut bubbles: Vec<BubbleData>,
) -> ImportedSession {
    bubbles.sort_by(|a, b| {
        let a_ts = a.created_at.as_deref().unwrap_or("");
        let b_ts = b.created_at.as_deref().unwrap_or("");
        a_ts.cmp(b_ts)
    });

    let mut events = Vec::new();
    for bubble in bubbles {
        let role = bubble_role(bubble.bubble_type);
        let timestamp = bubble
            .created_at
            .as_deref()
            .and_then(parse_timestamp)
            .unwrap_or_else(Utc::now);
        let mut blocks = Vec::new();
        if let Some(text) = bubble
            .text
            .as_deref()
            .map(str::trim)
            .filter(|text| !text.is_empty())
        {
            blocks.push(EventBlock::Text {
                text: text.to_string(),
            });
        }
        if blocks.is_empty() {
            blocks.push(EventBlock::ProviderPayload {
                kind: "cursor_bubble".to_string(),
                payload: bubble_payload(&bubble),
            });
        }

        let mut provider_ext = BTreeMap::new();
        if let Some(request_id) = bubble.request_id.as_ref() {
            provider_ext.insert("request_id".to_string(), Value::String(request_id.clone()));
        }
        if let Some(model_info) = bubble.model_info.as_ref() {
            provider_ext.insert("model_info".to_string(), model_info.clone());
        }
        provider_ext.insert("cursor_bubble".to_string(), bubble_payload(&bubble));

        events.push(SessionEvent {
            id: bubble.bubble_id.clone(),
            kind: SessionEventKind::Message,
            role,
            timestamp,
            links: EventLinks::default(),
            blocks,
            metadata: EventMetadata {
                source: EventSource {
                    provider_id: "cursor".to_string(),
                    original_id: Some(bubble.bubble_id),
                    original_role: Some(bubble.bubble_type.to_string()),
                    phase: None,
                },
                model: bubble_model_name(bubble.model_info.as_ref()),
                usage: None,
                fidelity: MappingDisposition::Preserved,
                provider_ext,
            },
        });
    }

    let created_at = composer
        .as_ref()
        .and_then(|composer| composer.created_at)
        .and_then(chrono::DateTime::from_timestamp_millis)
        .or_else(|| events.first().map(|event| event.timestamp));
    let last_active_at = events.last().map(|event| event.timestamp).or(created_at);
    let source_title = composer
        .as_ref()
        .and_then(cursor_source_title)
        .or_else(|| infer_title_from_events(&events));
    let workspace_dir = composer
        .as_ref()
        .and_then(|composer| composer.workspace_identifier.as_ref())
        .map(|workspace| workspace.uri.fs_path.clone());

    let mut extensions = BTreeMap::new();
    if let Some(composer) = composer.as_ref() {
        extensions.insert(
            "cursor_composer".to_string(),
            serde_json::to_value(composer).unwrap_or(Value::Null),
        );
    }

    ImportedSession {
        session: CanonicalSession {
            schema: CanonicalSchema::default(),
            identity: SessionIdentity {
                canonical_id: composer_id.to_string(),
                source_title,
            },
            provenance: SessionProvenance {
                imported_at: Utc::now(),
                imported_by: Some("memorph-cli".to_string()),
                primary_source: ProviderSessionRef {
                    provider_id: "cursor".to_string(),
                    session_id: composer_id.to_string(),
                    source_path: Some(composer_id.to_string()),
                },
                aliases: Vec::new(),
            },
            context: SessionContext {
                workspace_dir,
                created_at,
                last_active_at,
                tags: Vec::new(),
            },
            events,
            artifacts: Vec::new(),
            extensions,
        },
        report: MappingReport::new("cursor", MappingDirection::Import),
    }
}

fn bubble_role(bubble_type: i32) -> EventRole {
    match bubble_type {
        1 => EventRole::User,
        2 => EventRole::Assistant,
        _ => EventRole::System,
    }
}

fn parse_timestamp(raw: &str) -> Option<DateTime<Utc>> {
    DateTime::parse_from_rfc3339(raw)
        .ok()
        .map(|dt| dt.with_timezone(&Utc))
}

fn bubble_model_name(model_info: Option<&Value>) -> Option<String> {
    let model_info = model_info?;
    ["name", "modelName", "model", "id"]
        .iter()
        .find_map(|key| model_info.get(*key).and_then(Value::as_str))
        .map(str::to_string)
}

fn cursor_source_title(composer: &ComposerData) -> Option<String> {
    composer
        .text
        .as_deref()
        .map(str::trim)
        .filter(|text| !text.is_empty())
        .map(str::to_string)
        .or_else(|| {
            composer
                .name
                .as_deref()
                .map(str::trim)
                .filter(|text| !text.is_empty())
                .map(str::to_string)
        })
}

fn infer_title_from_events(events: &[SessionEvent]) -> Option<String> {
    events.iter().find_map(|event| {
        event.blocks.iter().find_map(|block| match block {
            EventBlock::Text { text } => {
                let trimmed = text.trim();
                if trimmed.is_empty() {
                    None
                } else if trimmed.chars().count() > 50 {
                    Some(format!(
                        "{}...",
                        trimmed.chars().take(50).collect::<String>()
                    ))
                } else {
                    Some(trimmed.to_string())
                }
            }
            _ => None,
        })
    })
}

fn bubble_payload(bubble: &BubbleData) -> Value {
    serde_json::to_value(bubble).unwrap_or(Value::Null)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::providers::cursor::db::{WorkspaceIdentifier, WorkspaceUri};

    #[test]
    fn imported_cursor_session_preserves_model_info_and_workspace() {
        let composer = ComposerData {
            composer_id: "composer-1".to_string(),
            status: None,
            text: Some("Workspace task".to_string()),
            name: None,
            workspace_identifier: Some(WorkspaceIdentifier {
                id: "workspace-1".to_string(),
                uri: WorkspaceUri {
                    fs_path: "/tmp/project".to_string(),
                },
            }),
            created_at: Some(1_700_000_000_000),
            is_agentic: Some(true),
        };
        let bubbles = vec![BubbleData {
            bubble_id: "bubble-1".to_string(),
            bubble_type: 2,
            text: Some("Hello from Cursor".to_string()),
            created_at: Some("2024-01-01T00:00:00Z".to_string()),
            request_id: Some("request-1".to_string()),
            model_info: Some(serde_json::json!({
                "modelName": "gpt-4.1"
            })),
        }];

        let imported = imported_session_from_cursor("composer-1", Some(composer), bubbles);
        let event = &imported.session.events[0];

        assert_eq!(
            imported.session.context.workspace_dir.as_deref(),
            Some("/tmp/project")
        );
        assert_eq!(
            imported.session.identity.source_title.as_deref(),
            Some("Workspace task")
        );
        assert_eq!(event.metadata.model.as_deref(), Some("gpt-4.1"));
        assert_eq!(
            event
                .metadata
                .provider_ext
                .get("request_id")
                .and_then(Value::as_str),
            Some("request-1")
        );
        assert!(matches!(event.blocks[0], EventBlock::Text { .. }));
    }
}