217 lines
7.6 KiB
Python
217 lines
7.6 KiB
Python
#!/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(<drain goal>) — 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())
|