feat(config,curator,recall): memory curator job on opus high, notes head inline in the envelope, portrait in the morning seed
This commit is contained in:
@@ -30,6 +30,7 @@ from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
|||||||
from beaver_gateway.frontends.telegram import TelegramFrontend
|
from beaver_gateway.frontends.telegram import TelegramFrontend
|
||||||
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
|
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
|
||||||
|
|
||||||
|
from curator import Watched, briefing
|
||||||
from mcps.homeassistant import HomeAssistant
|
from mcps.homeassistant import HomeAssistant
|
||||||
from mcps.komodo import Komodo
|
from mcps.komodo import Komodo
|
||||||
from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad
|
from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad
|
||||||
@@ -45,7 +46,7 @@ from policy import (
|
|||||||
skill_tracker,
|
skill_tracker,
|
||||||
vault_zones,
|
vault_zones,
|
||||||
)
|
)
|
||||||
from recall import Recall, ReplyLog
|
from recall import Recall, ReplyLog, notes_head
|
||||||
|
|
||||||
TZ = "Europe/Warsaw"
|
TZ = "Europe/Warsaw"
|
||||||
VAULT = Path("/vault")
|
VAULT = Path("/vault")
|
||||||
@@ -60,6 +61,8 @@ DIGESTS = BEAVER / "выжимки"
|
|||||||
INDEX = BEAVER / "индекс.md"
|
INDEX = BEAVER / "индекс.md"
|
||||||
NOTES = BEAVER / "наблюдения"
|
NOTES = BEAVER / "наблюдения"
|
||||||
REPLIES = BEAVER / "реплики"
|
REPLIES = BEAVER / "реплики"
|
||||||
|
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
||||||
|
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
||||||
PEOPLE = VAULT / "👤 люди"
|
PEOPLE = VAULT / "👤 люди"
|
||||||
BOARDS = VAULT / "📆 доски"
|
BOARDS = VAULT / "📆 доски"
|
||||||
STATE = BEAVER / "состояние.md"
|
STATE = BEAVER / "состояние.md"
|
||||||
@@ -118,6 +121,10 @@ _DISTILLER = (PROFILE, environment("джоб"), overlay("distiller", "дисти
|
|||||||
DISTILLER_PROMPTS = Prompts(fork=_DISTILLER, job=_DISTILLER)
|
DISTILLER_PROMPTS = Prompts(fork=_DISTILLER, job=_DISTILLER)
|
||||||
# §4.1: триаж без голоса и без vault - профиль, окружение джоба, оверлей.
|
# §4.1: триаж без голоса и без vault - профиль, окружение джоба, оверлей.
|
||||||
TRIAGE_PROMPTS = Prompts(job=(PROFILE, environment("джоб"), overlay("triage", "триаж")))
|
TRIAGE_PROMPTS = Prompts(job=(PROFILE, environment("джоб"), overlay("triage", "триаж")))
|
||||||
|
# Куратор памяти - тот же менеджер бобрения (голос и всё), в окружении джоба.
|
||||||
|
CURATOR_PROMPTS = Prompts(
|
||||||
|
job=(*_BASE, environment("джоб"), overlay("curator", "куратор"))
|
||||||
|
)
|
||||||
QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
||||||
|
|
||||||
# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault.
|
# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault.
|
||||||
@@ -321,6 +328,23 @@ def distiller(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def curator(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
||||||
|
"""Куратор памяти: opus high, файлы в мета/бобер, без MCP, `inject` в мастер."""
|
||||||
|
return ClaudeAgent(
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
cwd=VAULT,
|
||||||
|
prompts=CURATOR_PROMPTS,
|
||||||
|
gateway_tools=("inject",),
|
||||||
|
options=ClaudeOptions(
|
||||||
|
effort=effort,
|
||||||
|
tools=("Read", "Write", "Edit", "Grep", "Glob"),
|
||||||
|
disallowed_tools=DISTILLER_DISALLOWED,
|
||||||
|
),
|
||||||
|
policy=(vault_zones(STRICT_ZONES),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def triage(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
def triage(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
||||||
"""§4.1: дешёвый крон-тёрн - sonnet low, без vault, только say/inject."""
|
"""§4.1: дешёвый крон-тёрн - sonnet low, без vault, только say/inject."""
|
||||||
return ClaudeAgent(
|
return ClaudeAgent(
|
||||||
@@ -364,6 +388,7 @@ agents: list[BaseAgent] = [
|
|||||||
deep("beaver-fable-medium", "claude-fable-5", effort="medium"),
|
deep("beaver-fable-medium", "claude-fable-5", effort="medium"),
|
||||||
deep("beaver-opus-xhigh", "claude-opus-5", effort="xhigh"),
|
deep("beaver-opus-xhigh", "claude-opus-5", effort="xhigh"),
|
||||||
distiller("beaver-distiller", "claude-opus-5", effort="medium"),
|
distiller("beaver-distiller", "claude-opus-5", effort="medium"),
|
||||||
|
curator("beaver-curator", "claude-opus-5", effort="high"),
|
||||||
triage("beaver-triage", "claude-sonnet-5", effort="low"),
|
triage("beaver-triage", "claude-sonnet-5", effort="low"),
|
||||||
raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", reasoning_effort="low"),
|
raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", reasoning_effort="low"),
|
||||||
raycast(
|
raycast(
|
||||||
@@ -442,6 +467,12 @@ def seed_body(ctx: SeedContext) -> str | None:
|
|||||||
parts.append(
|
parts.append(
|
||||||
f"Состояние (первые строки, полностью в мета/бобер/состояние.md):\n{signal}"
|
f"Состояние (первые строки, полностью в мета/бобер/состояние.md):\n{signal}"
|
||||||
)
|
)
|
||||||
|
portrait = notes_head(PORTRAIT, ("сейчас", "паттерны"), cap=40)
|
||||||
|
if portrait:
|
||||||
|
parts.append(
|
||||||
|
"Портрет Бобра (шапка "
|
||||||
|
f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}"
|
||||||
|
)
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -686,6 +717,35 @@ async def memory(run: JobRun) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CURATOR_WATCH = [
|
||||||
|
Watched("хендауты", DAYS, "????-??-??.md"),
|
||||||
|
Watched("выжимки", DIGESTS, "*.md"),
|
||||||
|
Watched("наблюдения", NOTES, "*.md"),
|
||||||
|
Watched("состояние", BEAVER, "состояние.md"),
|
||||||
|
Watched("дневник", DIARY, "????-??-??.md"),
|
||||||
|
Watched("карточки", PEOPLE, "**/*.md"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def curate(run: JobRun) -> None:
|
||||||
|
"""§6.0: куратор памяти раз в несколько часов.
|
||||||
|
|
||||||
|
Крон собирает «что нового», модель раскладывает по шапкам и лентам;
|
||||||
|
нечего раскладывать - джоб не спавнится.
|
||||||
|
"""
|
||||||
|
text = briefing(
|
||||||
|
vault=VAULT,
|
||||||
|
journal=CURATOR_JOURNAL,
|
||||||
|
replies_dir=REPLIES,
|
||||||
|
watched=CURATOR_WATCH,
|
||||||
|
tz=TZ,
|
||||||
|
)
|
||||||
|
if text is None:
|
||||||
|
_log.info("curator: nothing new since the last run")
|
||||||
|
return
|
||||||
|
await run.spawn_job(agent="beaver-curator", title="куратор", text=text)
|
||||||
|
|
||||||
|
|
||||||
T3CODE_QUESTION_HINT = (
|
T3CODE_QUESTION_HINT = (
|
||||||
"Если ответ следует из задания - ответь сам: t3_answer(thread_id, "
|
"Если ответ следует из задания - ответь сам: t3_answer(thread_id, "
|
||||||
"{id вопроса: label}). Не знаешь - спроси Бобра через say своими словами, "
|
"{id вопроса: label}). Не знаешь - спроси Бобра через say своими словами, "
|
||||||
@@ -790,6 +850,9 @@ jobs = [
|
|||||||
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
||||||
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
|
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
|
||||||
Job("память", memory, cron="30 4 * * 0", critical=False),
|
Job("память", memory, cron="30 4 * * 0", critical=False),
|
||||||
|
# крон в UTC контейнера: 02/10/14/18/22 по Варшаве летом;
|
||||||
|
# руками - POST /api/jobs/куратор/run
|
||||||
|
Job("куратор", curate, cron="0 0,8,12,16,20 * * *", critical=False),
|
||||||
Job("deploy", deploy, webhook=True),
|
Job("deploy", deploy, webhook=True),
|
||||||
# алерты идут пачками (тревога и отбой подряд) - схлопывать нельзя
|
# алерты идут пачками (тревога и отбой подряд) - схлопывать нельзя
|
||||||
Job("komodo", komodo_alert, webhook=True, dedupe=False),
|
Job("komodo", komodo_alert, webhook=True, dedupe=False),
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
"""Куратор памяти: что нового с прошлого прогона (архитектура §4.5, §6.0).
|
||||||
|
|
||||||
|
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
||||||
|
для служебного тёрна `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)
|
||||||
@@ -61,6 +61,7 @@ services:
|
|||||||
- ./config.py:/config/config.py:ro
|
- ./config.py:/config/config.py:ro
|
||||||
- ./policy.py:/config/policy.py:ro
|
- ./policy.py:/config/policy.py:ro
|
||||||
- ./recall.py:/config/recall.py:ro
|
- ./recall.py:/config/recall.py:ro
|
||||||
|
- ./curator.py:/config/curator.py:ro
|
||||||
- ./mcps:/config/mcps:ro
|
- ./mcps:/config/mcps:ro
|
||||||
- ./config.json:/config/config.json:ro
|
- ./config.json:/config/config.json:ro
|
||||||
# §3.7: vault - дом диспетчера, rw целиком; границы (`.obsidian`,
|
# §3.7: vault - дом диспетчера, rw целиком; границы (`.obsidian`,
|
||||||
|
|||||||
@@ -153,6 +153,39 @@ def mentions(text: str, people: list[Person], *, limit: int = 3) -> list[Person]
|
|||||||
return [by_name[name] for name, _ in order]
|
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
|
@dataclass
|
||||||
class Recall:
|
class Recall:
|
||||||
"""Справка по сообщению: указатели в vault, которые gateway не может знать."""
|
"""Справка по сообщению: указатели в vault, которые gateway не может знать."""
|
||||||
@@ -186,30 +219,37 @@ class Recall:
|
|||||||
return None
|
return None
|
||||||
return "\n".join([LABEL, *lines])
|
return "\n".join([LABEL, *lines])
|
||||||
|
|
||||||
def person_line(self, person: Person) -> str:
|
def person_line(self, person: Person, *, now_lines: int = 6) -> str:
|
||||||
|
"""Указатели по человеку и `## сейчас` из его записок - инлайн."""
|
||||||
parts = [f"👤 {person.name} - карточка `{person.path}`"]
|
parts = [f"👤 {person.name} - карточка `{person.path}`"]
|
||||||
notes = self.notes_dir / f"{person.name} - наблюдения.md"
|
notes = self.notes_dir / f"{person.name} - наблюдения.md"
|
||||||
rel = notes.relative_to(self.vault)
|
rel = notes.relative_to(self.vault)
|
||||||
|
now: list[str] = []
|
||||||
if notes.exists():
|
if notes.exists():
|
||||||
entries = [
|
sections = notes_sections(notes)
|
||||||
ln.strip()
|
feed = sections.get("лента", [])
|
||||||
for ln in notes.read_text(
|
now = [ln for ln in sections.get("сейчас", []) if ln.startswith("- ")]
|
||||||
encoding="utf-8", errors="ignore"
|
patterns = [
|
||||||
).splitlines()
|
ln for ln in sections.get("паттерны", []) if ln.startswith("- ")
|
||||||
if ln.strip().startswith("- ")
|
|
||||||
]
|
]
|
||||||
if entries:
|
desc = f"записки `{rel}`: лента {len(feed)} стр."
|
||||||
last = entries[-1][2:]
|
if patterns:
|
||||||
last = last if len(last) <= 110 else last[:109] + "…"
|
desc += f", паттернов {len(patterns)}"
|
||||||
parts.append(f"записки `{rel}`: {len(entries)} стр., последняя: {last}")
|
if not now and feed:
|
||||||
else:
|
last = feed[-1][2:]
|
||||||
parts.append(f"записки `{rel}`: пусто")
|
desc += ", последняя: " + (
|
||||||
|
last if len(last) <= 110 else last[:109] + "…"
|
||||||
|
)
|
||||||
|
parts.append(desc)
|
||||||
else:
|
else:
|
||||||
parts.append(f"записок нет (`{rel}`)")
|
parts.append(f"записок нет (`{rel}`)")
|
||||||
days = self.diary_hits(person)
|
days = self.diary_hits(person)
|
||||||
if days:
|
if days:
|
||||||
parts.append("дневник: " + ", ".join(f"[[{d}]]" for d in days))
|
parts.append("дневник: " + ", ".join(f"[[{d}]]" for d in days))
|
||||||
return "; ".join(parts)
|
line = "; ".join(parts)
|
||||||
|
if now:
|
||||||
|
line += "; сейчас:\n" + "\n".join(f" {ln}" for ln in now[:now_lines])
|
||||||
|
return line
|
||||||
|
|
||||||
def diary_hits(
|
def diary_hits(
|
||||||
self, person: Person, *, limit: int = 3, ttl: float = 600.0
|
self, person: Person, *, limit: int = 3, ttl: float = 600.0
|
||||||
|
|||||||
+10
-6
@@ -371,15 +371,19 @@ def append_notes(name: str, lines: list[str]) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def sort_notes() -> None:
|
def sort_notes() -> None:
|
||||||
|
"""Сортирует и дедупит только `## лента`; шапку (сейчас/паттерны/…) не трогает."""
|
||||||
for path in NOTES.glob("* - наблюдения.md"):
|
for path in NOTES.glob("* - наблюдения.md"):
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
head, bullets = [], []
|
head_text, sep, feed = text.partition("## лента\n")
|
||||||
for ln in text.splitlines():
|
bullets = [ln for ln in feed.splitlines() if ln.startswith("- ")]
|
||||||
(bullets if ln.startswith("- ") else head).append(ln)
|
|
||||||
bullets = sorted(dict.fromkeys(bullets), key=lambda ln: ln[2:12])
|
bullets = sorted(dict.fromkeys(bullets), key=lambda ln: ln[2:12])
|
||||||
while head and not head[-1].strip():
|
if not sep:
|
||||||
head.pop()
|
head_text = head_text.rstrip("\n") + "\n\n"
|
||||||
path.write_text("\n".join([*head, "", *bullets]) + "\n", encoding="utf-8")
|
head_text = head_text.replace("\n- ", "\n").rstrip() + "\n\n"
|
||||||
|
path.write_text(
|
||||||
|
head_text.rstrip("\n") + "\n\n## лента\n" + "\n".join(bullets) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def link_card(person_path: Path, name: str) -> bool:
|
def link_card(person_path: Path, name: str) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from curator import Watched, briefing, last_run, new_replies
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_run_reads_first_line_or_default(tmp_path: Path) -> None:
|
||||||
|
default = datetime(2026, 9, 1, tzinfo=UTC)
|
||||||
|
assert last_run(tmp_path / "нет.md", default=default) == default
|
||||||
|
j = tmp_path / "куратор.md"
|
||||||
|
j.write_text(
|
||||||
|
"последний прогон: 2026-09-01T18:00:00+02:00\n\n# журнал\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert last_run(j, default=default) == datetime(2026, 9, 1, 16, 0, tzinfo=UTC)
|
||||||
|
j.write_text("# журнал без строки\n", encoding="utf-8")
|
||||||
|
assert last_run(j, default=default) == default
|
||||||
|
|
||||||
|
|
||||||
|
def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
vault = tmp_path
|
||||||
|
beaver = vault / "мета" / "бобер"
|
||||||
|
replies = beaver / "реплики"
|
||||||
|
notes = beaver / "наблюдения"
|
||||||
|
replies.mkdir(parents=True)
|
||||||
|
notes.mkdir()
|
||||||
|
(replies / "2026-09.md").write_text(
|
||||||
|
"# реплики\n\n- 2026-09-01 12:00 · мастер · старое\n- 2026-09-01 19:30 · мастер · новое сообщение\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
journal = beaver / "куратор.md"
|
||||||
|
journal.write_text(
|
||||||
|
"последний прогон: 2026-09-01T18:00:00+02:00\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
old = notes / "Глафира - наблюдения.md"
|
||||||
|
old.write_text("# Глафира\n", encoding="utf-8")
|
||||||
|
import os
|
||||||
|
|
||||||
|
stale = (datetime(2026, 9, 1, 10, tzinfo=UTC)).timestamp()
|
||||||
|
os.utime(old, (stale, stale))
|
||||||
|
fresh = notes / "Зина - наблюдения.md"
|
||||||
|
fresh.write_text(
|
||||||
|
"# Зина\n## лента\n- 2026-09-01 · факт · [[2026-09-01]]\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
now = datetime(2026, 9, 1, 20, 0, tzinfo=UTC)
|
||||||
|
recent = (now - timedelta(minutes=30)).timestamp()
|
||||||
|
os.utime(fresh, (recent, recent))
|
||||||
|
text = briefing(
|
||||||
|
vault=vault,
|
||||||
|
journal=journal,
|
||||||
|
replies_dir=replies,
|
||||||
|
watched=[Watched("наблюдения", notes)],
|
||||||
|
tz="Europe/Warsaw",
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
assert text is not None
|
||||||
|
assert "прошлый - 2026-09-01 18:00" in text
|
||||||
|
assert (
|
||||||
|
"Новые реплики Бобра (1):\n- 2026-09-01 19:30 · мастер · новое сообщение"
|
||||||
|
in text
|
||||||
|
)
|
||||||
|
assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text
|
||||||
|
assert "Глафира" not in text
|
||||||
|
assert "последний прогон: 2026-09-01T22:00:00+02:00" in text
|
||||||
|
|
||||||
|
journal.write_text(f"последний прогон: {now.isoformat()}\n", encoding="utf-8")
|
||||||
|
assert new_replies(replies, now, "Europe/Warsaw") == []
|
||||||
|
later = now + timedelta(hours=1)
|
||||||
|
assert (
|
||||||
|
briefing(
|
||||||
|
vault=vault,
|
||||||
|
journal=journal,
|
||||||
|
replies_dir=replies,
|
||||||
|
watched=[Watched("наблюдения", notes)],
|
||||||
|
tz="Europe/Warsaw",
|
||||||
|
now=later,
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
+38
-6
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
from beaver_gateway.core.conversations import UserSaid
|
from beaver_gateway.core.conversations import UserSaid
|
||||||
from beaver_gateway.core.envelope import RecallContext
|
from beaver_gateway.core.envelope import RecallContext
|
||||||
|
|
||||||
from recall import LABEL, Recall, ReplyLog, frontmatter_aliases, mentions
|
from recall import LABEL, Recall, ReplyLog, frontmatter_aliases, mentions, notes_head
|
||||||
|
|
||||||
|
|
||||||
def _card(path: Path, aliases: list[str] | None = None) -> None:
|
def _card(path: Path, aliases: list[str] | None = None) -> None:
|
||||||
@@ -38,10 +38,17 @@ def _vault(tmp_path: Path) -> Recall:
|
|||||||
notes = tmp_path / "мета" / "бобер" / "наблюдения"
|
notes = tmp_path / "мета" / "бобер" / "наблюдения"
|
||||||
notes.mkdir(parents=True)
|
notes.mkdir(parents=True)
|
||||||
(notes / "Зина - наблюдения.md").write_text(
|
(notes / "Зина - наблюдения.md").write_text(
|
||||||
"# Зина\n\n- 2026-08-26 · перенесла встречу на час · [[2026-08-26]]\n"
|
"# Зина - наблюдения\n\n## сейчас\n- просила перезвонить вечером 26.08\n"
|
||||||
|
"- ждёт ответа про поездку до четверга\n\n## паттерны\n"
|
||||||
|
"- переносит встречи: [[2026-08-10]], [[2026-08-26]]\n\n## факты\n\n## открытое\n\n"
|
||||||
|
"## лента\n- 2026-08-26 · перенесла встречу на час · [[2026-08-26]]\n"
|
||||||
"- 2026-08-31 · прислала список покупок · транскрипт\n",
|
"- 2026-08-31 · прислала список покупок · транскрипт\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
(notes / "Прохор - наблюдения.md").write_text(
|
||||||
|
"# Прохор - наблюдения\n\n## сейчас\n\n## лента\n- 2026-08-07 · вернулся · [[2026-08-07]]\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
boards = tmp_path / "📆 доски"
|
boards = tmp_path / "📆 доски"
|
||||||
boards.mkdir()
|
boards.mkdir()
|
||||||
(boards / "организация.md").write_text(
|
(boards / "организация.md").write_text(
|
||||||
@@ -91,14 +98,24 @@ def test_block_points_to_card_notes_diary_and_deadlines_once(tmp_path: Path) ->
|
|||||||
assert block is not None
|
assert block is not None
|
||||||
lines = block.splitlines()
|
lines = block.splitlines()
|
||||||
assert lines[0] == LABEL
|
assert lines[0] == LABEL
|
||||||
assert lines[1].startswith(
|
assert lines[1] == (
|
||||||
"👤 Зина - карточка `👤 люди/личное/Зина.md`; записки `мета/бобер/наблюдения/Зина - наблюдения.md`: 2 стр., последняя: 2026-08-31 · четыре смягчения"
|
"👤 Зина - карточка `👤 люди/личное/Зина.md`; записки "
|
||||||
|
"`мета/бобер/наблюдения/Зина - наблюдения.md`: лента 2 стр., паттернов 1; "
|
||||||
|
"дневник: [[2026-08-31]], [[2026-08-26]], [[2026-08-13]]; сейчас:"
|
||||||
)
|
)
|
||||||
assert lines[1].endswith("дневник: [[2026-08-31]], [[2026-08-26]], [[2026-08-13]]")
|
assert lines[2:4] == [
|
||||||
assert lines[2:] == [
|
" - просила перезвонить вечером 26.08",
|
||||||
|
" - ждёт ответа про поездку до четверга",
|
||||||
|
]
|
||||||
|
assert lines[4:] == [
|
||||||
"📆 09-01 организация: заполнить файрфлай",
|
"📆 09-01 организация: заполнить файрфлай",
|
||||||
"📆 09-02 организация: витамины",
|
"📆 09-02 организация: витамины",
|
||||||
]
|
]
|
||||||
|
artem = recall.person_line(
|
||||||
|
next(p for p in recall.people.all() if p.name == "Прохор")
|
||||||
|
)
|
||||||
|
assert "лента 1 стр., последняя: 2026-08-07 · вернулся" in artem
|
||||||
|
assert "сейчас:" not in artem
|
||||||
again = recall.block(RecallContext(text="и ещё про Зину", kind="master", now=now))
|
again = recall.block(RecallContext(text="и ещё про Зину", kind="master", now=now))
|
||||||
assert again is not None and "📆" not in again
|
assert again is not None and "📆" not in again
|
||||||
branch = recall.block(RecallContext(text="Фёдор звонил", kind="branch", now=now))
|
branch = recall.block(RecallContext(text="Фёдор звонил", kind="branch", now=now))
|
||||||
@@ -150,3 +167,18 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path)
|
|||||||
"- 2026-09-01 14:04 · ветка «крипто-карта» · сравни Trustee и RedotPay\n"
|
"- 2026-09-01 14:04 · ветка «крипто-карта» · сравни Trustee и RedotPay\n"
|
||||||
)
|
)
|
||||||
assert date.fromisoformat("2026-09-01") # tz shift stays inside the month here
|
assert date.fromisoformat("2026-09-01") # tz shift stays inside the month here
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
|
||||||
|
recall = _vault(tmp_path)
|
||||||
|
path = recall.notes_dir / "Зина - наблюдения.md"
|
||||||
|
head = notes_head(path, ("сейчас", "паттерны"))
|
||||||
|
assert head == (
|
||||||
|
"## сейчас\n- просила перезвонить вечером 26.08\n"
|
||||||
|
"- ждёт ответа про поездку до четверга\n"
|
||||||
|
"## паттерны\n- переносит встречи: [[2026-08-10]], [[2026-08-26]]"
|
||||||
|
)
|
||||||
|
assert notes_head(path, ("факты",)) is None
|
||||||
|
assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None
|
||||||
|
short = notes_head(path, ("сейчас", "паттерны"), cap=2)
|
||||||
|
assert short is not None and short.endswith("… ещё 3 строк в файле")
|
||||||
|
|||||||
Reference in New Issue
Block a user