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:
hh
2026-09-01 16:48:25 +02:00
parent 58e343aae0
commit 3a77a12447
7 changed files with 375 additions and 27 deletions
+64 -1
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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`,
+54 -14
View File
@@ -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
View File
@@ -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:
+80
View File
@@ -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
View File
@@ -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 строк в файле")