Zalohovani vsech podstatnych souboru

This commit is contained in:
lachtan
2026-06-10 06:39:52 +02:00
parent 1e10891945
commit 67e29c8b88
69 changed files with 9115 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import FILENAME_RE, TASKS, log
def find_by_slug(done: Path, slug: str) -> list[Path]:
return [f for f in done.glob("*.md") if (m := FILENAME_RE.match(f.name)) and m.group(2) == slug]
def main() -> int:
parser = argparse.ArgumentParser(description="Archive tasks from done/ to archive/")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--all", action="store_true", help="Archive all tasks in done/")
group.add_argument("--slug", action="append", dest="slugs", metavar="SLUG", help="Archive a specific task by slug (repeatable)")
args = parser.parse_args()
archive = TASKS / "archive"
archive.mkdir(exist_ok=True)
done = TASKS / "done"
if args.all:
targets = list(done.glob("*.md"))
else:
targets = []
for slug in args.slugs:
matches = find_by_slug(done, slug)
if not matches:
print(f"Not found in done/: {slug}", file=sys.stderr)
return 1
targets.extend(matches)
if not targets:
print("Nothing to archive.")
return 0
for f in targets:
f.rename(archive / f.name)
log(f"ARCHIVE {f.name}")
print(f"Archived {len(targets)} task(s).")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import argparse
import os
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import (
TASKS,
build_task_content,
build_task_filename,
load_preset_names,
log,
resolve_preset,
)
def ensure_queue_dirs() -> None:
for name in ("new", "inbox", "running", "done", "failed"):
(TASKS / name).mkdir(parents=True, exist_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description="Create a detach task and drop it in inbox/")
parser.add_argument("--goal", required=True, help="Self-contained goal restatement")
parser.add_argument("--slug", required=True, help="Short kebab-case identifier")
parser.add_argument("--channel", required=True, help="Channel name (e.g. telegram, websocket)")
parser.add_argument("--chat-id", required=True, dest="chat_id", help="Chat ID string")
parser.add_argument("--constraint", action="append", default=[], dest="constraints",
help="Extra constraint bullet (repeatable)")
parser.add_argument("--model", default=None,
help="Model preset to run the task on (fuzzy-matched against config.json); "
"omit to use the agent default")
args = parser.parse_args()
model = None
if args.model:
try:
model = resolve_preset(args.model, load_preset_names())
except KeyError as e:
print(e.args[0], file=sys.stderr)
return 1
ensure_queue_dirs()
now = datetime.now().astimezone()
timestamp_str = now.strftime("%Y-%m-%d_%H_%M_%S_%f")
created_iso = now.isoformat()
filename = build_task_filename(timestamp_str, args.slug)
content = build_task_content(
created_iso=created_iso,
channel=args.channel,
chat_id=args.chat_id,
slug=args.slug,
goal=args.goal,
constraints=args.constraints,
model=model,
)
tmp_path = TASKS / "new" / filename
inbox_path = TASKS / "inbox" / filename
try:
tmp_path.write_text(content)
os.replace(tmp_path, inbox_path)
log(f"CREATE {filename} slug={args.slug}")
except Exception as e:
print(f"Error writing task: {e}", file=sys.stderr)
return 1
print(args.slug)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import TASKS, render_list
def list_dir(path: Path) -> list[Path]:
if not path.exists():
return []
return sorted(path.glob("*.md"), key=lambda f: f.name, reverse=True)
def main() -> None:
running = list_dir(TASKS / "running")
done_all = list_dir(TASKS / "done")
failed_all = list_dir(TASKS / "failed")
if not running and not done_all and not failed_all:
print('No detached tasks yet. Start one by saying "detach: <your goal>".')
return
sections = []
if running:
sections.append(f"## Running ({len(running)})\n\n{render_list(running, len(running))}")
if done_all:
sections.append(f"## Done ({len(done_all)})\n\n{render_list(done_all[:10], len(done_all))}")
if failed_all:
sections.append(f"## Failed ({len(failed_all)})\n\n{render_list(failed_all[:10], len(failed_all))}")
print("\n\n".join(sections))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import FILENAME_RE, TASKS, format_result
def completed_files() -> list[Path]:
paths = []
for d in ("done", "failed"):
p = TASKS / d
if p.exists():
paths.extend(p.glob("*.md"))
return sorted(paths, key=lambda f: f.name, reverse=True)
def find_matches(identifier: str) -> list[Path]:
if not identifier:
done = sorted((TASKS / "done").glob("*.md"), key=lambda f: f.name, reverse=True) if (TASKS / "done").exists() else []
if done:
return [done[0]]
failed = sorted((TASKS / "failed").glob("*.md"), key=lambda f: f.name, reverse=True) if (TASKS / "failed").exists() else []
return [failed[0]] if failed else []
return [f for f in completed_files() if identifier.lower() in f.name.lower()]
def main() -> None:
for d in ("done", "failed"):
(TASKS / d).mkdir(parents=True, exist_ok=True)
identifier = sys.argv[1] if len(sys.argv) > 1 else ""
matches = find_matches(identifier)
if not matches:
if identifier:
print(f"No task matches `{identifier}`. Try `list` to see what's available.")
else:
print("No completed tasks yet.")
return
if len(matches) == 1:
print(format_result(matches[0]))
return
# Multiple matches — list for user to pick
print(f"Multiple tasks match `{identifier}`:\n")
for f in matches:
m = FILENAME_RE.match(f.name)
slug = m.group(2) if m else f.stem
ts = m.group(1) if m else ""
print(f"- `{slug}` ({ts}, {f.parent.name})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["nanobot-ai"]
# ///
"""tasks-daemon: vyprázdni ~/.nanobot/workspace/tasks/inbox/ v jednom průchodu.
Spouštěn systemd .path unitem (tasks-daemon.path) jakmile inbox není
prázdný. Souběh řeší systemd sám: Type=oneshot service se nespustí
podruhé, dokud první běh trvá; level-triggered .path ho restartne po
doběhu, pokud inbox stále není prázdný.
Partial-write race řeší skill atomickým mv z tasks/new/ → tasks/inbox/,
takže tu žádný flock není potřeba.
Pro každý *.md v inbox/:
1. mv → running/<file>.md
2. načti frontmatter (chat_id povinný, channel default telegram)
3. spusť Nanobot.run(goal, session_key=f"detach:<stem>") s 45min timeoutem;
pokud frontmatter nese `model: <preset>`, přepni na něj (jinak default)
4. append ## Result do souboru, mv → done/<file>.md (success)
nebo failed/<file>.md (exception/timeout)
5. pošli Telegram zprávu uživateli (chat_id z frontmatteru)
"""
import asyncio
import json
import shutil
import sys
import traceback
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import LOG, TASKS, log, parse_frontmatter
from nanobot import Nanobot
CONFIG = Path.home() / ".nanobot" / "config.json"
TIMEOUT_SECONDS = 20 * 60
def telegram_send(chat_id: str, text: str) -> None:
token = json.loads(CONFIG.read_text())["channels"]["telegram"]["token"]
url = f"https://api.telegram.org/bot{token}/sendMessage"
data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
req = urllib.request.Request(url, data=data, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
resp.read()
def resolve_telegram_chat_id(fm: dict[str, str]) -> tuple[str, str]:
"""Return (chat_id, source) — Telegram chat ID + 'frontmatter' or 'fallback'.
Pokud task přišel z Telegramu, použij chat_id z frontmatteru (multi-user ready).
Jinak (WebUI, CLI, ...) padni na první ID z channels.telegram.allowFrom v config.json.
"""
if fm.get("channel") == "telegram":
return fm["chat_id"], "frontmatter"
cfg = json.loads(CONFIG.read_text())
return cfg["channels"]["telegram"]["allowFrom"][0], "fallback"
async def run_agent(goal: str, session_key: str, preset: str | None = None) -> str:
bot = Nanobot.from_config()
if preset:
# Same switch the `/model <preset>` chat command performs; an invalid
# preset raises KeyError, caught by process_task and routed to failed/.
bot._loop.set_model_preset(preset)
result = await bot.run(goal, session_key=session_key)
return result.content or ""
def process_task(path: Path) -> None:
try:
content = path.read_text()
except Exception as e:
log(f"FAILED {path.name} read-error: {e}")
shutil.move(path, TASKS / "failed" / path.name)
return
fm, body = parse_frontmatter(content)
if not fm or "chat_id" not in fm:
log(f"FAILED {path.name} missing-chat_id-in-frontmatter")
shutil.move(path, TASKS / "failed" / path.name)
return
notify_chat_id, notify_source = resolve_telegram_chat_id(fm)
slug = fm.get("slug", path.stem)
preset = fm.get("model")
running = TASKS / "running" / path.name
shutil.move(path, running)
log(f"START {path.name} preset={preset or 'default'}")
goal = body.strip()
session_key = f"detach:{path.stem}"
started = datetime.now().astimezone()
try:
result_text = asyncio.run(
asyncio.wait_for(run_agent(goal, session_key, preset), timeout=TIMEOUT_SECONDS)
)
status = "done"
outcome = "✅ Hotovo"
except asyncio.TimeoutError:
result_text = f"(TIMEOUT po {TIMEOUT_SECONDS // 60} min)"
status = "failed"
outcome = "⏱️ Timeout"
log(f"TIMEOUT {path.name}")
except Exception as e:
result_text = f"(EXCEPTION: {e}\n\n{traceback.format_exc()})"
status = "failed"
outcome = "❌ Selhalo"
log(f"EXCEPTION {path.name}: {e}")
completed = datetime.now().astimezone()
duration_s = int((completed - started).total_seconds())
appended = (
f"{content}\n\n# Result\n\n{result_text}\n\n"
f"---\ncompleted: {completed.isoformat()}\n"
f"duration_seconds: {duration_s}\nstatus: {status}\n"
)
running.write_text(appended)
target_dir = TASKS / status
shutil.move(running, target_dir / path.name)
# Telegram notifikace — vždy přes Telegram, chat_id buď z frontmatteru
# (Telegram session) nebo z fallback configu (WebUI / CLI / atd.).
lines = result_text.strip().splitlines()
summary_line = lines[0][:200] if lines else "(prázdný výstup)"
msg = (
f"{outcome}: `{slug}`\n\n"
f"{summary_line}\n\n"
f"V chatu si vyžádej plný report: `výsledek {slug}`"
)
try:
telegram_send(notify_chat_id, msg)
log(f"NOTIFY {path.name} chat={notify_chat_id} source={notify_source}")
except Exception as e:
log(f"NOTIFY-FAILED {path.name}: {e}")
log(f"END {path.name} status={status} duration={duration_s}s")
def main() -> int:
for d in ("new", "inbox", "running", "done", "failed"):
(TASKS / d).mkdir(parents=True, exist_ok=True)
LOG.parent.mkdir(parents=True, exist_ok=True)
inbox = TASKS / "inbox"
tasks = sorted(inbox.glob("*.md"))
if not tasks:
return 0
log(f"DRAIN start {len(tasks)} task(s)")
for path in tasks:
try:
process_task(path)
except Exception as e:
log(f"FATAL {path.name}: {e}\n{traceback.format_exc()}")
log("DRAIN end")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,241 @@
"""Shared pure stdlib helpers for detach skill scripts."""
import json
import re
from datetime import datetime
from pathlib import Path
WORKSPACE = Path.home() / ".nanobot" / "workspace"
TASKS = WORKSPACE / "tasks"
CONFIG = Path.home() / ".nanobot" / "config.json"
LOG = WORKSPACE / "log" / "detach.log"
FILENAME_RE = re.compile(
r"^(\d{4}-\d{2}-\d{2}(?:T\d{6}|_\d{2}_\d{2}_\d{2}_\d{6}))-(.+)\.md$"
)
_NO_INTERACTION_BULLET = (
"- No user interaction (isolated session, no clarification questions"
" — work with what you have)."
)
def log(msg: str) -> None:
LOG.parent.mkdir(parents=True, exist_ok=True)
with LOG.open("a") as f:
f.write(f"{datetime.now().astimezone().isoformat()} {msg}\n")
def parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
"""Parse YAML-ish frontmatter delimited by --- lines.
Returns (fields, body). On no match returns ({}, original content).
"""
m = re.match(r"^---\n(.*?)\n---\n(.*)", content, re.DOTALL)
if not m:
return {}, content
fm: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" in line:
k, _, v = line.partition(":")
fm[k.strip()] = v.strip().strip('"').strip("'")
return fm, m.group(2)
def parse_kv(text: str) -> dict[str, str]:
"""Parse simple key: value lines into a dict (no quote stripping)."""
result: dict[str, str] = {}
for line in text.splitlines():
if ":" in line:
k, _, v = line.partition(":")
result[k.strip()] = v.strip()
return result
def parse_filename(name: str) -> tuple[str, str] | None:
"""Return (timestamp_str, slug) from a task filename, or None if no match."""
m = FILENAME_RE.match(name)
if not m:
return None
return m.group(1), m.group(2)
def parse_timestamp(ts_str: str) -> datetime:
"""Parse a filename timestamp in old (T-joined) or new (underscore-separated) format."""
for fmt in ("%Y-%m-%d_%H_%M_%S_%f", "%Y-%m-%dT%H%M%S"):
try:
return datetime.strptime(ts_str, fmt)
except ValueError:
continue
raise ValueError(f"unrecognized timestamp: {ts_str}")
def format_time(ts_str: str) -> str:
"""Format a filename timestamp to HH:MM."""
try:
return parse_timestamp(ts_str).strftime("%H:%M")
except ValueError:
return ts_str
def format_age(ts_str: str) -> str:
"""Return a human-readable age for a filename timestamp."""
try:
delta = datetime.now() - parse_timestamp(ts_str)
s = max(0, int(delta.total_seconds()))
if s < 60:
return f"{s}s ago"
if s < 3600:
return f"{s // 60}m ago"
if s < 86400:
return f"{s // 3600}h ago"
return f"{s // 86400}d ago"
except ValueError:
return "?"
def goal_summary(path: Path, width: int = 80) -> str:
"""Return first non-empty line of the Goal section, truncated to width."""
try:
_, body = parse_frontmatter(path.read_text())
except OSError:
return ""
goal = (extract_section(body, "Goal") or "").strip()
first = next((line for line in goal.splitlines() if line.strip()), "")
return first if len(first) <= width else first[:width - 1].rstrip() + ""
def render_list(paths: list[Path], total: int) -> str:
"""Render tasks as a flat bullet list — robust for LLM relaying (no table grammar)."""
blocks = []
for path in paths:
parsed = parse_filename(path.name)
if parsed:
ts_str, slug = parsed
head = f"- `{slug}` · {format_time(ts_str)} · {format_age(ts_str)}"
else:
head = f"- `{path.name}`"
summary = goal_summary(path)
blocks.append(f"{head}\n {summary}" if summary else head)
out = "\n".join(blocks)
if total > len(paths):
out += f"\n\n_(+ {total - len(paths)} older)_"
return out
def extract_section(text: str, name: str) -> str | None:
"""Return the text content of a markdown section by heading name, or None."""
m = re.search(rf"(?m)^#+ {re.escape(name)}\s*\n(.*?)(?=^#|\Z)", text, re.DOTALL)
return m.group(1).strip() if m else None
def format_result(path: Path) -> str:
"""Format a completed task file as a human-readable result block."""
content = path.read_text()
sep = "\n\n---\n"
main_part, _, meta_str = content.rpartition(sep)
if not main_part:
main_part = content
meta_str = ""
trailing = parse_kv(meta_str)
orig_fm, body = parse_frontmatter(main_part)
m = FILENAME_RE.match(path.name)
slug = m.group(2) if m else path.stem
goal = extract_section(body, "Goal") or body.strip()
result = extract_section(body, "Result") or "(no result)"
created = orig_fm.get("created", "")
completed = trailing.get("completed", "")
duration = trailing.get("duration_seconds", "")
status = trailing.get("status", path.parent.name)
model = orig_fm.get("model", "")
model_suffix = f" · model: `{model}`" if model else ""
if created:
meta_line = f"_Done in `{duration}`s · `{created}` → `{completed}` · status: `{status}`{model_suffix}_"
else:
meta_line = f"_Done in `{duration}`s · completed: `{completed}` · status: `{status}`{model_suffix}_"
return "\n".join([
f"**Result: `{slug}`**",
"",
goal,
"",
"---",
"",
result,
"",
"---",
meta_line,
])
def load_preset_names() -> list[str]:
"""Return the configured model preset names from config.json, sorted.
The config key may be written either camelCase (`modelPresets`) or
snake_case (`model_presets`) — nanobot accepts both, so we read both.
"""
config = json.loads(CONFIG.read_text())
presets = config.get("modelPresets") or config.get("model_presets") or {}
return sorted(presets.keys())
def resolve_preset(token: str, names: list[str]) -> str:
"""Resolve a user-typed model token to an exact preset name.
Exact match (case-insensitive) wins; otherwise a unique case-insensitive
substring match. Raises KeyError when nothing or more than one matches.
"""
token = token.strip()
exact = [n for n in names if n.lower() == token.lower()]
if exact:
return exact[0]
substring = [n for n in names if token.lower() in n.lower()]
if len(substring) == 1:
return substring[0]
available = ", ".join(names) or "(none)"
if not substring:
raise KeyError(f"model {token!r} not found. Available: {available}")
raise KeyError(f"model {token!r} is ambiguous: {', '.join(substring)}")
def build_task_filename(timestamp_str: str, slug: str) -> str:
"""Build the task filename from a formatted timestamp and slug."""
return f"{timestamp_str}-{slug}.md"
def build_task_content(
created_iso: str,
channel: str,
chat_id: str,
slug: str,
goal: str,
constraints: list[str],
model: str | None = None,
) -> str:
"""Build the full frontmatter+body content for a new task file."""
constraint_lines = [_NO_INTERACTION_BULLET] + [f"- {c}" for c in constraints]
constraints_block = "\n".join(constraint_lines)
model_line = f"model: {model}\n" if model else ""
return (
f"---\n"
f"created: {created_iso}\n"
f'channel: {channel}\n'
f'chat_id: "{chat_id}"\n'
f"slug: {slug}\n"
f"{model_line}"
f"---\n"
f"\n"
f"# Goal\n"
f"\n"
f"{goal}\n"
f"\n"
f"# Constraints\n"
f"\n"
f"{constraints_block}\n"
)