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

108
skills/detach/SKILL.md Normal file
View File

@@ -0,0 +1,108 @@
---
name: detach
description: >-
Run a task in the background and notify via Telegram when done. Subactions:
detach (capture), list (pending/done), read (fetch result), archive (move done tasks out of sight).
Triggers on: "detach", "background", "fire and forget", "list tasks", "result <slug>",
"archive tasks", "archive done tasks", "archive task".
---
# Detach
Four subactions:
1. **`detach`** (default) — capture a goal, write it to `workspace/tasks/inbox/`. A daemon runs the task in an isolated `nanobot agent` session and notifies the user via Telegram when done.
2. **`list`** — list pending and completed background tasks.
3. **`read`** — fetch and present the result of a completed task.
4. **`archive`** — move completed tasks from `done/` to `archive/` to keep the list clean.
## Rules
- **Respond to the user in their own language** (auto-detect from their message) — this skill is written in English, but all user-facing messages adapt to the user's language.
- **Do not** start solving a detached task yourself. Capture it and stop.
---
## Subaction: `detach` (capture)
### When NOT to use detach
- Fast tasks (<1 min) — answer directly in chat.
- **Reminders** ("remind me in an hour") — use the builtin `cron` tool.
- **Recurring** tasks ("every day at 9") — use `cron` with `cron_expr` / `every_seconds`.
### Procedure (execute in this order, no need to wait for confirmation)
#### 1. Identify channel and chat_id
The system prompt's runtime context contains `Channel: <name>` and `Chat ID: <id>`. Read both. If `Chat ID` is missing, see Failure handling.
#### 2. Prepare slug, goal, and optional model
- **Slug**: 35 words from the goal, kebab-case (`[a-z0-9-]` only). Example: "Research Qdrant vs Weaviate" → `qdrant-vs-weaviate`.
- **Goal**: restate the goal so it is self-contained without chat history. State-oriented, bounded, with a clear deliverable.
- **Model** (optional): only when the user explicitly names a model or preset for this task ("run it on kimi", "use the m3 model", "with glm"). Pass that spoken token verbatim as `--model "<token>"` — the script fuzzy-matches it against the configured presets. If the user says nothing about a model, omit `--model` and the task runs on the agent default.
#### 3. Create the task
Run:
```bash
exec skills/detach/scripts/create-task.py \
--goal "<self-contained goal>" \
--slug "<slug>" \
--channel "<channel from runtime context>" \
--chat-id "<chat_id from runtime context>"
```
Add `--constraint "<text>"` for each extra constraint (optional). Add `--model "<token>"` only when the user explicitly chose a model (see step 2). The script creates the task, ensures queue directories exist, and atomically moves the file into `tasks/inbox/` to trigger the daemon.
#### 4. Confirm to the user
Tell the user the task was queued — include the slug and the fetch hint (`result <slug>`). Do not restate the full goal.
### Failure handling for `detach`
- **No `Chat ID` in runtime context**: tell the user "Detach needs a chat context to remember where to read back results. Want me to do this task synchronously here instead?" — and do NOT create a task file.
- **Unknown or ambiguous `--model`**: the script exits non-zero and prints the available presets. Show the user those presets and ask which one to use, or offer to queue the task on the default model. Do NOT silently fall back to the default when the user explicitly asked for a model.
- **Script exits non-zero** (other reasons): report the error output, offer synchronous execution.
---
## Subaction: `list`
Triggered by phrases like "list detached", "list tasks", "pending tasks".
### Procedure
1. `exec skills/detach/scripts/list-tasks.py`
2. Output the result **verbatim** — it is already formatted as a bullet list. Do not convert it into a table or otherwise restructure it.
---
## Subaction: `read <identifier>`
Triggered by "result <identifier>", "result of <identifier>". If no identifier is given ("what was the last result?") → use most recent.
### Procedure
1. `exec skills/detach/scripts/read-task.py <identifier>` — omit the argument if no identifier.
2. If the output lists multiple matches, ask the user to pick one by slug.
3. Show the output to the user.
---
## Subaction: `archive`
Triggered by "archive tasks", "archive done tasks", "archive task".
### Procedure
1. If the user did not specify which tasks to archive, call `exec skills/detach/scripts/list-tasks.py` and show the `done/` contents, then ask which tasks to archive (or all).
2. If the user said "archive all" or equivalent → `exec skills/detach/scripts/archive-tasks.py --all`
3. If the user named specific task(s) by slug → `exec skills/detach/scripts/archive-tasks.py --slug <slug>` (repeat `--slug` for each).
4. Show the script output to the user.
---
_For a full description of the task lifecycle, directories, and scripts, see `architecture.md` in this skill directory._

