refactor: split config.py into the beaver_agent package (vault, prompts, skills, policy, hands, agents, frontends, texts, memory, jobs)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Память Бобра в vault: справка, лог реплик, хендауты, вотчер, куратор."""
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Куратор памяти: что нового с прошлого прогона .
|
||||
|
||||
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
||||
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
||||
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
|
||||
сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового -
|
||||
брифинга нет и джоб не спавнится.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
LAST_RUN_RE = re.compile(r"^последний прогон:\s*(\S+)")
|
||||
REPLY_RE = re.compile(r"^- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}) · ")
|
||||
MAX_REPLY_CHARS = 60_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Watched:
|
||||
label: str
|
||||
root: Path
|
||||
pattern: str = "**/*.md"
|
||||
|
||||
|
||||
def last_run(journal: Path, *, default: datetime) -> datetime:
|
||||
"""`последний прогон: <ISO>` из начала журнала, иначе ``default``."""
|
||||
if not journal.exists():
|
||||
return default
|
||||
for line in journal.read_text(encoding="utf-8", errors="ignore").splitlines()[:5]:
|
||||
m = LAST_RUN_RE.match(line.strip())
|
||||
if m:
|
||||
try:
|
||||
stamp = datetime.fromisoformat(m.group(1))
|
||||
except ValueError:
|
||||
return default
|
||||
return stamp if stamp.tzinfo else stamp.replace(tzinfo=UTC)
|
||||
return default
|
||||
|
||||
|
||||
def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
|
||||
"""Строки `реплики/YYYY-MM.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:]:
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
m = REPLY_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
at = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M") # noqa: DTZ007
|
||||
except ValueError:
|
||||
continue
|
||||
if at > local_since:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def changed_files(
|
||||
watched: list[Watched], since: datetime, vault: Path, *, skip: tuple[str, ...] = ()
|
||||
) -> list[str]:
|
||||
"""`путь (размер)` для файлов с mtime позже ``since``; ``skip`` - подстроки."""
|
||||
out: list[tuple[str, str]] = []
|
||||
stamp = since.timestamp()
|
||||
for w in watched:
|
||||
if not w.root.exists():
|
||||
continue
|
||||
for path in w.root.glob(w.pattern):
|
||||
if not path.is_file() or path.name.startswith("."):
|
||||
continue
|
||||
rel = str(path.relative_to(vault))
|
||||
if any(s in rel for s in skip):
|
||||
continue
|
||||
if path.stat().st_mtime > stamp:
|
||||
out.append((w.label, f"`{rel}` ({path.stat().st_size // 1024} КБ)"))
|
||||
out.sort()
|
||||
return [f"{label}: {desc}" for label, desc in out]
|
||||
|
||||
|
||||
def briefing(
|
||||
*,
|
||||
vault: Path,
|
||||
journal: Path,
|
||||
replies_dir: Path,
|
||||
watched: list[Watched],
|
||||
tz: str,
|
||||
now: datetime | None = None,
|
||||
default_window: timedelta = timedelta(hours=24),
|
||||
) -> str | None:
|
||||
"""Первое сообщение куратору или ``None``, если с прошлого прогона ничего нет."""
|
||||
now = now or datetime.now(UTC)
|
||||
since = last_run(journal, default=now - default_window)
|
||||
replies = new_replies(replies_dir, since, tz)
|
||||
changed = changed_files(
|
||||
watched, since, vault, skip=(str(journal.relative_to(vault)),)
|
||||
)
|
||||
if not replies and not changed:
|
||||
return None
|
||||
zone = ZoneInfo(tz)
|
||||
head = (
|
||||
f"Прогон куратора {now.astimezone(zone):%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')}`."
|
||||
)
|
||||
parts = [head]
|
||||
if replies:
|
||||
text = "\n".join(replies)
|
||||
if len(text) > MAX_REPLY_CHARS:
|
||||
text = text[-MAX_REPLY_CHARS:]
|
||||
text = "…(обрезано, начало - в файле)\n" + text[text.find("\n") + 1 :]
|
||||
parts.append(f"Новые реплики Бобра ({len(replies)}):\n{text}")
|
||||
else:
|
||||
parts.append("Новых реплик Бобра нет.")
|
||||
if changed:
|
||||
parts.append("Изменились файлы:\n" + "\n".join(f"- {c}" for c in changed[:80]))
|
||||
return "\n\n".join(parts)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Смена мастера и закрытие чата: утренний сид, хендаут, «новый день», дистиллятор."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beaver_gateway.frontends.turn_record import slugify
|
||||
|
||||
from beaver_agent.memory.recall import notes_head
|
||||
from beaver_agent.vault import (
|
||||
DAYS,
|
||||
DIARY,
|
||||
DIGESTS,
|
||||
GRANULES,
|
||||
PORTRAIT,
|
||||
STATE,
|
||||
VAULT,
|
||||
read,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from beaver_gateway.conversations.distill import DistillContext
|
||||
from beaver_gateway.conversations.rotation import HandoutContext
|
||||
from beaver_gateway.conversations.texts import NewDayContext, SeedContext
|
||||
|
||||
|
||||
def latest_handout() -> Path | None:
|
||||
"""Последний хендаут, а не «вчерашний по календарю»."""
|
||||
files = sorted(DAYS.glob("????-??-??.md")) if DAYS.exists() else []
|
||||
return files[-1] if files else None
|
||||
|
||||
|
||||
def seed_body(ctx: SeedContext) -> str | None:
|
||||
"""Утренний сид: последний хендаут, шапка состояния, портрет Бобра."""
|
||||
if ctx.seed != "morning":
|
||||
return None
|
||||
handout = latest_handout()
|
||||
if handout is None:
|
||||
return "Хендаут не приехал."
|
||||
parts = [
|
||||
f"Хендаут ({handout.stem}):\n\n{handout.read_text(encoding='utf-8').strip()}"
|
||||
]
|
||||
signal = "\n".join((read(STATE) or "").splitlines()[:5])
|
||||
if signal:
|
||||
parts.append(
|
||||
f"Состояние (первые строки, полностью в {STATE.relative_to(VAULT)}):\n"
|
||||
f"{signal}"
|
||||
)
|
||||
portrait = notes_head(PORTRAIT, ("сейчас", "паттерны"), cap=40)
|
||||
if portrait:
|
||||
parts.append(
|
||||
"Портрет Бобра (шапка "
|
||||
f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}"
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
DEFAULT_HANDOUT = (
|
||||
"Этот мастер закрывается ({reason}). Напиши хендаут за {day} в "
|
||||
"`мета/бобер/дни/{day}.md`: справку на утро, не задание."
|
||||
)
|
||||
|
||||
|
||||
def handout_prompt(ctx: HandoutContext) -> str:
|
||||
day = ctx.day.isoformat()
|
||||
body = (
|
||||
(read(GRANULES / "хендаут.md") or DEFAULT_HANDOUT)
|
||||
.replace("{day}", day)
|
||||
.replace("{reason}", ctx.reason)
|
||||
)
|
||||
if (DIARY / f"{day}.md").exists():
|
||||
return f"{body}\n\nДневник за {day} приехал: `📅 дни/{day}.md`, возьми из него."
|
||||
return f"{body}\n\nДневник за {day} не приехал - так и напиши в разделе «дневник»."
|
||||
|
||||
|
||||
NEW_DAY = (
|
||||
"Новый день ({day}). Ты - новый мастер того же треда: хендаут и сигнал состояния "
|
||||
"в сиде выше. Скажи Бобру что-то через say, только если есть что сказать."
|
||||
)
|
||||
SAME_DAY = (
|
||||
"Мастер пересоздан ({why}), день тот же - {day}. Хендаут в сиде выше - справка от "
|
||||
"предыдущего мастера за сегодня, не утро: продолжай с того места, ничего не "
|
||||
"приветствуй. Скажи Бобру что-то через say, только если есть что сказать."
|
||||
)
|
||||
ROTATION_WHY = {
|
||||
"age": "прошло больше 36 часов",
|
||||
"context": "контекст стал слишком большим",
|
||||
}
|
||||
|
||||
|
||||
def new_day(ctx: NewDayContext) -> str:
|
||||
"""«Новый день» - только ночная ротация; остальные причины - тот же день."""
|
||||
day = ctx.day.isoformat()
|
||||
if ctx.reason == "night":
|
||||
return NEW_DAY.format(day=day)
|
||||
return SAME_DAY.format(day=day, why=ROTATION_WHY.get(ctx.reason, ctx.reason))
|
||||
|
||||
|
||||
DISTILL = (
|
||||
"Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Выжимка - `Write` в "
|
||||
'`{path}`, фронтматтер `source: "[[{chat}]]"`, `date: {day}`; слив - текст '
|
||||
"ответа, до 5 строк, третье лицо."
|
||||
)
|
||||
DISTILL_NO_MEMORY = (
|
||||
"Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Память для него "
|
||||
"выключена: файл не пиши, только слив текстом ответа - до 5 строк, третье лицо."
|
||||
)
|
||||
|
||||
|
||||
def distill_prompt(ctx: DistillContext) -> str:
|
||||
day = ctx.day.isoformat()
|
||||
if not ctx.memory:
|
||||
return DISTILL_NO_MEMORY.format(chat=ctx.chat_name, reason=ctx.reason, day=day)
|
||||
path = DIGESTS / f"{day} - {slugify(ctx.chat_name, maxlen=60)}.md"
|
||||
return DISTILL.format(chat=ctx.chat_name, reason=ctx.reason, day=day, path=path)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Справка под сообщением Бобра и лог его реплик .
|
||||
|
||||
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
|
||||
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
|
||||
человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`),
|
||||
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок.
|
||||
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
|
||||
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
|
||||
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from beaver_gateway.conversations.envelope import RecallContext
|
||||
from beaver_gateway.conversations.texts import UserSaid
|
||||
|
||||
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
|
||||
VOWELS = "аеёиоуыэюя"
|
||||
KINDS = {"master": "мастер", "branch": "ветка"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Person:
|
||||
name: str
|
||||
"""Имя файла карточки без `.md`; записки - `<имя> - наблюдения.md`."""
|
||||
aliases: tuple[str, ...]
|
||||
path: str
|
||||
"""Путь карточки от корня vault."""
|
||||
plain: bool
|
||||
"""Искать по голому имени в тексте (ближний круг), а не только по `[[ссылке]]`."""
|
||||
|
||||
@property
|
||||
def keys(self) -> tuple[str, ...]:
|
||||
return (self.name, *self.aliases)
|
||||
|
||||
|
||||
def frontmatter_aliases(text: str) -> tuple[str, ...]:
|
||||
"""`aliases:` из фронтматтера карточки - список строк или пусто."""
|
||||
if not text.startswith("---"):
|
||||
return ()
|
||||
end = text.find("\n---", 3)
|
||||
if end < 0:
|
||||
return ()
|
||||
out: list[str] = []
|
||||
inside = False
|
||||
for line in text[3:end].splitlines():
|
||||
if line.startswith("aliases:"):
|
||||
inside = True
|
||||
rest = line[len("aliases:") :].strip()
|
||||
if rest and rest != "[]":
|
||||
out += [a.strip().strip("\"'") for a in rest.strip("[]").split(",")]
|
||||
continue
|
||||
if inside:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- "):
|
||||
out.append(stripped[2:].strip().strip("\"'"))
|
||||
continue
|
||||
if stripped:
|
||||
inside = False
|
||||
return tuple(a for a in out if a)
|
||||
|
||||
|
||||
class People:
|
||||
"""Индекс карточек `👤 люди/`: имя, алиасы, путь; пересобирается раз в `ttl`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
vault: Path,
|
||||
*,
|
||||
plain_dirs: tuple[str, ...] = ("личное", "профессиональное"),
|
||||
skip_dirs: tuple[str, ...] = ("архив", "рандомы", "группы"),
|
||||
ttl: float = 600.0,
|
||||
) -> None:
|
||||
self._root = root
|
||||
self._vault = vault
|
||||
self._plain_dirs = plain_dirs
|
||||
self._skip_dirs = skip_dirs
|
||||
self._ttl = ttl
|
||||
self._built = 0.0
|
||||
self._people: list[Person] = []
|
||||
|
||||
def all(self) -> list[Person]:
|
||||
if time.monotonic() - self._built > self._ttl:
|
||||
self._people = self._scan()
|
||||
self._built = time.monotonic()
|
||||
return self._people
|
||||
|
||||
def _scan(self) -> list[Person]:
|
||||
if not self._root.exists():
|
||||
return []
|
||||
out: list[Person] = []
|
||||
for card in sorted(self._root.rglob("*.md")):
|
||||
parts = card.relative_to(self._root).parts[:-1]
|
||||
plain = bool(parts) and parts[0] in self._plain_dirs
|
||||
plain = plain and not any(p in self._skip_dirs for p in parts)
|
||||
try:
|
||||
head = card.read_text(encoding="utf-8", errors="ignore")[:2000]
|
||||
except OSError:
|
||||
continue
|
||||
out.append(
|
||||
Person(
|
||||
name=card.stem,
|
||||
aliases=frontmatter_aliases(head),
|
||||
path=str(card.relative_to(self._vault)),
|
||||
plain=plain,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _stem_pattern(key: str) -> re.Pattern[str]:
|
||||
"""Имя с падежом: `Зина` → Зина/Зине/Зину/Зиной, `Прохор` → Прохора/Прохором."""
|
||||
stem = key[:-1] if key[-1].lower() in VOWELS and len(key) > 3 else key
|
||||
return re.compile(rf"(?<!\w){re.escape(stem)}\w{{0,2}}(?!\w)", re.IGNORECASE)
|
||||
|
||||
|
||||
def mentions(text: str, people: list[Person], *, limit: int = 3) -> list[Person]:
|
||||
"""Кто упомянут: `[[ссылкой]]` - любой, голым именем - только ближний круг."""
|
||||
found: dict[str, int] = {}
|
||||
by_key = {k.casefold(): p for p in people for k in p.keys}
|
||||
for m in re.finditer(r"\[\[([^\]|#]+)", text):
|
||||
target = m.group(1).strip().split("/")[-1].casefold()
|
||||
target = target.removesuffix(".md")
|
||||
person = by_key.get(target)
|
||||
if person is not None:
|
||||
found.setdefault(person.name, m.start())
|
||||
for person in people:
|
||||
if not person.plain or person.name in found:
|
||||
continue
|
||||
for key in person.keys:
|
||||
if " " in key:
|
||||
m = re.search(rf"(?<!\w){re.escape(key)}(?!\w)", text, re.IGNORECASE)
|
||||
elif len(key) >= 4:
|
||||
m = _stem_pattern(key).search(text)
|
||||
else:
|
||||
continue
|
||||
if m:
|
||||
found.setdefault(person.name, m.start())
|
||||
break
|
||||
order = sorted(found.items(), key=lambda kv: kv[1])[:limit]
|
||||
by_name = {p.name: p for p in people}
|
||||
return [by_name[name] for name, _ in order]
|
||||
|
||||
|
||||
def notes_sections(path: Path) -> dict[str, list[str]]:
|
||||
"""`## заголовок` → непустые строки под ним (до следующего `## `)."""
|
||||
out: dict[str, list[str]] = {}
|
||||
current: str | None = None
|
||||
for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = raw.rstrip()
|
||||
if line.startswith("## "):
|
||||
current = line[3:].strip().lower()
|
||||
out.setdefault(current, [])
|
||||
continue
|
||||
if current is not None and line.strip():
|
||||
out[current].append(line.strip())
|
||||
return out
|
||||
|
||||
|
||||
def notes_head(path: Path, sections: tuple[str, ...], *, cap: int = 40) -> str | None:
|
||||
"""Выбранные секции файла записок одним блоком - для сида (портрет Бобра)."""
|
||||
if not path.exists():
|
||||
return None
|
||||
parsed = notes_sections(path)
|
||||
lines: list[str] = []
|
||||
for name in sections:
|
||||
body = parsed.get(name, [])
|
||||
if body:
|
||||
lines.append(f"## {name}")
|
||||
lines.extend(body)
|
||||
if not lines:
|
||||
return None
|
||||
if len(lines) > cap:
|
||||
lines = [*lines[:cap], f"… ещё {len(lines) - cap} строк в файле"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recall:
|
||||
"""Справка по сообщению: указатели в vault, которые gateway не может знать."""
|
||||
|
||||
vault: Path
|
||||
people_dir: Path
|
||||
notes_dir: Path
|
||||
diary_dir: Path
|
||||
boards_dir: Path
|
||||
tz: str = "Europe/Warsaw"
|
||||
diary_scan: int = 400
|
||||
"""Сколько последних дней дневника просматривать на упоминания."""
|
||||
_people: People | None = field(default=None, init=False, repr=False)
|
||||
_diary_cache: dict[str, tuple[float, list[str]]] = field(
|
||||
default_factory=dict, init=False, repr=False
|
||||
)
|
||||
_deadlines_day: date | None = field(default=None, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def people(self) -> People:
|
||||
if self._people is None:
|
||||
self._people = People(self.people_dir, self.vault)
|
||||
return self._people
|
||||
|
||||
def block(self, ctx: RecallContext) -> str | None:
|
||||
today = ctx.now.astimezone(ZoneInfo(self.tz)).date()
|
||||
lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())]
|
||||
if ctx.kind == "master":
|
||||
lines += self.deadlines(today)
|
||||
if not lines:
|
||||
return None
|
||||
return "\n".join([LABEL, *lines])
|
||||
|
||||
def person_line(self, person: Person, *, now_lines: int = 6) -> str:
|
||||
"""Указатели по человеку и `## сейчас` из его записок - инлайн."""
|
||||
parts = [f"👤 {person.name} - карточка `{person.path}`"]
|
||||
notes = self.notes_dir / f"{person.name} - наблюдения.md"
|
||||
rel = notes.relative_to(self.vault)
|
||||
now: list[str] = []
|
||||
if notes.exists():
|
||||
sections = notes_sections(notes)
|
||||
feed = sections.get("лента", [])
|
||||
now = [ln for ln in sections.get("сейчас", []) if ln.startswith("- ")]
|
||||
patterns = [
|
||||
ln for ln in sections.get("паттерны", []) if ln.startswith("- ")
|
||||
]
|
||||
desc = f"записки `{rel}`: лента {len(feed)} стр."
|
||||
if patterns:
|
||||
desc += f", паттернов {len(patterns)}"
|
||||
if not now and feed:
|
||||
last = feed[-1][2:]
|
||||
desc += ", последняя: " + (
|
||||
last if len(last) <= 110 else last[:109] + "…"
|
||||
)
|
||||
parts.append(desc)
|
||||
else:
|
||||
parts.append(f"записок нет (`{rel}`)")
|
||||
days = self.diary_hits(person)
|
||||
if days:
|
||||
parts.append("дневник: " + ", ".join(f"[[{d}]]" for d in days))
|
||||
line = "; ".join(parts)
|
||||
if now:
|
||||
line += "; сейчас:\n" + "\n".join(f" {ln}" for ln in now[:now_lines])
|
||||
return line
|
||||
|
||||
def diary_hits(
|
||||
self, person: Person, *, limit: int = 3, ttl: float = 600.0
|
||||
) -> list[str]:
|
||||
cached = self._diary_cache.get(person.name)
|
||||
if cached and time.monotonic() - cached[0] < ttl:
|
||||
return cached[1]
|
||||
keys = [f"[[{k.casefold()}" for k in person.keys]
|
||||
hits: list[str] = []
|
||||
files = sorted(self.diary_dir.glob("????-??-??.md"), reverse=True)
|
||||
for path in files[: self.diary_scan]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore").casefold()
|
||||
except OSError:
|
||||
continue
|
||||
if any(k in text for k in keys):
|
||||
hits.append(path.stem)
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
self._diary_cache[person.name] = (time.monotonic(), hits)
|
||||
return hits
|
||||
|
||||
def deadlines(
|
||||
self, today: date, *, horizon_days: int = 1, limit: int = 8
|
||||
) -> list[str]:
|
||||
"""Открытые задачи досок с 📅 не позже завтра - раз в день, первым конвертом."""
|
||||
if self._deadlines_day == today:
|
||||
return []
|
||||
self._deadlines_day = today
|
||||
found: list[tuple[date, str, str]] = []
|
||||
for board in (
|
||||
sorted(self.boards_dir.glob("*.md")) if self.boards_dir.exists() else []
|
||||
):
|
||||
live = board.read_text(encoding="utf-8", errors="ignore").split("\n***", 1)[
|
||||
0
|
||||
]
|
||||
for m in re.finditer(
|
||||
r"^\s*- \[ \] (.+?)\s*📅 (\d{4}-\d{2}-\d{2})", live, re.MULTILINE
|
||||
):
|
||||
try:
|
||||
due = date.fromisoformat(m.group(2))
|
||||
except ValueError:
|
||||
continue
|
||||
if due <= today + timedelta(days=horizon_days):
|
||||
found.append((due, board.stem, m.group(1).strip()))
|
||||
found.sort()
|
||||
return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplyLog:
|
||||
"""`мета/бобер/реплики/YYYY-MM.md`: строка на реплику Бобра из мастера и веток."""
|
||||
|
||||
dir: Path
|
||||
tz: str = "Europe/Warsaw"
|
||||
max_chars: int = 600
|
||||
|
||||
def write(self, said: UserSaid) -> None:
|
||||
text = said.text
|
||||
if text.startswith("[сид:"):
|
||||
# спавн ветки с текстом: строка сида едет над самим сообщением
|
||||
text = text.split("\n\n", 1)[1] if "\n\n" in text else ""
|
||||
flat = " ⏎ ".join(ln.strip() for ln in text.strip().splitlines() if ln.strip())
|
||||
if not flat:
|
||||
return
|
||||
if len(flat) > self.max_chars:
|
||||
flat = flat[: self.max_chars - 1] + "…"
|
||||
local = said.at.astimezone(ZoneInfo(self.tz))
|
||||
where = KINDS.get(said.kind, said.kind)
|
||||
if said.kind != "master" and said.title:
|
||||
where = f"{where} «{said.title}»"
|
||||
path = self.dir / f"{local:%Y-%m}.md"
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
if not path.exists():
|
||||
path.write_text(
|
||||
f"# реплики Бобра, {local:%Y-%m}\n\n"
|
||||
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
|
||||
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"- {local:%Y-%m-%d %H:%M} · {where} · {flat}\n")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Что из vault попадает в конверт и за чем следит куратор."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from beaver_gateway.vault.watch import VaultWatch, WatchRules
|
||||
|
||||
from beaver_agent.memory.curator import Watched
|
||||
from beaver_agent.memory.recall import Recall, ReplyLog
|
||||
from beaver_agent.vault import (
|
||||
BEAVER,
|
||||
BOARDS,
|
||||
DAYS,
|
||||
DIARY,
|
||||
DIGESTS,
|
||||
NOTES,
|
||||
PEOPLE,
|
||||
REPLIES,
|
||||
TZ,
|
||||
VAULT,
|
||||
)
|
||||
|
||||
watch = VaultWatch(
|
||||
VAULT,
|
||||
WatchRules(
|
||||
full=("📅 дни/{today}.md",),
|
||||
names=(
|
||||
"📅 дни/**",
|
||||
"👤 люди/**",
|
||||
"📆 планирование/**",
|
||||
"📆 доски/**",
|
||||
"💻 проекты/**",
|
||||
"🧠 мысли/**",
|
||||
"📶 ресерчи/**",
|
||||
"мета/бобер/**",
|
||||
),
|
||||
ignore=(
|
||||
"💬 чаты/**",
|
||||
"мета/**",
|
||||
"мета/бобер/реплики/**",
|
||||
"**/attachments/**",
|
||||
".obsidian/**",
|
||||
),
|
||||
),
|
||||
tz=TZ,
|
||||
)
|
||||
"""Дневник за сегодня - строками, остальное именами; конкретный паттерн побеждает."""
|
||||
|
||||
recall = Recall(
|
||||
vault=VAULT,
|
||||
people_dir=PEOPLE,
|
||||
notes_dir=NOTES,
|
||||
diary_dir=DIARY,
|
||||
boards_dir=BOARDS,
|
||||
tz=TZ,
|
||||
)
|
||||
replies = ReplyLog(REPLIES, tz=TZ)
|
||||
|
||||
CURATOR_WATCH = [
|
||||
Watched("хендауты", DAYS, "????-??-??.md"),
|
||||
Watched("выжимки", DIGESTS, "*.md"),
|
||||
Watched("наблюдения", NOTES, "*.md"),
|
||||
Watched("состояние", BEAVER, "состояние.md"),
|
||||
Watched("дневник", DIARY, "????-??-??.md"),
|
||||
Watched("карточки", PEOPLE, "**/*.md"),
|
||||
]
|
||||
Reference in New Issue
Block a user