#!/usr/bin/env python3 # ruff: noqa: E501, EXE001, S603, S108, PLR0915 - одноразовый инструмент, не сервис """Одноразовый засев памяти агента из истории vault (решение h, 2026-09-01). Гоняет `claude -p` на маке (подписка, как бенч) по трём источникам и пишет в локальный vault - Sync донесёт до dell: - `💬 чаты/` (не тесты, ещё не в индексе) → выжимка в `мета/бобер/выжимки/` в формате дистиллятора + строка в `индекс.md`, плюс наблюдения по людям; - `📅 дни/` (заполненные, помесячно) → наблюдения по людям и о Бобре; - `🧠 мысли/` (по файлу) → наблюдения, в основном о Бобре. Наблюдения - `мета/бобер/наблюдения/<Имя> - наблюдения.md`, строка = `- дата · факт · [[источник]]`; имена только из карточек `👤 люди/` плюс «Бобёр». В карточку добавляется строка `наблюдения: [[Имя - наблюдения]]`. Сырые ответы модели лежат в `scripts/seed_out/` - повторный запуск их переиспользует (resume), файлы vault дописываются без дублей. python3 scripts/seed_memory.py --dry-run python3 scripts/seed_memory.py --only chats --limit 2 python3 scripts/seed_memory.py # всё """ from __future__ import annotations import argparse import json import re import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from datetime import date from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE.parent)) from beaver_gateway.core.prompt import assemble # noqa: E402 from recall import People # noqa: E402 VAULT = Path.home() / "Documents/obsidian/hh" BEAVER = VAULT / "мета/бобер" CHATS = VAULT / "💬 чаты" DIARY = VAULT / "📅 дни" THOUGHTS = VAULT / "🧠 мысли" PEOPLE = VAULT / "👤 люди" DIGESTS = BEAVER / "выжимки" INDEX = BEAVER / "индекс.md" NOTES = BEAVER / "наблюдения" OUT = HERE / "seed_out" MODEL = "claude-opus-5" MAX_CHARS = 350_000 BOBR = "Бобёр" PROMPTS = BEAVER / "промпты" GRANULES = PROMPTS / "гранулы" VOICE = tuple( (tag, GRANULES / "голос" / f"{tag}.md") for tag in ( "role", "philosophy", "user_profile", "operating_modes", "how_you_operate", "interaction_guidelines", ) ) # та же сборка, что у мастера в config.py, только окружение - джоб SEED_BUILD = ( *VOICE, ("profile", PROMPTS / "профиль.md"), ("corrections", PROMPTS / "поправки.md"), ("vault_map", GRANULES / "карта-vault.md"), ("environment", GRANULES / "окружения" / "джоб.md"), ("dispatcher", GRANULES / "диспетчер.md"), ) DIGEST_MARK = "=== ВЫЖИМКА ===" NOTES_MARK = "=== НАБЛЮДЕНИЯ ===" END_MARK = "=== КОНЕЦ ===" @dataclass(frozen=True) class Source: kind: str # chats | days | thoughts id: str # имя для seed_out и [[ссылки]] title: str text: str when: str # дата источника YYYY-MM-DD (для наблюдений без даты в тексте) link: str # что ставить в [[...]] у наблюдений # ---------------------------------------------------------------- prompts -- def system_prompt(names: list[str]) -> str: """Настоящие гранулы Менеджера Бобрения + блок задания на засев.""" distiller = (GRANULES / "дистиллятор.md").read_text(encoding="utf-8") keep = distiller[distiller.find("Что сохранять:") :].strip() known = ", ".join(names) seed = f"""Это засев памяти, одноразовый джоб (решение Бобра, 2026-09-01): тебе по одному отдают документы из истории vault - старые чаты с тобой, месяцы дневника, заметки-мысли, - и ты отдаёшь то, что из них должно остаться у тебя в памяти. Тулз нет, файлы запишет gateway из твоего ответа. Ответ - строго в формате ниже, ничего вне маркеров. {DIGEST_MARK} (только для чата; для дневника и мысли блок пропускается целиком) # тема ## контекст 2-4 строки: зачем открывали. ## решили - решения и их причины; цифры, имена, даты, [[ссылки]] дословно ## открытые вопросы - что не закрыто ## ссылки - [[люди]], [[проекты]], соседние чаты - только реально упомянутые {NOTES_MARK} Имя :: YYYY-MM-DD · факт {END_MARK} Выжимка - по правилам дистиллятора: нейтральный язык, третье лицо («Бобёр решил»), без мнения, которого в разговоре не было. {keep} Наблюдения - твои записки из блока «Память» оверлея: то, чего нет в vault и что понадобится, чтобы заметить повтор - реакция человека, отговорка, обещание, оценка, состояние, паттерн, событие в отношениях. Не биография и не пересказ карточки (возраст, где живёт), не общие слова. Имя - ровно одно из списка ниже, как написано, или «{BOBR}» для самого Бобра; людей не из списка не записывай. Дата - из текста, если она там есть, иначе дата документа. Одна строка, до 200 знаков, коротко и по факту; на человека до 6 строк с документа, на {BOBR} - до 12. Нет наблюдений - блок пустой. Известные имена: {known}""" parts = list(SEED_BUILD) text = assemble(parts) return f"{text}\n\n{seed}\n\n" def user_prompt(src: Source) -> str: what = { "chats": "чат с агентом", "days": "месяц дневника", "thoughts": "заметка-мысль", }[src.kind] return f"Документ: {what}, «{src.title}», дата {src.when}.\n\n{src.text}" # ---------------------------------------------------------------- sources -- CALLOUT_RE = re.compile(r"^> \[!(tool|thinking)\]") def strip_chat(text: str) -> str: """Без фронтматтера, тул-коллов и thinking: только реплики.""" if text.startswith("---"): end = text.find("\n---", 3) if end > 0: text = text[end + 4 :] out: list[str] = [] skipping = False for line in text.splitlines(): if CALLOUT_RE.match(line): skipping = True continue if skipping and line.startswith(">"): continue skipping = False out.append(line) cleaned = re.sub(r"\n{3,}", "\n\n", "\n".join(out)).strip() return cleaned[:MAX_CHARS] def indexed_chats() -> set[str]: if not INDEX.exists(): return set() return set(re.findall(r"\[\[([^\]]+)\]\] →", INDEX.read_text(encoding="utf-8"))) def chat_sources() -> list[Source]: done = indexed_chats() out: list[Source] = [] for path in sorted(CHATS.rglob("*.md")): if ( "тест" in path.stem.lower() or path.stat().st_size < 5_000 or path.stem in done ): continue text = strip_chat(path.read_text(encoding="utf-8", errors="ignore")) if len(text) < 1_500: continue when = ( path.stem[:10] if re.match(r"\d{4}-\d{2}-\d{2}", path.stem) else date.today().isoformat() ) out.append( Source("chats", f"chat_{path.stem}", path.stem, text, when, path.stem) ) return out def day_sources() -> list[Source]: months: dict[str, list[Path]] = {} for path in sorted(DIARY.glob("????-??-??.md")): if path.stat().st_size > 700: months.setdefault(path.stem[:7], []).append(path) out: list[Source] = [] for month, files in sorted(months.items()): parts = [] for f in files: body = f.read_text(encoding="utf-8", errors="ignore") body = body.split("\n# планы", 1)[0] # хвост с tasks-запросами parts.append(f"### [[{f.stem}]]\n{body.strip()}") text = "\n\n".join(parts)[:MAX_CHARS] out.append( Source( "days", f"days_{month}", f"дневник {month}", text, f"{month}-01", "дневник", ) ) return out def thought_sources() -> list[Source]: out: list[Source] = [] for path in sorted(THOUGHTS.rglob("*.md")): if path.stat().st_size < 400: continue m = re.match(r"(\d{4}-\d{2}-\d{2})", path.stem) when = m.group(1) if m else date.today().isoformat() text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_CHARS] out.append( Source("thoughts", f"thought_{path.stem}", path.stem, text, when, path.stem) ) return out # ------------------------------------------------------------------ model -- def _result(payload: str) -> dict: """`claude -p --output-format json` отдаёт либо объект, либо массив сообщений.""" data = json.loads(payload) if isinstance(data, list): results = [x for x in data if isinstance(x, dict) and x.get("type") == "result"] return results[-1] if results else {} return data if isinstance(data, dict) else {} def ask(src: Source, system: str) -> str: raw = OUT / f"{src.id}.json" if raw.exists(): try: data = _result(raw.read_text(encoding="utf-8")) except json.JSONDecodeError: data = {} if data.get("result") and not data.get("is_error"): return data["result"] cmd = [ "claude", "-p", user_prompt(src), "--system-prompt", system, "--model", MODEL, "--effort", "medium", "--tools", "", "--setting-sources", "", "--strict-mcp-config", "--disable-slash-commands", "--no-session-persistence", "--output-format", "json", ] proc = subprocess.run( cmd, capture_output=True, text=True, timeout=1200, cwd="/tmp", check=False ) OUT.mkdir(exist_ok=True) raw.write_text( proc.stdout or json.dumps({"error": proc.stderr[-2000:]}), encoding="utf-8" ) if proc.returncode != 0 or not proc.stdout: msg = f"{src.id}: claude exit {proc.returncode}: {proc.stderr[-500:]}" raise RuntimeError(msg) data = _result(proc.stdout) if data.get("is_error") or not data.get("result"): msg = f"{src.id}: {data.get('result') or data}" raise RuntimeError(msg) return data["result"] def parse(answer: str) -> tuple[str | None, list[tuple[str, str, str]]]: digest = None if DIGEST_MARK in answer and NOTES_MARK in answer: digest = answer.split(DIGEST_MARK, 1)[1].split(NOTES_MARK, 1)[0].strip() or None if digest and digest.startswith("("): # «(только для чата ...)» пропуск digest = None notes_part = answer.split(NOTES_MARK, 1)[1] if NOTES_MARK in answer else "" notes_part = notes_part.split(END_MARK, 1)[0] notes: list[tuple[str, str, str]] = [] for line in notes_part.splitlines(): m = re.match( r"^\s*-?\s*(.+?)\s*::\s*(\d{4}-\d{2}-\d{2})\s*[·\-]\s*(.+?)\s*$", line ) if m: notes.append((m.group(1).strip(), m.group(2), m.group(3).strip())) return digest, notes # ------------------------------------------------------------------ write -- SLUG_BAD = re.compile(r"[^\w\s-]", re.UNICODE) def slugify(text: str, maxlen: int = 40) -> str: cleaned = re.sub(r"[\s-]+", "-", SLUG_BAD.sub(" ", text).strip()).strip("-") return cleaned[:maxlen].rstrip("-") or "untitled" def write_digest(src: Source, body: str, today: str) -> str: DIGESTS.mkdir(parents=True, exist_ok=True) stem = f"{today} - {slugify(src.title)}" path = DIGESTS / f"{stem}.md" n = 2 while path.exists() and f"[[{src.title}]]" not in path.read_text(encoding="utf-8"): path = DIGESTS / f"{stem} ({n}).md" n += 1 path.write_text( f'---\ntype: выжимка\nsource: "[[{src.title}]]"\ndate: {today}\n---\n\n{body.strip()}\n', encoding="utf-8", ) line = f"- {today} [[{src.title}]] → [[{path.stem}]]" text = ( INDEX.read_text(encoding="utf-8") if INDEX.exists() else "# индекс\n\nстрока на выжимку: чат → его выжимка.\n" ) if line not in text.splitlines(): INDEX.write_text(text.rstrip("\n") + "\n" + line + "\n", encoding="utf-8") return path.stem def note_file(name: str) -> Path: return NOTES / f"{name} - наблюдения.md" def append_notes(name: str, lines: list[str]) -> int: NOTES.mkdir(parents=True, exist_ok=True) path = note_file(name) if not path.exists(): path.write_text( f"# {name} - наблюдения\n\n> папка терапевта: пишет менеджер бобрения. " "Строка = дата · факт · источник. Бобёр по умолчанию не читает, но может.\n\n", encoding="utf-8", ) existing = set(path.read_text(encoding="utf-8").splitlines()) fresh = [ln for ln in lines if ln not in existing] if fresh: with path.open("a", encoding="utf-8") as fh: fh.write("\n".join(fresh) + "\n") return len(fresh) 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) 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") def link_card(person_path: Path, name: str) -> bool: text = person_path.read_text(encoding="utf-8") link = f"[[{name} - наблюдения]]" if link in text: return False person_path.write_text( text.rstrip("\n") + f"\n\nнаблюдения: {link}\n", encoding="utf-8" ) return True # ------------------------------------------------------------------- main -- def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--only", choices=["chats", "days", "thoughts"]) ap.add_argument("--limit", type=int) ap.add_argument("--dry-run", action="store_true") ap.add_argument("--jobs", type=int, default=4) ap.add_argument("--pick", help="подстрока в названии источника") args = ap.parse_args() # хабы групп («бывшие», «рандомы») - не люди, наблюдения им не пишем people = [ p for p in People(PEOPLE, VAULT, ttl=10_000).all() if "/группы/" not in p.path ] by_name = {p.name: p for p in people} for p in people: # алиасы тоже принимаем, но пишем под именем карточки for a in p.aliases: by_name.setdefault(a, p) names = sorted(p.name for p in people) system = system_prompt(names) sources: list[Source] = [] if args.only in (None, "chats"): sources += chat_sources() if args.only in (None, "days"): sources += day_sources() if args.only in (None, "thoughts"): sources += thought_sources() if args.pick: sources = [s for s in sources if args.pick.lower() in s.title.lower()] if args.limit: sources = sources[: args.limit] total_chars = sum(len(s.text) for s in sources) print( f"источников: {len(sources)}, символов: {total_chars:,} (~{total_chars // 3.5:,.0f} токенов)" ) for s in sources: print(f" {s.kind:8} {s.title[:60]:60} {len(s.text):>8}") if args.dry_run: return today = date.today().isoformat() written: dict[str, int] = {} linked: list[str] = [] failures: list[str] = [] with ThreadPoolExecutor(max_workers=args.jobs) as pool: futs = {pool.submit(ask, s, system): s for s in sources} for fut in as_completed(futs): src = futs[fut] try: answer = fut.result() except Exception as exc: # noqa: BLE001 failures.append(str(exc)) print(f"FAIL {src.id}: {exc}"[:300]) continue digest, notes = parse(answer) digest_stem = ( write_digest(src, digest, today) if src.kind == "chats" and digest else None ) grouped: dict[str, list[str]] = {} unknown: set[str] = set() for raw_name, when, fact in notes: name = ( BOBR if raw_name.casefold() in (BOBR.casefold(), "бобер", "h", "андрей") else None ) if name is None: person = by_name.get(raw_name) if person is None: unknown.add(raw_name) continue name = person.name link = f"[[{src.link}]]" if src.kind != "days" else "" if src.kind == "days": link = ( f"[[{when}]]" if (DIARY / f"{when}.md").exists() else "[[дневник]]" ) grouped.setdefault(name, []).append(f"- {when} · {fact} · {link}") for name, lines in grouped.items(): n = append_notes(name, lines) written[name] = written.get(name, 0) + n if ( name != BOBR and name in by_name and link_card(VAULT / by_name[name].path, name) ): linked.append(name) print( f"ok {src.id}: digest={digest_stem or '-'} notes={sum(len(v) for v in grouped.values())}" + (f" unknown={sorted(unknown)}" if unknown else "") ) sort_notes() print("\nнаблюдений добавлено по людям:") for name, n in sorted(written.items(), key=lambda kv: -kv[1]): print(f" {n:4} {name}") if linked: print("ссылка добавлена в карточки:", ", ".join(sorted(set(linked)))) if failures: print(f"\nошибок: {len(failures)}") sys.exit(1) if __name__ == "__main__": main()