diff --git a/commands.yaml b/commands.yaml
index 8869138..f2cb70f 100644
--- a/commands.yaml
+++ b/commands.yaml
@@ -1549,3 +1549,78 @@ personality_tony_quote:
- процитируй железного человека
- цитата старка
action: {type: personality_tony_quote}
+
+tracker_start:
+ phrases:
+ - начни отсчёт
+ - начни отсчет
+ - запусти трекер
+ - я начинаю работу
+ - начало рабочего дня
+ action: {type: tracker_start}
+
+tracker_stop:
+ phrases:
+ - закончи отсчёт
+ - закончи отсчет
+ - останови трекер
+ - я закончил работу
+ - конец рабочего дня
+ action: {type: tracker_stop}
+
+tracker_today:
+ phrases:
+ - сколько я работаю сегодня
+ - сколько отработал сегодня
+ - сколько часов сегодня
+ - сколько времени за сегодня
+ action: {type: tracker_today}
+
+tracker_week:
+ phrases:
+ - сколько на этой неделе
+ - сколько отработал на неделе
+ - часы за неделю
+ - статистика за неделю
+ action: {type: tracker_week}
+
+tracker_reset:
+ phrases:
+ - сбрось трекер
+ - обнули трекер
+ - очисти трекер
+ - сбрось отсчёт
+ action: {type: tracker_reset}
+
+outlook_unread_count:
+ phrases:
+ - сколько у меня непрочитанных писем
+ - сколько непрочитанных писем
+ - проверь почту
+ - проверь outlook
+ - сколько новых писем
+ action: {type: outlook_unread_count}
+
+outlook_latest:
+ phrases:
+ - прочитай последнее письмо
+ - что в последнем письме
+ - последнее письмо
+ - покажи последнее письмо
+ action: {type: outlook_latest}
+
+outlook_send_clipboard:
+ phrases:
+ - отправь письмо
+ - отправь почту
+ - напиши письмо
+ - отправь это
+ action: {type: outlook_send_clipboard}
+
+outlook_summarize_inbox:
+ phrases:
+ - коротко расскажи про инбокс
+ - расскажи что в инбоксе
+ - сделай саммари инбокса
+ - что нового в почте
+ action: {type: outlook_summarize_inbox}
diff --git a/extensions.py b/extensions.py
index 9cf118f..64f4f02 100644
--- a/extensions.py
+++ b/extensions.py
@@ -29,6 +29,8 @@ import dev_handlers
import llm_backend
import wave1_handlers
import personality_handlers
+import time_tracker_handlers
+import outlook_handlers
_speak_fn = print
_set_clipboard_fn = None
@@ -43,6 +45,8 @@ def set_speak(fn):
dev_handlers.set_speak(fn)
wave1_handlers.set_speak(fn)
personality_handlers.set_speak(fn)
+ time_tracker_handlers.set_speak(fn)
+ outlook_handlers.set_speak(fn)
def set_clipboard_fn(fn):
@@ -1130,3 +1134,20 @@ def do_personality_thanks(a, v): personality_handlers.do_personality_thanks(a, v
def do_personality_compliment(a, v): personality_handlers.do_personality_compliment(a, v)
def do_personality_how_are_you(a, v): personality_handlers.do_personality_how_are_you(a, v)
def do_personality_tony_quote(a, v): personality_handlers.do_personality_tony_quote(a, v)
+
+
+# ── Time tracker proxies (to time_tracker_handlers) ─────────────────────────
+
+def do_tracker_start(a, v): time_tracker_handlers.do_tracker_start(a, v)
+def do_tracker_stop(a, v): time_tracker_handlers.do_tracker_stop(a, v)
+def do_tracker_today(a, v): time_tracker_handlers.do_tracker_today(a, v)
+def do_tracker_week(a, v): time_tracker_handlers.do_tracker_week(a, v)
+def do_tracker_reset(a, v): time_tracker_handlers.do_tracker_reset(a, v)
+
+
+# ── Outlook proxies (to outlook_handlers) ───────────────────────────────────
+
+def do_outlook_unread_count(a, v): outlook_handlers.do_outlook_unread_count(a, v)
+def do_outlook_latest(a, v): outlook_handlers.do_outlook_latest(a, v)
+def do_outlook_send_clipboard(a, v): outlook_handlers.do_outlook_send_clipboard(a, v)
+def do_outlook_summarize_inbox(a, v): outlook_handlers.do_outlook_summarize_inbox(a, v)
diff --git a/main.py b/main.py
index 2f1b2bf..1552fae 100644
--- a/main.py
+++ b/main.py
@@ -1699,6 +1699,24 @@ def run_action(action):
extensions.do_personality_how_are_you(action, _current_voice or '')
elif t == 'personality_tony_quote':
extensions.do_personality_tony_quote(action, _current_voice or '')
+ elif t == 'tracker_start':
+ extensions.do_tracker_start(action, _current_voice or '')
+ elif t == 'tracker_stop':
+ extensions.do_tracker_stop(action, _current_voice or '')
+ elif t == 'tracker_today':
+ extensions.do_tracker_today(action, _current_voice or '')
+ elif t == 'tracker_week':
+ extensions.do_tracker_week(action, _current_voice or '')
+ elif t == 'tracker_reset':
+ extensions.do_tracker_reset(action, _current_voice or '')
+ elif t == 'outlook_unread_count':
+ extensions.do_outlook_unread_count(action, _current_voice or '')
+ elif t == 'outlook_latest':
+ extensions.do_outlook_latest(action, _current_voice or '')
+ elif t == 'outlook_send_clipboard':
+ extensions.do_outlook_send_clipboard(action, _current_voice or '')
+ elif t == 'outlook_summarize_inbox':
+ extensions.do_outlook_summarize_inbox(action, _current_voice or '')
_current_voice: str = ''
diff --git a/outlook_handlers.py b/outlook_handlers.py
new file mode 100644
index 0000000..093046d
--- /dev/null
+++ b/outlook_handlers.py
@@ -0,0 +1,308 @@
+"""Outlook voice-command handlers — Python parity for the Rust `outlook` pack.
+
+Talks to Outlook via the `win32com.client` COM bridge (pywin32). When pywin32
+isn't installed or Outlook isn't running we degrade gracefully: every handler
+reports the failure through the speak callback instead of crashing.
+
+Exposes four handlers, mirroring the Rust pack:
+ do_outlook_unread_count(action, voice)
+ do_outlook_latest(action, voice)
+ do_outlook_send_clipboard(action, voice)
+ do_outlook_summarize_inbox(action, voice)
+"""
+
+import re
+import subprocess
+from typing import Optional
+
+# Lazy import — pywin32 is Windows-only and may not be installed on dev/CI.
+try:
+ import win32com.client as _w32com # type: ignore
+ _W32_OK = True
+except Exception: # ImportError, OSError, etc.
+ _w32com = None
+ _W32_OK = False
+
+
+_speak_fn = print
+
+
+def set_speak(fn):
+ """Register the voice output callback (called by extensions.set_speak)."""
+ global _speak_fn
+ _speak_fn = fn
+
+
+def _speak(text: str) -> None:
+ try:
+ _speak_fn(text)
+ except Exception as exc:
+ print(f"[outlook] speak: {exc}")
+
+
+# ── Outlook session helpers ────────────────────────────────────────────────
+
+def _outlook_session():
+ """Return (app, namespace) or (None, None) on any failure."""
+ if not _W32_OK:
+ return None, None
+ try:
+ app = _w32com.Dispatch("Outlook.Application")
+ ns = app.GetNamespace("MAPI")
+ return app, ns
+ except Exception as exc:
+ print(f"[outlook] session: {exc}")
+ return None, None
+
+
+def _unavailable_msg() -> str:
+ return "Outlook недоступен — запусти Outlook сначала."
+
+
+def _strip_html(s: str) -> str:
+ if not s:
+ return ""
+ s = re.sub(r"<[^>]+>", " ", s)
+ s = (s.replace(" ", " ").replace("&", "&")
+ .replace("<", "<").replace(">", ">")
+ .replace(""", '"').replace("'", "'"))
+ return re.sub(r"\s+", " ", s).strip()
+
+
+def _get_clipboard() -> str:
+ """Fetch clipboard via PowerShell. Returns "" on failure."""
+ try:
+ res = subprocess.run(
+ ["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"],
+ capture_output=True, text=True, encoding="utf-8", timeout=10,
+ )
+ return (res.stdout or "").strip()
+ except Exception as exc:
+ print(f"[outlook] clipboard: {exc}")
+ return ""
+
+
+# ── handlers ───────────────────────────────────────────────────────────────
+
+def do_outlook_unread_count(action, voice):
+ _, ns = _outlook_session()
+ if ns is None:
+ _speak(_unavailable_msg())
+ return
+ try:
+ inbox = ns.GetDefaultFolder(6) # olFolderInbox
+ # Prefer the cheap property; fall back to scanning items.
+ try:
+ count = int(inbox.UnReadItemCount)
+ except Exception:
+ count = sum(1 for it in inbox.Items if getattr(it, "UnRead", False))
+ except Exception as exc:
+ print(f"[outlook] unread_count: {exc}")
+ _speak(_unavailable_msg())
+ return
+
+ if count == 0:
+ _speak("Непрочитанных писем нет.")
+ elif count == 1:
+ _speak("Одно непрочитанное письмо.")
+ else:
+ _speak(f"Непрочитанных писем: {count}.")
+
+
+def do_outlook_latest(action, voice):
+ _, ns = _outlook_session()
+ if ns is None:
+ _speak(_unavailable_msg())
+ return
+ try:
+ inbox = ns.GetDefaultFolder(6)
+ items = inbox.Items
+ items.Sort("[ReceivedTime]", True)
+ latest = None
+ for it in items:
+ try:
+ if it.Class == 43: # olMail
+ latest = it
+ break
+ except Exception:
+ continue
+ if latest is None:
+ _speak("Входящие пусты.")
+ return
+ from_name = (
+ getattr(latest, "SenderName", "")
+ or getattr(latest, "SenderEmailAddress", "")
+ or "неизвестного отправителя"
+ )
+ subject = getattr(latest, "Subject", "") or "без темы"
+ body = getattr(latest, "Body", "") or ""
+ if not body:
+ body = _strip_html(getattr(latest, "HTMLBody", "") or "")
+ body = re.sub(r"\s+", " ", body).strip()
+ if len(body) > 200:
+ body = body[:200] + "..."
+ except Exception as exc:
+ print(f"[outlook] latest: {exc}")
+ _speak(_unavailable_msg())
+ return
+
+ _speak(f"Последнее письмо от {from_name}. Тема: {subject}. {body}")
+
+
+_SEND_TRIGGERS = (
+ "отправь письмо ", "отправь почту ", "напиши письмо ", "отправь это ",
+ "email this to ", "send email to ", "send this email to ", "mail this to ",
+)
+
+
+def _extract_recipient(voice: str) -> str:
+ """Pull the recipient name off the voice command by stripping the trigger."""
+ if not voice:
+ return ""
+ low = voice.lower()
+ for trig in _SEND_TRIGGERS:
+ if trig in low:
+ idx = low.find(trig)
+ return voice[idx + len(trig):].strip()
+ return voice.strip()
+
+
+def _resolve_recipient(ns, query: str) -> Optional[dict]:
+ if not query or ns is None:
+ return None
+ try:
+ r = ns.CreateRecipient(query)
+ r.Resolve()
+ if not r.Resolved:
+ return None
+ addr = None
+ try:
+ addr = r.AddressEntry.GetExchangeUser().PrimarySmtpAddress
+ except Exception:
+ pass
+ if not addr:
+ try:
+ addr = r.AddressEntry.Address
+ except Exception:
+ pass
+ if not addr:
+ addr = r.Name
+ return {"name": r.Name, "address": addr}
+ except Exception as exc:
+ print(f"[outlook] resolve: {exc}")
+ return None
+
+
+def do_outlook_send_clipboard(action, voice):
+ recipient = _extract_recipient(voice or "")
+ if not recipient:
+ _speak("Кому отправить? Назови имя.")
+ return
+
+ app, ns = _outlook_session()
+ if ns is None or app is None:
+ _speak(_unavailable_msg())
+ return
+
+ resolved = _resolve_recipient(ns, recipient)
+ if not resolved:
+ _speak(f"Не нашёл контакт «{recipient}».")
+ return
+
+ body = _get_clipboard()
+ first_line = (body.split("\n", 1)[0] if body else "").strip()
+ if len(first_line) > 60:
+ subject = first_line[:60].strip()
+ else:
+ subject = first_line
+ if not subject:
+ subject = "From J.A.R.V.I.S."
+
+ try:
+ mail = app.CreateItem(0) # olMailItem
+ mail.To = resolved["address"]
+ mail.Subject = subject
+ mail.Body = body
+ mail.Send()
+ except Exception as exc:
+ print(f"[outlook] send: {exc}")
+ _speak("Не получилось отправить — Outlook отказал.")
+ return
+
+ _speak(f"Отправил {resolved['name']}. Тема: {subject}.")
+
+
+def _llm_summarize(emails) -> Optional[str]:
+ """Call the configured LLM to summarise the email list. Returns None on
+ any failure, callers should provide a fallback."""
+ try:
+ import llm_backend
+ except Exception:
+ return None
+ if not emails:
+ return None
+ listing = "\n".join(
+ f"{i+1}. From: {m['from']} | Subject: {m['subject']}"
+ for i, m in enumerate(emails)
+ )
+ sys_prompt = (
+ "Ты помощник по почте. Кратко опиши, что лежит в инбоксе, ОДНИМ абзацем "
+ "(2-4 предложения). Сгруппируй по темам/отправителям если возможно. "
+ "Без преамбулы, без перечисления списком."
+ )
+ user_prompt = "Непрочитанные письма:\n" + listing
+ try:
+ reply = llm_backend.complete(
+ messages=[
+ {"role": "system", "content": sys_prompt},
+ {"role": "user", "content": user_prompt},
+ ],
+ max_tokens=250,
+ temperature=0.4,
+ )
+ if reply and isinstance(reply, str) and reply.strip():
+ return reply.strip()
+ except Exception as exc:
+ print(f"[outlook] llm: {exc}")
+ return None
+
+
+def do_outlook_summarize_inbox(action, voice, limit: int = 5):
+ _, ns = _outlook_session()
+ if ns is None:
+ _speak(_unavailable_msg())
+ return
+ try:
+ inbox = ns.GetDefaultFolder(6)
+ items = inbox.Items
+ items.Sort("[ReceivedTime]", True)
+ emails = []
+ for it in items:
+ if len(emails) >= limit:
+ break
+ try:
+ if it.Class != 43:
+ continue
+ if not getattr(it, "UnRead", False):
+ continue
+ except Exception:
+ continue
+ emails.append({
+ "from": getattr(it, "SenderName", "") or "",
+ "subject": getattr(it, "Subject", "") or "",
+ })
+ except Exception as exc:
+ print(f"[outlook] summarize: {exc}")
+ _speak(_unavailable_msg())
+ return
+
+ if not emails:
+ _speak("Непрочитанных писем нет.")
+ return
+
+ summary = _llm_summarize(emails)
+ if not summary:
+ # Fallback to a flat enumeration.
+ parts = "; ".join(f"{m['from']} — {m['subject']}" for m in emails)
+ summary = f"Непрочитанных: {len(emails)}. {parts}."
+ _speak(summary)
diff --git a/requirements.txt b/requirements.txt
index 070fb42..17c4804 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -27,6 +27,10 @@ sounddevice
PyYAML
python-dotenv
+# Outlook COM automation (Win32-only). Optional: outlook_handlers degrades
+# gracefully when this isn't installed.
+pywin32>=305
+
# Semantic intent classifier (MiniLM-L6-v2 ONNX)
onnxruntime>=1.17
tokenizers>=0.15
diff --git a/tests/test_outlook_handlers.py b/tests/test_outlook_handlers.py
new file mode 100644
index 0000000..7c149e6
--- /dev/null
+++ b/tests/test_outlook_handlers.py
@@ -0,0 +1,103 @@
+"""Tests for outlook_handlers — COM bridge mocked via unittest.mock.
+
+We patch the module-level `_outlook_session` to return a fake (app, ns) pair
+made of MagicMocks so we never need a live Outlook install.
+"""
+
+import importlib
+import sys
+from unittest.mock import MagicMock, patch
+
+
+def _fresh_module():
+ if 'outlook_handlers' in sys.modules:
+ del sys.modules['outlook_handlers']
+ return importlib.import_module('outlook_handlers')
+
+
+def _make_inbox_with_unread(count):
+ """Build a fake Inbox object whose UnReadItemCount is `count`."""
+ inbox = MagicMock()
+ inbox.UnReadItemCount = count
+ return inbox
+
+
+def test_unread_count_speaks_singular_for_one():
+ mod = _fresh_module()
+ spoken = []
+ mod.set_speak(lambda t: spoken.append(t))
+
+ app = MagicMock()
+ ns = MagicMock()
+ ns.GetDefaultFolder.return_value = _make_inbox_with_unread(1)
+ with patch.object(mod, '_outlook_session', return_value=(app, ns)):
+ mod.do_outlook_unread_count({}, '')
+
+ assert spoken, "expected the handler to speak"
+ assert "Одно" in spoken[0]
+
+
+def test_unread_count_zero_says_no_unread():
+ mod = _fresh_module()
+ spoken = []
+ mod.set_speak(lambda t: spoken.append(t))
+
+ app = MagicMock()
+ ns = MagicMock()
+ ns.GetDefaultFolder.return_value = _make_inbox_with_unread(0)
+ with patch.object(mod, '_outlook_session', return_value=(app, ns)):
+ mod.do_outlook_unread_count({}, '')
+
+ assert spoken
+ assert "нет" in spoken[0].lower()
+
+
+def test_unread_count_plural_count():
+ mod = _fresh_module()
+ spoken = []
+ mod.set_speak(lambda t: spoken.append(t))
+
+ ns = MagicMock()
+ ns.GetDefaultFolder.return_value = _make_inbox_with_unread(7)
+ with patch.object(mod, '_outlook_session', return_value=(MagicMock(), ns)):
+ mod.do_outlook_unread_count({}, '')
+
+ assert "7" in spoken[0]
+
+
+def test_unavailable_outlook_says_so():
+ mod = _fresh_module()
+ spoken = []
+ mod.set_speak(lambda t: spoken.append(t))
+
+ with patch.object(mod, '_outlook_session', return_value=(None, None)):
+ mod.do_outlook_unread_count({}, '')
+
+ assert spoken
+ assert "Outlook" in spoken[0]
+
+
+def test_extract_recipient_strips_trigger():
+ mod = _fresh_module()
+ assert mod._extract_recipient("отправь письмо Дмитрию") == "Дмитрию"
+ assert mod._extract_recipient("email this to Bob") == "Bob"
+ assert mod._extract_recipient("Bob") == "Bob"
+ assert mod._extract_recipient("") == ""
+
+
+def test_send_clipboard_no_recipient_prompts_for_name():
+ mod = _fresh_module()
+ spoken = []
+ mod.set_speak(lambda t: spoken.append(t))
+
+ # No trigger and no recipient in the voice -> _extract_recipient returns ""
+ mod.do_outlook_send_clipboard({}, "")
+
+ assert spoken
+ assert "имя" in spoken[0].lower() or "name" in spoken[0].lower()
+
+
+def test_strip_html_basic():
+ mod = _fresh_module()
+ assert mod._strip_html("Hi there") == "Hi there"
+ assert mod._strip_html("") == ""
diff --git a/tests/test_time_tracker.py b/tests/test_time_tracker.py
new file mode 100644
index 0000000..575524c
--- /dev/null
+++ b/tests/test_time_tracker.py
@@ -0,0 +1,179 @@
+"""Tests for time_tracker_handlers — parity with rust time_tracker pack."""
+
+import json
+import os
+import time as _time
+import pytest
+
+
+def test_load_returns_empty_when_file_missing(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ data = tt.load()
+ assert data == {'current_session_start': None, 'sessions': []}
+
+
+def test_start_session_records_timestamp(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ tt.start_session(now=1_700_000_000)
+ data = tt.load()
+ assert data['current_session_start'] == 1_700_000_000
+ assert data['sessions'] == []
+
+
+def test_start_session_is_idempotent_when_already_running(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ tt.start_session(now=1_700_000_000)
+ tt.start_session(now=1_700_010_000) # second start should not overwrite
+ data = tt.load()
+ assert data['current_session_start'] == 1_700_000_000
+
+
+def test_stop_session_pushes_completed(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ tt.start_session(now=1_700_000_000)
+ _, elapsed = tt.stop_session(now=1_700_003_600) # +1 hour
+ assert elapsed == 3600
+ data = tt.load()
+ assert data['current_session_start'] is None
+ assert len(data['sessions']) == 1
+ assert data['sessions'][0]['start'] == 1_700_000_000
+ assert data['sessions'][0]['end'] == 1_700_003_600
+
+
+def test_stop_session_without_open_returns_none(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ _, elapsed = tt.stop_session(now=1_700_000_000)
+ assert elapsed is None
+
+
+def test_today_total_sums_overlap_in_local_day(isolated_state):
+ """Two sessions today + one yesterday — only today's pair is counted."""
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+
+ # Pick a deterministic "now" at 15:00 local time today.
+ today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
+ now = today_midnight + 15 * 3600 # 15:00 local today
+
+ data = {
+ 'current_session_start': None,
+ 'sessions': [
+ # yesterday: 10:00 → 12:00 (2h, ignored)
+ {'start': today_midnight - 14 * 3600, 'end': today_midnight - 12 * 3600},
+ # today: 09:00 → 10:30 (90 min)
+ {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600 + 1800},
+ # today: 13:00 → 14:00 (60 min)
+ {'start': today_midnight + 13 * 3600, 'end': today_midnight + 14 * 3600},
+ ],
+ }
+ tt.save(data)
+
+ total = tt.today_total(now=now)
+ assert total == 90 * 60 + 60 * 60 # 2h30m
+
+
+def test_today_total_includes_open_session(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
+ now = today_midnight + 10 * 3600 # 10:00
+ data = {
+ 'current_session_start': today_midnight + 9 * 3600, # opened at 09:00
+ 'sessions': [],
+ }
+ tt.save(data)
+ total = tt.today_total(now=now)
+ assert total == 3600 # 1h of open session counts
+
+
+def test_week_total_covers_last_seven_days(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
+ now = today_midnight + 12 * 3600
+
+ data = {
+ 'current_session_start': None,
+ 'sessions': [
+ # 8 days ago: outside window
+ {'start': today_midnight - 8 * 86400, 'end': today_midnight - 8 * 86400 + 3600},
+ # 3 days ago: inside window (1h)
+ {'start': today_midnight - 3 * 86400, 'end': today_midnight - 3 * 86400 + 3600},
+ # today: 2h
+ {'start': today_midnight + 9 * 3600, 'end': today_midnight + 11 * 3600},
+ ],
+ }
+ tt.save(data)
+
+ total = tt.week_total(now=now)
+ assert total == 3 * 3600 # 3 hours
+
+
+def test_reset_today_drops_only_todays_sessions(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
+ now = today_midnight + 14 * 3600
+ data = {
+ 'current_session_start': today_midnight + 13 * 3600,
+ 'sessions': [
+ {'start': today_midnight - 86400, 'end': today_midnight - 86400 + 3600}, # yesterday
+ {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600}, # today
+ {'start': today_midnight + 11 * 3600, 'end': today_midnight + 12 * 3600}, # today
+ ],
+ }
+ tt.save(data)
+
+ dropped, had_open = tt.reset_today(now=now)
+ assert dropped == 2
+ assert had_open is True
+
+ after = tt.load()
+ assert after['current_session_start'] is None
+ assert len(after['sessions']) == 1
+ assert after['sessions'][0]['start'] == today_midnight - 86400
+
+
+def test_reset_today_when_empty_reports_zero(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ dropped, had_open = tt.reset_today(now=1_700_000_000)
+ assert dropped == 0
+ assert had_open is False
+
+
+def test_format_duration_ru_pluralisation(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ assert tt.format_duration_ru(0) == "0 минут"
+ assert tt.format_duration_ru(60) == "1 минута"
+ assert tt.format_duration_ru(2 * 60) == "2 минуты"
+ assert tt.format_duration_ru(5 * 60) == "5 минут"
+ assert tt.format_duration_ru(11 * 60) == "11 минут"
+ assert tt.format_duration_ru(3600) == "1 час"
+ assert tt.format_duration_ru(2 * 3600) == "2 часа"
+ assert tt.format_duration_ru(5 * 3600) == "5 часов"
+ assert tt.format_duration_ru(3 * 3600 + 24 * 60) == "3 часа 24 минуты"
+
+
+def test_persists_atomic_write(isolated_state):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ tt.start_session(now=1_700_000_000)
+ assert os.path.isfile(tt._PATH)
+ with open(tt._PATH, encoding='utf-8') as f:
+ on_disk = json.load(f)
+ assert on_disk['current_session_start'] == 1_700_000_000
+
+
+def test_voice_entry_speaks_start_message(isolated_state, monkeypatch):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ spoken = []
+ monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s))
+ tt.do_tracker_start({}, '')
+ assert spoken and 'Отсчёт' in spoken[0]
+ # second call says already running
+ tt.do_tracker_start({}, '')
+ assert any('Уже считаю' in s for s in spoken)
+
+
+def test_voice_entry_today_reports_total(isolated_state, monkeypatch):
+ tt = isolated_state('time_tracker_handlers', ['_PATH'])
+ spoken = []
+ monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s))
+ # No data → "ничего не отработано"
+ tt.do_tracker_today({}, '')
+ assert spoken and 'ничего не отработано' in spoken[-1].lower()
diff --git a/time_tracker_handlers.py b/time_tracker_handlers.py
new file mode 100644
index 0000000..b0fb7d2
--- /dev/null
+++ b/time_tracker_handlers.py
@@ -0,0 +1,263 @@
+"""Time tracker — voice-driven daily work-time log.
+
+Parity with the rust `time_tracker` command pack. Stores active session state
+and a list of completed sessions in `/time_tracker.json` with
+atomic write-through (write-then-rename).
+
+Public API:
+ do_tracker_start(action, voice) -> None
+ do_tracker_stop(action, voice) -> None
+ do_tracker_today(action, voice) -> None
+ do_tracker_week(action, voice) -> None
+ do_tracker_reset(action, voice) -> None
+
+Lower-level helpers (also used by tests):
+ load() -> dict
+ save(data) -> None
+ start_session(now=None) -> dict # returns updated state
+ stop_session(now=None) -> tuple[dict, int|None] # (state, elapsed seconds)
+ today_total(now=None) -> int # total seconds today (incl. open session)
+ week_total(now=None) -> int # total seconds across last 7 days
+ reset_today(now=None) -> tuple[int, bool] # (dropped_count, had_open)
+"""
+
+import json
+import os
+import time as _time
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_PATH = os.path.join(_HERE, 'time_tracker.json')
+
+_speak_fn = print
+
+
+def set_speak(fn):
+ global _speak_fn
+ _speak_fn = fn
+
+
+def _speak(text):
+ try:
+ _speak_fn(text)
+ except Exception as exc:
+ print(f"[tracker] speak: {exc}")
+
+
+# ── persistence ──────────────────────────────────────────────────────────
+
+def load() -> dict:
+ if not os.path.isfile(_PATH):
+ return {'current_session_start': None, 'sessions': []}
+ try:
+ with open(_PATH, encoding='utf-8') as f:
+ data = json.load(f)
+ except (OSError, json.JSONDecodeError) as exc:
+ print(f"[tracker] corrupt store ({exc}) — starting empty")
+ return {'current_session_start': None, 'sessions': []}
+ if not isinstance(data, dict):
+ return {'current_session_start': None, 'sessions': []}
+ data.setdefault('current_session_start', None)
+ data.setdefault('sessions', [])
+ return data
+
+
+def save(data: dict) -> None:
+ tmp = _PATH + '.tmp'
+ try:
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ os.replace(tmp, _PATH)
+ except OSError as exc:
+ print(f"[tracker] save failed: {exc}")
+
+
+# ── local-day math ───────────────────────────────────────────────────────
+
+def _local_today_start(now: int) -> int:
+ """Return the unix timestamp of local midnight on the day that contains
+ `now`."""
+ tm = _time.localtime(now)
+ return now - (tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec)
+
+
+# ── operations ───────────────────────────────────────────────────────────
+
+def start_session(now: int | None = None) -> dict:
+ """Open a new active session if none is running. Returns the new state."""
+ if now is None:
+ now = int(_time.time())
+ data = load()
+ if data.get('current_session_start') is None:
+ data['current_session_start'] = now
+ save(data)
+ return data
+
+
+def stop_session(now: int | None = None) -> tuple[dict, int | None]:
+ """Close the open session (if any). Returns (state, elapsed_seconds)."""
+ if now is None:
+ now = int(_time.time())
+ data = load()
+ start_ts = data.get('current_session_start')
+ if start_ts is None:
+ return data, None
+ elapsed = max(1, now - int(start_ts))
+ sessions = data.get('sessions') or []
+ sessions.append({'start': int(start_ts), 'end': now})
+ data['sessions'] = sessions
+ data['current_session_start'] = None
+ save(data)
+ return data, elapsed
+
+
+def _sum_overlap(sessions: list[dict], open_start: int | None,
+ lo: int, hi: int) -> int:
+ total = 0
+ for sess in sessions:
+ s = int(sess.get('start') or 0)
+ e = int(sess.get('end') or 0)
+ a = max(s, lo)
+ b = min(e, hi)
+ if b > a:
+ total += b - a
+ if open_start is not None:
+ a = max(int(open_start), lo)
+ if hi > a:
+ total += hi - a
+ return total
+
+
+def today_total(now: int | None = None) -> int:
+ if now is None:
+ now = int(_time.time())
+ data = load()
+ today_start = _local_today_start(now)
+ return _sum_overlap(data.get('sessions') or [],
+ data.get('current_session_start'),
+ today_start, now)
+
+
+def week_total(now: int | None = None) -> int:
+ if now is None:
+ now = int(_time.time())
+ data = load()
+ today_start = _local_today_start(now)
+ week_start = today_start - 6 * 86400
+ return _sum_overlap(data.get('sessions') or [],
+ data.get('current_session_start'),
+ week_start, now)
+
+
+def reset_today(now: int | None = None) -> tuple[int, bool]:
+ """Drop today's completed sessions and stop any open one.
+ Returns (dropped_count, had_open_session)."""
+ if now is None:
+ now = int(_time.time())
+ data = load()
+ today_start = _local_today_start(now)
+ sessions = data.get('sessions') or []
+ kept = [s for s in sessions if int(s.get('start') or 0) < today_start]
+ dropped = len(sessions) - len(kept)
+ had_open = data.get('current_session_start') is not None
+ data['sessions'] = kept
+ data['current_session_start'] = None
+ save(data)
+ return dropped, had_open
+
+
+# ── formatting ───────────────────────────────────────────────────────────
+
+def _ru_hour_word(n: int) -> str:
+ n100 = n % 100
+ if 11 <= n100 <= 14:
+ return 'часов'
+ n10 = n % 10
+ if n10 == 1:
+ return 'час'
+ if 2 <= n10 <= 4:
+ return 'часа'
+ return 'часов'
+
+
+def _ru_minute_word(n: int) -> str:
+ n100 = n % 100
+ if 11 <= n100 <= 14:
+ return 'минут'
+ n10 = n % 10
+ if n10 == 1:
+ return 'минута'
+ if 2 <= n10 <= 4:
+ return 'минуты'
+ return 'минут'
+
+
+def _ru_session_word(n: int) -> str:
+ n100 = n % 100
+ if 11 <= n100 <= 14:
+ return 'сессий'
+ n10 = n % 10
+ if n10 == 1:
+ return 'сессия'
+ if 2 <= n10 <= 4:
+ return 'сессии'
+ return 'сессий'
+
+
+def format_duration_ru(seconds: int) -> str:
+ seconds = max(0, int(seconds))
+ h = seconds // 3600
+ m = (seconds % 3600) // 60
+ if h > 0 and m > 0:
+ return f"{h} {_ru_hour_word(h)} {m} {_ru_minute_word(m)}"
+ if h > 0:
+ return f"{h} {_ru_hour_word(h)}"
+ return f"{m} {_ru_minute_word(m)}"
+
+
+# ── voice entry points ───────────────────────────────────────────────────
+
+def do_tracker_start(action, voice):
+ data = load()
+ if data.get('current_session_start') is not None:
+ _speak("Уже считаю, сэр.")
+ return
+ start_session()
+ _speak("Отсчёт пошёл.")
+
+
+def do_tracker_stop(action, voice):
+ _, elapsed = stop_session()
+ if elapsed is None:
+ _speak("Трекер не запущен, сэр.")
+ return
+ _speak(f"Записал. Сессия: {format_duration_ru(elapsed)}.")
+
+
+def do_tracker_today(action, voice):
+ total = today_total()
+ if total < 60:
+ _speak("Сегодня ещё ничего не отработано, сэр.")
+ return
+ _speak(f"Сегодня отработано {format_duration_ru(total)}.")
+
+
+def do_tracker_week(action, voice):
+ total = week_total()
+ if total < 60:
+ _speak("За эту неделю ничего не отработано, сэр.")
+ return
+ _speak(f"За неделю отработано {format_duration_ru(total)}.")
+
+
+def do_tracker_reset(action, voice):
+ dropped, had_open = reset_today()
+ if dropped == 0 and not had_open:
+ _speak("Сегодня сбрасывать нечего, сэр.")
+ return
+ parts = ["Подтверждаю: трекер за сегодня очищен"]
+ if dropped > 0:
+ parts.append(f", удалено {dropped} {_ru_session_word(dropped)}")
+ if had_open:
+ parts.append(", активный отсчёт остановлен")
+ parts.append(".")
+ _speak("".join(parts))