Compare commits

..
51 Commits
Author SHA1 Message Date
hhandClaude Opus 5 21517e0948 feat(agents): the dispatcher may hand a file to the human
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:50:20 +02:00
hh f6d5d99287 feat(compose,gateway): mount the shared drop on the same path inside 2026-09-08 15:40:51 +02:00
hh a4ce40eaeb docs(readme,env): the leisure tick and its switches 2026-09-07 23:00:17 +00:00
hh a01a08737d fix(memory,curator): the leisure plan is not news for the curator 2026-09-07 23:00:17 +00:00
hh 014da65f86 feat(jobs,vault): hourly leisure tick, the day plan lives in the vault 2026-09-07 23:00:14 +00:00
hh bba8d9ce99 feat(voice): the answer streams unless the body opts out 2026-09-07 00:06:46 +02:00
hh 106f801e3c feat(prompts,agents,voice): the voice agent gets the beaver voice, its own environment and overlay 2026-09-06 23:54:37 +02:00
hh 0a6a2995fb docs(readme): drop the voice points section 2026-09-06 23:31:50 +02:00
hh af331e6d91 feat(voice,agents,memory): voice points get their own branch, agent and keys 2026-09-06 23:26:35 +02:00
hh 1ecfa01374 docs(readme): what the setup is, link to the site, memory seeding 2026-09-05 04:56:57 +02:00
hh ad571e1a2a feat(texts): russian framing for a subagent's report 2026-09-04 20:23:44 +02:00
hh db7e4e308c feat(frontends): vault root for the api graph 2026-09-02 03:48:42 +02:00
hh 645d000f8d chore: no personal addresses or nicks in the repo - machines by MagicDNS name, vibegram identity only from env 2026-09-02 01:33:58 +02:00
hh 51c27e76de docs: README follows the beaver_agent layout, MODELS.md moves to the vault 2026-09-02 00:25:20 +02:00
hh 3b2f5057da refactor: split config.py into the beaver_agent package (vault, prompts, skills, policy, hands, agents, frontends, texts, memory, jobs) 2026-09-02 00:24:31 +02:00
hh f161d6a46d feat(config): msos mcp for the dispatcher behind MSOS_MCP 2026-09-01 23:27:37 +02:00
hh f7844a5411 feat(config,policy): skill sets per kind, master without vault skills, attachments in the vault, crons in local tz 2026-09-01 23:13:29 +02:00
hh 43d0d43f9d docs(models): checklist for rolling out a new model 2026-09-01 22:00:45 +02:00
hh 5bd4c26ffc feat: fable 5 to fable 5.1 2026-09-01 21:24:36 +02:00
hh 444f145cf3 test(recall,curator): fictional names and facts in fixtures 2026-09-01 17:01:51 +02:00
hh 3a77a12447 feat(config,curator,recall): memory curator job on opus high, notes head inline in the envelope, portrait in the morning seed 2026-09-01 16:48:25 +02:00
hh 58e343aae0 feat(scripts): one-off memory seed from chats, diary and thoughts via claude -p 2026-09-01 16:24:26 +02:00
hh fe26760b84 fix(recall): notes file is '<имя> - наблюдения.md' so [[имя]] stays unambiguous 2026-09-01 16:22:06 +02:00
hh c3d5c944ea fix(config,compose): calendar url out of the config error, capped container logs 2026-09-01 14:13:22 +00:00
hh 56f8f2a1e1 fix(compose): mount recall.py into the gateway config dir 2026-09-01 16:10:38 +02:00
hh 505bf8d016 feat(config,recall): people recall block in the envelope, reply log in мета/бобер/реплики 2026-09-01 16:08:55 +02:00
hh 4765f9c7cf feat(config): dispatcher effort high by default 2026-09-01 15:26:18 +02:00
hh ec55994fc7 fix(config,vibegram): triage wakes master by alias with wake urgency, event times in local tz 2026-08-30 20:34:17 +02:00
hh 05fcfb6447 docs(env): komodo hook token is the shared alerter secret via BOOTSTRAP_TOKENS 2026-08-30 18:27:19 +02:00
hh b1f3fc93ed feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones 2026-08-30 18:16:15 +02:00
hh 6f8711c1a8 feat(t3code): thread events as master injects, hook job without dedupe 2026-08-29 22:48:45 +02:00
hh ab6812f3ee feat(t3code): allow every project on both machines 2026-08-29 20:54:15 +02:00
hh 96a7820026 feat(config,compose): t3code-mcp as dispatcher hands on mac and dell 2026-08-29 20:36:23 +02:00
hh 4415e890c9 fix(komodo,config): fresh pagerless shell per exec, panel and api injects framed as the user 2026-08-29 17:07:45 +02:00
hh 02723c21a7 fix(config): rotation at 500k context, same-day text for size and age rotations 2026-08-29 16:54:31 +02:00
hh 40231049eb feat(policy,komodo,config): vault zone and firefly skill rules, komodo python_tool with exec, tests 2026-08-29 15:27:32 +02:00
hh 9dfb8ba201 feat(config): distiller agent, deep chat closing and weekly state jobs, distill prompt 2026-08-29 03:05:50 +02:00
hh 80806d18d8 feat(config): triage agent, rotation and vibegram jobs, deploy and komodo hooks, vault watch rules 2026-08-29 02:29:08 +02:00
hh c830ca73e6 feat(config,caddy): single gateway port and site.caddy per the komodo convention 2026-08-29 00:49:27 +02:00
hh c6bf30b5af feat(config,caddy): admin spa under /admin and memory_root for the api frontend 2026-08-28 22:14:25 +02:00
hh 566fb5e895 feat(config): telegram frontend as home of master and branch 2026-08-28 17:57:24 +02:00
hh 6ff0da31a3 feat(caddy): route /api to the conversations frontend 2026-08-28 17:06:08 +02:00
hh 7af833a4d7 refactor(config): prompts per kind and frontend agents as parameters 2026-08-28 16:36:45 +02:00
hh 03ed732f7a feat(config): chat_path hook and default agents per kind 2026-08-28 03:50:40 +02:00
hh 03774c54e3 feat(config,compose): dispatcher agent, api frontend, merge prompt and morning seed 2026-08-28 03:08:30 +02:00
hh 71af41f35f chore(*): ruff, ty, pre-commit and make check for config.py 2026-08-28 02:20:36 +02:00
hh ef5adf231a chore(deps): local extra installs ../beaver-gateway editable, prod extra from git 2026-08-28 02:16:43 +02:00
hh 62b8b1c14b feat(config,compose): prompt tags in config, voice granule folder, runner config dir and .claude.json in entrypoint 2026-08-28 02:10:17 +02:00
hh 91c638186b feat(config,compose): sdk agents with vault prompt granules and skill plugins, ro vault with rw sub-mounts, beaver-runner 2026-08-28 01:56:40 +02:00
hhandClaude Fable 5 95fc8a4578 docs(env): documented .env.example, token-based claude auth in README
Every variable the compose file, the gateway and config.py read, grouped
and explained, including the ones the Jarvis rework will need
(CLAUDE_CODE_OAUTH_TOKEN, TELEGRAM_*, T3_*, KOMODO_*).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:45:51 +02:00
hhandClaude Fable 5 a937500d9b feat(infra): compose profiles, Makefile, local override example
All services sit behind profiles (db, gateway, obsidian) selected via
COMPOSE_PROFILES in .env; Makefile with the usual verbs and a `deploy`
target that pushes main:stable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:21:31 +02:00
7 changed files with 4 additions and 909 deletions

