Ctrl K
ComunidadGuías…

butler/tools/project_todos.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Project-level persistent todo tools — survive across sessions."""

from __future__ import annotations

import json
import logging
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, cast

logger = logging.getLogger(__name__)

_LOCK = threading.RLock()
_MAX = 50
_TODO_PRIORITIES = ("high", "medium", "low")
_PRIORITY_RANK = {"high": 0, "medium": 1, "low": 2}


def _get_workspace() -> Path | None:
    from butler.tools.project_todos_ops import get_active_project_workspace_safe

    return cast("Path | None", get_active_project_workspace_safe())


def _todos_path(workspace: Path) -> Path:
    return workspace / ".butler" / "todos.json"


def _load(workspace: Path) -> list[dict[str, str]]:
    path = _todos_path(workspace)
    if not path.is_file():
        return []
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return []
    items = data.get("items") if isinstance(data, dict) else None
    if not isinstance(items, list):
        return []
    out: list[dict[str, str]] = []
    for row in items:
        if isinstance(row, dict) and row.get("content"):
            pr = str(row.get("priority") or "medium").strip().lower()
            if pr not in _TODO_PRIORITIES:
                pr = "medium"
            out.append({
                "id": str(row.get("id") or ""),
                "content": str(row.get("content") or ""),
                "status": str(row.get("status") or "pending"),
                "priority": pr,
            })
    return sorted(
        out,
        key=lambda t: (
            _PRIORITY_RANK.get(str(t.get("priority") or "medium"), 1),
            str(t.get("id") or ""),
        ),
    )


def _save(workspace: Path, items: list[dict[str, str]]) -> dict[str, Any]:
    record = {
        "project": workspace.name,
        "updated_at": datetime.now(timezone.utc).isoformat(),
        "items": items[:_MAX],
    }
    path = _todos_path(workspace)
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".json.tmp")
    payload = json.dumps(record, ensure_ascii=False, indent=2)
    with _LOCK:
        try:
            tmp.write_text(payload, encoding="utf-8")
            os.replace(tmp, path)
        except OSError as exc:
            logger.warning("Project todos write failed: %s", exc)
            try:
                tmp.unlink(missing_ok=True)
            except OSError:
                pass
            return {"ok": False, "error": str(exc)}
    return {"ok": True, "count": len(items[:_MAX])}


def _normalize_item(raw: Any, position: int) -> dict[str, str] | None:
    if isinstance(raw, str):
        text = raw.strip()
        if not text:
            return None
        return {"id": str(position), "content": text[:500], "status": "pending", "priority": "medium"}
    if not isinstance(raw, dict):
        return None
    content = str(raw.get("content") or raw.get("text") or "").strip()
    if not content:
        return None
    status = str(raw.get("status") or "pending").strip().lower()
    if status not in ("pending", "in_progress", "completed", "cancelled"):
        status = "pending"
    priority = str(raw.get("priority") or "medium").strip().lower()
    if priority not in _TODO_PRIORITIES:
        priority = "medium"
    item_id = str(raw.get("id") or position).strip() or str(position)
    return {"id": item_id[:32], "content": content[:500], "status": status, "priority": priority}


def _tool_project_todos_list(**_: Any) -> str:
    ws = _get_workspace()
    if ws is None:
        return json.dumps({"ok": False, "error": "NO_ACTIVE_PROJECT"})
    items = _load(ws)
    open_count = sum(1 for t in items if t.get("status") in ("pending", "in_progress"))
    return json.dumps({
        "ok": True, "project": ws.name, "count": len(items),
        "open_count": open_count, "items": items,
    }, ensure_ascii=False)


def _tool_project_todos_write(items: list[Any] | None = None, merge: bool = False, **_: Any) -> str:
    ws = _get_workspace()
    if ws is None:
        return json.dumps({"ok": False, "error": "NO_ACTIVE_PROJECT"})
    if not isinstance(items, list):
        return json.dumps({"ok": False, "error": "items must be an array"})

    if merge:
        existing = {str(t.get("id") or ""): dict(t) for t in _load(ws)}
        for raw in items:
            if isinstance(raw, dict):
                iid = str(raw.get("id") or "").strip()
                if iid and iid in existing:
                    for k in ("content", "status", "priority"):
                        v = str(raw.get(k) or "").strip()
                        if v:
                            existing[iid][k] = v
                    continue
            item = _normalize_item(raw, len(existing) + 1)
            if item:
                existing[item["id"]] = item
        normalized = list(existing.values())
    else:
        normalized = []
        for i, raw in enumerate(items):
            if len(normalized) >= _MAX:
                break
            item = _normalize_item(raw, i + 1)
            if item:
                normalized.append(item)

    result = _save(ws, normalized)
    if not result.get("ok"):
        return json.dumps(result)
    return json.dumps({
        "ok": True, "project": ws.name, "count": result["count"],
        "mode": "merge" if merge else "replace",
        "wechat": "主公可发 /项目待办 查看",
    }, ensure_ascii=False)


def format_project_todos_for_wechat(workspace: Path, *, limit: int = 10) -> str:
    items = _load(workspace)
    if not items:
        return "项目待办: (空)"
    open_items = [t for t in items if t.get("status") in ("pending", "in_progress")]
    done_items = [t for t in items if t.get("status") not in ("pending", "in_progress")]
    lines = [f"项目待办({len(open_items)} 进行中 / {len(done_items)} 已完成):"]
    for row in open_items[:limit]:
        mark = "…" if row.get("status") == "in_progress" else "○"
        pr = row.get("priority") or "medium"
        lines.append(f"  {mark} [{pr}] {row.get('content', '')[:80]}")
    if len(open_items) > limit:
        lines.append(f"  … 另有 {len(open_items) - limit} 条")
    return "\n".join(lines)


def register_project_todos_tools(register: Callable[..., None]) -> None:
    register(
        name="project_todos_list",
        description=(
            "List the persistent project-level todo checklist. "
            "Unlike session_todos, these survive across sessions."
        ),
        schema={"type": "object", "properties": {}},
        handler=_tool_project_todos_list,
        toolset="planning",
    )
    register(
        name="project_todos_write",
        description=(
            "【persistent·project JSON】Replace or merge project-level todos on disk. "
            "merge=true updates by id. Survives gateway restarts and /new."
        ),
        schema={
            "type": "object",
            "properties": {
                "items": {
                    "type": "array",
                    "description": "Todo rows: {id?, content, status?, priority?}",
                    "items": {
                        "type": "object",
                        "properties": {
                            "id": {"type": "string"},
                            "content": {"type": "string"},
                            "status": {"type": "string", "enum": ["pending", "in_progress", "completed", "cancelled"]},
                            "priority": {"type": "string", "enum": ["high", "medium", "low"]},
                        },
                        "required": ["content"],
                    },
                },
                "merge": {"type": "boolean", "description": "Merge by id instead of replace-all", "default": False},
            },
            "required": ["items"],
        },
        handler=_tool_project_todos_write,
        toolset="planning",
    )