View File

@@ -0,0 +1,78 @@
# Detach skill — architecture
## Directory layout
```
~/.nanobot/workspace/tasks/
inbox/ — tasks waiting to be picked up (written atomically from new/)
running/ — task currently executing
done/ — completed tasks (success)
failed/ — completed tasks (exception or timeout)
archive/ — tasks moved out of the active view; no longer shown by list
new/ — atomic write staging: skill writes here, then renames into inbox/
```
## Task lifecycle
```
capture (skill) → inbox/ → running/ → done/ or failed/ → archive/
```
1. **Capture** — the skill calls `create-task.py`, which writes the task file into `new/` and atomically renames it into `inbox/`. This rename is the trigger for the daemon. The filename timestamp uses microsecond precision (`%Y-%m-%d_%H_%M_%S_%f`, e.g. `2026-06-07_15_00_00_123456-slug.md`), making filename collisions impossible even for simultaneous calls with the same slug. `tasks_common.py` parsers accept both the old second-precision format (`T`-joined, e.g. `2026-06-07T150000`) and the new underscore format for backward compatibility with existing task files. When `--model <token>` is given, the script fuzzy-resolves it to an exact preset against `config.json` *at capture time* (fail-fast in chat) and stores it in the `model:` frontmatter field.
2. **Daemon pickup**`tasks-daemon.py` is started by a systemd `.path` unit whenever `inbox/` is non-empty. It processes all files in one pass (Type=oneshot). Concurrency is handled by systemd: the service won't start again while the previous run is still live; the level-triggered `.path` unit re-triggers it after the run if inbox is still non-empty.
3. **Execution** — for each file in `inbox/`: move to `running/`, read frontmatter, call `Nanobot.run(goal, session_key="detach:<stem>")` with a 45-minute timeout in an isolated session. If the frontmatter carries `model: <preset>`, the daemon switches to it via `bot._loop.set_model_preset(preset)` before running (the same switch the `/model` chat command performs); otherwise the task runs on `agents.defaults.modelPreset`.
4. **Completion** — daemon appends `## Result` and a trailing metadata block (`completed`, `duration_seconds`, `status`) to the file, then moves it to `done/` (success) or `failed/` (exception or timeout).
5. **Notification** — daemon sends a Telegram message to `chat_id` from the frontmatter (or falls back to the first `allowFrom` ID for non-Telegram channels).
6. **Archive** — user explicitly calls the `archive` subaction; `archive-tasks.py` moves selected files from `done/` to `archive/`.
## File format
Each task is a single Markdown file:
```
---
created: <ISO 8601>
channel: telegram | websocket | ...
chat_id: "<id>"
slug: <kebab-case>
model: <preset> # optional; omitted → agent default
---
# Goal
<self-contained goal text>
# Constraints
- No user interaction (isolated session, no clarification questions — work with what you have).
- <optional extra constraints>
# Result
<appended by daemon after completion>
---
completed: <ISO 8601>
duration_seconds: <int>
status: done | failed
```
The daemon appends the `# Result` section and the trailing `---` block; everything before that is written by `create-task.py` at capture time.
## Scripts
| Script | Role |
|---|---|
| `create-task.py` | Capture: writes task file, ensures queue dirs, atomically moves to `inbox/`; logs `CREATE` to `detach.log` |
| `tasks-daemon.py` | Long-running one-shot systemd service; executes tasks, notifies via Telegram; logs lifecycle events to `detach.log` |
| `list-tasks.py` | List `running/`, `done/`, `failed/` (capped at 10 newest each) as a flat bullet list, one task per bullet (slug · time · age + indented goal) |
| `read-task.py` | Format and print a completed task's result |
| `archive-tasks.py` | Move tasks from `done/` to `archive/` (by slug or all); logs `ARCHIVE` to `detach.log` |
| `tasks_common.py` | Shared stdlib helpers: paths, parsers, formatters, model-preset resolution, shared `log()``~/.nanobot/workspace/log/detach.log` |
## Systemd units
Two user-level units under `~/.config/systemd/user/`:
- `tasks-daemon.service` — Type=oneshot, runs `tasks-daemon.py`
- `tasks-daemon.path` — level-triggered, watches `inbox/`, starts the service when non-empty

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"
)

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Trigger detach daemon when tasks/inbox has files
[Path]
DirectoryNotEmpty=%h/.nanobot/workspace/tasks/inbox
Unit=tasks-daemon.service
[Install]
WantedBy=paths.target