No files matched your search

-3
View File
@@ -62,9 +62,6 @@ FIREFLY_PAT=
# Календари: name=url,name=url (пусто - без календарей). # Календари: name=url,name=url (пусто - без календарей).
# В url лежит приватный ical-фид (google `/private-<токен>/`, usos `key=`) - # В url лежит приватный ical-фид (google `/private-<токен>/`, usos `key=`) -
# это секрет: утёк url = утёк календарь, лечится перевыпуском фида. # это секрет: утёк url = утёк календарь, лечится перевыпуском фида.
# Модели календари даются MCP-руками, а справка конверта раз в день читает
# тот же ical напрямую - урл фида она берёт из параметра `icsUrl` этих же
# записей; записи без `icsUrl` остаются рукой, но в справку не попадают.
CALENDAR_MCPS= CALENDAR_MCPS=
# MCP beavergram (telegram-history) # MCP beavergram (telegram-history)
BEAVERGRAM_MCP= BEAVERGRAM_MCP=
+1 -5
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from beaver_gateway.agents.base import ExposedMcp from beaver_gateway.agents.base import ExposedMcp
from beaver_gateway.mcp.types import McpServer, McpServerT from beaver_gateway.mcp.types import McpServer, McpServerT
from beaver_agent.hands.calendars import Calendars, calendars, feeds from beaver_agent.hands.calendars import calendars
from beaver_agent.hands.homeassistant import HomeAssistant from beaver_agent.hands.homeassistant import HomeAssistant
from beaver_agent.hands.komodo import Komodo from beaver_agent.hands.komodo import Komodo
from beaver_agent.hands.vibegram import Vibegram from beaver_agent.hands.vibegram import Vibegram
@@ -52,10 +52,6 @@ telegram = (
calendar_servers = calendars(env("CALENDAR_MCPS", "")) calendar_servers = calendars(env("CALENDAR_MCPS", ""))
calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_servers) calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_servers)
CALENDAR_FEEDS = feeds(env("CALENDAR_MCPS", ""))
CALENDARS = Calendars(CALENDAR_FEEDS, tz=TZ) if CALENDAR_FEEDS else None
"""Те же фиды, но напрямую: ближайшие события едут в справку без модели."""
KOMODO = ( KOMODO = (
Komodo( Komodo(
url=os.environ["KOMODO_URL"], url=os.environ["KOMODO_URL"],
+1 -516
View File
@@ -1,70 +1,9 @@
"""Календари: MCP-руки для модели и тот же ical-фид, читаемый gateway напрямую. """Календари из `CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном."""
`CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном, и там же,
параметром `icsUrl`, лежит сам ical. Модель ходит в календари через MCP, а
справка конверта читает фид сама: канал, о котором никто не спрашивает, молчит
незаметно - так экзаменационная сессия и пролежала невидимой.
Парсер здесь свой и маленький: в образе gateway ical-библиотеки нет, а нужно
немного - когда начинается, как называется, где. Повторы разворачиваются по
FREQ=DAILY/WEEKLY/MONTHLY/YEARLY с INTERVAL, BYDAY, BYMONTH, COUNT и UNTIL;
EXDATE и перенесённые экземпляры (RECURRENCE-ID) вычитаются. Шаг считается по
настенным часам события, а не в абсолютном времени: еженедельная встреча
переживает перевод часов. Событие, порождённое ежедневным правилом, помечается
`routine`: в справке такие едут одной строкой, чтобы не вытеснять разовые.
"""
from __future__ import annotations from __future__ import annotations
import logging
import re
import urllib.error
import urllib.request
from calendar import monthrange
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, timedelta
from typing import TYPE_CHECKING
from urllib.parse import parse_qs, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from beaver_gateway.mcp.types import HttpMcp, McpServer from beaver_gateway.mcp.types import HttpMcp, McpServer
if TYPE_CHECKING:
from collections.abc import Iterator
from datetime import tzinfo
__all__ = [
"Calendars",
"Event",
"Feed",
"Upcoming",
"calendars",
"feeds",
"is_routine",
"short",
]
_log = logging.getLogger(__name__)
USER_AGENT = "beaver-gateway (ical)"
MAX_BYTES = 4_000_000
"""Больше четырёх мегабайт фида - это уже не календарь, а чей-то экспорт всего."""
STEPS_CAP = 20_000
"""Потолок шагов разворачивания одного повтора: страховка от вечного цикла."""
PLACE_MAX = 70
TITLE_MAX = 90
ERROR_MAX = 90
NO_TITLE = "(без названия)"
WEEKDAYS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
DT_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$")
BYDAY_RE = re.compile(r"^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$")
ESCAPE_RE = re.compile(r"\\([\\;,nN])")
PLACE_RE = re.compile(
r"^\s*(?:sala|room|зал|аудитория)\b\s*[:.]?\s*(.+)$", re.IGNORECASE | re.MULTILINE
)
"""Зал USOS живёт в описании (`Sala: 315`), а LOCATION - это адрес корпуса."""
def calendars(raw: str) -> list[HttpMcp]: def calendars(raw: str) -> list[HttpMcp]:
servers: list[HttpMcp] = [] servers: list[HttpMcp] = []
@@ -78,457 +17,3 @@ def calendars(raw: str) -> list[HttpMcp]:
raise ValueError(msg) raise ValueError(msg)
servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip())) servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip()))
return servers return servers
@dataclass(frozen=True, slots=True)
class Feed:
name: str
url: str
"""Ical по подписке: приватный, с токеном внутри - в логи не писать."""
def feeds(raw: str) -> tuple[Feed, ...]:
"""Ical-фиды из тех же записей `CALENDAR_MCPS`: урл лежит в `icsUrl`.
Запись без ical-урла просто не даёт фида: MCP-рука у модели остаётся,
справка про этот календарь молчит - конфигурация, а не отказ канала.
"""
out: list[Feed] = []
for raw_entry in raw.split(","):
name, sep, url = raw_entry.strip().partition("=")
if not sep or not url.strip():
continue
ics = _ics_url(url.strip())
if ics:
out.append(Feed(name=name.strip(), url=ics))
return tuple(out)
def _ics_url(url: str) -> str | None:
inner = parse_qs(urlsplit(url).query).get("icsUrl") or []
if inner and inner[0].strip():
return inner[0].strip()
return url if urlsplit(url).path.endswith(".ics") else None
@dataclass(frozen=True, slots=True)
class Event:
start: datetime
"""С зоной: у события своя (TZID), плавающее время читается в зоне агента."""
title: str
place: str
all_day: bool
routine: bool = False
"""Порождено правилом, повторяющимся раз в день или чаще: см. `is_routine`."""
@dataclass(frozen=True, slots=True)
class Upcoming:
at: datetime
"""Момент запроса - его и показывает справка: «данные на …»."""
events: tuple[Event, ...] = ()
errors: tuple[str, ...] = ()
"""По строке на фид, который не ответил; пустое окно - это не ошибка."""
read: int = 0
"""Сколько фидов ответило: ноль - показывать нечего, кроме отказа."""
@dataclass(frozen=True, slots=True)
class Calendars:
"""Ближайшие события всех фидов - одним списком, отсортированным по началу."""
feeds: tuple[Feed, ...]
tz: str = "Europe/Warsaw"
timeout: float = 8.0
days: int = 7
def upcoming(self, now: datetime) -> Upcoming:
"""Окно `days` от `now`; отказ фида - строка в `errors`, не исключение."""
zone = ZoneInfo(self.tz)
since = now.astimezone(zone)
events: list[Event] = []
errors: list[str] = []
read = 0
for feed in self.feeds:
try:
text = self.fetch(feed.url)
found = events_in(text, zone=zone, since=since, days=self.days)
except Exception as exc: # noqa: BLE001
_log.warning("календарь %s: %s", feed.name, short(exc), exc_info=True)
errors.append(f"{feed.name}: {short(exc)}")
continue
read += 1
events.extend(found)
events.sort(key=lambda e: (e.start, e.title))
return Upcoming(at=since, events=tuple(events), errors=tuple(errors), read=read)
def fetch(self, url: str) -> str:
if urlsplit(url).scheme not in ("http", "https"):
msg = "фид не по http(s)"
raise ValueError(msg)
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310
with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310
raw: bytes = response.read(MAX_BYTES)
return raw.decode("utf-8", errors="replace")
def short(exc: BaseException) -> str:
"""Причина отказа одной строкой: в конверт едет она, урл фида - никогда."""
if isinstance(exc, urllib.error.HTTPError):
reason = f"HTTP {exc.code}"
elif isinstance(exc, TimeoutError):
reason = "таймаут"
elif isinstance(exc, urllib.error.URLError):
reason = f"сеть: {exc.reason}"
else:
reason = f"{type(exc).__name__}: {exc}".strip(": ")
return " ".join(reason.split())[:ERROR_MAX]
@dataclass(frozen=True, slots=True)
class Prop:
value: str
params: dict[str, str] = field(default_factory=dict)
Vevent = dict[str, list[Prop]]
def unfold(text: str) -> list[str]:
"""Ical-строки, склеенные обратно: продолжение начинается с пробела или таба."""
lines: list[str] = []
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
if lines and raw[:1] in (" ", "\t"):
lines[-1] += raw[1:]
else:
lines.append(raw)
return lines
def _split(line: str) -> tuple[str, str] | None:
"""`NAME;PARAM=x:значение` - двоеточие вне кавычек делит имя и значение."""
quoted = False
for i, char in enumerate(line):
if char == '"':
quoted = not quoted
elif char == ":" and not quoted:
return line[:i], line[i + 1 :]
return None
def _prop(line: str) -> tuple[str, Prop] | None:
parts = _split(line)
if parts is None:
return None
head, value = parts
chunks = head.split(";")
name = chunks[0].strip().upper()
if not name:
return None
params: dict[str, str] = {}
for chunk in chunks[1:]:
key, sep, raw = chunk.partition("=")
if sep:
params[key.strip().upper()] = raw.strip().strip('"')
return name, Prop(value=value, params=params)
def vevents(lines: list[str]) -> list[Vevent]:
"""Только свойства самих VEVENT: VALARM внутри них пропускается целиком."""
out: list[Vevent] = []
current: Vevent | None = None
depth = 0
for line in lines:
parsed = _prop(line)
if parsed is None:
continue
name, prop = parsed
block = prop.value.strip().upper()
if name == "BEGIN":
if block == "VEVENT" and current is None:
current = {}
elif current is not None:
depth += 1
continue
if name == "END":
if block == "VEVENT" and current is not None and depth == 0:
out.append(current)
current = None
elif current is not None and depth:
depth -= 1
continue
if current is not None and depth == 0:
current.setdefault(name, []).append(prop)
return out
def unescape(value: str) -> str:
return ESCAPE_RE.sub(lambda m: "\n" if m.group(1) in "nN" else m.group(1), value)
def _flat(value: str, limit: int) -> str:
flat = " ".join(unescape(value).split())
return flat if len(flat) <= limit else flat[: limit - 1] + "…"
def _first(event: Vevent, name: str) -> Prop | None:
props = event.get(name)
return props[0] if props else None
def _zone_of(prop: Prop, default: tzinfo) -> tzinfo:
tzid = prop.params.get("TZID")
if not tzid:
return default
try:
return ZoneInfo(tzid)
except (ZoneInfoNotFoundError, ValueError):
return default
def moment(prop: Prop, value: str, zone: tzinfo) -> tuple[datetime, bool] | None:
"""Значение DTSTART/EXDATE в момент времени; второе - «на весь день»."""
match = DT_RE.match(value.strip())
if match is None:
return None
year, month, day = (int(match.group(i)) for i in (1, 2, 3))
if match.group(4) is None or prop.params.get("VALUE", "").upper() == "DATE":
return datetime(year, month, day, tzinfo=zone), True
hour, minute, second = (int(match.group(i)) for i in (4, 5, 6))
where = UTC if match.group(7) else _zone_of(prop, zone)
return datetime(year, month, day, hour, minute, second, tzinfo=where), False
def _instants(event: Vevent, name: str, zone: tzinfo) -> set[datetime]:
"""Даты-исключения в UTC: значений в одной строке может быть несколько."""
out: set[datetime] = set()
for prop in event.get(name, []):
for chunk in prop.value.split(","):
parsed = moment(prop, chunk, zone)
if parsed is not None:
out.add(parsed[0].astimezone(UTC))
return out
def _rule(value: str) -> dict[str, str]:
out: dict[str, str] = {}
for chunk in value.split(";"):
key, sep, raw = chunk.partition("=")
if sep:
out[key.strip().upper()] = raw.strip()
return out
def _int(value: str | None, default: int) -> int:
try:
return int(value) if value else default
except ValueError:
return default
def _byday(value: str) -> list[tuple[int, int]]:
"""`-1SU,2TU` → [(-1, воскресенье), (2, вторник)]; без числа - каждый такой день."""
out: list[tuple[int, int]] = []
for chunk in value.split(","):
match = BYDAY_RE.match(chunk.strip().upper())
if match:
out.append((_int(match.group(1), 0), WEEKDAYS[match.group(2)]))
return out
def _month_days(
year: int, month: int, days: list[tuple[int, int]], clock: datetime
) -> list[datetime]:
total = monthrange(year, month)[1]
out: list[datetime] = []
for nth, weekday in days:
matching = [
day
for day in range(1, total + 1)
if date(year, month, day).weekday() == weekday
]
if not nth:
picked = matching
elif abs(nth) <= len(matching):
picked = [matching[nth - 1] if nth > 0 else matching[nth]]
else:
picked = []
out += [
datetime(year, month, day, clock.hour, clock.minute, clock.second) # noqa: DTZ001
for day in picked
]
return sorted(out)
def _candidates(
naive: datetime, freq: str, interval: int, days: list[tuple[int, int]], month: int
) -> Iterator[datetime]:
"""Настенные времена повтора после первого - по возрастанию, бесконечно."""
if freq == "DAILY":
moving = naive
while True:
moving += timedelta(days=interval)
yield moving
elif freq == "WEEKLY" and not days:
moving = naive
while True:
moving += timedelta(weeks=interval)
yield moving
elif freq == "WEEKLY":
start = naive.date() - timedelta(days=naive.weekday())
step = 0
while True:
base = start + timedelta(weeks=interval * step)
for _, weekday in sorted(days, key=lambda d: d[1]):
yield datetime.combine(base + timedelta(days=weekday), naive.time())
step += 1
elif freq in ("MONTHLY", "YEARLY"):
step = 0
while True:
step += 1
if freq == "MONTHLY":
index = naive.year * 12 + naive.month - 1 + interval * step
year, number = divmod(index, 12)
number += 1
else:
year, number = naive.year + interval * step, month or naive.month
if days:
yield from _month_days(year, number, days, naive)
elif naive.day <= monthrange(year, number)[1]:
yield naive.replace(year=year, month=number)
def occurrences(
start: datetime, rule: dict[str, str], zone: tzinfo, *, end: datetime
) -> list[datetime]:
"""Начала повтора от `start` до `end`; правило, которого мы не знаем, - разовое."""
freq = rule.get("FREQ", "").upper()
if freq not in ("DAILY", "WEEKLY", "MONTHLY", "YEARLY"):
return [start]
where = start.tzinfo or zone
naive = start.replace(tzinfo=None)
interval = max(_int(rule.get("INTERVAL"), 1), 1)
count = _int(rule.get("COUNT"), 0)
until = _until(rule.get("UNTIL"), where)
days = _byday(rule.get("BYDAY", ""))
out = [start]
stream = _candidates(naive, freq, interval, days, _int(rule.get("BYMONTH"), 0))
for step, candidate in enumerate(stream):
if step > STEPS_CAP:
break
if candidate <= naive:
continue
moving = candidate.replace(tzinfo=where)
if moving > end or (until is not None and moving > until):
break
out.append(moving)
if count and len(out) >= count:
break
return out
def _until(value: str | None, zone: tzinfo) -> datetime | None:
if not value:
return None
parsed = moment(Prop(value=value), value, zone)
if parsed is None:
return None
stop, all_day = parsed
return stop + timedelta(days=1) if all_day else stop
ROUTINE_DAYS = 5
"""Со скольких дней недели в BYDAY повтор перестаёт быть событием и делается бытом."""
def is_routine(rule: dict[str, str]) -> bool:
"""Правило, срабатывающее раз в день или чаще: завтрак, зарядка, wind down.
Такие повторы за неделю дают десятки строк и топят в себе то единственное,
ради чего справку и читают, - экзамен, встречу, приём. В конверт они едут
одной свёрнутой строкой, а не списком.
"""
freq = rule.get("FREQ", "").upper()
if max(_int(rule.get("INTERVAL"), 1), 1) != 1:
return False
if freq == "DAILY":
return True
return freq == "WEEKLY" and len(_byday(rule.get("BYDAY", ""))) >= ROUTINE_DAYS
def place_of(event: Vevent) -> str:
"""Зал из описания и адрес из LOCATION - то, что помогает дойти."""
parts: list[str] = []
description = _first(event, "DESCRIPTION")
if description is not None:
match = PLACE_RE.search(unescape(description.value))
if match:
parts.append(" ".join(match.group(0).split()))
location = _first(event, "LOCATION")
if location is not None:
flat = " ".join(unescape(location.value).split())
if flat:
parts.append(flat)
return _flat(", ".join(parts), PLACE_MAX)
def events_in(text: str, *, zone: tzinfo, since: datetime, days: int) -> list[Event]:
"""События фида, начинающиеся в окне `[since, since + days]`, с повторами."""
end = since + timedelta(days=days)
day_start = since.astimezone(zone).replace(
hour=0, minute=0, second=0, microsecond=0
)
parsed = vevents(unfold(text))
moved: dict[str, set[datetime]] = {}
for event in parsed:
uid = _first(event, "UID")
if uid is not None and "RECURRENCE-ID" in event:
moved.setdefault(uid.value, set()).update(
_instants(event, "RECURRENCE-ID", zone)
)
out: list[Event] = []
for event in parsed:
status = _first(event, "STATUS")
dtstart = _first(event, "DTSTART")
if dtstart is None or (
status is not None and status.value.upper() == "CANCELLED"
):
continue
parsed_start = moment(dtstart, dtstart.value, zone)
if parsed_start is None:
continue
start, all_day = parsed_start
rrule = _first(event, "RRULE")
if rrule is None or "RECURRENCE-ID" in event:
starts = [start]
skip: set[datetime] = set()
routine = False
else:
rule = _rule(rrule.value)
starts = occurrences(start, rule, zone, end=end)
routine = is_routine(rule)
uid = _first(event, "UID")
skip = _instants(event, "EXDATE", zone) | moved.get(
uid.value if uid else "", set()
)
title = _flat(_text(event, "SUMMARY"), TITLE_MAX) or NO_TITLE
place = place_of(event)
for moving in starts:
floor = day_start if all_day else since
if moving < floor or moving > end or moving.astimezone(UTC) in skip:
continue
out.append(
Event(
start=moving,
title=title,
place=place,
all_day=all_day,
routine=routine,
)
)
return out
def _text(event: Vevent, name: str) -> str:
prop = _first(event, name)
return prop.value if prop is not None else ""
+1 -84
View File
@@ -3,8 +3,7 @@
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`), человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`),
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и дни дневника, где он встречался, и раз в день - подошедшие сроки из досок.
ближайшая неделя календарей.
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в ``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны: `мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`. транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
@@ -12,7 +11,6 @@ Gateway путей не знает; здесь - что искать по тек
from __future__ import annotations from __future__ import annotations
import logging
import re import re
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -21,25 +19,14 @@ from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime
from pathlib import Path from pathlib import Path
from beaver_gateway.conversations.envelope import RecallContext from beaver_gateway.conversations.envelope import RecallContext
from beaver_gateway.conversations.texts import UserSaid from beaver_gateway.conversations.texts import UserSaid
from beaver_agent.hands.calendars import Calendars, Event, Upcoming
_log = logging.getLogger(__name__)
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):" LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
VOWELS = "аеёиоуыэюя" VOWELS = "аеёиоуыэюя"
KINDS = {"master": "мастер", "branch": "ветка"} KINDS = {"master": "мастер", "branch": "ветка"}
CALENDAR = "🗓 календарь"
CALENDAR_LIMIT = 40
"""Предохранитель на разовые события; повседневные повторы под него не идут."""
CALENDAR_NAMES = 5
"""Сколько названий показывать в свёрнутой строке повседневных повторов."""
CALENDAR_ERROR_MAX = 90
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -213,14 +200,11 @@ class Recall:
"""Сколько последних дней дневника просматривать на упоминания.""" """Сколько последних дней дневника просматривать на упоминания."""
mute: frozenset[str] = frozenset() mute: frozenset[str] = frozenset()
"""Агенты, которым справку не показывают: чужие люди, чужие уши.""" """Агенты, которым справку не показывают: чужие люди, чужие уши."""
calendar: Calendars | None = None
"""Ical-фиды календарей; None - календари не настроены, секции нет вовсе."""
_people: People | None = field(default=None, init=False, repr=False) _people: People | None = field(default=None, init=False, repr=False)
_diary_cache: dict[str, tuple[float, list[str]]] = field( _diary_cache: dict[str, tuple[float, list[str]]] = field(
default_factory=dict, init=False, repr=False default_factory=dict, init=False, repr=False
) )
_deadlines_day: date | None = field(default=None, init=False, repr=False) _deadlines_day: date | None = field(default=None, init=False, repr=False)
_calendar_day: date | None = field(default=None, init=False, repr=False)
@property @property
def people(self) -> People: def people(self) -> People:
@@ -235,7 +219,6 @@ class Recall:
lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())] lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())]
if ctx.kind == "master": if ctx.kind == "master":
lines += self.deadlines(today) lines += self.deadlines(today)
lines += self.calendar_lines(ctx.now, today)
if not lines: if not lines:
return None return None
return "\n".join([LABEL, *lines]) return "\n".join([LABEL, *lines])
@@ -319,72 +302,6 @@ class Recall:
found.sort() found.sort()
return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]] return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]]
def calendar_lines(self, now: datetime, today: date) -> list[str]:
"""Ближайшая неделя календарей - раз в день, следом за сроками досок.
Секция печатается всегда, когда до неё дошло: пустое окно - строкой
«событий нет», упавший фид - строкой «ОШИБКА» и записью в лог. Молча
пропасть она не может, иначе отказ канала неотличим от спокойного
календаря - так однажды и пролежала незамеченной сессия в USOS.
"""
if self.calendar is None or self._calendar_day == today:
return []
self._calendar_day = today
try:
found = self.calendar.upcoming(now)
except Exception as exc: # noqa: BLE001
_log.exception("календарь: справка не собралась")
reason = " ".join(f"{type(exc).__name__}: {exc}".split())
return [f"{CALENDAR}: ОШИБКА {reason[:CALENDAR_ERROR_MAX]}"]
lines = [f"{CALENDAR}: ОШИБКА {reason}" for reason in found.errors]
if found.read:
lines += self.calendar_window(found, self.calendar.days)
return lines or [f"{CALENDAR}: ОШИБКА фиды не ответили"]
def calendar_window(self, found: Upcoming, days: int) -> list[str]:
"""Окно ответивших фидов: разовые событиями, повседневный быт - одной строкой.
Разовых за неделю физически мало, и ровно они и есть сигнал: экзамен,
встреча, приём. Их видно целиком; лимит тут - предохранитель от абсурда,
а не способ уместиться. Ежедневные повторы считаются и называются, но
своими строками разовые не вытесняют - иначе секция выглядит здоровой и
при этом показывает двое суток вместо недели.
"""
stamp = f"{found.at:%Y-%m-%d %H:%M}"
if not found.events:
return [f"{CALENDAR} ({days} дней): событий нет, данные на {stamp}"]
zone = ZoneInfo(self.tz)
singles = [event for event in found.events if not event.routine]
routine = [event for event in found.events if event.routine]
shown = singles[:CALENDAR_LIMIT]
head = (
f"{CALENDAR} ({days} дней), разовых {len(singles)}, "
f"повседневных {len(routine)}, данные на {stamp}:"
)
lines = [head]
lines += [f" - {event_line(event, zone)}" for event in shown]
if len(singles) > len(shown):
lines.append(f" … и ещё {len(singles) - len(shown)} разовых в окне")
if routine:
lines.append(
f" - повседневных повторов: {len(routine)} ({routine_names(routine)})"
)
return lines
def routine_names(events: list[Event], *, limit: int = CALENDAR_NAMES) -> str:
"""Уникальные названия бытовых повторов по первому появлению, до `limit`."""
names = list(dict.fromkeys(event.title for event in events))
return ", ".join(names[:limit]) + (" и др." if len(names) > limit else "")
def event_line(event: Event, zone: ZoneInfo) -> str:
"""Дата, начало, название и место - одной строкой в зоне агента."""
start = event.start.astimezone(zone)
when = "весь день" if event.all_day else f"{start:%H:%M}"
place = f" · {event.place}" if event.place else ""
return f"{start:%m-%d} {when} {event.title}{place}"
@dataclass @dataclass
class ReplyLog: class ReplyLog:
-2
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from beaver_gateway.vault.watch import VaultWatch, WatchRules from beaver_gateway.vault.watch import VaultWatch, WatchRules
from beaver_agent.hands import CALENDARS
from beaver_agent.memory.curator import Watched from beaver_agent.memory.curator import Watched
from beaver_agent.memory.recall import Recall, ReplyLog from beaver_agent.memory.recall import Recall, ReplyLog
from beaver_agent.vault import ( from beaver_agent.vault import (
@@ -55,7 +54,6 @@ recall = Recall(
boards_dir=BOARDS, boards_dir=BOARDS,
tz=TZ, tz=TZ,
mute=frozenset({VOICE}), mute=frozenset({VOICE}),
calendar=CALENDARS,
) )
"""Голосовому агенту справки нет: за колонкой может стоять кто угодно.""" """Голосовому агенту справки нет: за колонкой может стоять кто угодно."""
-169
View File
@@ -1,169 +0,0 @@
import urllib.error
from email.message import Message
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from beaver_agent.hands.calendars import (
Calendars,
Feed,
calendars,
events_in,
feeds,
is_routine,
short,
unfold,
)
ZONE = ZoneInfo("Europe/Warsaw")
NOW = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) # 14:00 в Варшаве
MCPS = (
"personal=https://calendar-mcp.com/api/mcp?email=b%40b.com"
"&icsUrl=https%3A%2F%2Fcalendar.google.com%2Fical%2Fprivate-abc%2Fbasic.ics,"
"student=https://calendar-mcp.com/api/mcp?icsUrl=https%3A%2F%2Fusos.pl%2Fics%3Fkey%3Dz,"
"bare=https://calendar-mcp.com/mcp/AAAA"
)
def ics(*events: str) -> str:
body = "".join(f"BEGIN:VEVENT\n{e.strip()}\nEND:VEVENT\n" for e in events)
return f"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//test//EN\n{body}END:VCALENDAR\n"
def starts(text: str, *, days: int = 7) -> list[str]:
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=days)
return [
f"{e.start.astimezone(ZONE):%m-%d %H:%M} {e.title}"
for e in sorted(found, key=lambda e: e.start)
]
def test_feeds_take_the_ical_url_out_of_the_same_mcp_urls():
assert [f.name for f in feeds(MCPS)] == ["personal", "student"]
assert feeds(MCPS)[0].url == (
"https://calendar.google.com/ical/private-abc/basic.ics"
)
assert feeds(MCPS)[1].url == "https://usos.pl/ics?key=z"
assert feeds("") == ()
assert feeds("plain=https://example.com/plan.ics")[0].url.endswith("plan.ics")
# MCP-рука остаётся у модели, даже если ical-фида в записи нет
assert [m.name for m in calendars(MCPS)] == [
"calendar-personal",
"calendar-student",
"calendar-bare",
]
def test_folded_lines_are_glued_back():
assert unfold("SUMMARY:длин\r\n ное\r\nUID:x") == ["SUMMARY:длинное", "UID:x"]
def test_window_sorts_by_start_and_keeps_only_the_next_days():
text = ics(
"SUMMARY:позже\nDTSTART:20260904T080000Z",
"SUMMARY:раньше\nDTSTART:20260902T080000Z",
"SUMMARY:за окном\nDTSTART:20260920T080000Z",
"SUMMARY:уже прошло\nDTSTART:20260801T080000Z",
)
assert starts(text) == ["09-02 10:00 раньше", "09-04 10:00 позже"]
def test_all_day_event_of_today_still_counts_as_upcoming():
text = ics("SUMMARY:отпуск\nDTSTART;VALUE=DATE:20260901\nDTEND;VALUE=DATE:20260902")
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
assert len(found) == 1
assert found[0].all_day
assert f"{found[0].start:%H:%M}" == "00:00"
def test_place_takes_the_room_from_the_description_and_the_address_from_location():
text = ics(
"SUMMARY:Analiza matematyczna II - Egzamin\n"
"DTSTART;VALUE=DATE-TIME:20260903T110000\n"
"DESCRIPTION:Sala: 3\\nA23 - CW - Centrum Wykładowe\\n\n"
"LOCATION:ul. Piotrowo 2\\, 61-138 Poznań\\, Polska"
)
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
assert found[0].place == "Sala: 3, ul. Piotrowo 2, 61-138 Poznań, Polska"
# плавающее время читается в зоне агента, а не в UTC
assert f"{found[0].start:%H:%M}" == "11:00"
def test_cancelled_events_are_skipped():
text = ics("SUMMARY:отменено\nDTSTART:20260902T080000Z\nSTATUS:CANCELLED")
assert starts(text) == []
def test_weekly_repeat_expands_and_exdate_with_moved_instance_are_subtracted():
series = (
"UID:w1\nSUMMARY:польский\nDTSTART;TZID=Europe/Warsaw:20260824T100000\n"
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE"
)
assert starts(ics(series)) == ["09-02 10:00 польский", "09-07 10:00 польский"]
moved = ics(
series + "\nEXDATE;TZID=Europe/Warsaw:20260902T100000",
"UID:w1\nSUMMARY:польский (перенос)\n"
"RECURRENCE-ID;TZID=Europe/Warsaw:20260907T100000\n"
"DTSTART;TZID=Europe/Warsaw:20260907T173000",
)
assert starts(moved) == ["09-07 17:30 польский (перенос)"]
def test_daily_repeat_stops_on_until_and_count():
until = ics(
"SUMMARY:завтрак\nDTSTART:20260801T060000Z\n"
"RRULE:FREQ=DAILY;UNTIL=20260903T060000Z"
)
assert starts(until) == ["09-02 08:00 завтрак", "09-03 08:00 завтрак"]
counted = ics("SUMMARY:курс\nDTSTART:20260902T060000Z\nRRULE:FREQ=DAILY;COUNT=3")
assert len(starts(counted)) == 3
yearly = ics(
"SUMMARY:часы\nDTSTART:20200906T060000Z\nRRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=1SU"
)
assert starts(yearly) == ["09-06 08:00 часы"]
def test_a_working_week_is_routine_but_one_weekday_a_week_is_an_event():
text = ics(
"SUMMARY:пара\nDTSTART;TZID=Europe/Warsaw:20260831T100000\n"
"RRULE:FREQ=WEEKLY;BYDAY=MO",
"SUMMARY:зарядка\nDTSTART;TZID=Europe/Warsaw:20260831T070000\n"
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
)
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
assert {e.title for e in found if e.routine} == {"зарядка"}
assert {e.title for e in found if not e.routine} == {"пара"}
assert len([e for e in found if e.routine]) == 5
def test_routine_is_a_rule_that_fires_once_a_day_or_more_often():
assert is_routine({"FREQ": "DAILY"})
assert is_routine({"FREQ": "WEEKLY", "BYDAY": "MO,TU,WE,TH,FR,SA"})
assert not is_routine({"FREQ": "WEEKLY", "BYDAY": "MO,WE,FR"})
assert not is_routine({"FREQ": "WEEKLY"})
assert not is_routine({"FREQ": "MONTHLY"})
# через день - это уже не быт, такое видно поимённо
assert not is_routine({"FREQ": "DAILY", "INTERVAL": "2"})
def test_a_single_event_is_never_routine():
text = ics("SUMMARY:Egzamin\nDTSTART:20260903T090000Z")
assert not events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)[0].routine
def test_a_feed_that_fails_becomes_a_line_not_an_exception():
class Broken(Calendars):
def fetch(self, url: str) -> str:
raise urllib.error.HTTPError(url, 403, "Forbidden", Message(), None)
broken = Broken((Feed("student", "https://usos.pl/ics"),), tz="Europe/Warsaw")
found = broken.upcoming(NOW)
assert found.read == 0
assert found.events == ()
assert found.errors == ("student: HTTP 403",)
def test_short_reason_is_one_line_and_never_carries_the_url():
assert short(TimeoutError()) == "таймаут"
assert short(urllib.error.URLError("no route")).startswith("сеть:")
assert short(ValueError("а\nб")) == "ValueError: а б"
+1 -130
View File
@@ -4,7 +4,6 @@ from pathlib import Path
from beaver_gateway.conversations.texts import UserSaid from beaver_gateway.conversations.texts import UserSaid
from beaver_gateway.conversations.envelope import RecallContext from beaver_gateway.conversations.envelope import RecallContext
from beaver_agent.hands.calendars import Calendars, Event, Feed
from beaver_agent.memory.recall import ( from beaver_agent.memory.recall import (
LABEL, LABEL,
Recall, Recall,
@@ -12,7 +11,6 @@ from beaver_agent.memory.recall import (
frontmatter_aliases, frontmatter_aliases,
mentions, mentions,
notes_head, notes_head,
routine_names,
) )
@@ -24,7 +22,7 @@ def _card(path: Path, aliases: list[str] | None = None) -> None:
) )
def _vault(tmp_path: Path, calendar: Calendars | None = None) -> Recall: def _vault(tmp_path: Path) -> Recall:
people = tmp_path / "👤 люди" people = tmp_path / "👤 люди"
_card(people / "личное" / "Зина.md", ["Зина"]) _card(people / "личное" / "Зина.md", ["Зина"])
_card(people / "личное" / "Прохор.md", ["Прохор Тестов"]) _card(people / "личное" / "Прохор.md", ["Прохор Тестов"])
@@ -70,7 +68,6 @@ def _vault(tmp_path: Path, calendar: Calendars | None = None) -> Recall:
diary_dir=diary, diary_dir=diary,
boards_dir=boards, boards_dir=boards,
tz="Europe/Warsaw", tz="Europe/Warsaw",
calendar=calendar,
) )
@@ -189,129 +186,3 @@ def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None
short = notes_head(path, ("сейчас", "паттерны"), cap=2) short = notes_head(path, ("сейчас", "паттерны"), cap=2)
assert short is not None and short.endswith("… ещё 3 строк в файле") assert short is not None and short.endswith("… ещё 3 строк в файле")
def _ics(*events: str) -> str:
body = "".join(f"BEGIN:VEVENT\n{e}\nEND:VEVENT\n" for e in events)
return f"BEGIN:VCALENDAR\nVERSION:2.0\n{body}END:VCALENDAR\n"
def _calendar(text: str | None) -> Calendars:
"""Фид, который отдаёт готовый ical; None - фид, который падает."""
class Fake(Calendars):
def fetch(self, url: str) -> str:
if text is None:
raise OSError("фид не ответил")
return text
return Fake((Feed("личный", "https://cal.example/x.ics"),), tz="Europe/Warsaw")
def _daily(title: str, clock: str) -> str:
return f"SUMMARY:{title}\nDTSTART;TZID=Europe/Warsaw:20260902T{clock}\nRRULE:FREQ=DAILY"
SINGLES = (
"SUMMARY:Созвон\nDTSTART:20260902T160000Z",
"SUMMARY:Врач\nDTSTART:20260903T090000Z",
"SUMMARY:Analiza matematyczna II - Egzamin\nDTSTART;VALUE=DATE-TIME:20260905T110000\n"
"DESCRIPTION:Sala: 3\\nA23 - CW\\n\nLOCATION:ul. Piotrowo 2\\, Poznań",
"SUMMARY:за окном\nDTSTART:20260925T080000Z",
)
ROUTINE = (
_daily("Завтрак", "080000"),
_daily("Обед", "150000"),
_daily("Ужин", "200000"),
_daily("Перекус", "180000"),
_daily("Wind down", "223000"),
)
BUSY = _ics(*SINGLES, *ROUTINE)
def _section(recall: Recall, now: datetime, text: str = "привет") -> list[str]:
block = recall.block(RecallContext(text=text, kind="master", now=now))
assert block is not None
return [ln for ln in block.splitlines() if "календар" in ln or ln.startswith(" -")]
def test_calendar_shows_every_single_event_and_folds_the_daily_routine(
tmp_path: Path,
) -> None:
recall = _vault(tmp_path, _calendar(BUSY))
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
lines = _section(recall, now)
assert lines == [
"🗓 календарь (7 дней), разовых 3, повседневных 31, данные на 2026-09-01 14:00:",
" - 09-02 18:00 Созвон",
" - 09-03 11:00 Врач",
" - 09-05 11:00 Analiza matematyczna II - Egzamin · Sala: 3, ul. Piotrowo 2, Poznań",
" - повседневных повторов: 31 (Завтрак, Обед, Перекус, Ужин, Wind down)",
]
# секция дневная, как и сроки досок: второй конверт за те же сутки её не несёт
again = recall.block(RecallContext(text="и ещё", kind="master", now=now))
assert again is None or "календарь" not in again
def test_no_amount_of_routine_can_push_out_a_single_event(tmp_path: Path) -> None:
noise = tuple(_daily(f"быт {i}", f"{i:02d}0000") for i in range(9, 24))
recall = _vault(tmp_path, _calendar(_ics(*SINGLES, *noise)))
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
lines = _section(recall, now)
assert lines[0].startswith("🗓 календарь (7 дней), разовых 3, повседневных 96")
assert [ln for ln in lines if "Egzamin" in ln]
assert (
len([ln for ln in lines if ln.startswith(" - ")]) == 4
) # 3 разовых + свёртка
assert lines[-1].startswith(" - повседневных повторов: 96")
assert "и др." in lines[-1]
def test_routine_line_counts_and_names_up_to_five(tmp_path: Path) -> None:
events = [
Event(
start=datetime(2026, 9, 2, 8, tzinfo=UTC),
title=t,
place="",
all_day=False,
routine=True,
)
for t in ("Завтрак", "Обед", "Завтрак", "Ужин")
]
assert routine_names(events) == "Завтрак, Обед, Ужин"
assert routine_names(events, limit=2) == "Завтрак, Обед и др."
def test_calendar_section_is_printed_even_when_the_week_is_empty(
tmp_path: Path,
) -> None:
recall = _vault(
tmp_path, _calendar(_ics("SUMMARY:потом\nDTSTART:20261101T080000Z"))
)
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
block = recall.block(RecallContext(text="просто привет", kind="master", now=now))
assert block is not None
assert (
"🗓 календарь (7 дней): событий нет, данные на 2026-09-01 14:00"
in block.splitlines()
)
def test_calendar_failure_shows_up_in_the_envelope_instead_of_disappearing(
tmp_path: Path,
) -> None:
recall = _vault(tmp_path, _calendar(None))
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
block = recall.block(RecallContext(text="просто привет", kind="master", now=now))
assert block is not None
assert "🗓 календарь: ОШИБКА личный: OSError: фид не ответил" in block.splitlines()
# ветке справка календарь не носит, как и сроки досок
branch = recall.block(RecallContext(text="просто привет", kind="branch", now=now))
assert branch is None
def test_without_configured_feeds_there_is_no_calendar_section(tmp_path: Path) -> None:
recall = _vault(tmp_path)
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
block = recall.block(RecallContext(text="просто привет", kind="master", now=now))
assert block is not None and "календарь" not in block