use regex::Regex;
use super::phantom_lang;
pub fn has_phantom_tool_intent_no_tools(text: &str) -> bool {
let trimmed = text.trim();
let lead = prose_lead_in(trimmed);
if lead.is_empty() {
return false;
}
if matches_work_announcement(lead) {
return true;
}
if trimmed.len() < 20 {
return false;
}
let lower = lead.to_lowercase();
if lang_intent_match_any(&lower) {
return true;
}
let lang = phantom_lang::detect_language(trimmed);
has_past_tense_action_claim(&lower, &lang.action_verbs)
}
fn has_past_tense_action_claim(lower: &str, action_verbs: &[String]) -> bool {
for raw_sentence in lower.split(['.', '\n', '!']) {
let s = raw_sentence.trim();
if s.is_empty() || s.len() > 80 {
continue;
}
for verb in action_verbs {
if s.split_whitespace().take(4).any(|w| {
let w = w.trim_matches(|c: char| !c.is_alphanumeric());
w == verb
}) {
return true;
}
}
}
false
}
pub fn has_investigative_intent(text: &str) -> bool {
let lower = text.to_lowercase();
lang_intent_match_any(&lower)
}
pub fn has_forward_intent_post_success(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.len() < 20 {
return false;
}
let lead = prose_lead_in(trimmed);
if lead.is_empty() {
return false;
}
let lower = lead.to_lowercase();
lang_intent_match_any(&lower)
}
pub fn count_intent_line_starts(text: &str) -> usize {
let lang = phantom_lang::detect_language(text);
if lang.line_start_re.is_empty() {
return 0;
}
let re = Regex::new(&lang.line_start_re).unwrap_or_else(|_| {
Regex::new(r"$^").unwrap() });
re.find_iter(text).count()
}
pub const STUCK_INTENT_LOOP_THRESHOLD: usize = 3;
pub fn max_repeated_intent_line(text: &str) -> usize {
let lang = phantom_lang::detect_language(text);
if lang.line_start_re.is_empty() {
return 0;
}
let Ok(re) = Regex::new(&lang.line_start_re) else {
return 0;
};
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut max = 0;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if re.find(trimmed).map(|m| m.start() == 0).unwrap_or(false) {
let norm = trimmed
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let c = counts.entry(norm).or_insert(0);
*c += 1;
max = max.max(*c);
}
}
max
}
pub fn is_stuck_in_intent_loop(text: &str) -> bool {
max_repeated_intent_line(text) >= STUCK_INTENT_LOOP_THRESHOLD
}
pub fn has_phantom_tool_intent(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.len() < 40 {
return false;
}
let lower = trimmed.to_lowercase();
let lang = phantom_lang::detect_language(trimmed);
if !lang.now_imperative_re.is_empty()
&& let Ok(re) = Regex::new(&lang.now_imperative_re)
&& re.find_iter(&lower).count() >= 2
{
return true;
}
if !lang.numbered_steps_re.is_empty()
&& let Ok(re) = Regex::new(&lang.numbered_steps_re)
&& re.find_iter(&lower).count() >= 2
{
return true;
}
if !lang.past_tense_standalone_re.is_empty()
&& let Ok(re) = Regex::new(&lang.past_tense_standalone_re)
&& re.find_iter(&lower).count() >= 2
{
return true;
}
if lang_completion_match(&lower, &lang.completion_claims) {
return true;
}
if !lang.gerund_re.is_empty()
&& let Ok(re) = Regex::new(&lang.gerund_re)
&& re.is_match(trimmed)
{
return true;
}
if !lang.trailing_colon_re.is_empty()
&& let Ok(re) = Regex::new(&lang.trailing_colon_re)
&& re.is_match(trimmed)
{
return true;
}
let has_intent = lang_intent_match(&lower, &lang.intent_phrases);
if has_intent {
let path_match = !lang.path_re.is_empty()
&& Regex::new(&lang.path_re)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
let ext_match = !lang.ext_re.is_empty()
&& Regex::new(&lang.ext_re)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
let backtick_match = !lang.backtick_code_re.is_empty()
&& Regex::new(&lang.backtick_code_re)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
if path_match || ext_match || backtick_match {
return true;
}
}
false
}
fn lang_intent_match(lower: &str, phrases: &[String]) -> bool {
phrases.iter().any(|p| lower.contains(p.as_str()))
}
fn lang_intent_match_any(lower: &str) -> bool {
phantom_lang::all_langs()
.iter()
.any(|lang| lang_intent_match(lower, &lang.intent_phrases))
}
pub(crate) fn matches_work_announcement(lead: &str) -> bool {
phantom_lang::all_langs().iter().any(|lang| {
!lang.work_announcement_re.is_empty()
&& Regex::new(&lang.work_announcement_re)
.map(|re| re.is_match(lead))
.unwrap_or(false)
})
}
fn lang_completion_match(lower: &str, claims: &[String]) -> bool {
claims.iter().any(|c| lower.contains(c.as_str()))
}
fn prose_lead_in(text: &str) -> &str {
let mut byte_offset: usize = 0;
for (idx, line) in text.lines().enumerate() {
let trimmed_line = line.trim_start();
let is_structural = trimmed_line.starts_with("```")
|| (trimmed_line.starts_with('|') && trimmed_line.contains('|'))
|| trimmed_line.starts_with("- ")
|| trimmed_line.starts_with("* ")
|| trimmed_line.starts_with("• ")
|| (trimmed_line
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
&& trimmed_line.contains(". "));
if is_structural {
return text[..byte_offset].trim_end();
}
if idx >= 6 {
break;
}
byte_offset += line.len() + 1;
}
text
}
pub fn is_analysis_intent(text: &str) -> bool {
let lower = text.to_lowercase();
let body = lower.rsplit('\n').next().unwrap_or(&lower);
let head: String = body.chars().take(200).collect();
let leading_word = |w: &str| -> bool {
let needle = format!(" {w} ");
if head.starts_with(&format!("{w} ")) {
return true;
}
head.contains(&needle)
};
const ANALYSIS_VERBS: &[&str] = &[
"audit",
"review",
"compare",
"explain",
"summarise",
"summarize",
"check",
"describe",
"analyse",
"analyze",
"find",
"look up",
"look at",
"what does",
"how does",
"why does",
"what is",
"what are",
"tell me",
"show me",
"investigate",
"diagnose",
];
ANALYSIS_VERBS.iter().any(|v| leading_word(v))
}
pub fn looks_truncated_mid_sentence(text: &str) -> bool {
let trimmed = text.trim_end();
if trimmed.chars().count() < 40 {
return false;
}
if trimmed.ends_with("```") {
return false;
}
if trimmed.ends_with('|') {
return false;
}
if ends_with_url(trimmed) {
return false;
}
let last = match trimmed.chars().next_back() {
Some(c) => c,
None => return false,
};
if last.is_alphanumeric() {
return true;
}
matches!(
last,
',' | ';' | ':' | '-' | '(' | '[' | '{' | '<' | '/' | '\\' | '&' | '@' | '#'
)
}
fn ends_with_url(text: &str) -> bool {
let trimmed = text.trim_end();
let boundary = trimmed
.rfind(|c: char| c.is_whitespace() || matches!(c, '(' | '[' | '{' | '<' | '"' | '\''))
.map(|i| i + 1)
.unwrap_or(0);
let tail = &trimmed[boundary..];
tail.contains("://")
}