use regex::Regex;
use std::sync::LazyLock;
use super::phantom_lang;
pub fn has_phantom_tool_intent_no_tools(text: &str) -> bool {
let cleaned = strip_inline_directives(text);
let trimmed = cleaned.trim();
if trimmed.is_empty() {
return false;
}
let lang = phantom_lang::detect_language(trimmed);
let too_short_for_phrases = trimmed.len() < 20;
for window in [prose_lead_in(trimmed), prose_tail(trimmed)] {
if window.is_empty() {
continue;
}
if matches_work_announcement(window) {
return true;
}
if matches_now_gerund(window) {
return true;
}
if too_short_for_phrases {
continue;
}
let lower = window.to_lowercase();
if lang_intent_match_any(&lower) {
return true;
}
if has_past_tense_action_claim(&lower, &lang.action_verbs) {
return true;
}
}
false
}
fn prose_tail(text: &str) -> &str {
const TAIL_LINES: usize = 5;
let lines: Vec<&str> = text.lines().collect();
let first_kept = lines
.iter()
.enumerate()
.rev()
.take(TAIL_LINES)
.take_while(|(_, line)| !is_structural_line(line))
.map(|(idx, _)| idx)
.last();
let Some(first_kept) = first_kept else {
return "";
};
let offset: usize = lines[..first_kept].iter().map(|l| l.len() + 1).sum();
text[offset.min(text.len())..].trim()
}
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;
}
let words: Vec<&str> = s
.split_whitespace()
.take(4)
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.collect();
for verb in action_verbs {
if let Some(pos) = words.iter().position(|w| w == verb) {
const AUX: &[&str] = &[
"is", "are", "was", "were", "been", "be", "being", "gets", "get", "got",
"está", "están", "foi", "é", "est", "sont",
];
let passive = pos > 0 && AUX.contains(&words[pos - 1]);
if !passive {
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 cleaned = strip_inline_directives(text);
let trimmed = cleaned.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) || matches_work_announcement(lead) || matches_now_gerund(lead)
}
fn strip_inline_directives(text: &str) -> String {
static DIRECTIVE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<<[^<>\n]{1,120}>>").expect("directive regex"));
DIRECTIVE_RE.replace_all(text, "").into_owned()
}
pub fn mentions_registered_tool(text: &str, tool_names: &[String]) -> bool {
let lower = text.to_lowercase();
for name in tool_names {
if !name.contains('_') {
continue;
}
let mut from = 0;
while let Some(rel) = lower[from..].find(name.as_str()) {
let start = from + rel;
let end = start + name.len();
let before_ok = start == 0
|| !lower[..start]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || c == '_');
let after_ok = end == lower.len()
|| !lower[end..]
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_');
if before_ok && after_ok {
return true;
}
from = end;
}
}
false
}
const SIDE_EFFECT_CATEGORIES: &[&[&str]] = &[
&[
"shipped",
"released",
"release complete",
"release is shipped",
"release was completed",
"release is done",
],
&[
"pushed to origin",
"pushed to remote",
"tag pushed",
"pushed (",
"push status",
"git push",
],
&[
"tag created",
"created and pushed",
"tagged v",
"tag `v",
"created` and pushed",
],
&[
"bumped to",
"version bump",
"version bumped",
"cargo.toml bumped",
"bumped cargo",
],
&[
"changelog updated",
"changelog entry",
"entry inserted",
"new entry inserted",
],
&[
"posts appended",
"posts appended to",
"release posts",
"published to",
"posted to",
],
];
pub fn count_unbacked_side_effect_claims(text: &str) -> usize {
let lower = text.to_lowercase();
SIDE_EFFECT_CATEGORIES
.iter()
.filter(|category| category.iter().any(|phrase| lower.contains(phrase)))
.count()
}
pub fn claims_unbacked_side_effects(text: &str) -> bool {
count_unbacked_side_effect_claims(text) >= 2
}
pub fn claims_unbacked_media_result(text: &str) -> bool {
if text.contains("<<IMG:") || text.contains("<<VID:") {
return false;
}
let lower = text.to_lowercase();
let claims_delivery = phantom_lang::all_langs()
.iter()
.flat_map(|l| l.media_delivery_phrases.iter())
.any(|p| lower.contains(p.as_str()));
let is_visual = phantom_lang::all_langs()
.iter()
.flat_map(|l| l.media_context_words.iter())
.any(|w| lower.contains(w.as_str()));
claims_delivery && is_visual
}
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
}
fn max_lang_regex_count(
lower: &str,
regex_of: impl Fn(&phantom_lang::LangConfig) -> &str,
) -> usize {
phantom_lang::all_langs()
.iter()
.filter_map(|lang| {
let re = regex_of(lang);
(!re.is_empty())
.then(|| Regex::new(re).ok().map(|r| r.find_iter(lower).count()))
.flatten()
})
.max()
.unwrap_or(0)
}
fn any_lang_regex_match(text: &str, regex_of: impl Fn(&phantom_lang::LangConfig) -> &str) -> bool {
phantom_lang::all_langs().iter().any(|lang| {
let re = regex_of(lang);
!re.is_empty() && Regex::new(re).map(|r| r.is_match(text)).unwrap_or(false)
})
}
pub fn has_phantom_tool_intent(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.len() < 40 {
return false;
}
let lower = trimmed.to_lowercase();
if max_lang_regex_count(&lower, |l| &l.now_imperative_re) >= 2 {
return true;
}
if max_lang_regex_count(&lower, |l| &l.numbered_steps_re) >= 2 {
return true;
}
if max_lang_regex_count(&lower, |l| &l.past_tense_standalone_re) >= 2 {
return true;
}
if phantom_lang::all_langs()
.iter()
.any(|l| lang_completion_match(&lower, &l.completion_claims))
{
return true;
}
if any_lang_regex_match(trimmed, |l| &l.gerund_re) {
return true;
}
if any_lang_regex_match(trimmed, |l| &l.trailing_colon_re) {
return true;
}
if lang_intent_match_any(&lower) {
let path = any_lang_regex_match(trimmed, |l| &l.path_re)
|| any_lang_regex_match(trimmed, |l| &l.ext_re)
|| any_lang_regex_match(trimmed, |l| &l.backtick_code_re);
if path {
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 {
let lead = strip_inline_directives(lead);
let lead = lead.trim();
phantom_lang::all_langs().iter().any(|lang| {
!lang.work_announcement_re.is_empty()
&& Regex::new(&lang.work_announcement_re)
.map(|re| announcement_matches_anywhere(&re, lead))
.unwrap_or(false)
})
}
fn announcement_matches_anywhere(re: &Regex, lead: &str) -> bool {
let mut starts: Vec<usize> = vec![0];
let mut after_ender = false;
for (idx, ch) in lead.char_indices() {
if after_ender && !ch.is_whitespace() {
starts.push(idx);
after_ender = false;
}
if matches!(ch, '.' | '!' | '?' | '\n' | '…') {
after_ender = true;
}
}
for &start in &starts {
let suffix = &lead[start..];
if re.is_match(suffix) {
return true;
}
let window_end = suffix
.char_indices()
.take_while(|(i, _)| *i <= 48)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
if let Some(comma) = suffix[..window_end].find(", ")
&& re.is_match(&suffix[comma + 2..])
{
return true;
}
}
false
}
pub(crate) fn matches_now_gerund(text: &str) -> bool {
let text = strip_inline_directives(text);
phantom_lang::all_langs().iter().any(|lang| {
!lang.gerund_re.is_empty()
&& Regex::new(&lang.gerund_re)
.map(|re| re.is_match(&text))
.unwrap_or(false)
})
}
fn lang_completion_match(lower: &str, claims: &[String]) -> bool {
claims.iter().any(|c| lower.contains(c.as_str()))
}
fn is_structural_line(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("```")
|| (t.starts_with('|') && t.contains('|'))
|| t.starts_with("- ")
|| t.starts_with("* ")
|| t.starts_with("• ")
|| (t.chars().next().is_some_and(|c| c.is_ascii_digit()) && t.contains(". "))
}
fn prose_lead_in(text: &str) -> &str {
let mut byte_offset: usize = 0;
for (idx, line) in text.lines().enumerate() {
if is_structural_line(line) {
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))
}
const BARE_COMPLETION_PHRASES: &[&str] = &[
"done",
"all done",
"ready",
"all ready",
"ready to go",
"good to go",
"finished",
"all finished",
"complete",
"completed",
"task complete",
"task completed",
"all set",
"all good",
"taken care of",
"handled",
"sorted",
"there you go",
"here you go",
];
pub fn is_bare_completion_only(text: &str) -> bool {
let cleaned = strip_inline_directives(text);
if cleaned.contains("```") {
return false;
}
let trimmed = cleaned.trim();
if trimmed.chars().count() > 48 {
return false;
}
let normalized: String = trimmed
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { ' ' })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
BARE_COMPLETION_PHRASES.contains(&normalized.as_str())
}
pub fn is_delivery_intent(text: &str) -> bool {
let lower = text.to_lowercase();
let body = lower.trim_start();
let body = if body.starts_with('[') {
body.split_once(']')
.map(|x| x.1)
.unwrap_or(body)
.trim_start()
} else {
body
};
const QUESTION_OPENERS: &[&str] = &[
"did ", "do ", "does ", "is ", "are ", "was ", "were ", "have ", "has ", "had ", "can ",
"could ", "will ", "would ", "should ", "what ", "where ", "when ", "why ", "how ",
"which ", "who ",
];
if QUESTION_OPENERS.iter().any(|q| body.starts_with(q)) {
return false;
}
let head: String = body.chars().take(1000).collect();
let leading_word = |w: &str| -> bool {
let needle = format!(" {w} ");
head.starts_with(&format!("{w} ")) || head.contains(&needle)
};
const DELIVERY_VERBS: &[&str] = &[
"create",
"build",
"generate",
"write",
"implement",
"produce",
"make",
"draft",
"compose",
"design",
"provide",
];
DELIVERY_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("://")
}
pub fn claims_unbacked_evidence(text: &str, tool_outputs: &[String]) -> bool {
const MIN_EVIDENCE_LINES: usize = 3;
let evidence: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| is_evidence_line(l))
.collect();
if evidence.len() < MIN_EVIDENCE_LINES {
return false;
}
let backed = evidence
.iter()
.filter(|line| tool_outputs.iter().any(|out| out.contains(**line)))
.count();
backed * 2 < evidence.len()
}
fn is_evidence_line(line: &str) -> bool {
let numbered = line.split_once(':').is_some_and(|(n, rest)| {
!n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) && !rest.trim().is_empty()
});
let marker = line.len() > 6 && line.starts_with("===") && line.ends_with("===");
let aligned_kv = line.split_once(':').is_some_and(|(k, v)| {
let key = k.trim_end();
!key.is_empty()
&& k.len() > key.len()
&& key.len() <= 24
&& !key.contains(' ')
&& !v.trim().is_empty()
});
let column_row = line.starts_with('#')
&& line.contains(" ")
&& line[1..].chars().take_while(|c| c.is_ascii_digit()).count() >= 2;
numbered || marker || aligned_kv || column_row
}
pub fn claims_uncalled_commands(text: &str, executed_inputs: &[String]) -> Vec<String> {
let mut out = Vec::new();
for sentence in text.split(['.', '\n', '!', '?']) {
if !frames_as_executed_any(&sentence.to_lowercase()) {
continue;
}
for cmd in backticked_commands(sentence) {
let head: String = cmd.split_whitespace().take(2).collect::<Vec<_>>().join(" ");
if head.is_empty() {
continue;
}
if !executed_inputs.iter().any(|inp| inp.contains(&head)) {
out.push(cmd);
}
}
}
out
}
fn frames_as_executed_any(lower: &str) -> bool {
phantom_lang::all_langs().iter().any(|lang| {
lang.executed_framings
.iter()
.any(|m| lower.contains(m.as_str()))
})
}
fn backticked_commands(text: &str) -> Vec<String> {
const PROGRAMS: &[&str] = &[
"gh",
"git",
"cargo",
"npm",
"pnpm",
"yarn",
"grep",
"rg",
"ls",
"cat",
"wc",
"sed",
"awk",
"curl",
"docker",
"psql",
"sqlite3",
"python",
"python3",
"make",
"find",
"head",
"tail",
"shasum",
"sha256sum",
"diff",
"kubectl",
"terraform",
];
let mut out = Vec::new();
let mut rest = text;
while let Some(open) = rest.find('`') {
let after = &rest[open + 1..];
let Some(close) = after.find('`') else { break };
let span = after[..close].trim();
rest = &after[close + 1..];
let mut words = span.split_whitespace();
if let Some(prog) = words.next()
&& words.next().is_some()
&& PROGRAMS.contains(&prog)
{
out.push(span.to_string());
}
}
out
}
pub fn all_calls_were_null_effect(tool_inputs: &[String]) -> bool {
!tool_inputs.is_empty() && tool_inputs.iter().all(|input| is_null_effect_call(input))
}
fn is_null_effect_call(input_json: &str) -> bool {
let Some(command) = extract_command_field(input_json) else {
return false;
};
is_null_effect_command(&command)
}
pub fn is_null_effect_command(command: &str) -> bool {
let trimmed = command.trim();
if trimmed.is_empty() {
return true;
}
if trimmed.contains("&&")
|| trimmed.contains("||")
|| trimmed.contains('|')
|| trimmed.contains('>')
|| trimmed.contains('<')
|| trimmed.contains(';')
|| trimmed.contains('`')
|| trimmed.contains("$(")
{
return false;
}
if trimmed == "true" || trimmed == ":" {
return true;
}
trimmed.strip_prefix("echo ").is_some_and(|rest| {
!rest.contains('$')
})
}
fn extract_command_field(input_json: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(input_json).ok()?;
value
.get("command")
.and_then(|v| v.as_str())
.map(str::to_string)
}
const FILE_SEND_MARKERS: &[&str] = &[
"send_document",
"send_file",
"upload_file",
"document_url",
"file_url",
"media_url",
];
pub fn claims_unsent_file(text: &str, tool_inputs: &[String]) -> bool {
if tool_inputs
.iter()
.any(|input| FILE_SEND_MARKERS.iter().any(|m| input.contains(m)))
{
return false;
}
let lower = text.to_lowercase();
let claims_delivery = phantom_lang::all_langs()
.iter()
.flat_map(|l| l.file_delivery_phrases.iter())
.any(|p| lower.contains(p.as_str()));
if !claims_delivery {
return false;
}
phantom_lang::all_langs()
.iter()
.flat_map(|l| l.file_context_words.iter())
.any(|w| lower.contains(w.as_str()))
}