1use std::collections::HashMap;
19use std::sync::Arc;
20use std::time::Duration;
21
22use async_trait::async_trait;
23
24use crate::error::Result;
25use crate::processor::frame::{Frame, FrameKind};
26use crate::processor::{Envelope, FrameProcessor, Link};
27
28pub trait WakeClock: Send {
30 fn now(&self) -> Duration;
32}
33
34pub struct SystemWakeClock {
36 base: std::time::Instant,
37}
38
39impl Default for SystemWakeClock {
40 fn default() -> Self {
41 Self {
42 base: std::time::Instant::now(),
43 }
44 }
45}
46
47impl WakeClock for SystemWakeClock {
48 fn now(&self) -> Duration {
49 self.base.elapsed()
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum WakeState {
55 Idle,
56 Awake,
57}
58
59struct ParticipantState {
60 state: WakeState,
61 wake_timer: Duration,
62 accumulator: String,
63}
64
65impl ParticipantState {
66 fn new() -> Self {
67 Self {
68 state: WakeState::Idle,
69 wake_timer: Duration::ZERO,
70 accumulator: String::new(),
71 }
72 }
73}
74
75pub struct WakeCheckFilter {
86 name: &'static str,
87 wake_phrases: Vec<Vec<String>>,
88 keepalive: Duration,
89 clock: Box<dyn WakeClock>,
90 participants: HashMap<String, ParticipantState>,
91}
92
93impl WakeCheckFilter {
94 pub fn new(wake_phrases: Vec<String>, keepalive: Duration) -> Self {
97 Self::with_clock(
98 wake_phrases,
99 keepalive,
100 Box::new(SystemWakeClock::default()),
101 )
102 }
103
104 pub fn with_clock(
106 wake_phrases: Vec<String>,
107 keepalive: Duration,
108 clock: Box<dyn WakeClock>,
109 ) -> Self {
110 let phrases = wake_phrases
111 .into_iter()
112 .map(|p| p.split_whitespace().map(|w| w.to_lowercase()).collect())
113 .collect();
114 Self {
115 name: "wake-check",
116 wake_phrases: phrases,
117 keepalive,
118 clock,
119 participants: HashMap::new(),
120 }
121 }
122}
123
124fn match_wake(wake_phrases: &[Vec<String>], haystack: &str) -> Option<usize> {
131 let lower = haystack.to_lowercase();
132 let words: Vec<(usize, &str)> = word_offsets(&lower);
133 for phrase in wake_phrases {
134 if phrase.is_empty() {
135 continue;
136 }
137 'start: for start in 0..words.len() {
140 if words.len() - start < phrase.len() {
141 break;
142 }
143 for (i, pw) in phrase.iter().enumerate() {
144 if words[start + i].1 != pw.as_str() {
145 continue 'start;
146 }
147 }
148 return Some(words[start].0);
149 }
150 }
151 None
152}
153
154fn word_offsets(s: &str) -> Vec<(usize, &str)> {
156 let mut out = Vec::new();
157 let mut idx = 0;
158 for word in s.split_whitespace() {
159 if let Some(rel) = s[idx..].find(word) {
161 let at = idx + rel;
162 out.push((at, word));
163 idx = at + word.len();
164 }
165 }
166 out
167}
168
169#[async_trait]
170impl FrameProcessor for WakeCheckFilter {
171 fn name(&self) -> &str {
172 self.name
173 }
174
175 async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
176 let Frame::Transcription {
177 ref text,
178 ref user_id,
179 ..
180 } = env.frame
181 else {
182 link.push(env.meta, env.frame, env.direction).await;
184 return Ok(());
185 };
186
187 let now = self.clock.now();
188 let keepalive = self.keepalive;
189 let user_key = user_id.to_string();
190 let wake_phrases = &self.wake_phrases;
193 let p = self
194 .participants
195 .entry(user_key)
196 .or_insert_with(ParticipantState::new);
197
198 if p.state == WakeState::Awake {
200 if now.saturating_sub(p.wake_timer) < keepalive {
201 p.wake_timer = now;
202 link.push(env.meta, env.frame, env.direction).await;
203 return Ok(());
204 }
205 p.state = WakeState::Idle;
206 }
207
208 if !p.accumulator.is_empty() && !p.accumulator.ends_with(char::is_whitespace) {
211 p.accumulator.push(' ');
212 }
213 p.accumulator.push_str(text);
214 if let Some(start) = match_wake(wake_phrases, &p.accumulator) {
215 p.state = WakeState::Awake;
216 p.wake_timer = now;
217 let gated = p.accumulator[start..].to_string();
219 p.accumulator.clear();
220 let (uid, lang, final_) = match env.frame {
221 Frame::Transcription {
222 user_id,
223 language,
224 final_,
225 ..
226 } => (user_id, language, final_),
227 _ => unreachable!(),
228 };
229 link.push(
230 env.meta,
231 Frame::Transcription {
232 text: gated,
233 user_id: uid,
234 language: lang,
235 final_,
236 },
237 env.direction,
238 )
239 .await;
240 }
241 Ok(())
243 }
244}
245
246pub type WakeNotifier = Arc<dyn Fn() + Send + Sync>;
248
249pub type WakePredicate = Arc<dyn Fn(&Frame) -> bool + Send + Sync>;
251
252pub struct WakeNotifierProcessor {
257 name: &'static str,
258 kinds: Vec<FrameKind>,
259 predicate: WakePredicate,
260 notifier: WakeNotifier,
261}
262
263impl WakeNotifierProcessor {
264 pub fn new(kinds: Vec<FrameKind>, predicate: WakePredicate, notifier: WakeNotifier) -> Self {
266 Self {
267 name: "wake-notifier",
268 kinds,
269 predicate,
270 notifier,
271 }
272 }
273}
274
275#[async_trait]
276impl FrameProcessor for WakeNotifierProcessor {
277 fn name(&self) -> &str {
278 self.name
279 }
280
281 async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
282 if self.kinds.contains(&env.frame.kind()) && (self.predicate)(&env.frame) {
283 (self.notifier)();
284 }
285 link.push(env.meta, env.frame, env.direction).await;
286 Ok(())
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::agent::test_harness::drive;
294 use crate::processor::frame::Direction;
295 use std::sync::atomic::{AtomicU64, Ordering};
296
297 #[derive(Clone, Default)]
299 struct FakeClock(Arc<AtomicU64>);
300 impl FakeClock {
301 fn advance(&self, ms: u64) {
302 self.0.fetch_add(ms, Ordering::SeqCst);
303 }
304 }
305 impl WakeClock for FakeClock {
306 fn now(&self) -> Duration {
307 Duration::from_millis(self.0.load(Ordering::SeqCst))
308 }
309 }
310
311 fn transcription(text: &str, user: &str) -> Frame {
312 Frame::Transcription {
313 text: text.into(),
314 user_id: Arc::from(user),
315 language: None,
316 final_: true,
317 }
318 }
319
320 #[tokio::test]
321 async fn gates_until_wake_phrase_then_opens() {
322 let filter = WakeCheckFilter::new(vec!["hey assistant".into()], Duration::from_secs(3));
323 let out = drive(
324 Box::new(filter),
325 vec![
326 transcription("what time is it", "u1"), transcription("hey assistant set a timer", "u1"), transcription("for ten minutes", "u1"), ],
330 Direction::Downstream,
331 )
332 .await;
333 let texts: Vec<String> = out
334 .into_iter()
335 .filter_map(|f| match f {
336 Frame::Transcription { text, .. } => Some(text),
337 _ => None,
338 })
339 .collect();
340 assert_eq!(texts, vec!["hey assistant set a timer", "for ten minutes"]);
343 }
344
345 #[tokio::test]
346 async fn wake_only_fires_on_the_phrase() {
347 let filter = WakeCheckFilter::new(vec!["computer".into()], Duration::from_secs(3));
348 let out = drive(
349 Box::new(filter),
350 vec![
351 transcription("hello world", "u1"),
352 transcription("the computers are noisy", "u1"),
353 ],
354 Direction::Downstream,
355 )
356 .await;
357 let count = out
359 .iter()
360 .filter(|f| matches!(f, Frame::Transcription { .. }))
361 .count();
362 assert_eq!(count, 0, "fired on a non-wake word: {out:?}");
363 }
364
365 #[tokio::test]
366 async fn keepalive_expires_and_regates() {
367 let clock = FakeClock::default();
368 let filter = WakeCheckFilter::with_clock(
369 vec!["wake".into()],
370 Duration::from_millis(1000),
371 Box::new(clock.clone()),
372 );
373 use crate::processor::runtime::{channel, NORMAL_CHAN_CAP};
376 use crate::processor::{Envelope, Link};
377 use std::sync::atomic::AtomicI64;
378
379 let (cap_tx, mut cap_rx) = channel(Arc::from("cap"), NORMAL_CHAN_CAP);
380 let link = Link {
381 next: Some(cap_tx.clone()),
382 prev: Some(cap_tx),
383 name: Arc::from("wake-check"),
384 clock: crate::processor::Clock::new(),
385 observer: None,
386 enable_metrics: false,
387 enable_usage_metrics: false,
388 ttfb_start: Arc::new(AtomicI64::new(0)),
389 processing_start: Arc::new(AtomicI64::new(0)),
390 };
391 let mut filter: Box<dyn FrameProcessor> = Box::new(filter);
392
393 let drain = |rx: &mut crate::processor::runtime::ProcessorRx| -> Vec<Frame> {
394 let mut v = Vec::new();
395 while let Ok(e) = rx.normal.try_recv() {
396 v.push(e.frame);
397 }
398 v
399 };
400
401 filter
403 .process_frame(
404 Envelope::new(transcription("wake up", "u"), Direction::Downstream),
405 &link,
406 )
407 .await
408 .unwrap();
409 assert_eq!(drain(&mut cap_rx).len(), 1);
410
411 clock.advance(500);
413 filter
414 .process_frame(
415 Envelope::new(transcription("still here", "u"), Direction::Downstream),
416 &link,
417 )
418 .await
419 .unwrap();
420 assert_eq!(drain(&mut cap_rx).len(), 1);
421
422 clock.advance(2000);
424 filter
425 .process_frame(
426 Envelope::new(transcription("ignored now", "u"), Direction::Downstream),
427 &link,
428 )
429 .await
430 .unwrap();
431 assert_eq!(
432 drain(&mut cap_rx).len(),
433 0,
434 "should re-gate after keepalive"
435 );
436 }
437
438 #[tokio::test]
439 async fn notifier_fires_on_matching_kind_and_predicate() {
440 let hits = Arc::new(AtomicU64::new(0));
441 let h2 = hits.clone();
442 let proc = WakeNotifierProcessor::new(
443 vec![FrameKind::Transcription],
444 Arc::new(|f| matches!(f, Frame::Transcription { text, .. } if text.contains("now"))),
445 Arc::new(move || {
446 h2.fetch_add(1, Ordering::SeqCst);
447 }),
448 );
449 let out = drive(
450 Box::new(proc),
451 vec![
452 transcription("later", "u"),
453 transcription("do it now", "u"),
454 Frame::Text("now".into()), ],
456 Direction::Downstream,
457 )
458 .await;
459 assert_eq!(hits.load(Ordering::SeqCst), 1);
461 assert_eq!(out.len(), 3, "notifier must forward every frame");
462 }
463}