feat(profile): mirror connected-account profiles into PROFILE.md (#1262)

This commit is contained in:
Steven Enamakel
2026-05-05 21:32:40 -07:00
committed by GitHub
parent 0eeb0c6c78
commit adebdf90ed
4 changed files with 522 additions and 0 deletions
+12
View File
@@ -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 {
+1
View File
@@ -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;
@@ -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
//! <!-- openhuman:connected-accounts:start -->
//! ...
//! <!-- openhuman:connected-accounts:end -->
//! ```
//!
//! 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 = "<!-- openhuman:connected-accounts:start -->";
const BLOCK_END: &str = "<!-- openhuman:connected-accounts:end -->";
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<String> {
let mut fields: Vec<String> = 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!("<!-- acct:{toolkit}:{identifier} -->")
}
/// 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<String> = 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("- <!-- acct:"))
.collect::<Vec<_>>();
bullets.sort();
let block = format!(
"{BLOCK_START}\n{SECTION_HEADING}\n\n{body}\n{BLOCK_END}",
body = bullets.join("\n")
);
assemble(&prefix, &block, &suffix)
}
/// Remove the bullet matching `(toolkit, identifier)` from the managed
/// block. Drops the block entirely if no bullets remain.
fn remove_bullet(existing: &str, toolkit: &str, identifier: &str) -> String {
let marker = bullet_marker(toolkit, identifier);
let (prefix, block_body, suffix) = split_block(existing);
if block_body.is_empty() && prefix == existing {
// No managed block present.
return existing.to_string();
}
let bullets: Vec<String> = block_body
.lines()
.filter(|l| l.trim_start().starts_with("- <!-- acct:") && !l.contains(&marker))
.map(|l| l.to_string())
.collect();
if bullets.is_empty() {
// Drop the entire block.
return assemble(&prefix, "", &suffix);
}
let block = format!(
"{BLOCK_START}\n{SECTION_HEADING}\n\n{body}\n{BLOCK_END}",
body = bullets.join("\n")
);
assemble(&prefix, &block, &suffix)
}
/// Split the file into `(prefix, block_body, suffix)` around the
/// managed block. Bytes outside the markers are returned verbatim so
/// the caller can preserve user-authored whitespace, indentation, and
/// trailing newlines exactly. If no block is present, `prefix` is the
/// full file and `block_body` / `suffix` are empty.
fn split_block(existing: &str) -> (String, String, String) {
if let (Some(start), Some(end)) = (existing.find(BLOCK_START), existing.find(BLOCK_END)) {
if end > start {
let prefix = existing[..start].to_string();
let body = existing[start + BLOCK_START.len()..end].to_string();
let suffix_start = end + BLOCK_END.len();
let suffix = existing[suffix_start..].to_string();
return (prefix, body, suffix);
}
}
(existing.to_string(), String::new(), String::new())
}
/// Assemble `prefix + block + suffix`, preserving the user-authored
/// bytes in `prefix` and `suffix` verbatim. We only normalize the
/// newline separators *immediately adjacent* to the managed block —
/// the bytes we own — to keep one blank line on each boundary.
fn assemble(prefix: &str, block: &str, suffix: &str) -> String {
if block.is_empty() {
// Removing the block entirely. Strip the newlines we previously
// added on each side of the block, but leave the rest of the
// user's content untouched.
let p = prefix.trim_end_matches('\n');
let s = suffix.trim_start_matches('\n');
let mut out = String::with_capacity(p.len() + s.len() + 2);
out.push_str(p);
if !p.is_empty() {
// Keep one trailing newline on the prefix.
out.push('\n');
if !s.is_empty() {
// Plus a blank-line separator before whatever the user
// had after the block.
out.push('\n');
}
}
out.push_str(s);
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
return out;
}
let mut out = String::new();
if prefix.trim().is_empty() {
// Empty / whitespace-only file → seed with a friendly header so
// the agent prompt loader has a sensible top of the file.
out.push_str(FILE_HEADER);
out.push('\n');
} else {
// Preserve user prefix bytes verbatim, then ensure exactly one
// blank line before the block.
let p = prefix.trim_end_matches('\n');
out.push_str(p);
out.push_str("\n\n");
}
out.push_str(block);
// The block string we emit doesn't include a trailing newline.
if suffix.is_empty() {
out.push('\n');
} else {
// Drop any newlines we previously inserted between block and
// suffix; preserve the rest of the user's bytes.
let s = suffix.trim_start_matches('\n');
out.push_str("\n\n");
out.push_str(s);
if !out.ends_with('\n') {
out.push('\n');
}
}
out
}
fn normalize_token(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
let lower = ch.to_ascii_lowercase();
if lower.is_ascii_alphanumeric() || lower == '-' || lower == '_' {
out.push(lower);
} else {
out.push('_');
}
}
out.trim_matches('_').to_string()
}
fn title_case(raw: &str) -> String {
let mut chars = raw.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
}
fn sanitize(raw: &str) -> String {
let replaced = raw.replace(['\n', '\r', '\t'], " ").replace('|', "/");
replaced.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample(toolkit: &str, conn: &str) -> ProviderUserProfile {
ProviderUserProfile {
toolkit: toolkit.into(),
connection_id: Some(conn.into()),
display_name: Some("Jane Doe".into()),
email: Some("jane@example.com".into()),
username: Some("janedoe".into()),
avatar_url: None,
profile_url: Some("https://example.com/jane".into()),
extras: serde_json::Value::Null,
}
}
#[test]
fn creates_file_when_missing() {
let tmp = TempDir::new().unwrap();
merge_provider_into_profile_md(tmp.path(), &sample("gmail", "c-1")).unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(body.starts_with("# User Profile"), "body was:\n{body}");
assert!(body.contains(BLOCK_START));
assert!(body.contains(SECTION_HEADING));
assert!(body.contains("**Gmail** (c-1):"));
assert!(body.contains("jane@example.com"));
assert!(body.contains("@janedoe"));
assert!(body.contains(BLOCK_END));
}
#[test]
fn upsert_is_idempotent_for_same_toolkit_connection() {
let tmp = TempDir::new().unwrap();
let mut p = sample("gmail", "c-1");
merge_provider_into_profile_md(tmp.path(), &p).unwrap();
p.display_name = Some("Jane D.".into());
merge_provider_into_profile_md(tmp.path(), &p).unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
let occurrences = body.matches("acct:gmail:c-1").count();
assert_eq!(occurrences, 1, "duplicate bullet:\n{body}");
assert!(body.contains("Jane D."));
assert!(!body.contains("Jane Doe"));
}
#[test]
fn multiple_toolkits_render_separate_bullets() {
let tmp = TempDir::new().unwrap();
merge_provider_into_profile_md(tmp.path(), &sample("gmail", "c-1")).unwrap();
merge_provider_into_profile_md(tmp.path(), &sample("twitter", "c-2")).unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(body.contains("acct:gmail:c-1"));
assert!(body.contains("acct:twitter:c-2"));
assert_eq!(body.matches(BLOCK_START).count(), 1);
assert_eq!(body.matches(BLOCK_END).count(), 1);
}
#[test]
fn preserves_user_authored_content_outside_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("PROFILE.md");
fs::write(
&path,
"# User Profile\n\nSome bio paragraph from LinkedIn.\n\n## Key facts\n- a\n- b\n",
)
.unwrap();
merge_provider_into_profile_md(tmp.path(), &sample("gmail", "c-1")).unwrap();
let body = fs::read_to_string(&path).unwrap();
assert!(body.contains("Some bio paragraph from LinkedIn."));
assert!(body.contains("## Key facts"));
assert!(body.contains("- a"));
assert!(body.contains("acct:gmail:c-1"));
}
#[test]
fn skips_when_no_useful_fields() {
let tmp = TempDir::new().unwrap();
let p = ProviderUserProfile {
toolkit: "gmail".into(),
connection_id: Some("c-1".into()),
display_name: Some(" ".into()),
email: None,
username: Some("".into()),
avatar_url: None,
profile_url: None,
extras: serde_json::Value::Null,
};
merge_provider_into_profile_md(tmp.path(), &p).unwrap();
assert!(!tmp.path().join("PROFILE.md").exists());
}
#[test]
fn remove_drops_specific_bullet() {
let tmp = TempDir::new().unwrap();
merge_provider_into_profile_md(tmp.path(), &sample("gmail", "c-1")).unwrap();
merge_provider_into_profile_md(tmp.path(), &sample("twitter", "c-2")).unwrap();
remove_provider_from_profile_md(tmp.path(), "gmail", "c-1").unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(!body.contains("acct:gmail:c-1"));
assert!(body.contains("acct:twitter:c-2"));
}
#[test]
fn remove_drops_block_when_empty() {
let tmp = TempDir::new().unwrap();
merge_provider_into_profile_md(tmp.path(), &sample("gmail", "c-1")).unwrap();
remove_provider_from_profile_md(tmp.path(), "gmail", "c-1").unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(!body.contains(BLOCK_START), "block remained:\n{body}");
assert!(!body.contains(BLOCK_END));
assert!(body.starts_with("# User Profile"));
}
#[test]
fn remove_is_noop_when_file_missing() {
let tmp = TempDir::new().unwrap();
remove_provider_from_profile_md(tmp.path(), "gmail", "c-1").unwrap();
assert!(!tmp.path().join("PROFILE.md").exists());
}
#[test]
fn skips_when_connection_id_missing() {
let tmp = TempDir::new().unwrap();
let p = ProviderUserProfile {
toolkit: "gmail".into(),
connection_id: None,
display_name: Some("Jane".into()),
email: Some("jane@example.com".into()),
username: None,
avatar_url: None,
profile_url: None,
extras: serde_json::Value::Null,
};
merge_provider_into_profile_md(tmp.path(), &p).unwrap();
// No file written — without a connection_id we'd orphan the
// bullet at disconnect time.
assert!(!tmp.path().join("PROFILE.md").exists());
}
#[test]
fn preserves_indentation_and_blank_lines_around_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("PROFILE.md");
// User-authored content on both sides of where the block will
// land, with intentional blank lines and trailing whitespace.
let original = "# User Profile\n\n indented bio line\n\n## Notes\n- alpha\n- beta\n\n";
fs::write(&path, original).unwrap();
merge_provider_into_profile_md(tmp.path(), &sample("gmail", "c-1")).unwrap();
let body = fs::read_to_string(&path).unwrap();
// User content unchanged byte-for-byte.
assert!(body.contains(" indented bio line"));
assert!(body.contains("## Notes\n- alpha\n- beta"));
// Block landed somewhere.
assert!(body.contains(BLOCK_START) && body.contains(BLOCK_END));
// Now remove and verify the user content is still intact.
remove_provider_from_profile_md(tmp.path(), "gmail", "c-1").unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains(" indented bio line"));
assert!(after.contains("## Notes\n- alpha\n- beta"));
assert!(!after.contains(BLOCK_START));
}
#[test]
fn sanitize_strips_pipes_and_newlines() {
assert_eq!(sanitize("foo\nbar"), "foo bar");
assert_eq!(sanitize("a | b"), "a / b");
assert_eq!(sanitize(" multi space "), "multi space");
}
}
@@ -107,6 +107,21 @@ pub trait ComposioProvider: Send + Sync {
facets_written = facets,
"[composio:provider] identity_set persisted profile facets"
);
// Mirror the same identity fragment into PROFILE.md so
// it lands in the agent's prompt context on the next
// turn (the facets table feeds queries; PROFILE.md
// feeds the system prompt).
if let Err(e) = super::profile_md::merge_provider_into_profile_md(
&ctx.config.workspace_dir,
&profile,
) {
tracing::warn!(
toolkit = %toolkit,
error = %e,
"[composio:provider] PROFILE.md merge failed (non-fatal)"
);
}
}
Err(e) => {
tracing::warn!(