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.mcp.types import HttpMcp, McpServer, McpServerT
|
||||
|
||||
from curator import Watched, briefing
|
||||
from mcps.homeassistant import HomeAssistant
|
||||
from mcps.komodo import Komodo
|
||||
from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad
|
||||
@@ -45,7 +46,7 @@ from policy import (
|
||||
skill_tracker,
|
||||
vault_zones,
|
||||
)
|
||||
from recall import Recall, ReplyLog
|
||||
from recall import Recall, ReplyLog, notes_head
|
||||
|
||||
TZ = "Europe/Warsaw"
|
||||
VAULT = Path("/vault")
|
||||
@@ -60,6 +61,8 @@ DIGESTS = BEAVER / "выжимки"
|
||||
INDEX = BEAVER / "индекс.md"
|
||||
NOTES = BEAVER / "наблюдения"
|
||||
REPLIES = BEAVER / "реплики"
|
||||
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
||||
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
||||
PEOPLE = VAULT / "👤 люди"
|
||||
BOARDS = VAULT / "📆 доски"
|
||||
STATE = BEAVER / "состояние.md"
|
||||
@@ -118,6 +121,10 @@ _DISTILLER = (PROFILE, environment("джоб"), overlay("distiller", "дисти
|
||||
DISTILLER_PROMPTS = Prompts(fork=_DISTILLER, job=_DISTILLER)
|
||||
# §4.1: триаж без голоса и без vault - профиль, окружение джоба, оверлей.
|
||||
TRIAGE_PROMPTS = Prompts(job=(PROFILE, environment("джоб"), overlay("triage", "триаж")))
|
||||
# Куратор памяти - тот же менеджер бобрения (голос и всё), в окружении джоба.
|
||||
CURATOR_PROMPTS = Prompts(
|
||||
job=(*_BASE, environment("джоб"), overlay("curator", "куратор"))
|
||||
)
|
||||
QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
||||
|
||||
# §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:
|
||||
"""§4.1: дешёвый крон-тёрн - sonnet low, без vault, только say/inject."""
|
||||
return ClaudeAgent(
|
||||
@@ -364,6 +388,7 @@ agents: list[BaseAgent] = [
|
||||
deep("beaver-fable-medium", "claude-fable-5", effort="medium"),
|
||||
deep("beaver-opus-xhigh", "claude-opus-5", effort="xhigh"),
|
||||
distiller("beaver-distiller", "claude-opus-5", effort="medium"),
|
||||
curator("beaver-curator", "claude-opus-5", effort="high"),
|
||||
triage("beaver-triage", "claude-sonnet-5", effort="low"),
|
||||
raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", reasoning_effort="low"),
|
||||
raycast(
|
||||
@@ -442,6 +467,12 @@ def seed_body(ctx: SeedContext) -> str | None:
|
||||
parts.append(
|
||||
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)
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
"Если ответ следует из задания - ответь сам: t3_answer(thread_id, "
|
||||
"{id вопроса: label}). Не знаешь - спроси Бобра через say своими словами, "
|
||||
@@ -790,6 +850,9 @@ jobs = [
|
||||
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
||||
Job("закрытие", close_idle, cron="20 4 * * *", 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("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
|
||||
- ./policy.py:/config/policy.py:ro
|
||||
- ./recall.py:/config/recall.py:ro
|
||||
- ./curator.py:/config/curator.py:ro
|
||||
- ./mcps:/config/mcps:ro
|
||||
- ./config.json:/config/config.json:ro
|
||||
# §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]
|
||||
|
||||
|
||||
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 не может знать."""
|
||||
@@ -186,30 +219,37 @@ class Recall:
|
||||
return None
|
||||
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}`"]
|
||||
notes = self.notes_dir / f"{person.name} - наблюдения.md"
|
||||
rel = notes.relative_to(self.vault)
|
||||
now: list[str] = []
|
||||
if notes.exists():
|
||||
entries = [
|
||||
ln.strip()
|
||||
for ln in notes.read_text(
|
||||
encoding="utf-8", errors="ignore"
|
||||
).splitlines()
|
||||
if ln.strip().startswith("- ")
|
||||
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("- ")
|
||||
]
|
||||
if entries:
|
||||
last = entries[-1][2:]
|
||||
last = last if len(last) <= 110 else last[:109] + "…"
|
||||
parts.append(f"записки `{rel}`: {len(entries)} стр., последняя: {last}")
|
||||
else:
|
||||
parts.append(f"записки `{rel}`: пусто")
|
||||
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))
|
||||
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(
|
||||
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:
|
||||
"""Сортирует и дедупит только `## лента`; шапку (сейчас/паттерны/…) не трогает."""
|
||||
for path in NOTES.glob("* - наблюдения.md"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
head, bullets = [], []
|
||||
for ln in text.splitlines():
|
||||
(bullets if ln.startswith("- ") else head).append(ln)
|
||||
head_text, sep, feed = text.partition("## лента\n")
|
||||
bullets = [ln for ln in feed.splitlines() if ln.startswith("- ")]
|
||||
bullets = sorted(dict.fromkeys(bullets), key=lambda ln: ln[2:12])
|
||||
while head and not head[-1].strip():
|
||||
head.pop()
|
||||
path.write_text("\n".join([*head, "", *bullets]) + "\n", encoding="utf-8")
|
||||
if not sep:
|
||||
head_text = head_text.rstrip("\n") + "\n\n"
|
||||
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:
|
||||
|
||||
@@ -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.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:
|
||||
@@ -38,10 +38,17 @@ def _vault(tmp_path: Path) -> Recall:
|
||||
notes = tmp_path / "мета" / "бобер" / "наблюдения"
|
||||
notes.mkdir(parents=True)
|
||||
(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",
|
||||
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.mkdir()
|
||||
(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
|
||||
lines = block.splitlines()
|
||||
assert lines[0] == LABEL
|
||||
assert lines[1].startswith(
|
||||
"👤 Зина - карточка `👤 люди/личное/Зина.md`; записки `мета/бобер/наблюдения/Зина - наблюдения.md`: 2 стр., последняя: 2026-08-31 · четыре смягчения"
|
||||
assert lines[1] == (
|
||||
"👤 Зина - карточка `👤 люди/личное/Зина.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:] == [
|
||||
assert lines[2:4] == [
|
||||
" - просила перезвонить вечером 26.08",
|
||||
" - ждёт ответа про поездку до четверга",
|
||||
]
|
||||
assert lines[4:] == [
|
||||
"📆 09-01 организация: заполнить файрфлай",
|
||||
"📆 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))
|
||||
assert again is not None and "📆" not in again
|
||||
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"
|
||||
)
|
||||
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