Update projektu

This commit is contained in:
lachtan
2026-07-22 12:32:02 +02:00
parent 19014ed3d9
commit 8e66d6b92a
22 changed files with 1995 additions and 503 deletions

View 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())