Skip to content

Commit 7d3d759

Browse files
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7wpfleger96
andcommitted
feat(acp): inject requester git email into [Context] for commit trailers
Agents currently fall back to `git config user.email` for commit trailers, which resolves to the repo owner's identity — not the human who triggered the turn. NIP-01 kind:0 already has a standard `email` field; this change stores it and surfaces it in every prompt so agents can use the correct identity. - Add nullable `git_email` column to `users` table (migration 0004) - Parse the NIP-01 `email` field from kind:0 events in `handle_kind0_profile` and store it via `update_user_profile` - Add `git_email` to `PromptProfile`; parse it in `parse_kind0_profile_lookup` - Emit `Requester-Git-Email: Name <email>` in `[Context]` when the triggering event's author has a git_email set; omit the line entirely when absent - Update `nest_agents.md`: prefer `Requester-Git-Email:` from `[Context]` over `git config user.email`; fall back to git config when absent Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent d32f3c0 commit 7d3d759

8 files changed

Lines changed: 227 additions & 11 deletions

File tree

crates/buzz-acp/src/pool.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1648,7 +1648,7 @@ fn profile_event_is_agent(ev: &serde_json::Value) -> bool {
16481648
/// Parse kind:0 profile events into a `PromptProfileLookup`.
16491649
///
16501650
/// Each kind:0 event has `pubkey` and JSON `content` with optional fields:
1651-
/// `display_name` (or `name`), `nip05`.
1651+
/// `display_name` (or `name`), `nip05`, `email`.
16521652
fn parse_kind0_profile_lookup(json: serde_json::Value) -> Option<PromptProfileLookup> {
16531653
let events = json.as_array()?;
16541654
let mut lookup = PromptProfileLookup::new();
@@ -1667,13 +1667,19 @@ fn parse_kind0_profile_lookup(json: serde_json::Value) -> Option<PromptProfileLo
16671667
.get("nip05")
16681668
.and_then(|v| v.as_str())
16691669
.map(str::to_string);
1670+
let git_email = profile
1671+
.get("email")
1672+
.and_then(|v| v.as_str())
1673+
.filter(|s| !s.is_empty())
1674+
.map(str::to_string);
16701675
let is_agent = profile_event_is_agent(ev);
16711676
lookup.insert(
16721677
pk.to_ascii_lowercase(),
16731678
PromptProfile {
16741679
display_name,
16751680
nip05_handle,
16761681
is_agent,
1682+
git_email,
16771683
},
16781684
);
16791685
}
@@ -2805,6 +2811,7 @@ mod tests {
28052811
display_name: Some("Wes".into()),
28062812
nip05_handle: Some("wes@example.com".into()),
28072813
is_agent: false,
2814+
git_email: None,
28082815
})
28092816
);
28102817
}
@@ -2837,6 +2844,46 @@ mod tests {
28372844
assert!(parse_kind0_profile_lookup(json!({})).is_none());
28382845
}
28392846

