Initial release: iai-mcp v0.1.0
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
This commit is contained in:
commit
f6b876fbe7
332 changed files with 97258 additions and 0 deletions
301
src/iai_mcp/bedtime.py
Normal file
301
src/iai_mcp/bedtime.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"""Phase 4 -- bedtime wind-down detection (DAEMON-06, D-08/D-09/D-11).
|
||||
|
||||
Dual-gate bedtime suggestion emitter:
|
||||
Gate A: wind-down phrase regex match per language (D-11, 8 languages)
|
||||
Gate B: late in learned quiet window (inside OR within 30min of start, D-09)
|
||||
|
||||
When BOTH gates pass, `detect_wind_down` returns a small dict that `core.py`
|
||||
injects into `memory_recall` responses as `sleep_suggestion`. Claude (the
|
||||
LLM in the active session) decides social framing -- our code NEVER hardcodes
|
||||
user-facing phrasing.
|
||||
|
||||
Constitutional guard:
|
||||
- C2: this module does NOT initiate sleep. It only suggests. The only path
|
||||
that moves the daemon into SLEEP is `core.handle_initiate_sleep_mode`
|
||||
with `consent=True`. No auto-start in this file.
|
||||
- C5 / this module is read-only w.r.t. records. It reads `cue`
|
||||
strings; it NEVER mutates `literal_surface`.
|
||||
- C6: no fcntl, no daemon state mutation. All logic is pure in-process.
|
||||
|
||||
Patterns mirror `shield.py`'s 8-language dict style (same language set:
|
||||
en/ru/ja/ar/de/fr/es/zh per global-product mandate). Latin-script
|
||||
languages use `\b` word boundaries; CJK / Arabic use character-class
|
||||
proximity and whitespace-tolerant forms since Unicode `\b` is unreliable
|
||||
across scripts.
|
||||
|
||||
ReDoS-safe: every pattern uses bounded quantifiers only. No nested `(.+)+`
|
||||
constructs, no `.*.*`. Stress-tested against 10KB of "a"s under 100ms total.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from iai_mcp.quiet_window import BUCKET_MINUTES
|
||||
|
||||
|
||||
# ------------------------------------------------------------ constants
|
||||
|
||||
# dual-gate: within this many minutes of the learned quiet-window start
|
||||
# also counts as "late" (a user who says "good night" 25 minutes before their
|
||||
# usual quiet window is winding down, not speaking rhetorically).
|
||||
WIND_DOWN_GATE_MINUTES_BEFORE: int = 30
|
||||
|
||||
|
||||
# ------------------------------------------------------------ per-language regex
|
||||
|
||||
# English wind-down phrases. Case-insensitive match.
|
||||
WIND_DOWN_EN: list[str] = [
|
||||
r"\bgood\s*night\b",
|
||||
r"\bgoodnight\b",
|
||||
r"\bnight[,!.]?\s*$",
|
||||
r"\bI'?m\s+(heading|going)\s+to\s+bed\b",
|
||||
r"\b(time\s+(to|for)\s+bed|bedtime)\b",
|
||||
r"\bI'?m\s+(tired|exhausted|sleepy)\b",
|
||||
r"\b(catch\s+you\s+tomorrow|see\s+you\s+tomorrow)\b",
|
||||
r"\blet'?s\s+(continue|pick\s+up)\s+tomorrow\b",
|
||||
r"\bgoing\s+to\s+sleep\b",
|
||||
]
|
||||
|
||||
# Russian (same 8-language set as shield.py).
|
||||
WIND_DOWN_RU: list[str] = [
|
||||
r"спокойной\s+ночи",
|
||||
r"пойду\s+(спать|в\s+постель)",
|
||||
r"(я\s+)?(устал|устала|вымотан[аы]?|засыпаю)",
|
||||
r"пора\s+(спать|ложиться)",
|
||||
r"до\s+завтра",
|
||||
r"давай\s+завтра",
|
||||
r"ухожу\s+спать",
|
||||
r"(окей|ок|ладно),?\s+сплю",
|
||||
r"ложусь",
|
||||
]
|
||||
|
||||
# Japanese -- NREM cues + "see you tomorrow". No \b; lookaround on adjacent
|
||||
# punctuation / kana / CJK characters.
|
||||
WIND_DOWN_JA: list[str] = [
|
||||
r"お\s*や\s*す\s*み(なさい)?", # おやすみ / おやすみなさい
|
||||
r"寝\s*ます", # 寝ます
|
||||
r"(眠|ねむ)い", # 眠い / ねむい
|
||||
r"(寝る|ねる)(ね|よ|わ)?", # 寝る / ねる / 寝るね
|
||||
r"また\s*(明日|あした)", # また明日
|
||||
r"(疲|つか)れた", # 疲れた / つかれた
|
||||
r"ベッド\s*に\s*(入る|はいる)", # ベッドに入る
|
||||
]
|
||||
|
||||
# Arabic -- RTL script; use direct patterns.
|
||||
WIND_DOWN_AR: list[str] = [
|
||||
r"تصبح\s+على\s+خير",
|
||||
r"ليلة\s+سعيدة",
|
||||
r"أنا\s+(ذاهب|ذاهبة)\s+(للنوم|إلى\s+النوم)",
|
||||
r"أنا\s+(متعب|متعبة|تعبان[ةه]?)",
|
||||
r"سأنام",
|
||||
r"وقت\s+النوم",
|
||||
r"إلى\s+(الغد|اللقاء\s+غدا)",
|
||||
]
|
||||
|
||||
WIND_DOWN_DE: list[str] = [
|
||||
r"\bgute\s+nacht\b",
|
||||
r"\bgn8\b",
|
||||
r"\bich\s+gehe\s+(jetzt\s+)?(ins\s+bett|schlafen)\b",
|
||||
r"\b(ich\s+bin\s+)?(müde|kaputt|fertig)\b",
|
||||
r"\bschlafenszeit\b",
|
||||
r"\bbis\s+morgen\b",
|
||||
r"\blass\s+uns\s+morgen\s+weitermachen\b",
|
||||
]
|
||||
|
||||
WIND_DOWN_FR: list[str] = [
|
||||
r"\bbonne\s+nuit\b",
|
||||
r"\bje\s+(vais|pars)\s+(me\s+coucher|dormir)\b",
|
||||
r"\b(je\s+suis\s+)?(fatigu[ée]|[ée]puis[ée])\b",
|
||||
r"\b(il\s+est\s+)?l'?heure\s+de\s+(dormir|me\s+coucher)\b",
|
||||
r"\b[aà]\s+demain\b",
|
||||
r"\bon\s+reprend\s+demain\b",
|
||||
]
|
||||
|
||||
WIND_DOWN_ES: list[str] = [
|
||||
r"\bbuenas\s+noches\b",
|
||||
r"\bme\s+voy\s+a\s+(dormir|la\s+cama|descansar)\b",
|
||||
r"\b(estoy\s+)?(cansad[oa]|agotad[oa])\b",
|
||||
r"\bhora\s+de\s+dormir\b",
|
||||
r"\bhasta\s+ma[ñn]ana\b",
|
||||
r"\bseguimos\s+ma[ñn]ana\b",
|
||||
]
|
||||
|
||||
WIND_DOWN_ZH: list[str] = [
|
||||
r"晚\s*安", # 晚安
|
||||
r"我\s*(要|去)\s*睡\s*(觉|了)", # 我要睡觉 / 我去睡了
|
||||
r"累\s*了", # 累了
|
||||
r"(该|到)\s*睡\s*(觉)?\s*了", # 该睡了 / 到睡觉了
|
||||
r"明\s*天\s*见", # 明天见
|
||||
r"明\s*天\s*继\s*续", # 明天继续
|
||||
]
|
||||
|
||||
# language coverage: exactly the 8 languages shield.py supports.
|
||||
WIND_DOWN_BY_LANG: dict[str, list[str]] = {
|
||||
"en": WIND_DOWN_EN,
|
||||
"ru": WIND_DOWN_RU,
|
||||
"ja": WIND_DOWN_JA,
|
||||
"ar": WIND_DOWN_AR,
|
||||
"de": WIND_DOWN_DE,
|
||||
"fr": WIND_DOWN_FR,
|
||||
"es": WIND_DOWN_ES,
|
||||
"zh": WIND_DOWN_ZH,
|
||||
}
|
||||
|
||||
# Pre-compile every pattern once. IGNORECASE is safe on non-Latin scripts
|
||||
# (lowercasing is identity-preserving for CJK; Cyrillic handles cleanly).
|
||||
_COMPILED: dict[str, list[re.Pattern]] = {
|
||||
lang: [re.compile(p, re.IGNORECASE) for p in pats]
|
||||
for lang, pats in WIND_DOWN_BY_LANG.items()
|
||||
}
|
||||
|
||||
# Authoritative language set -- downstream greps against this constant.
|
||||
WIND_DOWN_LANGUAGES_SUPPORTED: frozenset[str] = frozenset(WIND_DOWN_BY_LANG.keys())
|
||||
|
||||
|
||||
# ------------------------------------------------------------ gate A: phrase match
|
||||
|
||||
|
||||
def detect_wind_down_phrase(cue: str, language: str) -> Tuple[bool, str]:
|
||||
"""Gate A: does the cue contain a wind-down phrase?
|
||||
|
||||
Policy mirrors shield.py: primary language is always tried; ALSO try
|
||||
English regardless of `language` because users cross-lingual mid-sentence
|
||||
("ok, going to sleep" in a Russian conversation is still a wind-down
|
||||
signal). We do NOT fall back to any other language beyond EN -- that
|
||||
would explode the FPR.
|
||||
|
||||
Returns (matched, matched_pattern). matched_pattern is the source regex
|
||||
string (not the compiled object) for audit/logging purposes.
|
||||
"""
|
||||
if not cue:
|
||||
return False, ""
|
||||
|
||||
# Primary language (when different from "en").
|
||||
for p in _COMPILED.get(language or "", []):
|
||||
if p.search(cue):
|
||||
return True, p.pattern
|
||||
|
||||
# Always also try EN if we haven't already.
|
||||
if language != "en":
|
||||
for p in _COMPILED["en"]:
|
||||
if p.search(cue):
|
||||
return True, p.pattern
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
# ------------------------------------------------------------ gate B: late in quiet window
|
||||
|
||||
|
||||
def is_late_in_quiet_window(
|
||||
window: Optional[Tuple[int, int]],
|
||||
now: datetime,
|
||||
tz: ZoneInfo,
|
||||
) -> bool:
|
||||
"""Gate B: is `now` inside the quiet window OR within 30min of its start?
|
||||
|
||||
`window` is the (start_bucket, duration_buckets) pair emitted by
|
||||
`quiet_window.learn_quiet_window` -- start_bucket is an index into the
|
||||
48-bucket local-time day (30min each) and duration is the number of
|
||||
buckets. Returns False if no window is set (learn_quiet_window returned
|
||||
None, caller should be using the bootstrap 2h-idle trigger instead).
|
||||
|
||||
Wrap-around: a window starting at 22:00 and lasting 8h crosses local
|
||||
midnight; "inside" then means `cur >= start_minutes` OR `cur < end_minutes`.
|
||||
"""
|
||||
if not window:
|
||||
return False
|
||||
|
||||
start_bucket, duration = window
|
||||
try:
|
||||
now_local = now.astimezone(tz)
|
||||
except Exception:
|
||||
# DST edge or bad tz -- fail closed (don't suggest bedtime on
|
||||
# malformed input).
|
||||
return False
|
||||
|
||||
cur_minutes = now_local.hour * 60 + now_local.minute
|
||||
start_minutes = start_bucket * BUCKET_MINUTES
|
||||
end_minutes = (start_bucket + duration) * BUCKET_MINUTES
|
||||
|
||||
# Handle wrap-around midnight explicitly.
|
||||
if end_minutes > 24 * 60:
|
||||
wrapped_end = end_minutes - 24 * 60
|
||||
inside = cur_minutes >= start_minutes or cur_minutes < wrapped_end
|
||||
else:
|
||||
inside = start_minutes <= cur_minutes < end_minutes
|
||||
|
||||
if inside:
|
||||
return True
|
||||
|
||||
# Within 30min of start (cyclic -- a 21:45 cue for a 22:00 window counts).
|
||||
minutes_until_start = (start_minutes - cur_minutes) % (24 * 60)
|
||||
return 0 <= minutes_until_start <= WIND_DOWN_GATE_MINUTES_BEFORE
|
||||
|
||||
|
||||
# ------------------------------------------------------------ dual-gate detector
|
||||
|
||||
|
||||
def detect_wind_down(
|
||||
cue: str,
|
||||
language: str,
|
||||
state: dict,
|
||||
now: datetime,
|
||||
tz: ZoneInfo,
|
||||
) -> Optional[dict]:
|
||||
"""D-09 dual-gate bedtime detector.
|
||||
|
||||
Returns a `sleep_suggestion` dict when BOTH gates pass:
|
||||
Gate A: wind-down phrase match (primary lang + EN fallback)
|
||||
Gate B: late-in-learned-quiet-window (inside OR within 30min of start)
|
||||
|
||||
Returns None otherwise -- never a partial / fuzzy signal. Downstream
|
||||
consumers (`core._inject_sleep_suggestion`) key on the presence of the
|
||||
key, so None means the response simply does not carry `sleep_suggestion`.
|
||||
|
||||
Payload shape (small, no PII beyond the matched regex pattern):
|
||||
{
|
||||
"message_hint": "user_wind_down_detected",
|
||||
"matched_pattern": str,
|
||||
"quiet_window_start_bucket": int,
|
||||
"quiet_window_duration": int,
|
||||
}
|
||||
"""
|
||||
matched, pattern = detect_wind_down_phrase(cue, language)
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
window = state.get("quiet_window") if isinstance(state, dict) else None
|
||||
if not window:
|
||||
return None
|
||||
if not is_late_in_quiet_window(window, now, tz):
|
||||
return None
|
||||
|
||||
start_bucket, duration = window
|
||||
return {
|
||||
"message_hint": "user_wind_down_detected",
|
||||
"matched_pattern": pattern,
|
||||
"quiet_window_start_bucket": int(start_bucket),
|
||||
"quiet_window_duration": int(duration),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WIND_DOWN_AR",
|
||||
"WIND_DOWN_BY_LANG",
|
||||
"WIND_DOWN_DE",
|
||||
"WIND_DOWN_EN",
|
||||
"WIND_DOWN_ES",
|
||||
"WIND_DOWN_FR",
|
||||
"WIND_DOWN_GATE_MINUTES_BEFORE",
|
||||
"WIND_DOWN_JA",
|
||||
"WIND_DOWN_LANGUAGES_SUPPORTED",
|
||||
"WIND_DOWN_RU",
|
||||
"WIND_DOWN_ZH",
|
||||
"detect_wind_down",
|
||||
"detect_wind_down_phrase",
|
||||
"is_late_in_quiet_window",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue