use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::kv::TriggeredRef;
use crate::triggers;
pub const DEFAULT_BUDGET: usize = 2;
const DERIVED_FRAGMENT_MAX: usize = 200;
pub const FIRED_KEY: &str = "doors_fired";
#[derive(Debug, Deserialize)]
pub struct HookInput {
#[serde(default, deserialize_with = "lenient_string")]
pub session_id: String,
#[serde(default, deserialize_with = "lenient_string")]
pub prompt: String,
}
fn lenient_string<'de, D>(d: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match serde_json::Value::deserialize(d)? {
serde_json::Value::String(s) => s,
serde_json::Value::Null => String::new(),
other => other.to_string(),
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FiredDoor {
pub key: String,
pub id: String,
pub trigger: String,
pub fragment: String,
pub dig: String,
}
impl FiredDoor {
pub fn render(&self) -> String {
format!(
"\u{1f6aa} {} \u{2192} {} (dig: {})",
self.trigger, self.fragment, self.dig
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Selection {
pub fired: Vec<FiredDoor>,
pub deferred: usize,
}
pub fn strip_channel_tags(prompt: &str) -> String {
let mut out = String::with_capacity(prompt.len());
let bytes = prompt.as_bytes();
let mut i = 0;
while i < prompt.len() {
let rest = &prompt[i..];
if let Some(after) = rest.strip_prefix("</channel>") {
out.push(' ');
i += rest.len() - after.len();
continue;
}
if rest.starts_with("<channel")
&& rest[8..]
.chars()
.next()
.is_some_and(|c| c.is_whitespace() || c == '>')
{
let mut j = i + 8;
let mut in_quote = false;
let mut closed = false;
while j < prompt.len() {
match bytes[j] {
b'"' => in_quote = !in_quote,
b'>' if !in_quote => {
j += 1;
closed = true;
break;
}
_ => {}
}
j += 1;
}
if closed {
out.push(' ');
i = j;
continue;
}
out.push_str(&prompt[i..]);
break;
}
let ch = rest.chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
pub fn derive_fragment(value: &str, fragment: Option<&str>) -> String {
if let Some(f) = fragment {
let f = f.trim();
if !f.is_empty() {
return f.to_string();
}
}
let first_line = value.split('\n').next().unwrap_or("").trim();
if first_line.chars().count() > DERIVED_FRAGMENT_MAX {
let kept: String = first_line.chars().take(DERIVED_FRAGMENT_MAX - 1).collect();
format!("{}\u{2026}", kept)
} else {
first_line.to_string()
}
}
pub fn dig_pointer(key: &str, id: &str, memory: Option<&str>) -> String {
match memory {
Some(m) if !m.trim().is_empty() => format!("{}/kv-{}, {}", key, id, m.trim()),
_ => format!("{}/kv-{}", key, id),
}
}
const MIN_TRIGGER_TOKEN_CHARS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TriggerVerdict {
Dead,
Broad { matched_as: String },
Fine { matched_as: String },
}
pub fn matched_form(trigger: &str) -> String {
triggers::tokens(trigger, false).join(" ")
}
pub fn inspect_trigger(trigger: &str) -> TriggerVerdict {
let toks = triggers::tokens(trigger, false);
if toks.is_empty() {
return TriggerVerdict::Dead;
}
let matched_as = toks.join(" ");
if toks.len() == 1 && toks[0].chars().count() < MIN_TRIGGER_TOKEN_CHARS {
TriggerVerdict::Broad { matched_as }
} else {
TriggerVerdict::Fine { matched_as }
}
}
fn specificity(trigger: &str) -> (usize, usize) {
(
triggers::tokens(trigger, false).len(),
trigger.chars().count(),
)
}
pub fn select(
prompt: &str,
candidates: &[TriggeredRef<'_>],
already_fired: &HashSet<(String, String)>,
fire_counts: &HashMap<(String, String), u64>,
budget: usize,
) -> Selection {
let text = strip_channel_tags(prompt);
let message_tokens = triggers::tokens(&text, false);
if message_tokens.is_empty() {
return Selection {
fired: Vec::new(),
deferred: 0,
};
}
let mut matched: Vec<(u64, &str, FiredDoor)> = Vec::new();
for cand in candidates {
let pair = (cand.key.to_string(), cand.id.to_string());
if already_fired.contains(&pair) {
continue;
}
let hits = triggers::match_triggers(&message_tokens, cand.triggers, false);
let Some(trigger) = hits.into_iter().reduce(|best, t| {
if specificity(&t) > specificity(&best) {
t
} else {
best
}
}) else {
continue;
};
let count = fire_counts.get(&pair).copied().unwrap_or(0);
matched.push((
count,
cand.ts,
FiredDoor {
key: pair.0,
id: pair.1,
trigger,
fragment: derive_fragment(cand.value, cand.fragment),
dig: dig_pointer(cand.key, cand.id, cand.memory),
},
));
}
matched.sort_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| a.1.cmp(b.1))
.then_with(|| a.2.key.cmp(&b.2.key))
.then_with(|| a.2.id.cmp(&b.2.id))
});
let total = matched.len();
let fired: Vec<FiredDoor> = matched.into_iter().take(budget).map(|m| m.2).collect();
let deferred = total - fired.len();
Selection { fired, deferred }
}
#[cfg(test)]
mod tests {
use super::*;
const CHANNEL: &str = concat!(
r#"<channel source="matrix" chat_id="!r:s" message_id="$e" user="carmel" "#,
r#"user_id="@j:s" room_name="delta">"#,
"\ngood morning konkon\n</channel>"
);
fn cand<'a>(
key: &'a str,
id: &'a str,
value: &'a str,
ts: &'a str,
triggers: &'a [String],
) -> TriggeredRef<'a> {
TriggeredRef {
key,
id,
value,
ts,
triggers,
fragment: None,
memory: None,
}
}
#[test]
fn strip_channel_removes_tag_and_keeps_body() {
let out = strip_channel_tags(CHANNEL);
assert!(out.contains("good morning konkon"));
assert!(
!out.contains("carmel"),
"attributes must not survive: {out}"
);
assert!(!out.contains("delta"));
assert!(!out.contains("channel"));
}
#[test]
fn strip_channel_attribute_alone_does_not_fire_a_door() {
let trig = vec!["carmel".to_string()];
let cands = [cand("facts", "aaa", "x", "2026-01-01T00:00:00Z", &trig)];
let sel = select(CHANNEL, &cands, &HashSet::new(), &HashMap::new(), 2);
assert!(
sel.fired.is_empty(),
"user=\"carmel\" must not open a carmel door"
);
}
#[test]
fn strip_channel_tolerates_gt_inside_an_attribute() {
let out = strip_channel_tags(r#"<channel room_name="a > b">hi</channel>"#);
assert_eq!(out.trim(), "hi");
}
#[test]
fn unterminated_channel_tag_does_not_swallow_the_turn() {
let out = strip_channel_tags("<channel source=\"matrix\" oops konkon is here");
assert!(out.contains("konkon"), "body survived: {out}");
let out = strip_channel_tags("<channel user=\"unclosed konkon");
assert!(out.contains("konkon"), "body survived: {out}");
}
#[test]
fn unterminated_channel_tag_still_fires_doors() {
let trig = vec!["konkon".to_string()];
let cands = [cand("facts", "aaa", "v", "2026-01-01T00:00:00Z", &trig)];
let sel = select(
"<channel user=\"unclosed good morning konkon",
&cands,
&HashSet::new(),
&HashMap::new(),
2,
);
assert_eq!(sel.fired.len(), 1, "a malformed tag must not lose the door");
}
#[test]
fn most_specific_trigger_is_reported() {
let trig = vec!["gerf".to_string(), "gerf_slips".to_string()];
let cands = [cand(
"doors",
"aaa",
"Gerf is GEOFF",
"2026-01-01T00:00:00Z",
&trig,
)];
let sel = select(
"gerf_slips is permanent",
&cands,
&HashSet::new(),
&HashMap::new(),
2,
);
assert_eq!(sel.fired[0].trigger, "gerf_slips");
let sel = select("ask gerf", &cands, &HashSet::new(), &HashMap::new(), 2);
assert_eq!(sel.fired[0].trigger, "gerf");
}
#[test]
fn dead_triggers_are_recognised() {
assert_eq!(inspect_trigger("\u{1f98a}"), TriggerVerdict::Dead);
assert_eq!(inspect_trigger("!!!"), TriggerVerdict::Dead);
assert_eq!(inspect_trigger("---"), TriggerVerdict::Dead);
}
#[test]
fn punctuation_names_collapse_to_a_broad_single_token() {
for (raw, becomes) in [("c++", "c"), ("c#", "c"), ("F#", "f")] {
assert_eq!(
inspect_trigger(raw),
TriggerVerdict::Broad {
matched_as: becomes.to_string()
},
"{raw} must be flagged"
);
}
}
#[test]
fn ordinary_triggers_are_fine_and_report_what_they_match() {
assert_eq!(
inspect_trigger("konkon"),
TriggerVerdict::Fine {
matched_as: "konkon".to_string()
}
);
assert_eq!(
inspect_trigger("ayo-"),
TriggerVerdict::Fine {
matched_as: "ayo".to_string()
}
);
assert_eq!(matched_form("gerf_slips"), "gerf slips");
assert_eq!(matched_form(".NET"), "net");
}
#[test]
fn lenient_string_accepts_a_numeric_session_id() {
let input: HookInput =
serde_json::from_str(r#"{"session_id": 12345, "prompt": "hi konkon"}"#).unwrap();
assert_eq!(input.session_id, "12345");
assert_eq!(input.prompt, "hi konkon");
}
#[test]
fn hook_input_tolerates_missing_null_and_unknown_fields() {
let input: HookInput = serde_json::from_str(
r#"{"prompt": "hi", "session_id": null, "something_new": {"a": 1}}"#,
)
.unwrap();
assert_eq!(input.session_id, "");
assert_eq!(input.prompt, "hi");
let bare: HookInput = serde_json::from_str("{}").unwrap();
assert_eq!(bare.prompt, "");
}
#[test]
fn specificity_prefers_more_tokens_then_more_characters() {
assert!(specificity("kon kon") > specificity("konkon"));
assert!(specificity("gerf_slips") > specificity("gerf"));
assert_eq!(specificity("alpha"), specificity("bravo"));
}
#[test]
fn strip_channel_leaves_ordinary_angle_brackets() {
let out = strip_channel_tags("if a < b and c > d");
assert_eq!(out, "if a < b and c > d");
}
#[test]
fn authored_fragment_is_verbatim_however_long() {
let long = "z".repeat(400);
assert_eq!(derive_fragment("value", Some(&long)), long);
}
#[test]
fn derived_fragment_is_the_first_line() {
assert_eq!(
derive_fragment(" first line \nsecond line", None),
"first line"
);
}
#[test]
fn derived_fragment_cuts_on_a_char_boundary() {
let mut line = "a".repeat(198);
line.push('é');
line.push_str(&"b".repeat(60));
let out = derive_fragment(&line, None);
assert_eq!(out.chars().count(), DERIVED_FRAGMENT_MAX);
assert!(out.ends_with('\u{2026}'));
assert!(out.contains('é'), "the boundary char must survive intact");
}
#[test]
fn empty_authored_fragment_falls_back_to_first_line() {
assert_eq!(derive_fragment("the value", Some(" ")), "the value");
}
#[test]
fn dig_pointer_shapes() {
assert_eq!(dig_pointer("doors", "2gRwr9", None), "doors/kv-2gRwr9");
assert_eq!(
dig_pointer("doors", "2gRwr9", Some("kn-02e73234")),
"doors/kv-2gRwr9, kn-02e73234"
);
}
#[test]
fn konkon_fires_from_inside_a_channel_block() {
let trig = vec!["konkon".to_string()];
let cands = [cand(
"facts",
"3gtR1J",
"Carmel's everyday word for Q, the fox-sound.",
"2026-01-01T00:00:00Z",
&trig,
)];
let sel = select(CHANNEL, &cands, &HashSet::new(), &HashMap::new(), 2);
assert_eq!(sel.fired.len(), 1);
assert_eq!(sel.fired[0].trigger, "konkon");
assert_eq!(sel.fired[0].dig, "facts/kv-3gtR1J");
assert_eq!(
sel.fired[0].render(),
"\u{1f6aa} konkon \u{2192} Carmel's everyday word for Q, the fox-sound. (dig: facts/kv-3gtR1J)"
);
}
#[test]
fn already_fired_entry_is_skipped() {
let trig = vec!["konkon".to_string()];
let cands = [cand("facts", "aaa", "v", "2026-01-01T00:00:00Z", &trig)];
let fired: HashSet<(String, String)> = [("facts".to_string(), "aaa".to_string())]
.into_iter()
.collect();
let sel = select("hi konkon", &cands, &fired, &HashMap::new(), 2);
assert!(sel.fired.is_empty());
assert_eq!(
sel.deferred, 0,
"a deduped door is not deferred, it is done"
);
}
#[test]
fn dedup_tuple_includes_the_key() {
let trig = vec!["konkon".to_string()];
let cands = [
cand("facts", "same01", "a", "2026-01-01T00:00:00Z", &trig),
cand("doors", "same01", "b", "2026-01-02T00:00:00Z", &trig),
];
let fired: HashSet<(String, String)> = [("facts".to_string(), "same01".to_string())]
.into_iter()
.collect();
let sel = select("konkon", &cands, &fired, &HashMap::new(), 2);
assert_eq!(sel.fired.len(), 1);
assert_eq!(sel.fired[0].key, "doors");
}
#[test]
fn budget_overflow_defers_the_most_fired() {
let trig = vec!["konkon".to_string()];
let cands = [
cand("d", "aaa", "a", "2026-01-01T00:00:00Z", &trig),
cand("d", "bbb", "b", "2026-01-01T00:00:00Z", &trig),
cand("d", "ccc", "c", "2026-01-01T00:00:00Z", &trig),
];
let counts: HashMap<(String, String), u64> = [
(("d".to_string(), "aaa".to_string()), 7),
(("d".to_string(), "bbb".to_string()), 1),
(("d".to_string(), "ccc".to_string()), 0),
]
.into_iter()
.collect();
let sel = select("konkon", &cands, &HashSet::new(), &counts, 2);
assert_eq!(sel.deferred, 1);
let ids: Vec<&str> = sel.fired.iter().map(|f| f.id.as_str()).collect();
assert_eq!(ids, vec!["ccc", "bbb"], "least-fired first");
}
#[test]
fn tie_on_fire_count_breaks_to_oldest_ts() {
let trig = vec!["konkon".to_string()];
let cands = [
cand("d", "new", "n", "2026-06-01T00:00:00Z", &trig),
cand("d", "old", "o", "2020-01-01T00:00:00Z", &trig),
];
let sel = select("konkon", &cands, &HashSet::new(), &HashMap::new(), 1);
assert_eq!(sel.fired.len(), 1);
assert_eq!(sel.fired[0].id, "old");
}
#[test]
fn no_match_is_an_empty_selection() {
let trig = vec!["konkon".to_string()];
let cands = [cand("d", "aaa", "a", "2026-01-01T00:00:00Z", &trig)];
let sel = select("hello there", &cands, &HashSet::new(), &HashMap::new(), 2);
assert!(sel.fired.is_empty());
assert_eq!(sel.deferred, 0);
}
#[test]
fn stemming_is_off_so_tagalog_ayos_does_not_open_the_ayo_door() {
let trig = vec!["ayo-".to_string()];
let cands = [cand(
"d",
"aaa",
"shell alias",
"2026-01-01T00:00:00Z",
&trig,
)];
assert!(
select("ayos lang", &cands, &HashSet::new(), &HashMap::new(), 2)
.fired
.is_empty()
);
assert_eq!(
select(
"switched to ayo-mirage",
&cands,
&HashSet::new(),
&HashMap::new(),
2
)
.fired
.len(),
1
);
}
}