From adebdf90edccdc47bc9158a922526374c5272ff8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 5 May 2026 21:32:40 -0700 Subject: [PATCH] feat(profile): mirror connected-account profiles into PROFILE.md (#1262) --- src/openhuman/composio/ops.rs | 12 + src/openhuman/composio/providers/mod.rs | 1 + .../composio/providers/profile_md.rs | 494 ++++++++++++++++++ src/openhuman/composio/providers/traits.rs | 15 + 4 files changed, 522 insertions(+) create mode 100644 src/openhuman/composio/providers/profile_md.rs diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index 196218c4a..8693c7e5a 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -143,6 +143,18 @@ pub async fn composio_delete_connection( facets_deleted = deleted, "[composio] deleted connected identity facets after connection removal" ); + if let Err(e) = super::providers::profile_md::remove_provider_from_profile_md( + &config.workspace_dir, + toolkit, + connection_id, + ) { + tracing::warn!( + toolkit = %toolkit, + connection_id = %connection_id, + error = %e, + "[composio] PROFILE.md bullet removal failed (non-fatal)" + ); + } } crate::core::event_bus::publish_global( crate::core::event_bus::DomainEvent::ComposioConnectionDeleted { diff --git a/src/openhuman/composio/providers/mod.rs b/src/openhuman/composio/providers/mod.rs index 26269e3e5..bd3ebaf2a 100644 --- a/src/openhuman/composio/providers/mod.rs +++ b/src/openhuman/composio/providers/mod.rs @@ -49,6 +49,7 @@ pub mod github; pub mod gmail; pub mod notion; pub mod profile; +pub mod profile_md; pub mod registry; pub mod slack; pub mod sync_state; diff --git a/src/openhuman/composio/providers/profile_md.rs b/src/openhuman/composio/providers/profile_md.rs new file mode 100644 index 000000000..bd511e958 --- /dev/null +++ b/src/openhuman/composio/providers/profile_md.rs @@ -0,0 +1,494 @@ +//! `PROFILE.md` markdown bridge — mirrors the per-toolkit identity +//! fragments we already persist into the `user_profile` facet table +//! into a managed block inside `{workspace_dir}/PROFILE.md` so the +//! agent prompt loader (`agent/prompts/mod.rs::UserFilesSection`) +//! picks them up on the next turn. +//! +//! The block lives between the markers +//! +//! ```md +//! +//! ... +//! +//! ``` +//! +//! Anything outside the markers is left untouched, so a profile authored +//! by the LinkedIn onboarding pipeline or hand-edited by the user is +//! preserved across reconnects. +//! +//! All operations are best-effort and log on failure rather than +//! propagating, matching the existing PII-discipline pattern in +//! `on_connection_created`. + +use super::ProviderUserProfile; +use std::fs; +use std::io; +use std::path::Path; + +const BLOCK_START: &str = ""; +const BLOCK_END: &str = ""; +const SECTION_HEADING: &str = "## Connected Accounts"; +const FILE_HEADER: &str = "# User Profile\n"; + +/// Upsert the per-toolkit bullet for `profile` inside the managed +/// Connected Accounts block of `{workspace_dir}/PROFILE.md`. +/// +/// Creates the file with a `# User Profile` header if it does not +/// exist. Idempotent — re-connecting the same toolkit replaces the +/// existing bullet rather than duplicating it. +pub fn merge_provider_into_profile_md( + workspace_dir: &Path, + profile: &ProviderUserProfile, +) -> io::Result<()> { + let toolkit = normalize_token(&profile.toolkit); + if toolkit.is_empty() { + return Ok(()); + } + // Require a real connection_id so the bullet keys match what the + // disconnect path (`composio_delete_connection`) will look up. A + // synthetic "default" fallback would orphan bullets when the + // connection is removed. + let identifier = profile + .connection_id + .as_deref() + .map(normalize_token) + .filter(|v| !v.is_empty()); + let identifier = match identifier { + Some(id) => id, + None => { + tracing::debug!( + toolkit = %toolkit, + "[composio:profile_md] skipping merge — connection_id missing or empty" + ); + return Ok(()); + } + }; + + let bullet = match render_bullet(&toolkit, &identifier, profile) { + Some(b) => b, + // No non-empty fields — nothing worth writing. + None => return Ok(()), + }; + + let path = workspace_dir.join("PROFILE.md"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let existing = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e), + }; + + let updated = upsert_bullet(&existing, &toolkit, &identifier, &bullet); + fs::write(&path, updated)?; + tracing::debug!( + target_file = "PROFILE.md", + toolkit = %toolkit, + identifier = %identifier, + "[composio:profile_md] merged provider profile into PROFILE.md" + ); + Ok(()) +} + +/// Remove the per-toolkit bullet for `(source, identifier)` from the +/// managed Connected Accounts block. If the block becomes empty as a +/// result, the whole block is dropped. Missing file or missing block +/// are no-ops. +pub fn remove_provider_from_profile_md( + workspace_dir: &Path, + source: &str, + identifier: &str, +) -> io::Result<()> { + let path = workspace_dir.join("PROFILE.md"); + let existing = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e), + }; + let toolkit = normalize_token(source); + let identifier = normalize_token(identifier); + if toolkit.is_empty() || identifier.is_empty() { + return Ok(()); + } + let updated = remove_bullet(&existing, &toolkit, &identifier); + if updated != existing { + fs::write(&path, updated)?; + tracing::debug!( + target_file = "PROFILE.md", + toolkit = %toolkit, + identifier = %identifier, + "[composio:profile_md] removed provider bullet from PROFILE.md" + ); + } + Ok(()) +} + +// ── Internals ──────────────────────────────────────────────────────── + +/// Build the markdown bullet for one provider connection. Returns +/// `None` if the profile carries no usable fields. +fn render_bullet(toolkit: &str, identifier: &str, profile: &ProviderUserProfile) -> Option { + let mut fields: Vec = Vec::new(); + if let Some(v) = profile.display_name.as_deref().map(sanitize) { + if !v.is_empty() { + fields.push(v); + } + } + if let Some(v) = profile.email.as_deref().map(sanitize) { + if !v.is_empty() { + fields.push(v); + } + } + if let Some(v) = profile.username.as_deref().map(sanitize) { + if !v.is_empty() { + fields.push(format!("@{v}")); + } + } + if let Some(v) = profile.profile_url.as_deref().map(sanitize) { + if !v.is_empty() { + fields.push(v); + } + } + if fields.is_empty() { + return None; + } + // Stable per-(toolkit,identifier) marker so we can locate this + // bullet on later upserts even if the rendered text changes. + let marker = bullet_marker(toolkit, identifier); + Some(format!( + "- {marker} **{title}** ({identifier}): {fields}", + title = title_case(toolkit), + identifier = identifier, + fields = fields.join(" | ") + )) +} + +fn bullet_marker(toolkit: &str, identifier: &str) -> String { + format!("") +} + +/// Insert or replace `bullet` inside the managed block. +fn upsert_bullet(existing: &str, toolkit: &str, identifier: &str, bullet: &str) -> String { + let marker = bullet_marker(toolkit, identifier); + let (prefix, block_body, suffix) = split_block(existing); + + let mut lines: Vec = block_body + .lines() + .filter(|l| !l.contains(&marker)) + .map(|l| l.to_string()) + .collect(); + lines.push(bullet.to_string()); + + let mut bullets = lines + .into_iter() + .filter(|l| l.trim_start().starts_with("-