#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = ["nanobot-ai"] # /// """note_compile.py — drain notes/inbox/ into the structured doc notes/notes.md. Thin launcher run by the nanobot user crontab every minute. All the intelligence lives in DRAIN_GOAL + the note skill's compile workflow; this script only decides *when* to run and guards against concurrent runs. Flow: 1. Cheap fs pre-check (no LLM): are there pending files in notes/inbox/? None -> exit 0 without importing nanobot (per-minute polling stays nearly free). 2. Lockfile (notes/.compile.lock, PID + start-timestamp): another compile running? -> exit 0. Stale lock (dead PID / older than STALE_SECONDS) is reclaimed. 3. Otherwise Nanobot.from_config().run() — drains ALL pending in one batch. process_direct has NO cron preamble (unlike cron/jobs.json agent jobs). 4. Quietly append to log/note_compile_cron.log; no Telegram. The immediate `/note` mode runs the SAME compile workflow inline and takes the SAME lock, so an inline merge and a background drain cannot corrupt notes.md at once. """ import asyncio import json import os import subprocess import sys import traceback from datetime import datetime from pathlib import Path # workspace/skills/note/scripts/note_compile.py -> parents[3] = workspace root. WORKSPACE = Path(__file__).resolve().parents[3] NOTES = WORKSPACE / "notes" INBOX = NOTES / "inbox" LOCK = NOTES / ".compile.lock" LOG = WORKSPACE / "log" / "note_compile_cron.log" TIMEOUT_SECONDS = 15 * 60 STALE_SECONDS = 30 * 60 DRAIN_GOAL = ( "Pomocí skillu note (Compile/drain) zpracuj VŠECHNY čekající soubory v `notes/inbox/` " "(regulérní soubory přímo v `notes/inbox/`, mimo skryté). Pro každý postupuj podle " "*Compile workflow* v note SKILL.md: přeformuluj na terse fakt(a) (zachovej jazyk vstupu, " "jeden koncept per záznam, zahoď filler); z těla vytáhni VŠECHNY URL (0..N) a každou stáhni " "přes `web` tool — když je za paywallem / login-wallem / neúplná, NEfabrikuj shrnutí, zapiš " "jen URL + titulek + značku `⚠ paywall/neúplné`. Zařaď obsah pod správnou tematickou sekci " "v `notes/notes.md` (novou sekci ## založ, když chybí; existující sekci uprav chirurgicky, " "nepřepisuj celý dokument). Po úspěšném zařazení přesuň zdrojový soubor do `notes/done/`; " "když z něj nešlo nic použitelného získat (vše za paywallem / nečitelné / nejednoznačné), " "přesuň ho do `notes/hard/`. Přesouvej HNED po každém souboru, ať ho příští cron tik " "nezpracovává znovu. Běžíš v izolované session na pozadí, bez interakce s uživatelem." ) def log(message: str) -> None: LOG.parent.mkdir(parents=True, exist_ok=True) stamp = datetime.now().astimezone().isoformat(timespec="seconds") with LOG.open("a", encoding="utf-8") as handle: handle.write(f"{stamp} {message}\n") def pending_sources() -> list[Path]: """Regular files directly in notes/inbox/ (hidden files excluded; done/ and hard/ are siblings).""" if not INBOX.exists(): return [] return [ p for p in sorted(INBOX.iterdir()) if p.is_file() and not p.name.startswith(".") ] def _pid_alive(pid: int) -> bool: try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: return True return True def _lock_is_stale() -> bool: """A lock is dead if unreadable, its PID is gone, or it is older than STALE_SECONDS.""" try: data = json.loads(LOCK.read_text()) pid = int(data["pid"]) started = datetime.fromisoformat(data["started"]) except (OSError, ValueError, KeyError): return True if not _pid_alive(pid): return True age = (datetime.now().astimezone() - started).total_seconds() return age > STALE_SECONDS def acquire_lock() -> bool: """Atomically create the lock. Return False when a live compile already runs.""" for _ in range(2): try: fd = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) except FileExistsError: if not _lock_is_stale(): return False log("stale lock, reclaiming") LOCK.unlink(missing_ok=True) continue payload = { "pid": os.getpid(), "started": datetime.now().astimezone().isoformat(), } with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle) return True return False async def run_compile(goal: str) -> str: # Heavy import deferred: the per-minute pre-check (no pending work) must not pay the # nanobot import cost — only an actual compile run needs it. from nanobot import Nanobot bot = Nanobot.from_config() result = await bot.run(goal, session_key="note-compile") return result.content or "" def commit_notes(count: int) -> None: """Stage and commit only notes/ after a successful drain. The Dream processor owns the rest of the workspace, so we never `git add -A`. A no-op when notes/ has no changes. .compile.lock is gitignored, so `git add notes/` (run while the lock is still held) does not stage it. Commit failure is logged, not raised — the drain itself already succeeded and must not be reported as failed. """ try: status = subprocess.run( ["git", "-C", str(WORKSPACE), "status", "--porcelain", "notes/"], check=True, capture_output=True, text=True, ) if not status.stdout.strip(): return subprocess.run( ["git", "-C", str(WORKSPACE), "add", "notes/"], check=True, capture_output=True, text=True, ) subprocess.run( [ "git", "-C", str(WORKSPACE), "commit", "-m", f"note: cron drain ({count} captures)", ], check=True, capture_output=True, text=True, ) log(f"COMMIT notes/ ({count} captures)") except (OSError, subprocess.CalledProcessError) as error: log(f"WARN commit failed: {error}") def main() -> int: dry_run = "--dry-run" in sys.argv[1:] pending = pending_sources() if not pending: return 0 if not acquire_lock(): log(f"SKIP compile already running ({len(pending)} pending)") return 0 if dry_run: names = ", ".join(p.name for p in pending) log(f"DRY-RUN would compile {len(pending)} pending: {names}") LOCK.unlink(missing_ok=True) return 0 started = datetime.now().astimezone() log(f"START compile {len(pending)} pending: {', '.join(p.name for p in pending)}") try: result_text = asyncio.run( asyncio.wait_for(run_compile(DRAIN_GOAL), timeout=TIMEOUT_SECONDS) ) summary = ( result_text.strip().splitlines()[0][:200] if result_text.strip() else "(prázdný výstup)" ) duration = int((datetime.now().astimezone() - started).total_seconds()) log( f"END compile duration={duration}s remaining={len(pending_sources())} :: {summary}" ) commit_notes(len(pending)) return 0 except asyncio.TimeoutError: log(f"TIMEOUT compile po {TIMEOUT_SECONDS // 60} min") return 1 except Exception as error: log(f"EXCEPTION compile: {error}\n{traceback.format_exc()}") return 1 finally: LOCK.unlink(missing_ok=True) if __name__ == "__main__": sys.exit(main())