Compare commits
8
Commits
0273f29589
..
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db6c7b6ec | ||
|
|
2bf2326178 | ||
|
|
1d8b8c68a3 | ||
|
|
fc97463f0f | ||
|
|
3e62529a0e | ||
|
|
fbfe39a968 | ||
|
|
eba0cc6168 | ||
|
|
dea793dbd7 |
No files matched your search
@@ -31,6 +31,11 @@ ADMIN_PASS=
|
|||||||
# Подпись session-cookie admin UI
|
# Подпись session-cookie admin UI
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# Мастер-ключ хранилища секретов (admin → System → Secrets): openssl rand -hex 32.
|
||||||
|
# Шифрует таблицу secrets в postgres, поэтому дамп базы без него закрыт.
|
||||||
|
# В env модели не попадает. Потерян или сменён - секреты задаются заново.
|
||||||
|
SECRETS_KEY=
|
||||||
|
|
||||||
### Claude ####################################################################
|
### Claude ####################################################################
|
||||||
|
|
||||||
# OAuth-токен подписки для Claude Agent SDK внутри контейнера.
|
# OAuth-токен подписки для Claude Agent SDK внутри контейнера.
|
||||||
|
|||||||
@@ -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
|
it out of `.obsidian`, `мета` outside `мета/бобер`, and from deleting
|
||||||
anything but its own files.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
+11
-2
@@ -26,6 +26,9 @@ from beaver_agent.voice import AGENT as VOICE
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from beaver_gateway.agents.base import BaseAgent
|
from beaver_gateway.agents.base import BaseAgent
|
||||||
|
|
||||||
|
REASONING = {"type": "adaptive", "display": "summarized"}
|
||||||
|
"""Рассуждения приезжают текстом: их хранит транскрипт и показывает админка."""
|
||||||
|
|
||||||
|
|
||||||
def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
|
def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||||
"""Мастер и ветки: полный голос, все руки, vault - дом."""
|
"""Мастер и ветки: полный голос, все руки, vault - дом."""
|
||||||
@@ -36,7 +39,9 @@ def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
|
|||||||
prompts=prompts.DISPATCHER,
|
prompts=prompts.DISPATCHER,
|
||||||
skill_sets=skills.DISPATCHER,
|
skill_sets=skills.DISPATCHER,
|
||||||
gateway_tools=("read_conversation", "spawn", "say", "schedule", "send_file"),
|
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=(
|
policy=(
|
||||||
*VAULT_POLICY,
|
*VAULT_POLICY,
|
||||||
forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)),
|
forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)),
|
||||||
@@ -54,7 +59,9 @@ def deep(name: str, model: str, effort: str) -> ClaudeAgent:
|
|||||||
prompts=prompts.DEEP,
|
prompts=prompts.DEEP,
|
||||||
skill_sets=skills.DEEP,
|
skill_sets=skills.DEEP,
|
||||||
gateway_tools=("close_chat",),
|
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,
|
policy=VAULT_POLICY,
|
||||||
expose_mcps=hands.CLAUDE,
|
expose_mcps=hands.CLAUDE,
|
||||||
)
|
)
|
||||||
@@ -69,6 +76,7 @@ def distiller(name: str, model: str, effort: str) -> ClaudeAgent:
|
|||||||
prompts=prompts.DISTILLER,
|
prompts=prompts.DISTILLER,
|
||||||
options=ClaudeOptions(
|
options=ClaudeOptions(
|
||||||
effort=effort,
|
effort=effort,
|
||||||
|
thinking=REASONING,
|
||||||
tools=("Read", "Write"),
|
tools=("Read", "Write"),
|
||||||
disallowed_tools=DISTILLER_DISALLOWED,
|
disallowed_tools=DISTILLER_DISALLOWED,
|
||||||
),
|
),
|
||||||
@@ -86,6 +94,7 @@ def curator(name: str, model: str, effort: str) -> ClaudeAgent:
|
|||||||
gateway_tools=("inject",),
|
gateway_tools=("inject",),
|
||||||
options=ClaudeOptions(
|
options=ClaudeOptions(
|
||||||
effort=effort,
|
effort=effort,
|
||||||
|
thinking=REASONING,
|
||||||
tools=("Read", "Write", "Edit", "Grep", "Glob"),
|
tools=("Read", "Write", "Edit", "Grep", "Glob"),
|
||||||
disallowed_tools=DISTILLER_DISALLOWED,
|
disallowed_tools=DISTILLER_DISALLOWED,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Ночью после ротации: глубокие чаты, тихие два дня, закрываются, не больше трёх."""
|
"""Ночью после ротации: тихие глубокие чаты - с выжимкой, тихие ветки - молча."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -21,3 +21,5 @@ async def close_idle(run: JobRun) -> None:
|
|||||||
result.digest.path if result.digest else None,
|
result.digest.path if result.digest else None,
|
||||||
result.error,
|
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)
|
||||||
+113
-14
@@ -6,19 +6,26 @@
|
|||||||
`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому
|
`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому
|
||||||
переписать друг друга они не могут. Сколько досуговых слотов израсходовано,
|
переписать друг друга они не могут. Сколько досуговых слотов израсходовано,
|
||||||
считается из плана и текущего часа, а не из журнала - хранить нечего.
|
считается из плана и текущего часа, а не из журнала - хранить нечего.
|
||||||
|
|
||||||
|
Два своих часа в сутках пустые: дела в них не заходят вовсе, и к тику
|
||||||
|
приложено эхо - абзац из его же старого дневника или из мыслей Бобра. Тик
|
||||||
|
называет и файлы дня: дневник для того, что он думает, журнал для следов.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import TYPE_CHECKING
|
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:
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver_gateway.jobs.scheduler import JobRun
|
from beaver_gateway.jobs.scheduler import JobRun
|
||||||
from beaver_gateway.storage.models import Conversation
|
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_DEFAULT = (13, 15, 18, 21, 0, 2, 6)
|
||||||
MINE_MIN = 7
|
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_RETRY = timedelta(minutes=5)
|
||||||
BUSY_ATTEMPTS = 3
|
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)
|
DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE)
|
||||||
QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE)
|
QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE)
|
||||||
MINE_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")
|
OFF = ("выкл", "off", "нет", "0", "false")
|
||||||
|
|
||||||
NO_PLAN = (
|
NO_PLAN = (
|
||||||
"Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши "
|
"Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши "
|
||||||
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:»."
|
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:», «пустые часы:»."
|
||||||
)
|
)
|
||||||
PLANNING = (
|
PLANNING = (
|
||||||
"Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, "
|
"Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, "
|
||||||
"какие четыре часа тихого окна оставить. Запиши в `{path}` строками "
|
"какие четыре часа тихого окна оставить. Запиши в `{path}` строками "
|
||||||
"«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь "
|
"«сутки:», «тихие часы:», «мои часы:», «пустые часы:». Своих - минимум {min}, "
|
||||||
"меньше - механизм доберёт сам."
|
"из них пустых - {empty}; запишешь меньше - механизм доберёт сам."
|
||||||
)
|
)
|
||||||
TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ."
|
TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ."
|
||||||
|
KINDS = {
|
||||||
|
"mine": "досуг - час твой, дела Бобра сюда не лезут.",
|
||||||
|
"mixed": "смешанный - можно и своё, и его.",
|
||||||
|
"empty": (
|
||||||
|
"пустой - час без дел вообще: прод, логи, хвосты, зонды и разбор "
|
||||||
|
"промахов сюда не заходят, ни его, ни твои. Думать, читать, писать - "
|
||||||
|
"или ничего."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
FILES = (
|
||||||
|
"Дневник - `{diary}`: то, что ты думаешь, и только оно. След тика, логи "
|
||||||
|
"и промахи - строкой в `{journal}`."
|
||||||
|
)
|
||||||
|
ECHO = "Эхо - `{path}`:\n{quote}\nЭто повод, не задание: можно спорить, можно мимо."
|
||||||
PLAN_PATH = str(LEISURE.relative_to(VAULT))
|
PLAN_PATH = str(LEISURE.relative_to(VAULT))
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +122,8 @@ class Plan:
|
|||||||
mine: tuple[int, ...]
|
mine: tuple[int, ...]
|
||||||
written: bool
|
written: bool
|
||||||
"""План прочитан из файла на эти сутки; иначе он собран по умолчанию."""
|
"""План прочитан из файла на эти сутки; иначе он собран по умолчанию."""
|
||||||
|
empty: tuple[int, ...] = ()
|
||||||
|
"""Свои часы, в которые дела не заходят вовсе; всегда часть ``mine``."""
|
||||||
|
|
||||||
|
|
||||||
def quiet_hour(hour: int) -> bool:
|
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:
|
def plan_of(text: str | None, day: str) -> Plan:
|
||||||
"""План на ``day`` из файла; чего нет или что не сходится - берётся дефолт."""
|
"""План на ``day`` из файла; чего нет или что не сходится - берётся дефолт."""
|
||||||
quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False
|
quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False
|
||||||
|
empty: tuple[int, ...] = ()
|
||||||
if text is not None and _first(text, DAY_RE) == day:
|
if text is not None and _first(text, DAY_RE) == day:
|
||||||
written = True
|
written = True
|
||||||
raw_quiet = _first(text, QUIET_RE)
|
raw_quiet = _first(text, QUIET_RE)
|
||||||
raw_mine = _first(text, MINE_RE)
|
raw_mine = _first(text, MINE_RE)
|
||||||
|
raw_empty = _first(text, EMPTY_RE)
|
||||||
named = _hours(raw_quiet) if raw_quiet else ()
|
named = _hours(raw_quiet) if raw_quiet else ()
|
||||||
quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS
|
quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS
|
||||||
mine = _hours(raw_mine) if raw_mine else ()
|
mine = _hours(raw_mine) if raw_mine else ()
|
||||||
|
empty = _hours(raw_empty) if raw_empty else ()
|
||||||
ticks = tick_hours(quiet)
|
ticks = tick_hours(quiet)
|
||||||
kept = [h for h in mine if h in ticks]
|
kept = [h for h in mine if h in ticks]
|
||||||
for candidate in (*MINE_DEFAULT, *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:
|
if candidate in ticks and candidate not in kept:
|
||||||
kept.append(candidate)
|
kept.append(candidate)
|
||||||
order = {h: i for i, h in enumerate(ticks)}
|
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(
|
return Plan(
|
||||||
day=day,
|
day=day,
|
||||||
quiet=quiet,
|
quiet=quiet,
|
||||||
mine=tuple(sorted(kept, key=lambda h: order[h])),
|
mine=tuple(sorted(kept, key=lambda h: order[h])),
|
||||||
written=written,
|
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})."
|
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
|
hour = now.hour
|
||||||
ticks = tick_hours(plan.quiet)
|
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]
|
spent = [h for h in plan.mine if order[h] < here]
|
||||||
ahead = [h for h in plan.mine if order[h] > here]
|
ahead = [h for h in plan.mine if order[h] > here]
|
||||||
lines = [
|
lines = [
|
||||||
f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}"
|
f"Тик {_stamp(now)}. Тип: {KINDS[kind_of(hour, plan)]}",
|
||||||
+ (
|
|
||||||
" - час твой, дела Бобра сюда не лезут."
|
|
||||||
if mine
|
|
||||||
else " - можно и своё, и его."
|
|
||||||
),
|
|
||||||
_left(now),
|
_left(now),
|
||||||
"Мои часы: "
|
"Мои часы: "
|
||||||
+ ", ".join(f"{h:02d}" for h in plan.mine)
|
+ ", ".join(f"{h:02d}" for h in plan.mine)
|
||||||
@@ -198,6 +279,11 @@ def brief(now: datetime, plan: Plan) -> str:
|
|||||||
if ahead
|
if ahead
|
||||||
else ", впереди больше нет"
|
else ", впереди больше нет"
|
||||||
)
|
)
|
||||||
|
+ (
|
||||||
|
". Пустые: " + ", ".join(f"{h:02d}" for h in plan.empty)
|
||||||
|
if plan.empty
|
||||||
|
else ""
|
||||||
|
)
|
||||||
+ ".",
|
+ ".",
|
||||||
f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают "
|
f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают "
|
||||||
+ ", ".join(f"{h:02d}" for h in plan.quiet)
|
+ ", ".join(f"{h:02d}" for h in plan.quiet)
|
||||||
@@ -206,7 +292,16 @@ def brief(now: datetime, plan: Plan) -> str:
|
|||||||
if not plan.written:
|
if not plan.written:
|
||||||
lines.append(NO_PLAN.format(path=PLAN_PATH))
|
lines.append(NO_PLAN.format(path=PLAN_PATH))
|
||||||
if here == 0:
|
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)
|
lines.append(TAIL)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -268,12 +363,16 @@ async def tick(run: JobRun) -> None:
|
|||||||
_log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt)
|
_log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt)
|
||||||
return
|
return
|
||||||
riding = await _riding(run, master, now)
|
riding = await _riding(run, master, now)
|
||||||
|
kind = kind_of(now.hour, plan)
|
||||||
|
heard = echo(now) if kind == "empty" else None
|
||||||
await run.inject_master(
|
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(
|
_log.info(
|
||||||
"досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s",
|
"досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s",
|
||||||
now.hour,
|
now.hour,
|
||||||
"досуг" if now.hour in plan.mine else "смешанный",
|
kind,
|
||||||
riding,
|
riding,
|
||||||
)
|
)
|
||||||
@@ -11,6 +11,7 @@ from beaver_agent.memory.curator import briefing
|
|||||||
from beaver_agent.memory.watch import CURATOR_WATCH
|
from beaver_agent.memory.watch import CURATOR_WATCH
|
||||||
from beaver_agent.vault import (
|
from beaver_agent.vault import (
|
||||||
CURATOR_JOURNAL,
|
CURATOR_JOURNAL,
|
||||||
|
CURATOR_RUNS,
|
||||||
DAYS,
|
DAYS,
|
||||||
LEISURE,
|
LEISURE,
|
||||||
REPLIES,
|
REPLIES,
|
||||||
@@ -57,6 +58,7 @@ async def curate(run: JobRun) -> None:
|
|||||||
text = briefing(
|
text = briefing(
|
||||||
vault=VAULT,
|
vault=VAULT,
|
||||||
journal=CURATOR_JOURNAL,
|
journal=CURATOR_JOURNAL,
|
||||||
|
runs=CURATOR_RUNS,
|
||||||
replies_dir=REPLIES,
|
replies_dir=REPLIES,
|
||||||
watched=CURATOR_WATCH,
|
watched=CURATOR_WATCH,
|
||||||
tz=TZ,
|
tz=TZ,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста)."""
|
"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста).
|
||||||
|
|
||||||
|
Запуск руками (кнопка в админке, `POST /api/jobs/ротация/run`) ротирует мастера
|
||||||
|
сразу, с хендаутом: так новая сессия подхватывает свежие гранулы vault.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,5 +16,6 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def rotate(run: JobRun) -> None:
|
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)
|
_log.info("rotated master -> %s", master.external_id)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""`POST /hooks/t3code`: тред завершился, упал или спрашивает - срочно в мастер."""
|
"""`POST /hooks/t3code`: событие треда - срочно в разговор, который его запустил."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,7 +15,12 @@ QUESTION_HINT = (
|
|||||||
|
|
||||||
|
|
||||||
async def t3code_event(run: JobRun) -> None:
|
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:
|
def describe(p: dict[str, Any]) -> str:
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
||||||
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
||||||
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
|
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
|
||||||
сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового -
|
сам пишет первой строкой `мета/бобер/куратор.md`, запись о прогоне - в файл
|
||||||
брифинга нет и джоб не спавнится.
|
дня `мета/бобер/куратор/YYYY-MM-DD.md`; нет ничего нового - брифинга нет и
|
||||||
|
джоб не спавнится.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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]:
|
def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
|
||||||
"""Строки `реплики/YYYY-MM.md` позже ``since`` (время в строках локальное)."""
|
"""Строки `реплики/YYYY-MM-DD.md` позже ``since`` (время в строках локальное)."""
|
||||||
zone = ZoneInfo(tz)
|
zone = ZoneInfo(tz)
|
||||||
local_since = since.astimezone(zone).replace(tzinfo=None)
|
local_since = since.astimezone(zone).replace(tzinfo=None)
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
if not replies_dir.exists():
|
if not replies_dir.exists():
|
||||||
return out
|
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():
|
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||||
m = REPLY_RE.match(line)
|
m = REPLY_RE.match(line)
|
||||||
if not m:
|
if not m:
|
||||||
@@ -91,6 +95,7 @@ def briefing(
|
|||||||
*,
|
*,
|
||||||
vault: Path,
|
vault: Path,
|
||||||
journal: Path,
|
journal: Path,
|
||||||
|
runs: Path,
|
||||||
replies_dir: Path,
|
replies_dir: Path,
|
||||||
watched: list[Watched],
|
watched: list[Watched],
|
||||||
tz: str,
|
tz: str,
|
||||||
@@ -111,12 +116,14 @@ def briefing(
|
|||||||
if not replies and not changed:
|
if not replies and not changed:
|
||||||
return None
|
return None
|
||||||
zone = ZoneInfo(tz)
|
zone = ZoneInfo(tz)
|
||||||
|
local = now.astimezone(zone)
|
||||||
head = (
|
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"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
|
||||||
"остальное читай сам по слоям. В конце обнови первую строку "
|
"остальное читай сам по слоям. В конце запись о прогоне - в "
|
||||||
f"`{journal.relative_to(vault)}`: `последний прогон: "
|
f"`{(runs / f'{local:%Y-%m-%d}.md').relative_to(vault)}`, а в "
|
||||||
f"{now.astimezone(zone).isoformat(timespec='seconds')}`."
|
f"`{journal.relative_to(vault)}` только первая строка: `последний прогон: "
|
||||||
|
f"{local.isoformat(timespec='seconds')}`."
|
||||||
)
|
)
|
||||||
parts = [head]
|
parts = [head]
|
||||||
if replies:
|
if replies:
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ SAME_DAY = (
|
|||||||
ROTATION_WHY = {
|
ROTATION_WHY = {
|
||||||
"age": "прошло больше 36 часов",
|
"age": "прошло больше 36 часов",
|
||||||
"context": "контекст стал слишком большим",
|
"context": "контекст стал слишком большим",
|
||||||
|
"manual": "Бобёр запустил ротацию руками",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Gateway путей не знает; здесь - что искать по тек
|
|||||||
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и
|
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и
|
||||||
ближайшая неделя календарей.
|
ближайшая неделя календарей.
|
||||||
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
|
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
|
||||||
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
|
`мета/бобер/реплики/YYYY-MM-DD.md` - единственное место, где они grep-абельны:
|
||||||
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
|
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ def event_line(event: Event, zone: ZoneInfo) -> str:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ReplyLog:
|
class ReplyLog:
|
||||||
"""`мета/бобер/реплики/YYYY-MM.md`: строка на реплику Бобра из мастера и веток."""
|
"""`мета/бобер/реплики/YYYY-MM-DD.md`: строка на реплику из мастера и веток."""
|
||||||
|
|
||||||
dir: Path
|
dir: Path
|
||||||
tz: str = "Europe/Warsaw"
|
tz: str = "Europe/Warsaw"
|
||||||
@@ -416,11 +416,11 @@ class ReplyLog:
|
|||||||
if seeded:
|
if seeded:
|
||||||
# не человек сказал, а механизм спавна: видно по источнику, не по тексту
|
# не человек сказал, а механизм спавна: видно по источнику, не по тексту
|
||||||
where = f"{where} (бриф)"
|
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)
|
self.dir.mkdir(parents=True, exist_ok=True)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
path.write_text(
|
path.write_text(
|
||||||
f"# реплики Бобра, {local:%Y-%m}\n\n"
|
f"# реплики Бобра, {local:%Y-%m-%d}\n\n"
|
||||||
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
|
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
|
||||||
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n",
|
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ CHATS = VAULT / "💬 чаты"
|
|||||||
DIARY = VAULT / "📅 дни"
|
DIARY = VAULT / "📅 дни"
|
||||||
PEOPLE = VAULT / "👤 люди"
|
PEOPLE = VAULT / "👤 люди"
|
||||||
BOARDS = VAULT / "📆 доски"
|
BOARDS = VAULT / "📆 доски"
|
||||||
|
THOUGHTS = VAULT / "🧠 мысли"
|
||||||
|
|
||||||
BEAVER = VAULT / "мета" / "бобер"
|
BEAVER = VAULT / "мета" / "бобер"
|
||||||
"""Дом агента: всё, что он ведёт сам, лежит здесь."""
|
"""Дом агента: всё, что он ведёт сам, лежит здесь."""
|
||||||
@@ -31,6 +32,12 @@ NOTES = BEAVER / "наблюдения"
|
|||||||
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
||||||
REPLIES = BEAVER / "реплики"
|
REPLIES = BEAVER / "реплики"
|
||||||
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
||||||
|
"""Первая строка - время прошлого прогона; сами записи лежат в `CURATOR_RUNS`."""
|
||||||
|
CURATOR_RUNS = BEAVER / "куратор"
|
||||||
|
OWN_DIARY = BEAVER / "дневник"
|
||||||
|
"""Его собственный дневник, файл на день: то, что он думает, без логов."""
|
||||||
|
JOURNAL = BEAVER / "журнал"
|
||||||
|
"""Рабочий журнал, файл на день: следы тиков, логи, разбор промахов."""
|
||||||
LEISURE = BEAVER / "досуг.md"
|
LEISURE = BEAVER / "досуг.md"
|
||||||
"""Раскладка суток по тикам досуга: пишет её модель, механизм только читает."""
|
"""Раскладка суток по тикам досуга: пишет её модель, механизм только читает."""
|
||||||
STATE_DIR = Path(os.environ.get("BEAVER_STATE_DIR", BEAVER / ".состояние"))
|
STATE_DIR = Path(os.environ.get("BEAVER_STATE_DIR", BEAVER / ".состояние"))
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ unfixable = ["F401"]
|
|||||||
|
|
||||||
[lint.per-file-ignores]
|
[lint.per-file-ignores]
|
||||||
"config.py" = ["INP001"]
|
"config.py" = ["INP001"]
|
||||||
|
"scripts/split_logs.py" = ["INP001", "T201"]
|
||||||
"tests/*" = ["ALL"]
|
"tests/*" = ["ALL"]
|
||||||
|
|
||||||
[lint.pydocstyle]
|
[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()
|
||||||
@@ -25,7 +25,10 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
|||||||
notes = beaver / "наблюдения"
|
notes = beaver / "наблюдения"
|
||||||
replies.mkdir(parents=True)
|
replies.mkdir(parents=True)
|
||||||
notes.mkdir()
|
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",
|
"# реплики\n\n- 2026-09-01 12:00 · мастер · старое\n- 2026-09-01 19:30 · мастер · новое сообщение\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -49,6 +52,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
|||||||
text = briefing(
|
text = briefing(
|
||||||
vault=vault,
|
vault=vault,
|
||||||
journal=journal,
|
journal=journal,
|
||||||
|
runs=beaver / "куратор",
|
||||||
replies_dir=replies,
|
replies_dir=replies,
|
||||||
watched=[Watched("наблюдения", notes)],
|
watched=[Watched("наблюдения", notes)],
|
||||||
tz="Europe/Warsaw",
|
tz="Europe/Warsaw",
|
||||||
@@ -63,6 +67,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
|||||||
assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text
|
assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text
|
||||||
assert "Глафира" not in text
|
assert "Глафира" not in text
|
||||||
assert "последний прогон: 2026-09-01T22:00:00+02:00" 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")
|
journal.write_text(f"последний прогон: {now.isoformat()}\n", encoding="utf-8")
|
||||||
assert new_replies(replies, now, "Europe/Warsaw") == []
|
assert new_replies(replies, now, "Europe/Warsaw") == []
|
||||||
@@ -71,6 +76,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
|||||||
briefing(
|
briefing(
|
||||||
vault=vault,
|
vault=vault,
|
||||||
journal=journal,
|
journal=journal,
|
||||||
|
runs=beaver / "куратор",
|
||||||
replies_dir=replies,
|
replies_dir=replies,
|
||||||
watched=[Watched("наблюдения", notes)],
|
watched=[Watched("наблюдения", notes)],
|
||||||
tz="Europe/Warsaw",
|
tz="Europe/Warsaw",
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from beaver_agent.jobs.leisure import (
|
from beaver_agent.jobs.leisure import (
|
||||||
|
EMPTY_DEFAULT,
|
||||||
MINE_MIN,
|
MINE_MIN,
|
||||||
QUIET_TICKS,
|
QUIET_TICKS,
|
||||||
Plan,
|
Plan,
|
||||||
brief,
|
brief,
|
||||||
day_of,
|
day_of,
|
||||||
|
echo,
|
||||||
enabled,
|
enabled,
|
||||||
|
kind_of,
|
||||||
plan_of,
|
plan_of,
|
||||||
quiet_hour,
|
quiet_hour,
|
||||||
tick_hours,
|
tick_hours,
|
||||||
)
|
)
|
||||||
|
from beaver_agent.vault import VAULT
|
||||||
|
|
||||||
ZONE = ZoneInfo("Europe/Warsaw")
|
ZONE = ZoneInfo("Europe/Warsaw")
|
||||||
|
|
||||||
@@ -121,3 +126,81 @@ def test_last_night_tick_says_nothing_is_left_ahead():
|
|||||||
assert "впереди больше нет" in text
|
assert "впереди больше нет" in text
|
||||||
assert "израсходовано 2 из 2" in text
|
assert "израсходовано 2 из 2" in text
|
||||||
assert "Сутки заканчиваются через 6 ч" 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,
|
at=at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
text = (tmp_path / "реплики" / "2026-09.md").read_text(encoding="utf-8")
|
text = (tmp_path / "реплики" / "2026-09-01.md").read_text(encoding="utf-8")
|
||||||
assert text.startswith("# реплики Бобра, 2026-09\n")
|
assert text.startswith("# реплики Бобра, 2026-09-01\n")
|
||||||
assert text.endswith(
|
assert text.endswith(
|
||||||
"- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n"
|
"- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n"
|
||||||
"- 2026-09-01 14:04 · ветка «крипто-карта» (бриф) · "
|
"- 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") == [
|
assert new_replies(tmp_path / "реплики", since, "Europe/Warsaw") == [
|
||||||
line for line in text.splitlines() if line.startswith("- ")
|
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:
|
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
|
||||||
@@ -365,6 +365,7 @@ dependencies = [
|
|||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "claude-agent-sdk" },
|
{ name = "claude-agent-sdk" },
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
{ name = "greenlet" },
|
{ name = "greenlet" },
|
||||||
@@ -392,6 +393,7 @@ requires-dist = [
|
|||||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||||
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
|
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
|
||||||
{ name = "croniter", specifier = ">=6.2.4" },
|
{ name = "croniter", specifier = ">=6.2.4" },
|
||||||
|
{ name = "cryptography", specifier = ">=48.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||||
{ name = "fastmcp", specifier = ">=3.3.1" },
|
{ name = "fastmcp", specifier = ">=3.3.1" },
|
||||||
{ name = "greenlet", specifier = ">=3.5.0" },
|
{ name = "greenlet", specifier = ">=3.5.0" },
|
||||||
|
|||||||
Reference in new issue
Block a user