Zalohovani vsech podstatnych souboru
This commit is contained in:
108
skills/detach/SKILL.md
Normal file
108
skills/detach/SKILL.md
Normal 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**: 3–5 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._
|
||||
78
skills/detach/architecture.md
Normal file
78
skills/detach/architecture.md
Normal 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
|
||||
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"
|
||||
)
|
||||
9
skills/detach/systemd/tasks-daemon.path
Normal file
9
skills/detach/systemd/tasks-daemon.path
Normal 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
|
||||
18
skills/detach/systemd/tasks-daemon.service
Normal file
18
skills/detach/systemd/tasks-daemon.service
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
5
skills/detach/tests/conftest.py
Normal file
5
skills/detach/tests/conftest.py
Normal 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"))
|
||||
490
skills/detach/tests/test_tasks_common.py
Normal file
490
skills/detach/tests/test_tasks_common.py
Normal 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() == []
|
||||
Reference in New Issue
Block a user