Update projektu
This commit is contained in:
216
skills/note/scripts/note_compile.py
Normal file
216
skills/note/scripts/note_compile.py
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user