diff --git a/backend/crates/itcy/src/slack/handler.rs b/backend/crates/itcy/src/slack/handler.rs index 4d9609f..f35ea4b 100644 --- a/backend/crates/itcy/src/slack/handler.rs +++ b/backend/crates/itcy/src/slack/handler.rs @@ -1095,7 +1095,14 @@ Status: **published**.", Ok(s) => s, Err(e) => return format!("Could not open draft store: {e}"), }; - match rework_stored_draft(&self.llm, &stored, instructions, Some(self.tools.as_ref())).await + match rework_stored_draft( + &self.llm, + &stored, + instructions, + Some(self.tools.as_ref()), + &self.tools.handles_index(), + ) + .await { Ok(rew) => { let mut row = stored_from_payload(DraftPayload { diff --git a/backend/crates/itcy/src/slack/tweets.rs b/backend/crates/itcy/src/slack/tweets.rs index d603380..55f0341 100644 --- a/backend/crates/itcy/src/slack/tweets.rs +++ b/backend/crates/itcy/src/slack/tweets.rs @@ -545,7 +545,14 @@ Status: **published**.", Ok(s) => s, Err(e) => return format!("Could not open draft store: {e}"), }; - match rework_stored_tweet(&self.llm, &stored, instructions, Some(self.tools.as_ref())).await + match rework_stored_tweet( + &self.llm, + &stored, + instructions, + Some(self.tools.as_ref()), + &self.tools.handles_index(), + ) + .await { Ok(rew) => { let mut row = stored_from_payload(DraftPayload { diff --git a/backend/crates/itcy/src/sources/handles.rs b/backend/crates/itcy/src/sources/handles.rs index 849e5f7..bcbcc4b 100644 --- a/backend/crates/itcy/src/sources/handles.rs +++ b/backend/crates/itcy/src/sources/handles.rs @@ -204,6 +204,9 @@ pub fn resolve_handles_path() -> Option { /// Load the handle registry. Returns an empty index when the file is not found. /// +/// Prefer passing [`ItcyTools::handles_index`] at runtime when tools are wired +/// (includes `/handle_add` hot reload without restart). +/// /// # Errors /// /// Returns [`HandlesError`] when the file exists but cannot be read or parsed. @@ -810,8 +813,10 @@ fn ensure_named_handle_in_body( HandleMatch::X => e.x.eq_ignore_ascii_case(handle), }); if let Some(entry) = entry { - if let Some((start, end)) = find_phrase_outside_url(body, &entry.name) { - return replace_range(body, start, end, handle); + for label in name_labels_for_body_match(&entry.name) { + if let Some((start, end)) = find_phrase_outside_url(body, &label) { + return replace_range(body, start, end, handle); + } } } let trimmed = body.trim_start(); @@ -831,6 +836,17 @@ fn ensure_named_handle_in_body( } } +fn name_labels_for_body_match(name: &str) -> Vec { + let name = name.trim(); + let mut out = vec![name.to_string()]; + if let Some(first) = name.split_whitespace().next() { + if first != name && first.len() >= 5 { + out.push(first.to_string()); + } + } + out +} + fn use_publisher_name_lead(entry: &HandleEntry) -> bool { if entry.linkedin_url.contains("/company/") { return true; @@ -927,6 +943,9 @@ fn upsert_handles_line(pack: &str, line: &str) -> String { fn find_phrase_outside_url(hay: &str, phrase: &str) -> Option<(usize, usize)> { let hay_l = hay.to_ascii_lowercase(); let needle = phrase.to_ascii_lowercase(); + if needle.len() < 5 && !needle.contains(' ') { + return None; + } let mut from = 0usize; while from < hay_l.len() { let Some(rel) = hay_l.get(from..).and_then(|s| s.find(needle.as_str())) else { @@ -1117,6 +1136,49 @@ mod tests { assert!(!named.contains("Isaac Sacolick")); } + #[test] + fn short_handle_names_do_not_match_inside_common_phrases() { + let mut idx = HandlesIndex::default(); + idx.upsert_entry(HandleEntry { + name: "code".into(), + linkedin: String::new(), + x: "@code".into(), + linkedin_url: String::new(), + x_url: "https://x.com/code".into(), + }); + let pack = "handles: x=@code\n"; + let body = "25k code reviews weekly on the platform."; + let out = ensure_x_handle_from_pack(body, pack, &idx); + assert!( + !out.contains("@code"), + "short name 'code' must not tag inside 'code reviews': {out}" + ); + } + + #[test] + fn vs_code_handle_does_not_tag_code_reviews() { + let mut idx = HandlesIndex::default(); + idx.upsert_entry(HandleEntry { + name: "VS Code".into(), + linkedin: String::new(), + x: "@code".into(), + linkedin_url: String::new(), + x_url: "https://x.com/code".into(), + }); + idx.upsert_entry(HandleEntry { + name: "DoorDash @DoorDash".into(), + linkedin: "@doordash".into(), + x: "@DoorDash".into(), + linkedin_url: "https://www.linkedin.com/company/doordash/".into(), + x_url: "https://x.com/DoorDash".into(), + }); + let pack = "subject: DoorDash Flux\nhandles: x=@DoorDash\n"; + let body = "🚀 DoorDash shifted 130k tasks. 25k code reviews weekly."; + let out = ensure_x_handle_from_pack(body, pack, &idx); + assert!(out.contains("@DoorDash"), "{out}"); + assert!(!out.contains("@code"), "{out}"); + } + #[test] fn tweet_body_gets_x_handle_not_linkedin() { let idx = isaac_index(); diff --git a/backend/crates/itcy/src/sources/rework.rs b/backend/crates/itcy/src/sources/rework.rs index 1c8d1dc..26a915d 100644 --- a/backend/crates/itcy/src/sources/rework.rs +++ b/backend/crates/itcy/src/sources/rework.rs @@ -19,6 +19,7 @@ use crate::sources::draft_footer::{ compose_draft_message, ensure_primary_link_line, pick_link_options, }; use crate::sources::draft_url::{extract_in_post_url, promote_link_option, set_single_in_post_url}; +use crate::sources::handles::HandlesIndex; use crate::sources::tweet_farce::{ensure_farce_mentions, stored_is_farce}; use crate::sources::tweet_footer::{ aerate_tweet_commentary, coerce_tweet_body, compose_tweet_message, ensure_operator_https_lines, @@ -75,6 +76,7 @@ pub async fn rework_stored_draft( stored: &StoredDraft, instructions: &str, tools: Option<&dyn ToolProvider>, + handles: &HandlesIndex, ) -> Result { if stored.status != "open" { return Err(ReworkError::NotOpen( @@ -100,9 +102,8 @@ pub async fn rework_stored_draft( let prose = crate::llm::sanitize_itcy_text( &crate::sources::draft_footer::draft_prose_for_rework(&stored.body), ); - let handles = crate::sources::handles::load_handles().unwrap_or_default(); let brief_for_handles = format!("{}\n{instructions}\n{prose}", stored.subject); - crate::sources::handles::apply_brief_handles_to_pack(&mut pack, &brief_for_handles, &handles); + crate::sources::handles::apply_brief_handles_to_pack(&mut pack, &brief_for_handles, handles); let user = draft_rework_user_message( instructions, &stored.draft_id, @@ -150,7 +151,7 @@ pub async fn rework_stored_draft( .await; body = strip_leading_draft_id(&body); body = crate::sources::handles::ensure_linkedin_brand_mention(&body); - body = crate::sources::handles::ensure_linkedin_handle_from_pack(&body, &pack, &handles); + body = crate::sources::handles::ensure_linkedin_handle_from_pack(&body, &pack, handles); let body = compose_draft_message(&body, &stored.draft_id, &link_options); let body = with_disclosure(&body, &trace); info!( @@ -328,6 +329,7 @@ pub async fn rework_stored_tweet( stored: &StoredDraft, instructions: &str, tools: Option<&dyn ToolProvider>, + handles: &HandlesIndex, ) -> Result { if stored.status != "open" { return Err(ReworkError::NotOpen( @@ -342,11 +344,10 @@ pub async fn rework_stored_tweet( } else { stored.research_pack.clone() }; - let handles = crate::sources::handles::load_handles().unwrap_or_default(); crate::sources::handles::apply_brief_handles_to_pack( &mut pack, &format!("{}\n{instructions}", stored.subject), - &handles, + handles, ); let current = stored.link_options.first().cloned().unwrap_or_else(|| { crate::sources::draft_url::extract_in_post_url(&stored.body).unwrap_or_default() @@ -374,7 +375,7 @@ pub async fn rework_stored_tweet( if farce { body = ensure_farce_mentions(&body); } - body = crate::sources::handles::ensure_x_handle_from_pack(&body, &pack, &handles); + body = crate::sources::handles::ensure_x_handle_from_pack(&body, &pack, handles); let pack_urls = stored.sources.clone(); let (body, link_options) = finalize_rework_tweet_output(body, stored, &pack_urls, ¤t, instructions, farce); diff --git a/backend/crates/itcy/src/tools/mod.rs b/backend/crates/itcy/src/tools/mod.rs index 0f75199..c7164ff 100644 --- a/backend/crates/itcy/src/tools/mod.rs +++ b/backend/crates/itcy/src/tools/mod.rs @@ -64,6 +64,15 @@ impl ItcyTools { .clone() } + /// Registry for draft/tweet paths: in-memory (hot `/handle_add`) when tools exist. + #[must_use] + pub fn runtime_handles(tools: Option<&Self>) -> HandlesIndex { + tools.map_or_else( + || crate::sources::handles::load_handles().unwrap_or_default(), + Self::handles_index, + ) + } + /// Parse + append `handles.toml` + hot-reload memory (no process restart). /// /// # Errors diff --git a/backend/handles.toml b/backend/handles.toml index 8a1aac4..3395ea0 100644 --- a/backend/handles.toml +++ b/backend/handles.toml @@ -304,7 +304,7 @@ x = "@david_dossett" x_url = "https://x.com/david_dossett" [[handle]] -name = "code" +name = "VS Code" x = "@code" x_url = "https://x.com/code" @@ -7564,3 +7564,10 @@ linkedin = "@scylladb" x = "@ScyllaDB" linkedin_url = "https://www.linkedin.com/company/scylladb/" x_url = "https://x.com/ScyllaDB" + +[[handle]] +name = "DoorDash @DoorDash" +linkedin = "@doordash" +x = "@DoorDash" +linkedin_url = "https://www.linkedin.com/company/doordash/" +x_url = "https://x.com/DoorDash" diff --git a/scripts/lib/twitter-brave-lock.sh b/scripts/lib/twitter-brave-lock.sh index 30ca4fa..15a4f54 100644 --- a/scripts/lib/twitter-brave-lock.sh +++ b/scripts/lib/twitter-brave-lock.sh @@ -5,15 +5,40 @@ # One Brave CDP session at a time; pick a free debugging port (never steal :9224). # shellcheck shell=bash +twitter_brave_stale_lock_pid() { + local lock="${1:?}" + [[ -f "${lock}" ]] || return 1 + local pid + pid="$(tr -dc '0-9' <"${lock}" 2>/dev/null || true)" + [[ -n "${pid}" ]] || return 1 + kill -0 "${pid}" 2>/dev/null +} + +twitter_brave_clear_stale_lock() { + local lock="${1:?}" + if [[ -f "${lock}" ]] && ! twitter_brave_stale_lock_pid "${lock}"; then + rm -f "${lock}" + fi +} + twitter_brave_acquire_lock() { local run_root="${1:?run root}" + local wait_secs="${2:-180}" mkdir -p "${run_root}" local lock="${run_root}/brave.lock" - exec 9>"${lock}" - if ! flock -n 9; then - echo "another X Brave session holds ${lock}; wait or stop the other ship/pulse/status run" >&2 - return 1 - fi + local deadline=$((SECONDS + wait_secs)) + while (( SECONDS < deadline )); do + twitter_brave_clear_stale_lock "${lock}" + exec 9>"${lock}" + if flock -n 9; then + echo "$$" >&9 + return 0 + fi + exec 9>&- || true + sleep 2 + done + echo "another X Brave session holds ${lock}; wait or stop the other ship/pulse/status run" >&2 + return 1 } twitter_brave_pick_cdp_port() {