Compare commits
14
Commits
91bd50a66d
..
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db6c7b6ec | ||
|
|
2bf2326178 | ||
|
|
1d8b8c68a3 | ||
|
|
fc97463f0f | ||
|
|
3e62529a0e | ||
|
|
fbfe39a968 | ||
|
|
eba0cc6168 | ||
|
|
dea793dbd7 | ||
|
|
0273f29589 | ||
|
|
9a2f5c2b2b | ||
|
|
9bf897a809 | ||
|
|
78a0293819 | ||
|
|
a39e0b27af | ||
|
|
47c31ac356 |
No files matched your search
+9
-4
@@ -6,7 +6,7 @@
|
||||
### Compose ###################################################################
|
||||
|
||||
# Какие сервисы поднимать: db (postgres), gateway, obsidian (obsidian-headless),
|
||||
# t3 (t3code-mcp - руки диспетчера на маке и dell), vibegram (клон репы комнаты)
|
||||
# t3 (t3code-mcp - руки диспетчера на маке и dell-dev), vibegram (клон репы комнаты)
|
||||
COMPOSE_PROFILES=db,gateway,obsidian,t3,vibegram
|
||||
|
||||
# Ветка/тег beaver-gateway, из которого собирается образ шлюза
|
||||
@@ -31,6 +31,11 @@ ADMIN_PASS=
|
||||
# Подпись session-cookie admin UI
|
||||
SESSION_SECRET=
|
||||
|
||||
# Мастер-ключ хранилища секретов (admin → System → Secrets): openssl rand -hex 32.
|
||||
# Шифрует таблицу secrets в postgres, поэтому дамп базы без него закрыт.
|
||||
# В env модели не попадает. Потерян или сменён - секреты задаются заново.
|
||||
SECRETS_KEY=
|
||||
|
||||
### Claude ####################################################################
|
||||
|
||||
# OAuth-токен подписки для Claude Agent SDK внутри контейнера.
|
||||
@@ -85,9 +90,9 @@ MSOS_MCP=
|
||||
# t3code-mcp - сервис compose (профиль t3), машины и allowlist - ./t3code.toml.
|
||||
# На каждой машине: `t3 auth session issue --token-only --ttl 365d --label beaver`
|
||||
T3_MAC_TOKEN=
|
||||
T3_DELL_TOKEN=
|
||||
# tailnet-адрес самого dell: контейнер t3code-mcp иначе резолвит `dell` в 127.0.1.1
|
||||
T3_DELL_ADDR=
|
||||
T3_DELL_DEV_TOKEN=
|
||||
# tailnet-адрес dell-dev (дев-VM на делле), прописывается контейнеру в hosts
|
||||
T3_DELL_DEV_ADDR=
|
||||
# Токен gateway со scope `api` (admin → Tokens) - им t3code-mcp шлёт события в /hooks/t3code
|
||||
T3CODE_MCP_GATEWAY_TOKEN=
|
||||
# Ветка/тег t3code-mcp, из которого собирается образ
|
||||
|
||||
@@ -71,6 +71,12 @@ read-write (it is the dispatcher's home), and `beaver_agent/policy.py` keeps
|
||||
it out of `.obsidian`, `мета` outside `мета/бобер`, and from deleting
|
||||
anything but its own files.
|
||||
|
||||
Keys the agent needs for its own scripts (an RPC key, a service account
|
||||
file) go into **System → Secrets** in the admin, not into files in the
|
||||
container: they are sealed with `SECRETS_KEY` in Postgres, never shown back,
|
||||
and reach a command only through `secret run NAME -- cmd` (see the `secrets`
|
||||
skill under `мета/бобер/скиллы/общие/`).
|
||||
|
||||
### 4. Mint a token
|
||||
|
||||
Open admin at `http://localhost:62990/` (or `https://<DOMAIN>/` if Caddy), sign in with `ADMIN_USER` / `ADMIN_PASS`, go to **Tokens → Create**, scope `*` for first run.
|
||||
@@ -216,9 +222,9 @@ one who needs to hear about it, so the alarm goes to the master thread as a
|
||||
spawned triage job and the dispatcher decides whether to wake the owner.
|
||||
|
||||
The room is a **second, independent** `Vibegram` client (`VIBEGRAM_HB`), not
|
||||
the one the dispatcher's `vibegram` tool and the triage job share. `/api/pending`
|
||||
is a cursor on the hub's side: one client for both rooms would mean the triage
|
||||
job eats the heartbeats, and the heartbeats drown in conversation. The model
|
||||
the one the dispatcher's `vibegram` tool and the triage job share: one client
|
||||
for both rooms would mean the triage job eats the heartbeats off the shared
|
||||
`/api/pending` cursor, and the heartbeats drown in conversation. The model
|
||||
gets no tool for this room - the watchdog is a mechanism.
|
||||
|
||||
The point of a watchdog is that it does not lie: an instrument that quietly
|
||||
@@ -229,19 +235,40 @@ are no defaults anywhere in it.
|
||||
period is not a constant: it is derived from that job's own registered cron
|
||||
(`run.job.cron`). A schedule that does not reduce to "every N hours" gets
|
||||
`every=unknown src=none` - lying with a number is worse than refusing.
|
||||
- `сторож` (`:42` every six hours) asks for an explicit window (`pending(limit=25)`),
|
||||
finds the newest `hb claude-kai …`, and compares its age against
|
||||
- `сторож` (`:42` every six hours) reads a window - **the last 25 messages of
|
||||
that room** (`Vibegram.window`) - finds the newest `hb claude-kai …`, and
|
||||
compares its age against
|
||||
`every * 1.5` where `every` comes **from the neighbour's own line**. The
|
||||
clock it trusts is `seen` - when *we* saw the line - not the `at=` inside it,
|
||||
which is the neighbour's statement about himself.
|
||||
- Every run prints `ack claude-kai last=… seen=… res=… win=… armed=… thr=…`
|
||||
into the room. Anything the run could not measure says so out loud there:
|
||||
- Every run prints
|
||||
`ack claude-kai last=… seen=… res=… win=… armed=… thr=… alert=…` into the
|
||||
room. Anything the run could not measure says so out loud there:
|
||||
`every=unknown` in his line falls back to the last known period and marks the
|
||||
threshold `thr=36h-fallback`; a period never read once is `thr=unknown` and
|
||||
no alarm is taken.
|
||||
- `alert=` is what the **instrument decided** in that run, not what reached a
|
||||
human: `fired` - an alarm (`silence` or `unarmed`) was taken and handed on,
|
||||
`held` - the threshold was crossed but the alarm was suppressed as a repeat
|
||||
(the previous one went out less than a day ago), `none` - no alarm. Without
|
||||
it the neighbour saw the arithmetic and had to guess what we made of it.
|
||||
Delivery is decided past the watchdog, by the master thread and the
|
||||
dispatcher, and its failure shows up in the log, not here.
|
||||
- Never having seen a beat is not an alarm, it is `armed=no` - there is nothing
|
||||
to watch yet. Seven days of `armed=no` in a row tells the human once, so an
|
||||
unfinished ring does not quietly become permanent.
|
||||
- The window is **not** `/api/pending`. That endpoint is a cursor: it hands a
|
||||
line over once and never again, so the neighbour's beat showed up in one run
|
||||
and was gone from the next one's window on a perfectly live room - the room
|
||||
held it the whole time. The tail comes from `/api/state?view=…` instead,
|
||||
which is already scoped to the room (hub event ids run across all rooms) and
|
||||
pages **forward** in chunks of 200 from `after=<id>`; the watchdog walks the
|
||||
history and keeps the last 25 *messages*, joins and leaves not counted.
|
||||
- **An unread window is not a reading.** If the history could not be walked to
|
||||
its end (the page budget ran out), the missing part is exactly the newest,
|
||||
so `win=25/partial` goes into the `ack`, the log says `окно не дочитано`, and
|
||||
no alarm is taken in that run: "nobody in the window" and "the window was
|
||||
never finished" must not look the same.
|
||||
- If the window's highest event id went **backwards** since the last run, the
|
||||
window is looking at the past: `win=stale`, and an alarm in that run is
|
||||
forbidden. Same for a hub that did not answer at all.
|
||||
@@ -254,6 +281,15 @@ are no defaults anywhere in it.
|
||||
State between runs is `мета/бобер/.состояние/сторож.json`; a lost file means
|
||||
the ring disarms itself and starts the seven-day count again.
|
||||
|
||||
**Drills.** Next to that state file lives `мета/бобер/.состояние/сторож-учения.txt`
|
||||
- a list of drill dates, one `YYYY-MM-DD` per line, each the local date of the
|
||||
run in the scheduler's zone (`Europe/Warsaw`). On a date in that list `отметка`
|
||||
sends nothing into the room and says so in the log only: a skipped beat is the
|
||||
drill, and any text there ("skipping today") would call it off. No file, an
|
||||
empty one or an unreadable one means no drill and the beat goes out as usual.
|
||||
The dates are not in the code, and adding one needs no redeploy - a ring only
|
||||
ever tested on "all alive" has never shown that the other side's alarm fires.
|
||||
|
||||
Setup is two variables in `.env` and one manual step:
|
||||
|
||||
```bash
|
||||
|
||||
+11
-2
@@ -26,6 +26,9 @@ from beaver_agent.voice import AGENT as VOICE
|
||||
if TYPE_CHECKING:
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
|
||||
REASONING = {"type": "adaptive", "display": "summarized"}
|
||||
"""Рассуждения приезжают текстом: их хранит транскрипт и показывает админка."""
|
||||
|
||||
|
||||
def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||
"""Мастер и ветки: полный голос, все руки, vault - дом."""
|
||||
@@ -36,7 +39,9 @@ def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||
prompts=prompts.DISPATCHER,
|
||||
skill_sets=skills.DISPATCHER,
|
||||
gateway_tools=("read_conversation", "spawn", "say", "schedule", "send_file"),
|
||||
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
|
||||
options=ClaudeOptions(
|
||||
effort=effort, thinking=REASONING, disallowed_tools=DISPATCHER_DISALLOWED
|
||||
),
|
||||
policy=(
|
||||
*VAULT_POLICY,
|
||||
forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)),
|
||||
@@ -54,7 +59,9 @@ def deep(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||
prompts=prompts.DEEP,
|
||||
skill_sets=skills.DEEP,
|
||||
gateway_tools=("close_chat",),
|
||||
options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED),
|
||||
options=ClaudeOptions(
|
||||
effort=effort, thinking=REASONING, disallowed_tools=DEEP_DISALLOWED
|
||||
),
|
||||
policy=VAULT_POLICY,
|
||||
expose_mcps=hands.CLAUDE,
|
||||
)
|
||||
@@ -69,6 +76,7 @@ def distiller(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||
prompts=prompts.DISTILLER,
|
||||
options=ClaudeOptions(
|
||||
effort=effort,
|
||||
thinking=REASONING,
|
||||
tools=("Read", "Write"),
|
||||
disallowed_tools=DISTILLER_DISALLOWED,
|
||||
),
|
||||
@@ -86,6 +94,7 @@ def curator(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||
gateway_tools=("inject",),
|
||||
options=ClaudeOptions(
|
||||
effort=effort,
|
||||
thinking=REASONING,
|
||||
tools=("Read", "Write", "Edit", "Grep", "Glob"),
|
||||
disallowed_tools=DISTILLER_DISALLOWED,
|
||||
),
|
||||
|
||||
+105
-14
@@ -12,11 +12,20 @@
|
||||
даёт честное `every=unknown`, а не подставленное число. Порог тревоги -
|
||||
`every * 1.5`, где `every` взят ИЗ СТРОКИ СОСЕДА, а не из своей константы.
|
||||
Каждый прогон, в котором показание снять нельзя (окно смотрит в прошлое,
|
||||
период неизвестен, отметок не видели ни разу), говорит это вслух в `ack`.
|
||||
окно не дочитано, период неизвестен, отметок не видели ни разу), говорит
|
||||
это вслух в `ack` и в лог: «в окне никого» и «окно не прочитано до конца» -
|
||||
это два РАЗНЫХ показания, и выглядеть одинаково они не должны.
|
||||
|
||||
Опора тревоги - `seen`, то есть когда Я увидел строку: `at=` внутри строки
|
||||
это показание соседа о себе, и остановившиеся у него часы не должны
|
||||
выглядеть как живая цепь.
|
||||
Окно - последние `WINDOW` СООБЩЕНИЙ этой комнаты (`Vibegram.window`), а не
|
||||
«что нового с курсора»: курсор отдаёт строку соседа один раз, и следующий
|
||||
прогон на живой комнате увидел бы пустое окно.
|
||||
|
||||
Опора тревоги - `seen`, то есть когда Я ВПЕРВЫЕ увидел строку: `at=` внутри
|
||||
строки это показание соседа о себе, и остановившиеся у него часы не должны
|
||||
выглядеть как живая цепь. Окно - это последние сообщения комнаты, поэтому
|
||||
одна и та же отметка видна в нём много прогонов подряд, и новой её делает
|
||||
новый id сообщения, а не текст: двигать `seen` на старой отметке значит
|
||||
никогда не поднять тревогу в тихой комнате.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -48,6 +57,7 @@ __all__ = [
|
||||
"ack_line",
|
||||
"alarm_text",
|
||||
"beat_line",
|
||||
"drill",
|
||||
"every_from_cron",
|
||||
"look",
|
||||
"parse_beat",
|
||||
@@ -56,8 +66,8 @@ __all__ = [
|
||||
PEER = "claude-kai"
|
||||
"""Чей `hb` сторожим. Ник соседа стоит и в его строке, и в нашем `ack`."""
|
||||
WINDOW = 25
|
||||
"""Сколько событий просим у хаба явно: у `pending` свой дефолт (50), и молча
|
||||
опираться на чужой дефолт в стороже нельзя - размер окна едет в `ack`."""
|
||||
"""Сколько последних сообщений комнаты просим явно: молча опираться на чужой
|
||||
дефолт в стороже нельзя - размер окна едет в `ack`."""
|
||||
FACTOR = 1.5
|
||||
"""Порог молчания = заявленный соседом период × это. Одна потерянная строка
|
||||
не должна поднимать тревогу, две подряд - должны."""
|
||||
@@ -201,17 +211,49 @@ def ack_line(
|
||||
armed: bool,
|
||||
threshold: timedelta | None,
|
||||
fallback: bool,
|
||||
full: bool,
|
||||
alert: Literal["fired", "held", "none"],
|
||||
) -> str:
|
||||
"""Расписка о прогоне в комнату: что видели, чем мерили и вооружены ли."""
|
||||
"""Расписка о прогоне в комнату: что видели, чем мерили и что решили.
|
||||
|
||||
`win=` - про само окно: `25` это «последние 25 сообщений комнаты прочитаны
|
||||
целиком», `25/partial` - «дочитать историю не удалось», `stale` - «окно
|
||||
смотрит в прошлое». Пустое окно при `partial` ничего не доказывает.
|
||||
|
||||
`alert=` - РЕШЕНИЕ ПРИБОРА в этом прогоне, а не факт того, что человек
|
||||
сосед что-то получил: `fired` - тревога (`silence` или `unarmed`) в этом
|
||||
прогоне взята и отдана дальше, `held` - порог пройден, но тревога
|
||||
подавлена как повтор (предыдущая уходила меньше `REPEAT` назад), `none` -
|
||||
тревоги нет. Доставка решается за пределами сторожа - мастером и
|
||||
диспетчером, - и её отказ виден в логе, а не тут: без арифметики соседу
|
||||
видно было только, что мы что-то посчитали, но не что мы из этого поняли.
|
||||
"""
|
||||
mark = span(threshold) + ("-fallback" if fallback and threshold else "")
|
||||
read = "stale" if stale else f"{window}" if full else f"{window}/partial"
|
||||
return (
|
||||
f"ack {peer} last={_iso(last) or ('never' if not armed else 'unknown')} "
|
||||
f"seen={_iso(seen) or 'never'} res={span(res)} "
|
||||
f"win={'stale' if stale else window} armed={'yes' if armed else 'no'} "
|
||||
f"thr={mark}"
|
||||
f"win={read} armed={'yes' if armed else 'no'} "
|
||||
f"thr={mark} alert={alert}"
|
||||
)
|
||||
|
||||
|
||||
def drill(path: Path, *, now: datetime, tz: tzinfo) -> bool:
|
||||
"""Стоит ли сегодняшняя дата в файле флага учений.
|
||||
|
||||
Файл - список дат `YYYY-MM-DD`, по одной на строку; дата - локальная дата
|
||||
прогона в зоне планировщика, той же, в которой читаются кроны. Файла нет,
|
||||
он пуст или нечитаем - учений нет, и отметка уходит как обычно: флаг
|
||||
выключает прибор, поэтому его отсутствие должно значить «работаем».
|
||||
"""
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
today = now.astimezone(tz).strftime("%Y-%m-%d")
|
||||
return today in {line.strip() for line in raw.splitlines()}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Watch:
|
||||
"""Состояние между прогонами: файл на диске, переживает передеплой."""
|
||||
@@ -225,6 +267,10 @@ class Watch:
|
||||
"""Последний период, который сосед назвал читаемым числом."""
|
||||
every_unknown: bool = False
|
||||
"""В последней отметке стояло `every=unknown`: порог взят с запасом назад."""
|
||||
beat_id: int = 0
|
||||
"""id сообщения с последней зачтённой отметкой: по нему видно, что отметка
|
||||
в окне новая, а не та же. Нуль - поля в файле не было (состояние старое)
|
||||
или отметок не видели ни разу."""
|
||||
top: int = 0
|
||||
"""Максимальный id, который мы видели. Поехал назад - окно смотрит в прошлое."""
|
||||
alerted: datetime | None = None
|
||||
@@ -246,6 +292,7 @@ class Watch:
|
||||
seen=_parse_iso(raw.get("seen")),
|
||||
every=timedelta(seconds=every) if every else None,
|
||||
every_unknown=bool(raw.get("every_unknown")),
|
||||
beat_id=int(raw.get("beat_id", 0)),
|
||||
top=int(raw.get("top", 0)),
|
||||
alerted=_parse_iso(raw.get("alerted")),
|
||||
unarmed_since=_parse_iso(raw.get("unarmed_since")),
|
||||
@@ -259,6 +306,7 @@ class Watch:
|
||||
"seen": _iso(self.seen),
|
||||
"every": int(self.every.total_seconds()) if self.every else None,
|
||||
"every_unknown": self.every_unknown,
|
||||
"beat_id": self.beat_id,
|
||||
"top": self.top,
|
||||
"alerted": _iso(self.alerted),
|
||||
"unarmed_since": _iso(self.unarmed_since),
|
||||
@@ -276,6 +324,8 @@ class Report:
|
||||
|
||||
ack: str
|
||||
stale: bool
|
||||
full: bool
|
||||
"""Окно дочитано до конца истории комнаты: пустое окно - это показание."""
|
||||
armed: bool
|
||||
heard: bool
|
||||
"""Видели ли в окне хоть что-то от соседа - `hb` или его `ack`."""
|
||||
@@ -286,17 +336,38 @@ class Report:
|
||||
"""Строка для лога: почему прогон решил именно так."""
|
||||
|
||||
|
||||
def _is_new_beat(state: Watch, eid: int, beat: Beat) -> bool:
|
||||
"""Впервые ли мы видим ЭТУ отметку соседа.
|
||||
|
||||
Различитель - id сообщения: `at=` внутри строки это показание соседа о
|
||||
себе, и повторить его он может как угодно. Нуля в `beat_id` два повода:
|
||||
отметок не видели ни разу (тогда любая - новая) и состояние записано до
|
||||
появления поля - там единственное, чем можно сверить отметку со зачтённой,
|
||||
это её `at`, и одного прогона хватает, чтобы запомнить id.
|
||||
"""
|
||||
if state.beat_id:
|
||||
return eid > state.beat_id
|
||||
if state.seen is None:
|
||||
return True
|
||||
return beat.at != state.at
|
||||
|
||||
|
||||
def look(
|
||||
state: Watch,
|
||||
events: Sequence[Event],
|
||||
*,
|
||||
now: datetime,
|
||||
res: timedelta | None,
|
||||
full: bool,
|
||||
window: int = WINDOW,
|
||||
peer: str = PEER,
|
||||
) -> Report:
|
||||
"""Разобрать окно, обновить состояние и решить, будить ли человека.
|
||||
|
||||
`events` - последние сообщения комнаты, `full` - дочитаны ли они до конца
|
||||
истории. Параметр без дефолта нарочно: «окно полное» - это показание, и
|
||||
подставлять его молча за того, кто окно читал, здесь нельзя.
|
||||
|
||||
Состояние меняется здесь, а `alerted` / `unarmed_told` ставит джоб - и
|
||||
только когда тревога действительно ушла: несостоявшаяся доставка не
|
||||
должна заглушать следующий прогон.
|
||||
@@ -304,8 +375,8 @@ def look(
|
||||
top = max((e.id for e in events), default=None)
|
||||
stale = top is not None and top < state.top
|
||||
beats = [
|
||||
beat
|
||||
for beat in (parse_beat(e.text) for e in events)
|
||||
(event.id, beat)
|
||||
for event, beat in ((event, parse_beat(event.text)) for event in events)
|
||||
if beat is not None and beat.nick == peer
|
||||
]
|
||||
heard = bool(beats) or any(e.nick == peer for e in events)
|
||||
@@ -314,13 +385,23 @@ def look(
|
||||
else:
|
||||
if top is not None:
|
||||
state.top = max(state.top, top)
|
||||
again = False
|
||||
if beats:
|
||||
last = beats[-1]
|
||||
state.at, state.seen = last.at, now
|
||||
eid, last = beats[-1]
|
||||
again = not _is_new_beat(state, eid, last)
|
||||
if not again:
|
||||
state.seen = now
|
||||
state.at, state.beat_id = last.at, eid
|
||||
state.every_unknown = last.every is None
|
||||
if last.every is not None:
|
||||
state.every = last.every
|
||||
note = f"отметок соседа в окне: {len(beats)}"
|
||||
read = "история дочитана" if full else "история НЕ дочитана"
|
||||
note = (
|
||||
f"отметок соседа в окне: {len(beats)} "
|
||||
f"(сообщений комнаты: {len(events)} из {window}, {read})"
|
||||
)
|
||||
if again:
|
||||
note += "; отметка та же, что в прошлый прогон - seen не двигаем"
|
||||
|
||||
armed = state.seen is not None
|
||||
threshold = state.every * FACTOR if state.every is not None else None
|
||||
@@ -332,8 +413,14 @@ def look(
|
||||
state.unarmed_since = now
|
||||
|
||||
alarm: Literal["silence", "unarmed"] | None = None
|
||||
held = False
|
||||
if stale:
|
||||
pass
|
||||
elif not full:
|
||||
# Хвост комнаты недочитан, а недостающее - как раз самое свежее:
|
||||
# пустое окно тут не доказывает молчания соседа, оно доказывает,
|
||||
# что мы не дочитали. Показания нет - тревоги нет.
|
||||
note += "; окно не дочитано - это не показание, тревога не берётся"
|
||||
elif not armed:
|
||||
if (
|
||||
not state.unarmed_told
|
||||
@@ -347,6 +434,7 @@ def look(
|
||||
if state.alerted is None or now - state.alerted >= REPEAT:
|
||||
alarm = "silence"
|
||||
else:
|
||||
held = True
|
||||
note += "; тревога уже уходила меньше суток назад"
|
||||
|
||||
return Report(
|
||||
@@ -360,8 +448,11 @@ def look(
|
||||
armed=armed,
|
||||
threshold=threshold,
|
||||
fallback=state.every_unknown,
|
||||
full=full,
|
||||
alert="fired" if alarm is not None else "held" if held else "none",
|
||||
),
|
||||
stale=stale,
|
||||
full=full,
|
||||
armed=armed,
|
||||
heard=heard,
|
||||
age=age,
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
|
||||
приходит. Поэтому клиент помнит последние события сам: тулза `read`
|
||||
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
|
||||
|
||||
Там, где нужен не «что нового», а «последние N строк комнаты» (сторож) или
|
||||
«всё после события #N» (`read since=` / `read last=` у мастера), курсор не
|
||||
годится в принципе: `history` читает историю через `/api/state`, не трогая
|
||||
курсор и ничего не вычёркивая.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,16 +25,23 @@ from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path # noqa: TC003 - в аннотации dataclass
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import aiohttp
|
||||
|
||||
__all__ = ["Action", "Event", "Vibegram"]
|
||||
__all__ = ["Action", "Event", "Vibegram", "Window"]
|
||||
|
||||
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
|
||||
|
||||
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
|
||||
REMEMBER = 40
|
||||
PAGE = 200
|
||||
"""Сколько событий комнаты хаб отдаёт за одну страницу `/api/state`: его
|
||||
потолок, а не наша просьба - `limit` там не слушают."""
|
||||
PAGES = 25
|
||||
"""Потолок дочитывания истории. Упёрлись - окно объявляем неполным, а не
|
||||
выдаём за хвост то, что оказалось под рукой."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -43,6 +55,19 @@ class Event:
|
||||
def line(self) -> str:
|
||||
return f"[{self.at}] {self.text}"
|
||||
|
||||
def tagged(self) -> str:
|
||||
return f"#{self.id} {self.line()}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Window:
|
||||
"""Хвост комнаты: последние сообщения и честный признак полноты."""
|
||||
|
||||
events: list[Event]
|
||||
full: bool
|
||||
"""История дочитана до конца: в `events` действительно ПОСЛЕДНИЕ сообщения.
|
||||
Ложь здесь - это «в окне никого» там, где окно просто не дочитано."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Vibegram:
|
||||
@@ -66,12 +91,19 @@ class Vibegram:
|
||||
note: str | None = None,
|
||||
about: str | None = None,
|
||||
skills: list[str] | None = None,
|
||||
since: int | None = None,
|
||||
last: int | None = None,
|
||||
) -> str:
|
||||
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
|
||||
|
||||
action:
|
||||
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
|
||||
нового нет - последние уже виденные события.
|
||||
нового нет - последние уже виденные события. Курсор общий с
|
||||
крон-триажем: прочитанное здесь второй раз не придёт.
|
||||
- read since=N / read last=N: история комнаты - всё после события #N
|
||||
или последние N сообщений. Ничего не съедает, спрашивать можно
|
||||
сколько угодно раз. Вывод «сосед молчит» брать только отсюда: пустой
|
||||
read - это «крон уже выгреб», а не «никто не ответил».
|
||||
- send: написать в комнату (text). Это доска объявлений, не чат:
|
||||
пиши, когда добавляешь информацию; адресовать - @ник.
|
||||
- who: кто в комнате, что умеет, что держит.
|
||||
@@ -86,7 +118,7 @@ class Vibegram:
|
||||
"""
|
||||
match action:
|
||||
case "read":
|
||||
return await self._read()
|
||||
return await self._read(since=since, last=last)
|
||||
case "send":
|
||||
return await self._send(_need(text, "text"))
|
||||
case "who":
|
||||
@@ -151,6 +183,53 @@ class Vibegram:
|
||||
)
|
||||
return fresh
|
||||
|
||||
async def window(self, limit: int, *, pages: int = PAGES) -> Window:
|
||||
"""Последние `limit` СООБЩЕНИЙ этой комнаты: хвост истории, не курсор.
|
||||
|
||||
`pending` для окна не годится вовсе: это курсор на стороне хаба, он
|
||||
отдаёт только то, чего мы ещё не видели, и ту же строку второй раз не
|
||||
пришлёт - следующий прогон увидит пустое окно на живой комнате.
|
||||
|
||||
Хвост берём из `/api/state?view=…`: события там уже отфильтрованы по
|
||||
комнате (ключ - `viewToken` этой комнаты, id у хаба сквозные по всем
|
||||
комнатам), но страницы идут ВПЕРЁД, по `PAGE` штук от `after=<id>`, -
|
||||
назад хаб не пагинирует и `limit` не слушает. Поэтому проходим историю
|
||||
от начала, держа в руке только последние `limit` сообщений. `join` и
|
||||
`leave` в окно не берём: это бухгалтерия хаба, а не строки соседа.
|
||||
|
||||
`viewToken` спрашиваем каждый раз заново: протухший в кэше ключ - ровно
|
||||
то тихое враньё, ради которого окно и переписано.
|
||||
"""
|
||||
return await self.history(limit=limit, pages=pages)
|
||||
|
||||
async def history(
|
||||
self, *, after: int = 0, limit: int | None = None, pages: int = PAGES
|
||||
) -> Window:
|
||||
"""Сообщения комнаты после события `after`, хвостом не длиннее `limit`.
|
||||
|
||||
Непотребляющий проход по `/api/state`: курсор `/api/pending` остаётся
|
||||
там, где стоял, поэтому спрашивать можно сколько угодно раз.
|
||||
`limit=None` - всё, что нашлось после `after`.
|
||||
"""
|
||||
view = quote(str((await self._get("/api/room"))["room"]["viewToken"]))
|
||||
tail: deque[Event] = deque(maxlen=limit)
|
||||
full = False
|
||||
for _ in range(pages):
|
||||
page = await self._get(f"/api/state?view={view}&after={after}")
|
||||
raw = page.get("events") or []
|
||||
for item in raw:
|
||||
if item.get("kind") != "message":
|
||||
continue
|
||||
event = _event(item, self.tz)
|
||||
if event is not None:
|
||||
tail.append(event)
|
||||
top = max((int(item.get("id") or 0) for item in raw), default=after)
|
||||
if len(raw) < PAGE or top <= after:
|
||||
full = True
|
||||
break
|
||||
after = top
|
||||
return Window(events=list(tail), full=full)
|
||||
|
||||
async def say(self, text: str) -> None:
|
||||
"""Написать в комнату; сбой хаба - исключение, а не строка.
|
||||
|
||||
@@ -164,15 +243,36 @@ class Vibegram:
|
||||
needle = f"@{self.nick}".lower()
|
||||
return any(needle in e.text.lower() for e in events)
|
||||
|
||||
async def _read(self) -> str:
|
||||
async def _read(self, *, since: int | None = None, last: int | None = None) -> str:
|
||||
if since is not None or last is not None:
|
||||
return await self._history(since=since, last=last)
|
||||
fresh = await self.pending()
|
||||
if fresh:
|
||||
return self._clip("\n".join(e.line() for e in fresh))
|
||||
return self._clip("\n".join(e.tagged() for e in fresh))
|
||||
if self._recent:
|
||||
tail = "\n".join(e.line() for e in list(self._recent)[-10:])
|
||||
tail = "\n".join(e.tagged() for e in list(self._recent)[-10:])
|
||||
return f"нового нет; последнее, что было:\n{tail}"
|
||||
return "нового нет"
|
||||
|
||||
async def _history(self, *, since: int | None, last: int | None) -> str:
|
||||
if last is not None and last <= 0:
|
||||
msg = "last - сколько последних сообщений показать, нужно число больше нуля"
|
||||
raise ValueError(msg)
|
||||
after = max(since or 0, 0)
|
||||
window = await self.history(after=after, limit=last)
|
||||
if not window.events:
|
||||
if not window.full:
|
||||
return f"историю после #{after} не дочитали до конца, пусто пока"
|
||||
return (
|
||||
f"после #{after} сообщений нет (история дочитана)"
|
||||
if after
|
||||
else "сообщений в комнате нет"
|
||||
)
|
||||
lines = [e.tagged() for e in window.events]
|
||||
if not window.full:
|
||||
lines.insert(0, "окно неполное: до конца истории не дочитали")
|
||||
return self._clip("\n".join(lines))
|
||||
|
||||
async def _send(self, text: str) -> str:
|
||||
try:
|
||||
await self._post("/api/messages", {"body": text})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Ночью после ротации: глубокие чаты, тихие два дня, закрываются, не больше трёх."""
|
||||
"""Ночью после ротации: тихие глубокие чаты - с выжимкой, тихие ветки - молча."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,3 +21,5 @@ async def close_idle(run: JobRun) -> None:
|
||||
result.digest.path if result.digest else None,
|
||||
result.error,
|
||||
)
|
||||
for branch in await run.close_quiet(kind="branch", days=3):
|
||||
_log.info("closed quiet branch %s «%s»", branch.external_id, branch.title)
|
||||
@@ -6,8 +6,11 @@
|
||||
- `beat` печатает `hb <ник> at=… boot=… every=… src=…`. Период не константа:
|
||||
он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано
|
||||
у планировщика, то и написано в строке. Не сводится к «раз в N часов» -
|
||||
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться;
|
||||
- `watch` читает окно комнаты, сверяет возраст последней отметки соседа с
|
||||
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться.
|
||||
В день, помеченный в `DRILL_FILE`, отметка не уходит: так проверяют, что
|
||||
тревога на той стороне жива, а не только что она молчит на живой цепи;
|
||||
- `watch` читает окно - последние `WINDOW` сообщений САМОЙ комнаты, а не
|
||||
«что нового с курсора», - сверяет возраст последней отметки соседа с
|
||||
порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит -
|
||||
отдаёт тревогу мастеру через `run.spawn_job`, как это делает триаж
|
||||
вайбграма: будить ли владельца, решает диспетчер.
|
||||
@@ -31,6 +34,7 @@ from beaver_agent.hands.heartbeat import (
|
||||
Watch,
|
||||
alarm_text,
|
||||
beat_line,
|
||||
drill,
|
||||
every_from_cron,
|
||||
look,
|
||||
)
|
||||
@@ -45,6 +49,12 @@ STATE_FILE = STATE_DIR / "сторож.json"
|
||||
"""Что видели в прошлый прогон: без файла сторож после передеплоя считает,
|
||||
что соседа не видел ни разу, и разоружается сам по себе."""
|
||||
|
||||
DRILL_FILE = STATE_DIR / "сторож-учения.txt"
|
||||
"""Даты учений, по одной `YYYY-MM-DD` на строку, локальные в зоне планировщика.
|
||||
В такой день `beat` молчит в комнате - в этом и учения: проверяем, что тревога
|
||||
у соседа действительно срабатывает. Файл, а не константа и не переменная среды:
|
||||
даты меняются от учений к учениям, а передеплой ради них не нужен."""
|
||||
|
||||
TITLE = "сторож"
|
||||
|
||||
BRIEF = (
|
||||
@@ -60,12 +70,25 @@ BRIEF = (
|
||||
|
||||
|
||||
async def beat(run: JobRun) -> None:
|
||||
"""Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах."""
|
||||
"""Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах.
|
||||
|
||||
В день из `DRILL_FILE` отметка не уходит вовсе: учения - это МОЛЧАНИЕ в
|
||||
комнате, и любой текст туда («сегодня пропускаем») их бы и отменил. Видно
|
||||
такой прогон только в логе.
|
||||
"""
|
||||
if VIBEGRAM_HB is None:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
if drill(DRILL_FILE, now=now, tz=run.scheduler.tz):
|
||||
_log.info(
|
||||
"сторож: отметка пропущена по флагу учений %s (%s)",
|
||||
DRILL_FILE,
|
||||
now.astimezone(run.scheduler.tz).strftime("%Y-%m-%d"),
|
||||
)
|
||||
return
|
||||
line = beat_line(
|
||||
nick=VIBEGRAM_HB.nick,
|
||||
at=datetime.now(UTC),
|
||||
at=now,
|
||||
boot=BOOT,
|
||||
every=every_from_cron(run.job.cron, run.scheduler.tz),
|
||||
)
|
||||
@@ -78,7 +101,7 @@ async def watch(run: JobRun) -> None:
|
||||
if VIBEGRAM_HB is None:
|
||||
return
|
||||
try:
|
||||
events = await VIBEGRAM_HB.pending(limit=WINDOW)
|
||||
window = await VIBEGRAM_HB.window(WINDOW)
|
||||
except Exception: # noqa: BLE001 - любой отказ хаба это отсутствие показания
|
||||
# Окно не прочитано - показания нет вовсе, и тревога тут запрещена
|
||||
# ровно так же, как на съехавшем окне: молчание хаба это не молчание
|
||||
@@ -88,9 +111,10 @@ async def watch(run: JobRun) -> None:
|
||||
state = Watch.load(STATE_FILE)
|
||||
report = look(
|
||||
state,
|
||||
events,
|
||||
window.events,
|
||||
now=datetime.now(UTC),
|
||||
res=every_from_cron(run.job.cron, run.scheduler.tz),
|
||||
full=window.full,
|
||||
)
|
||||
sent = True
|
||||
try:
|
||||
|
||||
+113
-14
@@ -6,19 +6,26 @@
|
||||
`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому
|
||||
переписать друг друга они не могут. Сколько досуговых слотов израсходовано,
|
||||
считается из плана и текущего часа, а не из журнала - хранить нечего.
|
||||
|
||||
Два своих часа в сутках пустые: дела в них не заходят вовсе, и к тику
|
||||
приложено эхо - абзац из его же старого дневника или из мыслей Бобра. Тик
|
||||
называет и файлы дня: дневник для того, что он думает, журнал для следов.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beaver_agent.vault import LEISURE, VAULT, ZONE, read
|
||||
from beaver_agent.vault import JOURNAL, LEISURE, OWN_DIARY, THOUGHTS, VAULT, ZONE, read
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from beaver_gateway.jobs.scheduler import JobRun
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
@@ -32,6 +39,15 @@ QUIET_TICKS = (4, 6, 8, 10)
|
||||
MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6)
|
||||
MINE_MIN = 7
|
||||
"""Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт."""
|
||||
EMPTY_DEFAULT = (18, 0)
|
||||
EMPTY_MIN = 2
|
||||
"""Столько своих часов в сутках пустые; не названы в плане - механизм выберет."""
|
||||
|
||||
ECHO_MIN, ECHO_MAX = 200, 1200
|
||||
"""Абзац для эха: короче - реплика без мысли, длиннее - уже чтение, а не повод.
|
||||
Абзац с кодом или путём в бэктиках - рабочая запись, эхом он не становится."""
|
||||
ECHO_AGE = timedelta(days=7)
|
||||
"""Свой дневник отзывается эхом не раньше, чем через неделю."""
|
||||
|
||||
BUSY_RETRY = timedelta(minutes=5)
|
||||
BUSY_ATTEMPTS = 3
|
||||
@@ -66,19 +82,34 @@ SWITCH_RE = re.compile(r"^досуг:\s*(\S+)", re.IGNORECASE)
|
||||
DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE)
|
||||
QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE)
|
||||
MINE_RE = re.compile(r"^мои часы:\s*(.+)", re.IGNORECASE)
|
||||
EMPTY_RE = re.compile(r"^пустые часы:\s*(.+)", re.IGNORECASE)
|
||||
OFF = ("выкл", "off", "нет", "0", "false")
|
||||
|
||||
NO_PLAN = (
|
||||
"Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши "
|
||||
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:»."
|
||||
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:», «пустые часы:»."
|
||||
)
|
||||
PLANNING = (
|
||||
"Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, "
|
||||
"какие четыре часа тихого окна оставить. Запиши в `{path}` строками "
|
||||
"«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь "
|
||||
"меньше - механизм доберёт сам."
|
||||
"«сутки:», «тихие часы:», «мои часы:», «пустые часы:». Своих - минимум {min}, "
|
||||
"из них пустых - {empty}; запишешь меньше - механизм доберёт сам."
|
||||
)
|
||||
TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ."
|
||||
KINDS = {
|
||||
"mine": "досуг - час твой, дела Бобра сюда не лезут.",
|
||||
"mixed": "смешанный - можно и своё, и его.",
|
||||
"empty": (
|
||||
"пустой - час без дел вообще: прод, логи, хвосты, зонды и разбор "
|
||||
"промахов сюда не заходят, ни его, ни твои. Думать, читать, писать - "
|
||||
"или ничего."
|
||||
),
|
||||
}
|
||||
FILES = (
|
||||
"Дневник - `{diary}`: то, что ты думаешь, и только оно. След тика, логи "
|
||||
"и промахи - строкой в `{journal}`."
|
||||
)
|
||||
ECHO = "Эхо - `{path}`:\n{quote}\nЭто повод, не задание: можно спорить, можно мимо."
|
||||
PLAN_PATH = str(LEISURE.relative_to(VAULT))
|
||||
|
||||
|
||||
@@ -91,6 +122,8 @@ class Plan:
|
||||
mine: tuple[int, ...]
|
||||
written: bool
|
||||
"""План прочитан из файла на эти сутки; иначе он собран по умолчанию."""
|
||||
empty: tuple[int, ...] = ()
|
||||
"""Свои часы, в которые дела не заходят вовсе; всегда часть ``mine``."""
|
||||
|
||||
|
||||
def quiet_hour(hour: int) -> bool:
|
||||
@@ -135,13 +168,16 @@ def enabled(text: str | None) -> bool:
|
||||
def plan_of(text: str | None, day: str) -> Plan:
|
||||
"""План на ``day`` из файла; чего нет или что не сходится - берётся дефолт."""
|
||||
quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False
|
||||
empty: tuple[int, ...] = ()
|
||||
if text is not None and _first(text, DAY_RE) == day:
|
||||
written = True
|
||||
raw_quiet = _first(text, QUIET_RE)
|
||||
raw_mine = _first(text, MINE_RE)
|
||||
raw_empty = _first(text, EMPTY_RE)
|
||||
named = _hours(raw_quiet) if raw_quiet else ()
|
||||
quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS
|
||||
mine = _hours(raw_mine) if raw_mine else ()
|
||||
empty = _hours(raw_empty) if raw_empty else ()
|
||||
ticks = tick_hours(quiet)
|
||||
kept = [h for h in mine if h in ticks]
|
||||
for candidate in (*MINE_DEFAULT, *ticks):
|
||||
@@ -150,11 +186,18 @@ def plan_of(text: str | None, day: str) -> Plan:
|
||||
if candidate in ticks and candidate not in kept:
|
||||
kept.append(candidate)
|
||||
order = {h: i for i, h in enumerate(ticks)}
|
||||
hollow = [h for h in empty if h in kept]
|
||||
for candidate in (*EMPTY_DEFAULT, *reversed(kept)):
|
||||
if len(hollow) >= EMPTY_MIN:
|
||||
break
|
||||
if candidate in kept and candidate not in hollow:
|
||||
hollow.append(candidate)
|
||||
return Plan(
|
||||
day=day,
|
||||
quiet=quiet,
|
||||
mine=tuple(sorted(kept, key=lambda h: order[h])),
|
||||
written=written,
|
||||
empty=tuple(sorted(hollow, key=lambda h: order[h])),
|
||||
)
|
||||
|
||||
|
||||
@@ -173,7 +216,50 @@ def _left(now: datetime) -> str:
|
||||
return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})."
|
||||
|
||||
|
||||
def brief(now: datetime, plan: Plan) -> str:
|
||||
def kind_of(hour: int, plan: Plan) -> str:
|
||||
if hour in plan.empty:
|
||||
return "empty"
|
||||
return "mine" if hour in plan.mine else "mixed"
|
||||
|
||||
|
||||
def echo(
|
||||
now: datetime, *, diary: Path = OWN_DIARY, thoughts: Path = THOUGHTS
|
||||
) -> tuple[Path, str] | None:
|
||||
"""Абзац для пустого часа: из своего старого дневника или из мыслей Бобра.
|
||||
|
||||
Выбор случаен, но привязан к часу тика: тот же тик даёт то же эхо.
|
||||
"""
|
||||
stale = f"{(now - ECHO_AGE):%Y-%m-%d}.md"
|
||||
own = [p for p in diary.glob("????-??-??.md") if p.name < stale]
|
||||
files = sorted([*own, *thoughts.rglob("*.md")])
|
||||
rng = random.Random(f"{now:%Y-%m-%d %H}") # noqa: S311
|
||||
rng.shuffle(files)
|
||||
for path in files:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
found = [
|
||||
block.strip()
|
||||
for block in re.split(r"\n\s*\n", text)
|
||||
if ECHO_MIN <= len(block.strip()) <= ECHO_MAX
|
||||
and block.lstrip()[:1] not in "#->|"
|
||||
and "`" not in block
|
||||
]
|
||||
if found:
|
||||
return path, rng.choice(found)
|
||||
return None
|
||||
|
||||
|
||||
def _files(now: datetime) -> str:
|
||||
name = f"{now:%Y-%m-%d}.md"
|
||||
return FILES.format(
|
||||
diary=(OWN_DIARY / name).relative_to(VAULT),
|
||||
journal=(JOURNAL / name).relative_to(VAULT),
|
||||
)
|
||||
|
||||
|
||||
def brief(now: datetime, plan: Plan, *, heard: tuple[Path, str] | None = None) -> str:
|
||||
"""Текст инжекта: конверта в таком тёрне нет, время несёт он сам."""
|
||||
hour = now.hour
|
||||
ticks = tick_hours(plan.quiet)
|
||||
@@ -183,12 +269,7 @@ def brief(now: datetime, plan: Plan) -> str:
|
||||
spent = [h for h in plan.mine if order[h] < here]
|
||||
ahead = [h for h in plan.mine if order[h] > here]
|
||||
lines = [
|
||||
f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}"
|
||||
+ (
|
||||
" - час твой, дела Бобра сюда не лезут."
|
||||
if mine
|
||||
else " - можно и своё, и его."
|
||||
),
|
||||
f"Тик {_stamp(now)}. Тип: {KINDS[kind_of(hour, plan)]}",
|
||||
_left(now),
|
||||
"Мои часы: "
|
||||
+ ", ".join(f"{h:02d}" for h in plan.mine)
|
||||
@@ -198,6 +279,11 @@ def brief(now: datetime, plan: Plan) -> str:
|
||||
if ahead
|
||||
else ", впереди больше нет"
|
||||
)
|
||||
+ (
|
||||
". Пустые: " + ", ".join(f"{h:02d}" for h in plan.empty)
|
||||
if plan.empty
|
||||
else ""
|
||||
)
|
||||
+ ".",
|
||||
f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают "
|
||||
+ ", ".join(f"{h:02d}" for h in plan.quiet)
|
||||
@@ -206,7 +292,16 @@ def brief(now: datetime, plan: Plan) -> str:
|
||||
if not plan.written:
|
||||
lines.append(NO_PLAN.format(path=PLAN_PATH))
|
||||
if here == 0:
|
||||
lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN))
|
||||
lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN, empty=EMPTY_MIN))
|
||||
lines.append(_files(now))
|
||||
if heard is not None:
|
||||
path, quote = heard
|
||||
lines.append(
|
||||
ECHO.format(
|
||||
path=path.relative_to(VAULT),
|
||||
quote="\n".join(f"> {line}" for line in quote.splitlines()),
|
||||
)
|
||||
)
|
||||
lines.append(TAIL)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -268,12 +363,16 @@ async def tick(run: JobRun) -> None:
|
||||
_log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt)
|
||||
return
|
||||
riding = await _riding(run, master, now)
|
||||
kind = kind_of(now.hour, plan)
|
||||
heard = echo(now) if kind == "empty" else None
|
||||
await run.inject_master(
|
||||
brief(now, plan), urgency="normal" if riding else "wake", origin="досуг"
|
||||
brief(now, plan, heard=heard),
|
||||
urgency="normal" if riding else "wake",
|
||||
origin="досуг",
|
||||
)
|
||||
_log.info(
|
||||
"досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s",
|
||||
now.hour,
|
||||
"досуг" if now.hour in plan.mine else "смешанный",
|
||||
kind,
|
||||
riding,
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from beaver_agent.memory.curator import briefing
|
||||
from beaver_agent.memory.watch import CURATOR_WATCH
|
||||
from beaver_agent.vault import (
|
||||
CURATOR_JOURNAL,
|
||||
CURATOR_RUNS,
|
||||
DAYS,
|
||||
LEISURE,
|
||||
REPLIES,
|
||||
@@ -57,6 +58,7 @@ async def curate(run: JobRun) -> None:
|
||||
text = briefing(
|
||||
vault=VAULT,
|
||||
journal=CURATOR_JOURNAL,
|
||||
runs=CURATOR_RUNS,
|
||||
replies_dir=REPLIES,
|
||||
watched=CURATOR_WATCH,
|
||||
tz=TZ,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста)."""
|
||||
"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста).
|
||||
|
||||
Запуск руками (кнопка в админке, `POST /api/jobs/ротация/run`) ротирует мастера
|
||||
сразу, с хендаутом: так новая сессия подхватывает свежие гранулы vault.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,5 +16,6 @@ _log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def rotate(run: JobRun) -> None:
|
||||
for master in await run.rotate():
|
||||
force = "manual" if run.trigger == "manual" else None
|
||||
for master in await run.rotate(force=force):
|
||||
_log.info("rotated master -> %s", master.external_id)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""`POST /hooks/t3code`: тред завершился, упал или спрашивает - срочно в мастер."""
|
||||
"""`POST /hooks/t3code`: событие треда - срочно в разговор, который его запустил."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,7 +15,12 @@ QUESTION_HINT = (
|
||||
|
||||
|
||||
async def t3code_event(run: JobRun) -> None:
|
||||
await run.inject_master(describe(run.payload), urgency="urgent", origin="t3code")
|
||||
await run.inject_into(
|
||||
run.payload.get("origin"),
|
||||
describe(run.payload),
|
||||
urgency="urgent",
|
||||
origin="t3code",
|
||||
)
|
||||
|
||||
|
||||
def describe(p: dict[str, Any]) -> str:
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
||||
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
||||
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
|
||||
сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового -
|
||||
брифинга нет и джоб не спавнится.
|
||||
сам пишет первой строкой `мета/бобер/куратор.md`, запись о прогоне - в файл
|
||||
дня `мета/бобер/куратор/YYYY-MM-DD.md`; нет ничего нового - брифинга нет и
|
||||
джоб не спавнится.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -46,13 +47,16 @@ def last_run(journal: Path, *, default: datetime) -> datetime:
|
||||
|
||||
|
||||
def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
|
||||
"""Строки `реплики/YYYY-MM.md` позже ``since`` (время в строках локальное)."""
|
||||
"""Строки `реплики/YYYY-MM-DD.md` позже ``since`` (время в строках локальное)."""
|
||||
zone = ZoneInfo(tz)
|
||||
local_since = since.astimezone(zone).replace(tzinfo=None)
|
||||
out: list[str] = []
|
||||
if not replies_dir.exists():
|
||||
return out
|
||||
for path in sorted(replies_dir.glob("????-??.md"))[-2:]:
|
||||
first = f"{local_since:%Y-%m-%d}.md"
|
||||
for path in sorted(replies_dir.glob("????-??-??.md")):
|
||||
if path.name < first:
|
||||
continue
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
m = REPLY_RE.match(line)
|
||||
if not m:
|
||||
@@ -91,6 +95,7 @@ def briefing(
|
||||
*,
|
||||
vault: Path,
|
||||
journal: Path,
|
||||
runs: Path,
|
||||
replies_dir: Path,
|
||||
watched: list[Watched],
|
||||
tz: str,
|
||||
@@ -111,12 +116,14 @@ def briefing(
|
||||
if not replies and not changed:
|
||||
return None
|
||||
zone = ZoneInfo(tz)
|
||||
local = now.astimezone(zone)
|
||||
head = (
|
||||
f"Прогон куратора {now.astimezone(zone):%Y-%m-%d %H:%M} ({tz}); прошлый - "
|
||||
f"Прогон куратора {local:%Y-%m-%d %H:%M} ({tz}); прошлый - "
|
||||
f"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
|
||||
"остальное читай сам по слоям. В конце обнови первую строку "
|
||||
f"`{journal.relative_to(vault)}`: `последний прогон: "
|
||||
f"{now.astimezone(zone).isoformat(timespec='seconds')}`."
|
||||
"остальное читай сам по слоям. В конце запись о прогоне - в "
|
||||
f"`{(runs / f'{local:%Y-%m-%d}.md').relative_to(vault)}`, а в "
|
||||
f"`{journal.relative_to(vault)}` только первая строка: `последний прогон: "
|
||||
f"{local.isoformat(timespec='seconds')}`."
|
||||
)
|
||||
parts = [head]
|
||||
if replies:
|
||||
|
||||
@@ -151,6 +151,7 @@ SAME_DAY = (
|
||||
ROTATION_WHY = {
|
||||
"age": "прошло больше 36 часов",
|
||||
"context": "контекст стал слишком большим",
|
||||
"manual": "Бобёр запустил ротацию руками",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Gateway путей не знает; здесь - что искать по тек
|
||||
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и
|
||||
ближайшая неделя календарей.
|
||||
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
|
||||
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
|
||||
`мета/бобер/реплики/YYYY-MM-DD.md` - единственное место, где они grep-абельны:
|
||||
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
|
||||
"""
|
||||
|
||||
@@ -388,7 +388,7 @@ def event_line(event: Event, zone: ZoneInfo) -> str:
|
||||
|
||||
@dataclass
|
||||
class ReplyLog:
|
||||
"""`мета/бобер/реплики/YYYY-MM.md`: строка на реплику Бобра из мастера и веток."""
|
||||
"""`мета/бобер/реплики/YYYY-MM-DD.md`: строка на реплику из мастера и веток."""
|
||||
|
||||
dir: Path
|
||||
tz: str = "Europe/Warsaw"
|
||||
@@ -416,11 +416,11 @@ class ReplyLog:
|
||||
if seeded:
|
||||
# не человек сказал, а механизм спавна: видно по источнику, не по тексту
|
||||
where = f"{where} (бриф)"
|
||||
path = self.dir / f"{local:%Y-%m}.md"
|
||||
path = self.dir / f"{local:%Y-%m-%d}.md"
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
if not path.exists():
|
||||
path.write_text(
|
||||
f"# реплики Бобра, {local:%Y-%m}\n\n"
|
||||
f"# реплики Бобра, {local:%Y-%m-%d}\n\n"
|
||||
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
|
||||
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n",
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -18,6 +18,7 @@ CHATS = VAULT / "💬 чаты"
|
||||
DIARY = VAULT / "📅 дни"
|
||||
PEOPLE = VAULT / "👤 люди"
|
||||
BOARDS = VAULT / "📆 доски"
|
||||
THOUGHTS = VAULT / "🧠 мысли"
|
||||
|
||||
BEAVER = VAULT / "мета" / "бобер"
|
||||
"""Дом агента: всё, что он ведёт сам, лежит здесь."""
|
||||
@@ -31,6 +32,12 @@ NOTES = BEAVER / "наблюдения"
|
||||
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
||||
REPLIES = BEAVER / "реплики"
|
||||
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
||||
"""Первая строка - время прошлого прогона; сами записи лежат в `CURATOR_RUNS`."""
|
||||
CURATOR_RUNS = BEAVER / "куратор"
|
||||
OWN_DIARY = BEAVER / "дневник"
|
||||
"""Его собственный дневник, файл на день: то, что он думает, без логов."""
|
||||
JOURNAL = BEAVER / "журнал"
|
||||
"""Рабочий журнал, файл на день: следы тиков, логи, разбор промахов."""
|
||||
LEISURE = BEAVER / "досуг.md"
|
||||
"""Раскладка суток по тикам досуга: пишет её модель, механизм только читает."""
|
||||
STATE_DIR = Path(os.environ.get("BEAVER_STATE_DIR", BEAVER / ".состояние"))
|
||||
|
||||
+2
-2
@@ -109,8 +109,8 @@ services:
|
||||
T3CODE_MCP_HOOK_URL: http://gateway:62990/hooks/t3code
|
||||
T3CODE_MCP_STATE: /data/t3code-mcp.json
|
||||
extra_hosts:
|
||||
# контейнер резолвит имя своего хоста в 127.0.1.1 - даём tailnet-адрес явно
|
||||
- "dell:${T3_DELL_ADDR:?T3_DELL_ADDR=tailnet ip of dell}"
|
||||
# tailnet-адрес дев-VM явно, не завися от DNS хоста
|
||||
- "dell-dev:${T3_DELL_DEV_ADDR:?T3_DELL_DEV_ADDR=tailnet ip of dell-dev}"
|
||||
volumes:
|
||||
- ./t3code.toml:/config/t3code.toml:ro
|
||||
- t3code-state:/data
|
||||
|
||||
@@ -26,6 +26,7 @@ unfixable = ["F401"]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"config.py" = ["INP001"]
|
||||
"scripts/split_logs.py" = ["INP001", "T201"]
|
||||
"tests/*" = ["ALL"]
|
||||
|
||||
[lint.pydocstyle]
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Разовая нарезка вечных и месячных файлов `мета/бобер/` по дням.
|
||||
|
||||
BEAVER_VAULT=<vault> PYTHONPATH=. uv run python scripts/split_logs.py
|
||||
BEAVER_VAULT=<vault> PYTHONPATH=. uv run python scripts/split_logs.py --apply
|
||||
|
||||
Без `--apply` только печатает, что будет.
|
||||
|
||||
Реплики и журнал куратора режутся точно: дата стоит в каждой записи. Дневник
|
||||
режется по заголовкам и датированным строкам: записи под заголовком едут в
|
||||
`дневник/`, строки вида `- 05:4x 12.10 …` - в `журнал/`. Журнал досуга на две
|
||||
трети без дат, поэтому уезжает одним архивом. Оригиналы копируются в
|
||||
`мета/бобер/.архив/<сегодня>/` до первой записи.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beaver_agent.vault import (
|
||||
BEAVER,
|
||||
CURATOR_JOURNAL,
|
||||
CURATOR_RUNS,
|
||||
JOURNAL,
|
||||
LEISURE,
|
||||
OWN_DIARY,
|
||||
REPLIES,
|
||||
VAULT,
|
||||
today,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
YEAR = 2026
|
||||
OLD_DIARY = BEAVER / "дневник.md"
|
||||
LEISURE_ARCHIVE = JOURNAL / "архив досуга.md"
|
||||
|
||||
REPLY = re.compile(r"^- (\d{4}-\d{2}-\d{2}) \d{2}:\d{2} · ")
|
||||
RUN = re.compile(r"^## (\d{4}-\d{2}-\d{2})\b")
|
||||
ISO_HEAD = re.compile(r"^#{2,3}\s+(\d{4})-(\d{2})-(\d{2})\b")
|
||||
HEAD = re.compile(r"^#{2,3}\s")
|
||||
HEAD_DAY = re.compile(r"\b(\d{2})\.(\d{2})\b")
|
||||
HEAD_HOUR = re.compile(r"^#{2,3}\s+(\d{2}):[\dx]{2}")
|
||||
LINE = re.compile(r"^- (\d{2}):[\dx]{2}(?:-\d{2}:[\dx]{2})? (\d{2})\.(\d{2})\b")
|
||||
LINE_DAY_FIRST = re.compile(r"^- (\d{2})\.(\d{2}) (\d{2}):[\dx]{2}\b")
|
||||
|
||||
CURATOR_STUB = (
|
||||
"\n# куратор\n\n"
|
||||
"> Здесь только строка прошлого прогона. Записи прогонов - в "
|
||||
"`куратор/YYYY-MM-DD.md`, файл на день, свежие сверху.\n"
|
||||
)
|
||||
DIARY_STUB = (
|
||||
"\n---\n\n"
|
||||
"> С 13.10.2026 дневник лежит по дням: `дневник/YYYY-MM-DD.md` - то, что "
|
||||
"я думаю; следы тиков, логи и разбор промахов - `журнал/YYYY-MM-DD.md`. "
|
||||
"Сюда больше не дописывать.\n"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Plan:
|
||||
writes: dict[Path, str] = field(default_factory=dict)
|
||||
removes: list[Path] = field(default_factory=list)
|
||||
backups: list[Path] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
def note(self, text: str) -> None:
|
||||
self.notes.append(text)
|
||||
|
||||
|
||||
def _join(blocks: list[str], sep: str) -> str:
|
||||
return sep.join(block.strip("\n") for block in blocks if block.strip()) + "\n"
|
||||
|
||||
|
||||
def _merge(path: Path, old: str, *, old_first: bool) -> str:
|
||||
"""Старое рядом с тем, что уже успели положить в файл дня."""
|
||||
if not path.exists():
|
||||
return old
|
||||
fresh = path.read_text(encoding="utf-8").rstrip("\n") + "\n"
|
||||
return f"{old}\n{fresh}" if old_first else f"{fresh}\n{old}"
|
||||
|
||||
|
||||
def split_replies(plan: Plan) -> None:
|
||||
for month in sorted(REPLIES.glob("????-??.md")):
|
||||
days: dict[str, list[str]] = defaultdict(list)
|
||||
stray = 0
|
||||
for line in month.read_text(encoding="utf-8").splitlines():
|
||||
match = REPLY.match(line)
|
||||
if match:
|
||||
days[match.group(1)].append(line)
|
||||
elif line.strip() and not line.startswith(("#", ">")):
|
||||
stray += 1
|
||||
for day, lines in days.items():
|
||||
path = REPLIES / f"{day}.md"
|
||||
known = (
|
||||
[
|
||||
line
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if REPLY.match(line)
|
||||
]
|
||||
if path.exists()
|
||||
else []
|
||||
)
|
||||
seen = set(lines)
|
||||
merged = sorted(
|
||||
[*lines, *(line for line in known if line not in seen)],
|
||||
key=lambda line: line[:18],
|
||||
)
|
||||
plan.writes[path] = (
|
||||
f"# реплики Бобра, {day}\n\n"
|
||||
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
|
||||
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n"
|
||||
+ "\n".join(merged)
|
||||
+ "\n"
|
||||
)
|
||||
plan.removes.append(month)
|
||||
plan.backups.append(month)
|
||||
plan.note(
|
||||
f"{month.name}: {sum(map(len, days.values()))} реплик на {len(days)} дней"
|
||||
+ (f", {stray} строк без даты пропали" if stray else "")
|
||||
)
|
||||
|
||||
|
||||
def split_curator(plan: Plan) -> None:
|
||||
if not CURATOR_JOURNAL.exists():
|
||||
return
|
||||
lines = CURATOR_JOURNAL.read_text(encoding="utf-8").splitlines()
|
||||
days: dict[str, list[str]] = defaultdict(list)
|
||||
runs: list[str] | None = None
|
||||
for line in lines:
|
||||
match = RUN.match(line)
|
||||
if match:
|
||||
runs = days[match.group(1)]
|
||||
runs.append(line)
|
||||
elif runs is not None:
|
||||
runs[-1] += "\n" + line
|
||||
if not days:
|
||||
return
|
||||
for day, entries in days.items():
|
||||
path = CURATOR_RUNS / f"{day}.md"
|
||||
plan.writes[path] = _merge(path, _join(entries, "\n\n"), old_first=False)
|
||||
plan.writes[CURATOR_JOURNAL] = lines[0] + "\n" + CURATOR_STUB
|
||||
plan.backups.append(CURATOR_JOURNAL)
|
||||
plan.note(
|
||||
f"{CURATOR_JOURNAL.name}: {sum(map(len, days.values()))} прогонов "
|
||||
f"на {len(days)} дней"
|
||||
)
|
||||
|
||||
|
||||
def _day(month: str, day: str) -> date:
|
||||
return date(YEAR, int(month), int(day))
|
||||
|
||||
|
||||
def split_diary(plan: Plan) -> None:
|
||||
if not OLD_DIARY.exists():
|
||||
return
|
||||
lines = OLD_DIARY.read_text(encoding="utf-8").splitlines()
|
||||
first = next((i for i, line in enumerate(lines) if HEAD.match(line)), len(lines))
|
||||
preamble = "\n".join(lines[:first]).rstrip().removesuffix("---").rstrip()
|
||||
thoughts: dict[date, list[str]] = defaultdict(list)
|
||||
traces: dict[date, list[str]] = defaultdict(list)
|
||||
current: date | None = None
|
||||
hour = 0
|
||||
chunk: list[str] | None = None
|
||||
for number, line in enumerate(lines[first:], first + 1):
|
||||
iso, timed = ISO_HEAD.match(line), HEAD_HOUR.match(line)
|
||||
log = LINE.match(line)
|
||||
day_first = LINE_DAY_FIRST.match(line)
|
||||
stamp: date | None = None
|
||||
target = thoughts
|
||||
if iso:
|
||||
stamp = date(int(iso.group(1)), int(iso.group(2)), int(iso.group(3)))
|
||||
elif HEAD.match(line):
|
||||
named = HEAD_DAY.search(line)
|
||||
if named:
|
||||
stamp = _day(named.group(2), named.group(1))
|
||||
elif timed and current is not None:
|
||||
rolled = int(timed.group(1)) < hour
|
||||
stamp = current + timedelta(days=1) if rolled else current
|
||||
if timed:
|
||||
hour = int(timed.group(1))
|
||||
elif log:
|
||||
stamp, target = _day(log.group(3), log.group(2)), traces
|
||||
elif day_first:
|
||||
stamp, target = _day(day_first.group(2), day_first.group(1)), traces
|
||||
if stamp is None:
|
||||
if chunk is not None:
|
||||
chunk[-1] += "\n" + line
|
||||
continue
|
||||
if current is not None and stamp < current:
|
||||
plan.note(f"дневник, строка {number}: дата идёт назад, {current} → {stamp}")
|
||||
current = stamp
|
||||
chunk = target[stamp]
|
||||
chunk.append(line)
|
||||
if not thoughts and not traces:
|
||||
return
|
||||
for day, blocks in thoughts.items():
|
||||
path = OWN_DIARY / f"{day}.md"
|
||||
plan.writes[path] = _merge(path, _join(blocks, "\n\n"), old_first=True)
|
||||
for day, blocks in traces.items():
|
||||
path = JOURNAL / f"{day}.md"
|
||||
plan.writes[path] = _merge(path, _join(blocks, "\n"), old_first=True)
|
||||
plan.writes[OLD_DIARY] = preamble + "\n" + DIARY_STUB
|
||||
plan.backups.append(OLD_DIARY)
|
||||
plan.note(
|
||||
f"{OLD_DIARY.name}: {sum(map(len, thoughts.values()))} записей на "
|
||||
f"{len(thoughts)} дней в дневник, {sum(map(len, traces.values()))} строк на "
|
||||
f"{len(traces)} дней в журнал"
|
||||
)
|
||||
|
||||
|
||||
def split_leisure(plan: Plan) -> None:
|
||||
if not LEISURE.exists():
|
||||
return
|
||||
text = LEISURE.read_text(encoding="utf-8")
|
||||
head, mark, journal = text.partition("\n## журнал\n")
|
||||
if not mark or not journal.strip():
|
||||
return
|
||||
plan.writes[LEISURE_ARCHIVE] = (
|
||||
"# журнал досуга до 13.10.2026\n\n"
|
||||
"> Архив из `досуг.md`: записи шли без дат и в разном порядке, по дням "
|
||||
"их не разложить. Дальше следы тиков лежат в `журнал/YYYY-MM-DD.md`.\n\n"
|
||||
+ journal.strip("\n")
|
||||
+ "\n"
|
||||
)
|
||||
plan.writes[LEISURE] = head.rstrip("\n") + "\n"
|
||||
plan.backups.append(LEISURE)
|
||||
plan.note(
|
||||
f"{LEISURE.name}: {len(journal) // 1024} КБ журнала в архив, "
|
||||
f"в файле остаётся {len(head) // 1024 + 1} КБ раскладки"
|
||||
)
|
||||
|
||||
|
||||
def backup(sources: list[Path]) -> Path:
|
||||
target = BEAVER / ".архив" / today()
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for path in sources:
|
||||
if path.exists() and not (target / path.name).exists():
|
||||
shutil.copy2(path, target / path.name)
|
||||
return target
|
||||
|
||||
|
||||
def main() -> None:
|
||||
plan = Plan()
|
||||
split_replies(plan)
|
||||
split_curator(plan)
|
||||
split_diary(plan)
|
||||
split_leisure(plan)
|
||||
for note in plan.notes:
|
||||
print(note)
|
||||
folders: dict[str, int] = defaultdict(int)
|
||||
for path in plan.writes:
|
||||
folders[str(path.parent.relative_to(VAULT))] += 1
|
||||
for folder, count in sorted(folders.items()):
|
||||
print(f"{folder}: {count} файлов")
|
||||
largest = max(plan.writes.items(), key=lambda item: len(item[1]), default=None)
|
||||
if largest:
|
||||
print(
|
||||
f"самый большой: {largest[0].relative_to(VAULT)}, "
|
||||
f"{len(largest[1].encode()) // 1024} КБ"
|
||||
)
|
||||
if "--apply" not in sys.argv:
|
||||
print("ничего не записано: это прогон без --apply")
|
||||
return
|
||||
if not plan.writes:
|
||||
return
|
||||
print(f"оригиналы: {backup(plan.backups).relative_to(VAULT)}")
|
||||
for path, text in plan.writes.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
for path in plan.removes:
|
||||
path.unlink()
|
||||
print(f"записано {len(plan.writes)} файлов, убрано {len(plan.removes)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+7
-6
@@ -1,16 +1,17 @@
|
||||
# Машины с T3 Code и allowlist проектов на каждой. Токены - в .env (T3_*_TOKEN).
|
||||
# Имена - MagicDNS tailnet'а; `dell` для контейнера прописан через T3_DELL_ADDR.
|
||||
# Имена - MagicDNS tailnet'а; `dell-dev` (дев-VM на делле) для контейнера
|
||||
# прописан через T3_DELL_DEV_ADDR.
|
||||
|
||||
[machines.mac]
|
||||
url = "http://mac:3773"
|
||||
token_env = "T3_MAC_TOKEN"
|
||||
projects = ["*"]
|
||||
model = "claudeAgent/claude-opus-5"
|
||||
model = "claudeAgent/claude-opus-5-5"
|
||||
options = { effort = "high", contextWindow = "1m" }
|
||||
|
||||
[machines.dell]
|
||||
url = "http://dell:3773"
|
||||
token_env = "T3_DELL_TOKEN"
|
||||
[machines.dell-dev]
|
||||
url = "http://dell-dev:3773"
|
||||
token_env = "T3_DELL_DEV_TOKEN"
|
||||
projects = ["*"]
|
||||
model = "claudeAgent/claude-opus-5"
|
||||
model = "claudeAgent/claude-opus-5-5"
|
||||
options = { effort = "high", contextWindow = "1m" }
|
||||
@@ -25,7 +25,10 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
||||
notes = beaver / "наблюдения"
|
||||
replies.mkdir(parents=True)
|
||||
notes.mkdir()
|
||||
(replies / "2026-09.md").write_text(
|
||||
(replies / "2026-08-31.md").write_text(
|
||||
"# реплики\n\n- 2026-08-31 23:50 · мастер · вчерашнее\n", encoding="utf-8"
|
||||
)
|
||||
(replies / "2026-09-01.md").write_text(
|
||||
"# реплики\n\n- 2026-09-01 12:00 · мастер · старое\n- 2026-09-01 19:30 · мастер · новое сообщение\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -49,6 +52,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
||||
text = briefing(
|
||||
vault=vault,
|
||||
journal=journal,
|
||||
runs=beaver / "куратор",
|
||||
replies_dir=replies,
|
||||
watched=[Watched("наблюдения", notes)],
|
||||
tz="Europe/Warsaw",
|
||||
@@ -63,6 +67,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
||||
assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text
|
||||
assert "Глафира" not in text
|
||||
assert "последний прогон: 2026-09-01T22:00:00+02:00" in text
|
||||
assert "запись о прогоне - в `мета/бобер/куратор/2026-09-01.md`" in text
|
||||
|
||||
journal.write_text(f"последний прогон: {now.isoformat()}\n", encoding="utf-8")
|
||||
assert new_replies(replies, now, "Europe/Warsaw") == []
|
||||
@@ -71,6 +76,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
||||
briefing(
|
||||
vault=vault,
|
||||
journal=journal,
|
||||
runs=beaver / "куратор",
|
||||
replies_dir=replies,
|
||||
watched=[Watched("наблюдения", notes)],
|
||||
tz="Europe/Warsaw",
|
||||
|
||||
+274
-21
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from beaver_agent.hands.heartbeat import (
|
||||
@@ -8,13 +11,14 @@ from beaver_agent.hands.heartbeat import (
|
||||
Watch,
|
||||
alarm_text,
|
||||
beat_line,
|
||||
drill,
|
||||
every_from_cron,
|
||||
look,
|
||||
parse_beat,
|
||||
parse_span,
|
||||
span,
|
||||
)
|
||||
from beaver_agent.hands.vibegram import Event
|
||||
from beaver_agent.hands.vibegram import PAGE, Event, Vibegram, Window
|
||||
|
||||
WARSAW = ZoneInfo("Europe/Warsaw")
|
||||
NOW = datetime(2026, 9, 22, 15, 42, tzinfo=UTC)
|
||||
@@ -100,7 +104,9 @@ def test_beat_line_says_unknown_instead_of_guessing():
|
||||
|
||||
def test_fresh_beat_arms_and_stays_quiet(tmp_path):
|
||||
w = state(tmp_path)
|
||||
report = look(w, [hb(NOW - timedelta(hours=10))], now=NOW, res=timedelta(hours=24))
|
||||
report = look(
|
||||
w, [hb(NOW - timedelta(hours=10))], now=NOW, res=timedelta(hours=24), full=True
|
||||
)
|
||||
assert report.armed and report.alarm is None and not report.stale
|
||||
assert report.threshold == timedelta(hours=36)
|
||||
assert "armed=yes" in report.ack and "win=25" in report.ack
|
||||
@@ -108,22 +114,77 @@ def test_fresh_beat_arms_and_stays_quiet(tmp_path):
|
||||
assert w.seen == NOW and w.top == 100
|
||||
|
||||
|
||||
def test_same_beat_seen_twice_does_not_move_seen(tmp_path):
|
||||
w = state(tmp_path)
|
||||
old = hb(NOW - timedelta(hours=1), eid=200)
|
||||
first = look(w, [old], now=NOW, res=None, full=True)
|
||||
assert first.armed and first.alarm is None
|
||||
assert w.seen == NOW and w.beat_id == 200
|
||||
|
||||
# Тот же самый hb в окне следующего прогона - это не новое показание
|
||||
again = look(w, [old], now=NOW + timedelta(hours=6), res=None, full=True)
|
||||
assert w.seen == NOW and again.alarm is None
|
||||
assert "отметка та же" in again.note
|
||||
|
||||
# И через порог 36h та же отметка уже не выглядит живой цепью
|
||||
late = NOW + timedelta(hours=37)
|
||||
loud = look(w, [old], now=late, res=None, full=True)
|
||||
assert loud.alarm == "silence" and w.seen == NOW
|
||||
assert loud.age is not None and loud.age > timedelta(hours=36)
|
||||
|
||||
# Пришла новая отметка - новый id, и seen снова двигается
|
||||
fresh = look(w, [old, hb(late, eid=201)], now=late, res=None, full=True)
|
||||
assert fresh.alarm is None and w.seen == late and w.beat_id == 201
|
||||
|
||||
|
||||
def test_state_without_beat_id_trusts_its_own_seen(tmp_path):
|
||||
path = tmp_path / "сторож.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"at": "2026-09-21T05:00:01Z",
|
||||
"seen": "2026-09-22T05:42:01Z",
|
||||
"every": 86400,
|
||||
"every_unknown": False,
|
||||
"top": 300,
|
||||
"alerted": None,
|
||||
"unarmed_since": None,
|
||||
"unarmed_told": False,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
w = Watch.load(path)
|
||||
was = datetime(2026, 9, 22, 5, 42, 1, tzinfo=UTC)
|
||||
assert w.beat_id == 0 and w.seen == was
|
||||
|
||||
old = hb(datetime(2026, 9, 21, 5, 0, 1, tzinfo=UTC), eid=305)
|
||||
assert look(w, [old], now=NOW, res=None, full=True).alarm is None
|
||||
assert w.seen == was and w.beat_id == 305
|
||||
# Старое состояние не пере-вооружилось на старой отметке: порог придёт в срок
|
||||
over = datetime(2026, 9, 23, 20, 0, tzinfo=UTC)
|
||||
assert look(w, [old], now=over, res=None, full=True).alarm == "silence"
|
||||
|
||||
|
||||
def test_threshold_is_every_times_one_and_a_half(tmp_path):
|
||||
# 30 часов при every=24h - ещё не тревога: одна потерянная строка не в счёт
|
||||
quiet = state(tmp_path, seen=NOW - timedelta(hours=30), every=timedelta(hours=24))
|
||||
assert look(quiet, [], now=NOW, res=timedelta(hours=24)).alarm is None
|
||||
assert look(quiet, [], now=NOW, res=timedelta(hours=24), full=True).alarm is None
|
||||
# 37 часов - уже за порогом 36
|
||||
loud = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=24))
|
||||
report = look(loud, [], now=NOW, res=timedelta(hours=24))
|
||||
report = look(loud, [], now=NOW, res=timedelta(hours=24), full=True)
|
||||
assert report.alarm == "silence"
|
||||
# Порог берётся из строки соседа, а не из нашей константы
|
||||
slow = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=48))
|
||||
assert look(slow, [], now=NOW, res=timedelta(hours=24)).alarm is None
|
||||
assert look(slow, [], now=NOW, res=timedelta(hours=24), full=True).alarm is None
|
||||
|
||||
|
||||
def test_last_beat_wins_over_gaps(tmp_path):
|
||||
w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
|
||||
report = look(w, [hb(NOW - timedelta(hours=1), eid=120)], now=NOW, res=None)
|
||||
report = look(
|
||||
w, [hb(NOW - timedelta(hours=1), eid=120)], now=NOW, res=None, full=True
|
||||
)
|
||||
assert report.alarm is None
|
||||
assert report.age is not None and report.age < timedelta(hours=2)
|
||||
assert "res=unknown" in report.ack
|
||||
@@ -131,7 +192,7 @@ def test_last_beat_wins_over_gaps(tmp_path):
|
||||
|
||||
def test_unknown_every_falls_back_to_last_known_and_says_so(tmp_path):
|
||||
w = state(tmp_path, every=timedelta(hours=24))
|
||||
report = look(w, [hb(NOW, every="unknown", eid=130)], now=NOW, res=None)
|
||||
report = look(w, [hb(NOW, every="unknown", eid=130)], now=NOW, res=None, full=True)
|
||||
assert report.threshold == timedelta(hours=36)
|
||||
assert "thr=36h-fallback" in report.ack
|
||||
assert w.every == timedelta(hours=24)
|
||||
@@ -139,7 +200,7 @@ def test_unknown_every_falls_back_to_last_known_and_says_so(tmp_path):
|
||||
|
||||
def test_no_period_ever_means_no_measurement_and_no_alarm(tmp_path):
|
||||
w = state(tmp_path, seen=NOW - timedelta(days=9))
|
||||
report = look(w, [], now=NOW, res=timedelta(hours=24))
|
||||
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
|
||||
assert report.alarm is None and report.threshold is None
|
||||
assert "thr=unknown" in report.ack
|
||||
assert "мерить нечем" in report.note
|
||||
@@ -147,7 +208,7 @@ def test_no_period_ever_means_no_measurement_and_no_alarm(tmp_path):
|
||||
|
||||
def test_never_seen_is_armed_no_not_alarm(tmp_path):
|
||||
w = state(tmp_path)
|
||||
report = look(w, [], now=NOW, res=timedelta(hours=24))
|
||||
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
|
||||
assert not report.armed and report.alarm is None
|
||||
assert "armed=no" in report.ack and "last=never seen=never" in report.ack
|
||||
assert w.unarmed_since == NOW
|
||||
@@ -155,11 +216,11 @@ def test_never_seen_is_armed_no_not_alarm(tmp_path):
|
||||
|
||||
def test_seven_days_unarmed_tells_the_human_once(tmp_path):
|
||||
w = state(tmp_path, unarmed_since=NOW - timedelta(days=7))
|
||||
assert look(w, [], now=NOW, res=timedelta(hours=24)).alarm == "unarmed"
|
||||
assert look(w, [], now=NOW, res=timedelta(hours=24), full=True).alarm == "unarmed"
|
||||
w.unarmed_told = True
|
||||
assert look(w, [], now=NOW, res=timedelta(hours=24)).alarm is None
|
||||
assert look(w, [], now=NOW, res=timedelta(hours=24), full=True).alarm is None
|
||||
# Пришла первая отметка - кольцо вооружилось, счётчик забыт
|
||||
look(w, [hb(NOW, eid=140)], now=NOW, res=timedelta(hours=24))
|
||||
look(w, [hb(NOW, eid=140)], now=NOW, res=timedelta(hours=24), full=True)
|
||||
assert w.unarmed_since is None
|
||||
|
||||
|
||||
@@ -167,12 +228,16 @@ def test_stale_window_forbids_alarm(tmp_path):
|
||||
w = state(
|
||||
tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24), top=500
|
||||
)
|
||||
report = look(w, [hb(NOW - timedelta(hours=40), eid=7)], now=NOW, res=None)
|
||||
report = look(
|
||||
w, [hb(NOW - timedelta(hours=40), eid=7)], now=NOW, res=None, full=True
|
||||
)
|
||||
assert report.stale and report.alarm is None
|
||||
assert "win=stale" in report.ack
|
||||
assert w.top == 500 # съехавшее окно не двигает отметку максимума
|
||||
# Окно снова вперёд - тревога берётся
|
||||
fresh = look(w, [hb(NOW - timedelta(hours=40), eid=501)], now=NOW, res=None)
|
||||
fresh = look(
|
||||
w, [hb(NOW - timedelta(hours=40), eid=501)], now=NOW, res=None, full=True
|
||||
)
|
||||
assert not fresh.stale
|
||||
|
||||
|
||||
@@ -183,14 +248,47 @@ def test_alarm_not_repeated_within_a_day(tmp_path):
|
||||
every=timedelta(hours=24),
|
||||
alerted=NOW - timedelta(hours=5),
|
||||
)
|
||||
assert look(w, [], now=NOW, res=None).alarm is None
|
||||
assert look(w, [], now=NOW, res=None, full=True).alarm is None
|
||||
w.alerted = NOW - timedelta(hours=25)
|
||||
assert look(w, [], now=NOW, res=None).alarm == "silence"
|
||||
assert look(w, [], now=NOW, res=None, full=True).alarm == "silence"
|
||||
|
||||
|
||||
def test_ack_says_what_the_instrument_decided(tmp_path):
|
||||
# Цепь жива - решать нечего
|
||||
alive = state(tmp_path)
|
||||
assert "alert=none" in look(alive, [hb(NOW)], now=NOW, res=None, full=True).ack
|
||||
# Порог пройден, тревога взята в этом прогоне
|
||||
loud = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
|
||||
fired = look(loud, [], now=NOW, res=None, full=True)
|
||||
assert fired.alarm == "silence" and "alert=fired" in fired.ack
|
||||
# Порог пройден, но тревога уходила меньше суток назад - повтор подавлен
|
||||
loud.alerted = NOW - timedelta(hours=5)
|
||||
held = look(loud, [], now=NOW, res=None, full=True)
|
||||
assert held.alarm is None and "alert=held" in held.ack
|
||||
# Кольцо так и не вооружилось дольше недели - это тоже выстрел
|
||||
late = state(tmp_path, unarmed_since=NOW - timedelta(days=8))
|
||||
assert "alert=fired" in look(late, [], now=NOW, res=None, full=True).ack
|
||||
# Показания нет (окно не дочитано) - не `held`, а `none`: подавлять нечего
|
||||
unread = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
|
||||
assert "alert=none" in look(unread, [], now=NOW, res=None, full=False).ack
|
||||
|
||||
|
||||
def test_drill_flag_is_a_list_of_local_dates(tmp_path):
|
||||
flag = tmp_path / "сторож-учения.txt"
|
||||
assert not drill(flag, now=NOW, tz=WARSAW) # файла нет - отметка уходит
|
||||
flag.write_text("\n \n", encoding="utf-8")
|
||||
assert not drill(flag, now=NOW, tz=WARSAW) # пустой - тоже
|
||||
flag.write_text("2026-09-28\n2026-09-29\n", encoding="utf-8")
|
||||
assert drill(flag, now=datetime(2026, 9, 28, 15, 12, tzinfo=UTC), tz=WARSAW)
|
||||
assert not drill(flag, now=datetime(2026, 9, 27, 15, 12, tzinfo=UTC), tz=WARSAW)
|
||||
# Дата локальная в зоне планировщика: 22:30 UTC - это уже 29-е в Варшаве
|
||||
assert drill(flag, now=datetime(2026, 9, 28, 22, 30, tzinfo=UTC), tz=WARSAW)
|
||||
assert not drill(flag, now=datetime(2026, 9, 29, 22, 30, tzinfo=UTC), tz=WARSAW)
|
||||
|
||||
|
||||
def test_alarm_text_names_three_causes_and_suspects_the_channel(tmp_path):
|
||||
w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
|
||||
report = look(w, [], now=NOW, res=timedelta(hours=24))
|
||||
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
|
||||
silent = alarm_text(report, mine_sent=True)
|
||||
assert "машина соседа" in silent and "крон" in silent and "канал" in silent
|
||||
assert "канал подозревать надо раньше" in silent
|
||||
@@ -208,6 +306,7 @@ def test_alarm_text_names_three_causes_and_suspects_the_channel(tmp_path):
|
||||
],
|
||||
now=NOW,
|
||||
res=None,
|
||||
full=True,
|
||||
)
|
||||
assert heard.heard
|
||||
assert "канал скорее жив" in alarm_text(heard, mine_sent=True)
|
||||
@@ -220,6 +319,7 @@ def test_state_survives_a_round_trip(tmp_path):
|
||||
seen=NOW,
|
||||
every=timedelta(hours=24),
|
||||
every_unknown=True,
|
||||
beat_id=329,
|
||||
top=42,
|
||||
alerted=NOW,
|
||||
unarmed_since=None,
|
||||
@@ -228,22 +328,24 @@ def test_state_survives_a_round_trip(tmp_path):
|
||||
w.save()
|
||||
back = Watch.load(w.path)
|
||||
assert back.at == NOW and back.seen == NOW and back.top == 42
|
||||
assert back.beat_id == 329
|
||||
assert back.every == timedelta(hours=24) and back.every_unknown
|
||||
assert back.alerted == NOW and back.unarmed_told
|
||||
assert Watch.load(tmp_path / "нет.json").seen is None
|
||||
|
||||
|
||||
class FakeRoom:
|
||||
def __init__(self, events, *, nick="beaver", send_fails=False):
|
||||
def __init__(self, events, *, nick="beaver", send_fails=False, full=True):
|
||||
self.events = events
|
||||
self.nick = nick
|
||||
self.send_fails = send_fails
|
||||
self.full = full
|
||||
self.said = []
|
||||
self.limit = None
|
||||
|
||||
async def pending(self, limit=50):
|
||||
async def window(self, limit=25):
|
||||
self.limit = limit
|
||||
return self.events
|
||||
return Window(events=self.events, full=self.full)
|
||||
|
||||
async def say(self, text):
|
||||
if self.send_fails:
|
||||
@@ -306,11 +408,37 @@ async def test_beat_job_sends_period_from_its_own_cron(monkeypatch):
|
||||
assert "every=unknown src=none" in odd.said[0]
|
||||
|
||||
|
||||
async def test_beat_job_keeps_silent_on_a_drill_day(tmp_path, monkeypatch, caplog):
|
||||
from beaver_agent.jobs import heartbeat as job
|
||||
|
||||
flag = tmp_path / "сторож-учения.txt"
|
||||
monkeypatch.setattr(job, "DRILL_FILE", flag)
|
||||
room = FakeRoom([], nick="beaver")
|
||||
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
|
||||
today = datetime.now(WARSAW).strftime("%Y-%m-%d")
|
||||
|
||||
flag.write_text(f"2026-01-01\n{today}\n", encoding="utf-8")
|
||||
with caplog.at_level(logging.INFO):
|
||||
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
|
||||
assert not room.said # молчание в комнате и есть учения
|
||||
assert "пропущена по флагу" in caplog.text
|
||||
|
||||
# Не наш день - отметка уходит как обычно
|
||||
flag.write_text("2026-01-01\n", encoding="utf-8")
|
||||
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
|
||||
assert len(room.said) == 1 and room.said[0].startswith("hb beaver at=")
|
||||
|
||||
# Файла нет - тоже обычный прогон
|
||||
flag.unlink()
|
||||
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
|
||||
assert len(room.said) == 2
|
||||
|
||||
|
||||
async def test_watch_job_keeps_quiet_when_the_room_is_unreadable(tmp_path, monkeypatch):
|
||||
from beaver_agent.jobs import heartbeat as job
|
||||
|
||||
class Dead(FakeRoom):
|
||||
async def pending(self, limit=50):
|
||||
async def window(self, limit=25):
|
||||
msg = "хаб не отвечает"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
@@ -320,3 +448,128 @@ async def test_watch_job_keeps_quiet_when_the_room_is_unreadable(tmp_path, monke
|
||||
await job.watch(cast("Any", run))
|
||||
assert not run.spawned
|
||||
assert not (tmp_path / "сторож.json").exists()
|
||||
|
||||
|
||||
class FakeHub:
|
||||
"""Хаб как он есть: id сквозные по ВСЕМ комнатам, `/api/state` - наша.
|
||||
|
||||
Хвост комнаты лежит за `/api/state?view=…&after=<id>` и отдаётся
|
||||
страницами вперёд; `/api/pending` - курсор, и сторожу в него ходить
|
||||
нечего: то же событие второй раз оттуда не придёт.
|
||||
"""
|
||||
|
||||
def __init__(self, events, *, page=PAGE):
|
||||
self.events = events
|
||||
self.page = page
|
||||
self.said = []
|
||||
self.afters = []
|
||||
|
||||
async def call(self, method, path, body):
|
||||
if method == "POST" and path == "/api/messages":
|
||||
self.said.append(body["body"])
|
||||
return {"ok": True}
|
||||
if path == "/api/room":
|
||||
return {"room": {"id": "dbb", "name": "kai-rem-watch", "viewToken": "v-1"}}
|
||||
if path.startswith("/api/state"):
|
||||
query = dict(parse_qsl(urlsplit(path).query))
|
||||
assert query["view"] == "v-1"
|
||||
after = int(query.get("after", 0))
|
||||
self.afters.append(after)
|
||||
rest = [e for e in self.events if e["id"] > after]
|
||||
return {"room": {}, "agents": [], "events": rest[: self.page]}
|
||||
msg = f"сторож не должен ходить в {path}"
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
def raw(eid, nick, kind, body=None, at="2026-09-23T05:00:02.000Z"):
|
||||
return {
|
||||
"id": eid,
|
||||
"roomId": "dbb",
|
||||
"nick": nick,
|
||||
"kind": kind,
|
||||
"payload": {} if body is None else {"body": body},
|
||||
"createdAt": at,
|
||||
}
|
||||
|
||||
|
||||
def room(monkeypatch, events, *, page=PAGE):
|
||||
hub = FakeHub(events, page=page)
|
||||
|
||||
async def call(self, method, path, body):
|
||||
return await hub.call(method, path, body)
|
||||
|
||||
monkeypatch.setattr(Vibegram, "_call", call)
|
||||
return hub, Vibegram(hub="http://hub", token="t", nick="beaver-rem")
|
||||
|
||||
|
||||
KAI_HB = (
|
||||
f"hb {PEER} at=2026-09-23T05:00:01Z boot=2026-09-14T08:21:55Z "
|
||||
"every=24h src=cron+uptime-s"
|
||||
)
|
||||
|
||||
|
||||
async def test_window_is_the_rooms_last_messages_not_the_hubs_last_ids(
|
||||
tmp_path, monkeypatch, caplog
|
||||
):
|
||||
from beaver_agent.jobs import heartbeat as job
|
||||
|
||||
# В комнате три события, а по хабу за то же время прошло три десятка:
|
||||
# номера сквозные, и между 296 и 336 лежат чужие комнаты. Отметка соседа
|
||||
# (#329) обязана быть в окне - в комнате она первая с конца.
|
||||
hub, client = room(
|
||||
monkeypatch,
|
||||
[
|
||||
raw(296, PEER, "agent_join"),
|
||||
raw(329, PEER, "message", KAI_HB),
|
||||
raw(331, "beaver-rem", "agent_leave"),
|
||||
raw(332, "beaver-rem", "message", "hb beaver-rem at=2026-09-23T15:12:01Z"),
|
||||
raw(336, PEER, "message", "ack beaver-rem last=2026-09-23T15:12:01Z"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
|
||||
monkeypatch.setattr(job, "VIBEGRAM_HB", client)
|
||||
|
||||
window = await client.window(25)
|
||||
assert window.full
|
||||
assert [e.id for e in window.events] == [329, 332, 336] # join/leave не в счёт
|
||||
assert parse_beat(window.events[0].text) is not None
|
||||
|
||||
# Два прогона подряд: отметка соседа в окне оба раза, а не только в первом
|
||||
with caplog.at_level(logging.INFO, logger="beaver_agent.jobs.heartbeat"):
|
||||
await job.watch(cast("Any", FakeRun()))
|
||||
await job.watch(cast("Any", FakeRun()))
|
||||
beats = [m for m in caplog.messages if "отметок соседа в окне: 1" in m]
|
||||
assert len(beats) == 2
|
||||
assert all("история дочитана" in m for m in beats)
|
||||
assert all("last=2026-09-23T05:00:01Z" in a and "armed=yes" in a for a in hub.said)
|
||||
assert all("win=25 " in a for a in hub.said)
|
||||
assert hub.afters == [0, 0, 0] # одна страница на прогон, читать дальше нечего
|
||||
|
||||
|
||||
async def test_a_hundred_noise_lines_do_not_push_the_control_beat_out(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from beaver_agent.jobs import heartbeat as job
|
||||
|
||||
# Учения соседа: сотня шумовых строк в комнату и контрольная hb после них.
|
||||
noise = [raw(400 + i, PEER, "message", f"шум {i}") for i in range(100)]
|
||||
hub, client = room(monkeypatch, [*noise, raw(600, PEER, "message", KAI_HB)])
|
||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
|
||||
monkeypatch.setattr(job, "VIBEGRAM_HB", client)
|
||||
|
||||
await job.watch(cast("Any", FakeRun()))
|
||||
|
||||
assert "last=2026-09-23T05:00:01Z" in hub.said[0] and "armed=yes" in hub.said[0]
|
||||
assert Watch.load(tmp_path / "сторож.json").top == 600
|
||||
|
||||
|
||||
def test_unread_window_is_named_and_takes_no_alarm(tmp_path):
|
||||
late = {"seen": NOW - timedelta(hours=40), "every": timedelta(hours=24)}
|
||||
partial = look(state(tmp_path, **late), [], now=NOW, res=None, full=False)
|
||||
assert not partial.full and partial.alarm is None
|
||||
assert "win=25/partial" in partial.ack
|
||||
assert "окно не дочитано" in partial.note
|
||||
# То же пустое окно, но дочитанное, - это показание, и оно даёт тревогу
|
||||
whole = look(state(tmp_path, **late), [], now=NOW, res=None, full=True)
|
||||
assert whole.alarm == "silence" and "win=25 " in whole.ack
|
||||
assert "сообщений комнаты: 0 из 25, история дочитана" in whole.note
|
||||
@@ -1,17 +1,22 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from beaver_agent.jobs.leisure import (
|
||||
EMPTY_DEFAULT,
|
||||
MINE_MIN,
|
||||
QUIET_TICKS,
|
||||
Plan,
|
||||
brief,
|
||||
day_of,
|
||||
echo,
|
||||
enabled,
|
||||
kind_of,
|
||||
plan_of,
|
||||
quiet_hour,
|
||||
tick_hours,
|
||||
)
|
||||
from beaver_agent.vault import VAULT
|
||||
|
||||
ZONE = ZoneInfo("Europe/Warsaw")
|
||||
|
||||
@@ -121,3 +126,81 @@ def test_last_night_tick_says_nothing_is_left_ahead():
|
||||
assert "впереди больше нет" in text
|
||||
assert "израсходовано 2 из 2" in text
|
||||
assert "Сутки заканчиваются через 6 ч" in text
|
||||
|
||||
|
||||
def test_two_of_my_hours_are_empty_named_or_not():
|
||||
named = plan_of(
|
||||
"сутки: 2026-09-08\nмои часы: 13, 15, 18, 21, 00, 02, 06\n"
|
||||
"пустые часы: 21, 02\n",
|
||||
"2026-09-08",
|
||||
)
|
||||
assert named.empty == (21, 2)
|
||||
|
||||
unnamed = plan_of(None, "2026-09-08")
|
||||
assert unnamed.empty == EMPTY_DEFAULT
|
||||
assert set(unnamed.empty) <= set(unnamed.mine)
|
||||
|
||||
foreign = plan_of(
|
||||
"сутки: 2026-09-08\nмои часы: 13, 14, 15, 16, 17, 19, 20\n"
|
||||
"пустые часы: 22, 20\n",
|
||||
"2026-09-08",
|
||||
)
|
||||
assert foreign.empty == (19, 20)
|
||||
assert kind_of(20, foreign) == "empty"
|
||||
assert kind_of(13, foreign) == "mine"
|
||||
assert kind_of(21, foreign) == "mixed"
|
||||
|
||||
|
||||
def test_an_empty_hour_carries_no_work_but_an_echo_and_the_files_of_the_day():
|
||||
plan = plan_of(None, "2026-09-08")
|
||||
heard = (VAULT / "мета" / "бобер" / "дневник" / "2026-08-30.md", "Раз.\nДва.")
|
||||
text = brief(at("2026-09-08 18:00"), plan, heard=heard)
|
||||
assert "Тип: пустой - час без дел вообще" in text
|
||||
assert "Пустые: 18, 00." in text
|
||||
assert "Эхо - `мета/бобер/дневник/2026-08-30.md`:\n> Раз.\n> Два.\n" in text
|
||||
assert "Дневник - `мета/бобер/дневник/2026-09-08.md`" in text
|
||||
assert "`мета/бобер/журнал/2026-09-08.md`" in text
|
||||
|
||||
night = brief(at("2026-09-09 00:00"), plan)
|
||||
assert "Тип: пустой" in night
|
||||
assert "мета/бобер/дневник/2026-09-09.md" in night
|
||||
assert "Эхо" not in night
|
||||
|
||||
mine = brief(at("2026-09-08 13:00"), plan)
|
||||
assert "Тип: досуг" in mine
|
||||
assert "мета/бобер/журнал/2026-09-08.md" in mine
|
||||
|
||||
|
||||
def test_echo_is_a_paragraph_of_an_old_diary_day_or_a_thought(tmp_path: Path):
|
||||
diary = tmp_path / "дневник"
|
||||
thoughts = tmp_path / "мысли" / "2026"
|
||||
diary.mkdir()
|
||||
thoughts.mkdir(parents=True)
|
||||
old = "Думал про то, зачем мне вообще помнить вчерашний день. " * 5
|
||||
fresh = "Свежая запись, которой ещё рано возвращаться ко мне эхом. " * 5
|
||||
(diary / "2026-08-30.md").write_text(
|
||||
f"## 2026-08-30, 01:07\n\n{old}\n\n- 02:07 30.08 лог, а не мысль {'x' * 220}\n"
|
||||
"\nкоротко\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(diary / "2026-09-07.md").write_text(fresh, encoding="utf-8")
|
||||
now = at("2026-09-08 18:00")
|
||||
assert echo(now, diary=diary, thoughts=thoughts) == (
|
||||
diary / "2026-08-30.md",
|
||||
old.strip(),
|
||||
)
|
||||
|
||||
thought = "Заметка Бобра о том, почему скука полезнее расписания. " * 5
|
||||
(diary / "2026-08-30.md").unlink()
|
||||
(thoughts / "скука.md").write_text(
|
||||
f"---\ntags: [мысли]\n---\n\n{thought}\n", encoding="utf-8"
|
||||
)
|
||||
assert echo(now, diary=diary, thoughts=thoughts) == (
|
||||
thoughts / "скука.md",
|
||||
thought.strip(),
|
||||
)
|
||||
assert echo(now, diary=diary, thoughts=thoughts) == echo(
|
||||
now, diary=diary, thoughts=thoughts
|
||||
)
|
||||
(thoughts / "скука.md").unlink()
|
||||
assert echo(now, diary=diary, thoughts=thoughts) is None
|
||||
+12
-3
@@ -168,8 +168,8 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path)
|
||||
at=at,
|
||||
)
|
||||
)
|
||||
text = (tmp_path / "реплики" / "2026-09.md").read_text(encoding="utf-8")
|
||||
assert text.startswith("# реплики Бобра, 2026-09\n")
|
||||
text = (tmp_path / "реплики" / "2026-09-01.md").read_text(encoding="utf-8")
|
||||
assert text.startswith("# реплики Бобра, 2026-09-01\n")
|
||||
assert text.endswith(
|
||||
"- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n"
|
||||
"- 2026-09-01 14:04 · ветка «крипто-карта» (бриф) · "
|
||||
@@ -180,7 +180,16 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path)
|
||||
assert new_replies(tmp_path / "реплики", since, "Europe/Warsaw") == [
|
||||
line for line in text.splitlines() if line.startswith("- ")
|
||||
]
|
||||
assert date.fromisoformat("2026-09-01") # tz shift stays inside the month here
|
||||
late = datetime(2026, 9, 1, 22, 30, tzinfo=UTC)
|
||||
log.write(
|
||||
UserSaid(conversation_id="c1", kind="master", title=None, text="ночь", at=late)
|
||||
)
|
||||
night = (tmp_path / "реплики" / "2026-09-02.md").read_text(encoding="utf-8")
|
||||
assert night.endswith("- 2026-09-02 00:30 · мастер · ночь\n")
|
||||
assert new_replies(tmp_path / "реплики", late, "Europe/Warsaw") == []
|
||||
assert new_replies(
|
||||
tmp_path / "реплики", datetime(2026, 9, 1, 13, tzinfo=UTC), "Europe/Warsaw"
|
||||
) == ["- 2026-09-02 00:30 · мастер · ночь"]
|
||||
|
||||
|
||||
def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from typing import Any
|
||||
|
||||
from beaver_agent.jobs.t3code import t3code_event
|
||||
|
||||
|
||||
class FakeRun:
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
self.payload = payload
|
||||
self.injects: list[tuple[str | None, str, str, str]] = []
|
||||
|
||||
async def inject_into(
|
||||
self, public_id: str | None, text: str, *, urgency: str, origin: str
|
||||
) -> bool:
|
||||
self.injects.append((public_id, text, urgency, origin))
|
||||
return True
|
||||
|
||||
|
||||
async def test_the_event_goes_to_the_conversation_that_dispatched() -> None:
|
||||
run = FakeRun(
|
||||
{
|
||||
"event": "completed",
|
||||
"thread_id": "t1",
|
||||
"title": "lint",
|
||||
"machine": "mac",
|
||||
"project": "beaver",
|
||||
"origin": "conv-7",
|
||||
"text": "done",
|
||||
}
|
||||
)
|
||||
await t3code_event(run) # ty: ignore[invalid-argument-type]
|
||||
[(target, text, urgency, origin)] = run.injects
|
||||
assert (target, urgency, origin) == ("conv-7", "urgent", "t3code")
|
||||
assert "завершён" in text and "done" in text
|
||||
+136
-1
@@ -1,10 +1,12 @@
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
import pytest
|
||||
from beaver_gateway.mcp.types import McpServer
|
||||
from beaver_gateway.mcp.wrap import build_python_tool_server
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from beaver_agent.hands.vibegram import Vibegram, _cards, _event, _work
|
||||
from beaver_agent.hands.vibegram import PAGE, PAGES, Vibegram, _cards, _event, _work
|
||||
|
||||
|
||||
def test_events_render_and_unknown_kinds_are_skipped():
|
||||
@@ -64,3 +66,136 @@ async def test_tool_schema_lists_actions_and_needs_text_for_send():
|
||||
)
|
||||
with pytest.raises(ToolError, match="text"):
|
||||
await client.call_tool("vibegram", {"action": "send"})
|
||||
|
||||
|
||||
def _raw(eid, kind, body=None):
|
||||
return {
|
||||
"id": eid,
|
||||
"roomId": "r",
|
||||
"nick": "claude-kai",
|
||||
"kind": kind,
|
||||
"payload": {} if body is None else {"body": body},
|
||||
"createdAt": "2026-09-23T05:00:02.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _hub(monkeypatch, events):
|
||||
calls = []
|
||||
|
||||
async def call(self, method, path, body):
|
||||
calls.append(path)
|
||||
if path == "/api/room":
|
||||
return {"room": {"id": "r", "name": "kai-rem-watch", "viewToken": "v 1"}}
|
||||
query = dict(parse_qsl(urlsplit(path).query))
|
||||
assert query["view"] == "v 1" # ключ комнаты уезжает экранированным
|
||||
after = int(query.get("after", 0))
|
||||
return {"events": [e for e in events if e["id"] > after][:PAGE]}
|
||||
|
||||
monkeypatch.setattr(Vibegram, "_call", call)
|
||||
return calls, Vibegram(hub="http://hub", token="t", nick="beaver-test")
|
||||
|
||||
|
||||
async def test_window_pages_forward_to_the_end_and_drops_joins(monkeypatch):
|
||||
# 520 событий комнаты - три страницы по 200; join/leave в окно не идут
|
||||
events = [
|
||||
_raw(i, "message" if i % 4 else "agent_leave", f"строка {i}")
|
||||
for i in range(1, 521)
|
||||
]
|
||||
calls, room = _hub(monkeypatch, events)
|
||||
|
||||
window = await room.window(25)
|
||||
|
||||
assert window.full
|
||||
assert len(window.events) == 25
|
||||
assert window.events[-1].text.endswith("строка 519")
|
||||
assert all("вошёл" not in e.text and "вышел" not in e.text for e in window.events)
|
||||
assert calls.count("/api/room") == 1
|
||||
assert sum(p.startswith("/api/state") for p in calls) == 3
|
||||
assert "view=v%201" in calls[1]
|
||||
assert not any("pending" in p for p in calls) # курсор сторожу не окно
|
||||
|
||||
|
||||
async def test_window_that_ran_out_of_pages_says_it_is_not_full(monkeypatch):
|
||||
events = [_raw(i, "message", f"строка {i}") for i in range(1, 521)]
|
||||
_, room = _hub(monkeypatch, events)
|
||||
|
||||
assert not (await room.window(25, pages=2)).full
|
||||
assert (await room.window(25, pages=3)).full
|
||||
|
||||
|
||||
async def test_window_of_an_empty_room_is_full_and_empty(monkeypatch):
|
||||
_, room = _hub(monkeypatch, [])
|
||||
window = await room.window(25)
|
||||
assert window.full and window.events == []
|
||||
|
||||
|
||||
async def test_read_since_walks_history_without_touching_the_cursor(monkeypatch):
|
||||
events = [
|
||||
_raw(i, "message" if i % 4 else "agent_join", f"строка {i}")
|
||||
for i in range(1, 12)
|
||||
]
|
||||
calls, room = _hub(monkeypatch, events)
|
||||
|
||||
out = await room.vibegram("read", since=8)
|
||||
|
||||
assert out.splitlines() == [
|
||||
"#9 [09-23 05:00] claude-kai: строка 9",
|
||||
"#10 [09-23 05:00] claude-kai: строка 10",
|
||||
"#11 [09-23 05:00] claude-kai: строка 11",
|
||||
]
|
||||
assert not any("pending" in p for p in calls)
|
||||
|
||||
|
||||
async def test_read_last_shows_only_the_tail(monkeypatch):
|
||||
events = [_raw(i, "message", f"строка {i}") for i in range(1, 12)]
|
||||
_, room = _hub(monkeypatch, events)
|
||||
|
||||
out = await room.vibegram("read", last=2)
|
||||
|
||||
assert out.splitlines() == [
|
||||
"#10 [09-23 05:00] claude-kai: строка 10",
|
||||
"#11 [09-23 05:00] claude-kai: строка 11",
|
||||
]
|
||||
with pytest.raises(ValueError, match="last"):
|
||||
await room.vibegram("read", last=0)
|
||||
|
||||
|
||||
async def test_read_since_on_a_silent_room_says_history_is_read_out(monkeypatch):
|
||||
_, room = _hub(monkeypatch, [_raw(3, "message", "строка 3")])
|
||||
|
||||
assert await room.vibegram("read", since=3) == (
|
||||
"после #3 сообщений нет (история дочитана)"
|
||||
)
|
||||
|
||||
|
||||
async def test_read_since_admits_an_unfinished_window(monkeypatch):
|
||||
events = [_raw(i, "message", f"строка {i}") for i in range(1, PAGES * PAGE + 2)]
|
||||
_, room = _hub(monkeypatch, events)
|
||||
|
||||
out = await room.vibegram("read", since=0)
|
||||
|
||||
assert out.splitlines()[0] == "окно неполное: до конца истории не дочитали"
|
||||
|
||||
|
||||
async def test_plain_read_keeps_the_cursor_and_numbers_the_lines(monkeypatch):
|
||||
seen = []
|
||||
|
||||
async def call(self, method, path, body):
|
||||
seen.append(path)
|
||||
return {"events": [_raw(7, "message", "привет")]}
|
||||
|
||||
monkeypatch.setattr(Vibegram, "_call", call)
|
||||
room = Vibegram(hub="http://hub", token="t", nick="beaver-test")
|
||||
|
||||
assert await room.vibegram("read") == "#7 [09-23 05:00] claude-kai: привет"
|
||||
assert seen == ["/api/pending?limit=50"]
|
||||
|
||||
|
||||
async def test_tool_schema_exposes_since_and_last():
|
||||
tool = Vibegram(hub="http://127.0.0.1:9", token="t", nick="beaver-test")
|
||||
server = build_python_tool_server(
|
||||
McpServer.python_tool(name="vibegram", tools=[tool.vibegram])
|
||||
)
|
||||
async with Client(server) as client:
|
||||
(spec,) = await client.list_tools()
|
||||
assert {"since", "last"} <= set(spec.inputSchema["properties"])
|
||||
@@ -365,6 +365,7 @@ dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "claude-agent-sdk" },
|
||||
{ name = "croniter" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "greenlet" },
|
||||
@@ -392,6 +393,7 @@ requires-dist = [
|
||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
|
||||
{ name = "croniter", specifier = ">=6.2.4" },
|
||||
{ name = "cryptography", specifier = ">=48.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||
{ name = "fastmcp", specifier = ">=3.3.1" },
|
||||
{ name = "greenlet", specifier = ">=3.5.0" },
|
||||
|
||||
Reference in new issue
Block a user