View File

@@ -0,0 +1,18 @@
[Unit]
Description=nanobot detach tasks daemon (drain inbox)
After=nanobot.service
# Tolerate transient startup crashes without permanently latching the pipeline.
# 20 retries per 30 min, then pause + auto-resume as the window slides — no manual reset-failed.
StartLimitIntervalSec=1800
StartLimitBurst=20
[Service]
Type=oneshot
# On a crash (exit!=0) retry after a delay; clean drain (exit 0) and systemd-initiated
# stop (SIGTERM) do not restart. Gives a fixed deploy time to recover automatically.
Restart=on-failure
RestartSec=60
Environment=PATH=%h/.local/bin:/usr/bin:/bin
ExecStart=%h/.nanobot/workspace/skills/detach/scripts/tasks-daemon.py
StandardOutput=append:%h/.nanobot/workspace/log/tasks-daemon.stdout.log
StandardError=append:%h/.nanobot/workspace/log/tasks-daemon.stderr.log

View File

@@ -0,0 +1,5 @@
import sys
from pathlib import Path
# tasks_common.py lives in the sibling scripts/ directory.
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

View File

@@ -0,0 +1,490 @@
"""Tests for tasks_common pure logic — no I/O beyond tmp_path, no network."""
import json
from pathlib import Path
import pytest
import tasks_common
from tasks_common import (
FILENAME_RE,
build_task_content,
build_task_filename,
extract_section,
format_age,
format_result,
format_time,
goal_summary,
load_preset_names,
parse_filename,
parse_frontmatter,
parse_kv,
parse_timestamp,
render_list,
resolve_preset,
)
# ---------------------------------------------------------------------------
# parse_frontmatter
# ---------------------------------------------------------------------------
def test_parse_frontmatter_basic():
content = "---\ncreated: 2026-01-01T10:00:00+00:00\nslug: my-task\n---\n\n# Goal\n\nDo something.\n"
fm, body = parse_frontmatter(content)
assert fm["created"] == "2026-01-01T10:00:00+00:00"
assert fm["slug"] == "my-task"
assert "# Goal" in body
def test_parse_frontmatter_quoted_chat_id():
content = '---\nchat_id: "12345"\nchannel: telegram\n---\nbody\n'
fm, body = parse_frontmatter(content)
assert fm["chat_id"] == "12345"
assert fm["channel"] == "telegram"
assert body == "body\n"
def test_parse_frontmatter_no_match():
content = "No frontmatter here."
fm, body = parse_frontmatter(content)
assert fm == {}
assert body == content
def test_parse_frontmatter_roundtrip():
original = "---\nfoo: bar\nbaz: qux\n---\nbody text\n"
fm, body = parse_frontmatter(original)
assert fm == {"foo": "bar", "baz": "qux"}
assert body == "body text\n"
# ---------------------------------------------------------------------------
# parse_kv
# ---------------------------------------------------------------------------
def test_parse_kv_basic():
text = "completed: 2026-01-02T11:00:00+00:00\nduration_seconds: 42\nstatus: done\n"
kv = parse_kv(text)
assert kv["completed"] == "2026-01-02T11:00:00+00:00"
assert kv["duration_seconds"] == "42"
assert kv["status"] == "done"
def test_parse_kv_empty():
assert parse_kv("") == {}
def test_parse_kv_no_colon_lines_ignored():
kv = parse_kv("no colon here\nkey: value\n")
assert kv == {"key": "value"}
# ---------------------------------------------------------------------------
# FILENAME_RE / parse_filename
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name,expected", [
("2026-01-15T143022-my-task.md", ("2026-01-15T143022", "my-task")),
("2026-12-31T235959-qdrant-vs-weaviate.md", ("2026-12-31T235959", "qdrant-vs-weaviate")),
("2026-06-07_15_00_00_123456-qdrant-vs-weaviate.md", ("2026-06-07_15_00_00_123456", "qdrant-vs-weaviate")),
("2026-06-07_09_05_59_000001-deploy-api.md", ("2026-06-07_09_05_59_000001", "deploy-api")),
])
def test_filename_re_matches(name, expected):
assert parse_filename(name) == expected
@pytest.mark.parametrize("name", [
"not-a-task.md",
"2026-01-15-missing-time.md",
"2026-01-15T1430-short.md",
"2026-06-07_15_00_123456-too-few-groups.md",
])
def test_filename_re_no_match(name):
assert parse_filename(name) is None
def test_filename_re_direct_old_format():
assert FILENAME_RE.match("2026-06-02T120000-test-slug.md") is not None
def test_filename_re_direct_new_format():
assert FILENAME_RE.match("2026-06-07_15_00_00_123456-test-slug.md") is not None
# ---------------------------------------------------------------------------
# format_time
# ---------------------------------------------------------------------------
def test_format_time_old_format():
assert format_time("2026-06-02T143022") == "14:30"
def test_format_time_new_format():
assert format_time("2026-06-07_15_00_00_123456") == "15:00"
def test_format_time_invalid():
assert format_time("not-a-time") == "not-a-time"
# ---------------------------------------------------------------------------
# parse_timestamp
# ---------------------------------------------------------------------------
def test_parse_timestamp_old_format():
dt = parse_timestamp("2026-06-02T143022")
assert dt.hour == 14
assert dt.minute == 30
assert dt.second == 22
def test_parse_timestamp_new_format():
dt = parse_timestamp("2026-06-07_15_00_00_123456")
assert dt.hour == 15
assert dt.minute == 0
assert dt.microsecond == 123456
def test_parse_timestamp_invalid():
import pytest as _pytest
with _pytest.raises(ValueError):
parse_timestamp("not-a-timestamp")
# ---------------------------------------------------------------------------
# format_age
# ---------------------------------------------------------------------------
def test_format_age_invalid():
assert format_age("bad-value") == "?"
def test_format_age_future_clamps_to_zero():
# A timestamp far in the future still returns a non-negative age string.
result = format_age("2099-01-01T000000")
assert result.endswith("ago") or result == "0s ago"
# ---------------------------------------------------------------------------
# render_list / goal_summary
# ---------------------------------------------------------------------------
def _make_task_path(tmp_path: Path, name: str, goal: str = "Test goal.") -> Path:
path = tmp_path / name
path.write_text(
f'---\nslug: test\nchat_id: "1"\nchannel: telegram\n---\n\n# Goal\n\n{goal}\n'
)
return path
def test_render_list_basic(tmp_path):
paths = [
_make_task_path(tmp_path, "2026-06-01T120000-alpha.md", "Alpha goal."),
_make_task_path(tmp_path, "2026-06-02T130000-beta.md", "Beta goal."),
]
out = render_list(paths, len(paths))
assert "- `alpha`" in out
assert "- `beta`" in out
assert "|" not in out # no markdown table grammar
def test_render_list_shows_goal(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-01T120000-mytask.md", "Research Qdrant.")]
out = render_list(paths, 1)
assert "- `mytask`" in out
assert "\n Research Qdrant." in out # goal on its own indented line
def test_render_list_truncation_note(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-01T120000-alpha.md")]
out = render_list(paths, 5)
assert "(+ 4 older)" in out
def test_render_list_no_truncation_note_when_exact(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-01T120000-alpha.md")]
out = render_list(paths, 1)
assert "older" not in out
def test_render_list_unknown_filename(tmp_path):
path = tmp_path / "weird-name.md"
path.write_text("no frontmatter")
out = render_list([path], 1)
assert "- `weird-name.md`" in out
assert "" not in out # no table placeholders
def test_render_list_new_format_filename(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-07_15_00_00_123456-new-slug.md", "New task.")]
out = render_list(paths, 1)
assert "- `new-slug`" in out
assert "New task." in out
assert "15:00" in out
def test_render_list_omits_goal_line_when_empty(tmp_path):
path = tmp_path / "2026-06-01T120000-nogoal.md"
path.write_text('---\nslug: nogoal\n---\n\nNo goal section here.\n')
out = render_list([path], 1)
assert out.startswith("- `nogoal` · 12:00 · ")
assert "\n " not in out # no indented goal line
def test_goal_summary_truncates(tmp_path):
long_goal = "A" * 100
path = _make_task_path(tmp_path, "2026-06-01T120000-long.md", long_goal)
summary = goal_summary(path)
assert len(summary) <= 80
assert summary.endswith("")
def test_goal_summary_missing_file():
from pathlib import Path as _Path
assert goal_summary(_Path("/nonexistent/file.md")) == ""
# ---------------------------------------------------------------------------
# extract_section
# ---------------------------------------------------------------------------
def test_extract_section_found():
text = "# Goal\n\nDo something useful.\n\n# Constraints\n\n- bullet\n"
assert extract_section(text, "Goal") == "Do something useful."
def test_extract_section_not_found():
assert extract_section("# Goal\n\ntext\n", "Result") is None
def test_extract_section_stops_at_next_heading():
text = "# Goal\n\ngoal text\n\n# Result\n\nresult text\n"
assert extract_section(text, "Goal") == "goal text"
assert extract_section(text, "Result") == "result text"
# ---------------------------------------------------------------------------
# format_result (uses tmp_path)
# ---------------------------------------------------------------------------
def _make_task_file(tmp_path: Path, slug: str, goal: str, result_text: str) -> Path:
filename = f"2026-06-01T120000-{slug}.md"
path = tmp_path / "done" / filename
path.parent.mkdir(parents=True, exist_ok=True)
content = (
f"---\ncreated: 2026-06-01T12:00:00+02:00\nchannel: telegram\n"
f'chat_id: "99"\nslug: {slug}\n---\n\n'
f"# Goal\n\n{goal}\n\n# Result\n\n{result_text}\n\n"
f"---\ncompleted: 2026-06-01T12:05:00+02:00\nduration_seconds: 300\nstatus: done\n"
)
path.write_text(content)
return path
def test_format_result_contains_slug(tmp_path):
path = _make_task_file(tmp_path, "my-slug", "Research X.", "Found Y.")
output = format_result(path)
assert "my-slug" in output
def test_format_result_contains_goal(tmp_path):
path = _make_task_file(tmp_path, "task-one", "Research X.", "Found Y.")
output = format_result(path)
assert "Research X." in output
def test_format_result_contains_result(tmp_path):
path = _make_task_file(tmp_path, "task-two", "Research X.", "Found Y.")
output = format_result(path)
assert "Found Y." in output
def test_format_result_contains_meta(tmp_path):
path = _make_task_file(tmp_path, "task-three", "Do it.", "Done.")
output = format_result(path)
assert "300" in output # duration_seconds
assert "done" in output
# ---------------------------------------------------------------------------
# build_task_filename
# ---------------------------------------------------------------------------
def test_build_task_filename_old_format():
name = build_task_filename("2026-06-02T153045", "my-slug")
assert name == "2026-06-02T153045-my-slug.md"
assert FILENAME_RE.match(name) is not None
def test_build_task_filename_new_format():
name = build_task_filename("2026-06-07_15_00_00_123456", "my-slug")
assert name == "2026-06-07_15_00_00_123456-my-slug.md"
assert FILENAME_RE.match(name) is not None
# ---------------------------------------------------------------------------
# build_task_content
# ---------------------------------------------------------------------------
FIXED_TS = "2026-06-02T153045"
FIXED_ISO = "2026-06-02T15:30:45+02:00"
def test_build_task_content_frontmatter_fields():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
fm, body = parse_frontmatter(content)
assert fm["created"] == FIXED_ISO
assert fm["channel"] == "telegram"
assert fm["chat_id"] == "42"
assert fm["slug"] == "test-task"
def test_build_task_content_goal_section():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
_, body = parse_frontmatter(content)
assert extract_section(body, "Goal") == "Do the thing."
def test_build_task_content_constraints_section_has_no_interaction():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
_, body = parse_frontmatter(content)
constraints = extract_section(body, "Constraints")
assert constraints is not None
assert "No user interaction" in constraints
def test_build_task_content_extra_constraints():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=["Max 5 minutes.", "Output must be JSON."],
)
_, body = parse_frontmatter(content)
constraints = extract_section(body, "Constraints")
assert "Max 5 minutes." in constraints
assert "Output must be JSON." in constraints
def test_build_task_content_parseable_by_daemon():
"""The content produced must be parseable by parse_frontmatter as tasks-daemon does."""
content = build_task_content(
created_iso=FIXED_ISO,
channel="websocket",
chat_id="777",
slug="daemon-check",
goal="Verify parsing.",
constraints=[],
)
fm, body = parse_frontmatter(content)
assert fm.get("chat_id") == "777"
assert fm.get("channel") == "websocket"
assert "# Goal" in body
assert "# Constraints" in body
def test_build_task_content_omits_model_by_default():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
fm, _ = parse_frontmatter(content)
assert "model" not in fm
def test_build_task_content_includes_model_when_set():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
model="kimi-k2.6-openrouter",
)
fm, _ = parse_frontmatter(content)
assert fm["model"] == "kimi-k2.6-openrouter"
# ---------------------------------------------------------------------------
# resolve_preset
# ---------------------------------------------------------------------------
PRESETS = ["glm-5.1-ollama", "kimi-k2.6-openrouter", "qwen-3.5-ollama", "qwen-3.6-plus"]
def test_resolve_preset_exact_case_insensitive():
assert resolve_preset("Kimi-K2.6-OpenRouter", PRESETS) == "kimi-k2.6-openrouter"
def test_resolve_preset_unique_substring():
assert resolve_preset("kimi", PRESETS) == "kimi-k2.6-openrouter"
assert resolve_preset("glm", PRESETS) == "glm-5.1-ollama"
def test_resolve_preset_unknown_raises_with_available():
with pytest.raises(KeyError) as exc:
resolve_preset("gpt5", PRESETS)
assert "not found" in exc.value.args[0]
assert "kimi-k2.6-openrouter" in exc.value.args[0]
def test_resolve_preset_ambiguous_raises_with_candidates():
with pytest.raises(KeyError) as exc:
resolve_preset("qwen", PRESETS)
assert "ambiguous" in exc.value.args[0]
assert "qwen-3.5-ollama" in exc.value.args[0]
assert "qwen-3.6-plus" in exc.value.args[0]
# ---------------------------------------------------------------------------
# load_preset_names (uses tmp_path + monkeypatched CONFIG)
# ---------------------------------------------------------------------------
def test_load_preset_names_reads_camelcase(tmp_path, monkeypatch):
config = tmp_path / "config.json"
config.write_text(json.dumps({"modelPresets": {"b-preset": {}, "a-preset": {}}}))
monkeypatch.setattr(tasks_common, "CONFIG", config)
assert load_preset_names() == ["a-preset", "b-preset"]
def test_load_preset_names_reads_snake_case(tmp_path, monkeypatch):
config = tmp_path / "config.json"
config.write_text(json.dumps({"model_presets": {"kimi": {}, "glm": {}}}))
monkeypatch.setattr(tasks_common, "CONFIG", config)
assert load_preset_names() == ["glm", "kimi"]
def test_load_preset_names_empty_when_absent(tmp_path, monkeypatch):
config = tmp_path / "config.json"
config.write_text(json.dumps({"channels": {}}))
monkeypatch.setattr(tasks_common, "CONFIG", config)
assert load_preset_names() == []