Zalohovani vsech podstatnych souboru
This commit is contained in:
BIN
skills/detach/scripts/__pycache__/create-task.cpython-314.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/create-task.cpython-314.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-312.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-312.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-314.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-314.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-313.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-313.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-314.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-314.pyc
Normal file
Binary file not shown.
53
skills/detach/scripts/archive-tasks.py
Normal file
53
skills/detach/scripts/archive-tasks.py
Normal 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())
|
||||
83
skills/detach/scripts/create-task.py
Executable file
83
skills/detach/scripts/create-task.py
Executable 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())
|
||||
41
skills/detach/scripts/list-tasks.py
Executable file
41
skills/detach/scripts/list-tasks.py
Executable 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()
|
||||
61
skills/detach/scripts/read-task.py
Executable file
61
skills/detach/scripts/read-task.py
Executable 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()
|
||||
170
skills/detach/scripts/tasks-daemon.py
Executable file
170
skills/detach/scripts/tasks-daemon.py
Executable 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())
|
||||
241
skills/detach/scripts/tasks_common.py
Normal file
241
skills/detach/scripts/tasks_common.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user