2847+
#[test]
2848+
fn test_parse_kind0_profile_lookup_extracts_email() {
2849+
let lookup = parse_kind0_profile_lookup(json!([
2850+
{
2851+
"pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2852+
"kind": 0,
2853+
"content": "{\"display_name\":\"Will\",\"email\":\"will@example.com\"}",
2854+
"created_at": 1000,
2855+
"tags": []
2856+
}
2857+
]))
2858+
.expect("lookup should parse");
2859+
2860+
let profile = lookup
2861+
.get("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
2862+
.expect("profile should be present");
2863+
assert_eq!(profile.git_email, Some("will@example.com".into()));
2864+
assert_eq!(profile.display_name, Some("Will".into()));
2865+
}
2866+
2867+
#[test]
2868+
fn test_parse_kind0_profile_lookup_ignores_empty_email() {
2869+
let lookup = parse_kind0_profile_lookup(json!([
2870+
{
2871+
"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
2872+
"kind": 0,
2873+
"content": "{\"display_name\":\"Bob\",\"email\":\"\"}",
2874+
"created_at": 1000,
2875+
"tags": []
2876+
}
2877+
]))
2878+
.expect("lookup should parse");
2879+
2880+
let profile = lookup
2881+
.get("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
2882+
.expect("profile should be present");
2883+
// Empty email string should be treated as absent (None).
2884+
assert_eq!(profile.git_email, None);
2885+
}
2886+
28402887
#[test]
28412888
fn test_json_to_context_message_missing_pubkey_uses_default() {
28422889
let obj = json!({ "content": "hello" });

crates/buzz-acp/src/queue.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,9 @@ pub struct PromptProfile {
740740
/// i.e. it is an owned agent rather than a human. Used to gate reply-anchor
741741
/// flattening (UX routing heuristic, not a security boundary).
742742
pub is_agent: bool,
743+
/// Git email from the NIP-01 kind:0 `email` field. Injected into `[Context]`
744+
/// as `Requester-Git-Email:` so agents can use it for commit trailers.
745+
pub git_email: Option<String>,
743746
}
744747

745748
/// Pubkey-keyed profile lookup used while formatting ACP prompts.
@@ -966,12 +969,30 @@ fn format_context_hints(
966969
is_dm: bool,
967970
has_conversation_context: bool,
968971
reply_anchor: Option<&str>,
972+
sender_pubkey: &str,
973+
profile_lookup: Option<&PromptProfileLookup>,
969974
) -> String {
970975
let channel_display = match channel_info {
971976
Some(ci) => format!("{} (#{channel_id})", ci.name),
972977
None => channel_id.to_string(),
973978
};
974979

980+
// Emit `Requester-Git-Email: Name <email>` when the triggering event's author
981+
// has a git_email set in their profile. Agents use this for commit trailers
982+
// instead of falling back to `git config user.email`.
983+
let requester_git_email_line = profile_lookup
984+
.and_then(|lookup| lookup.get(&normalize_lookup_key(sender_pubkey)))
985+
.and_then(|profile| {
986+
let email = profile.git_email.as_deref()?;
987+
let name = profile
988+
.display_name
989+
.as_deref()
990+
.or(profile.nip05_handle.as_deref())
991+
.unwrap_or("Unknown");
992+
Some(format!("\nRequester-Git-Email: {name} <{email}>"))
993+
})
994+
.unwrap_or_default();
995+
975996
// DM check comes first — a DM reply has both thread tags AND is_dm=true,
976997
// and the scope should be "dm" (not "thread") because the agent is in a DM.
977998
if is_dm {
@@ -1005,6 +1026,7 @@ fn format_context_hints(
10051026
append_reply_instruction(&mut s, event_id);
10061027
}
10071028
}
1029+
s.push_str(&requester_git_email_line);
10081030
s
10091031
} else if let Some(ref root) = thread_tags.root_event_id {
10101032
let ctx_hint = if has_conversation_context {
@@ -1027,6 +1049,7 @@ fn format_context_hints(
10271049
if let Some(event_id) = reply_anchor {
10281050
append_reply_instruction(&mut s, event_id);
10291051
}
1052+
s.push_str(&requester_git_email_line);
10301053
s
10311054
} else {
10321055
let mut s = format!(
@@ -1038,6 +1061,7 @@ fn format_context_hints(
10381061
if let Some(event_id) = reply_anchor {
10391062
append_new_thread_reply_instruction(&mut s, event_id);
10401063
}
1064+
s.push_str(&requester_git_email_line);
10411065
s
10421066
}
10431067
}
@@ -1194,6 +1218,8 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
11941218
is_dm,
11951219
args.conversation_context.is_some(),
11961220
reply_anchor.as_deref(),
1221+
&sender_pubkey,
1222+
args.profile_lookup,
11971223
));
11981224

11991225
// 3. Conversation context (thread or DM).
@@ -3506,4 +3532,97 @@ mod tests {
35063532
None
35073533
);
35083534
}
3535+
3536+
// ── Requester-Git-Email injection ────────────────────────────────────────
3537+
3538+
/// When the triggering event's author has a git_email in their profile,
3539+
/// `[Context]` should include a `Requester-Git-Email:` line.
3540+
#[test]
3541+
fn test_format_context_hints_emits_requester_git_email_when_present() {
3542+
let batch = make_single_batch("hello");
3543+
let sender_hex = batch.events[0].event.pubkey.to_hex();
3544+
let profiles = HashMap::from([(
3545+
sender_hex.clone(),
3546+
PromptProfile {
3547+
display_name: Some("Will Pfleger".into()),
3548+
git_email: Some("will@example.com".into()),
3549+
..Default::default()
3550+
},
3551+
)]);
3552+
3553+
let prompt = format_prompt(
3554+
&batch,
3555+
&FormatPromptArgs {
3556+
profile_lookup: Some(&profiles),
3557+
..Default::default()
3558+
},
3559+
)
3560+
.join("\n\n");
3561+
3562+
assert!(
3563+
prompt.contains("Requester-Git-Email: Will Pfleger <will@example.com>"),
3564+
"context block should include Requester-Git-Email when profile has git_email"
3565+
);
3566+
}
3567+
3568+
/// When the triggering event's author has no git_email, `[Context]` must
3569+
/// NOT include a `Requester-Git-Email:` line.
3570+
#[test]
3571+
fn test_format_context_hints_omits_requester_git_email_when_absent() {
3572+
let batch = make_single_batch("hello");
3573+
let sender_hex = batch.events[0].event.pubkey.to_hex();
3574+
let profiles = HashMap::from([(
3575+
sender_hex.clone(),
3576+
PromptProfile {
3577+
display_name: Some("Will Pfleger".into()),
3578+
git_email: None,
3579+
..Default::default()
3580+
},
3581+
)]);
3582+
3583+
let prompt = format_prompt(
3584+
&batch,
3585+
&FormatPromptArgs {
3586+
profile_lookup: Some(&profiles),
3587+
..Default::default()
3588+
},
3589+
)
3590+
.join("\n\n");
3591+
3592+
assert!(
3593+
!prompt.contains("Requester-Git-Email"),
3594+
"context block should not include Requester-Git-Email when git_email is absent"
3595+
);
3596+
}
3597+
3598+
/// Falls back to display_name when constructing the Requester-Git-Email line.
3599+
/// When display_name is absent, nip05_handle is used as the name part.
3600+
#[test]
3601+
fn test_format_context_hints_requester_git_email_uses_nip05_as_name_fallback() {
3602+
let batch = make_single_batch("hello");
3603+
let sender_hex = batch.events[0].event.pubkey.to_hex();
3604+
let profiles = HashMap::from([(
3605+
sender_hex.clone(),
3606+
PromptProfile {
3607+
display_name: None,
3608+
nip05_handle: Some("will@relay.example.com".into()),
3609+
git_email: Some("will@example.com".into()),
3610+
..Default::default()
3611+
},
3612+
)]);
3613+
3614+
let prompt = format_prompt(
3615+
&batch,
3616+
&FormatPromptArgs {
3617+
profile_lookup: Some(&profiles),
3618+
..Default::default()
3619+
},
3620+
)
3621+
.join("\n\n");
3622+
3623+
assert!(
3624+
prompt.contains("Requester-Git-Email: will@relay.example.com <will@example.com>"),
3625+
"name part should fall back to nip05_handle when display_name is absent"
3626+
);
3627+
}
35093628
}

crates/buzz-db/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,7 @@ impl Db {
571571
avatar_url: Option<&str>,
572572
about: Option<&str>,
573573
nip05_handle: Option<&str>,
574+
git_email: Option<&str>,
574575
) -> Result<()> {
575576
user::update_user_profile(
576577
&self.pool,
@@ -579,6 +580,7 @@ impl Db {
579580
avatar_url,
580581
about,
581582
nip05_handle,
583+
git_email,
582584
)
583585
.await
584586
}

crates/buzz-db/src/migration.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ mod tests {
128128
fn embedded_migrator_contains_all_schema_migrations() {
129129
let migrations: Vec<_> = MIGRATOR.iter().collect();
130130

131-
assert_eq!(migrations.len(), 3);
131+
assert_eq!(migrations.len(), 4);
132132
assert_eq!(migrations[0].version, 1);
133133
assert_eq!(&*migrations[0].description, "initial schema");
134134
assert!(
@@ -160,6 +160,16 @@ mod tests {
160160
&& migrations[2].sql.as_str().contains("idx_events_not_before"),
161161
"third migration should add the NIP-ER reminder columns and index"
162162
);
163+
164+
assert_eq!(migrations[3].version, 4);
165+
assert_eq!(&*migrations[3].description, "user git email");
166+
assert!(
167+
migrations[3]
168+
.sql
169+
.as_str()
170+
.contains("ADD COLUMN git_email TEXT"),
171+
"fourth migration should add the git_email column to users"
172+
);
163173
}
164174

165175
async fn connect_test_pool() -> PgPool {

0 commit comments

Comments
 (0)