Ctrl K
ComunidadGuías…

butler/tools/memo.py

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

Archivo original ↗Preguntar a KiBot
"""Personal memo system — cross-project structured notes for daily life.

Memos are owner-level (tenant-scoped), not project-scoped. They persist
under ``~/.butler/tenants/<tenant>/memos/`` as one JSON file per entry.

Unlike ``butler_remember`` (cognitive memory the butler learns), memos are
explicit items the owner asks the butler to track — appointments, shopping
lists, health notes, etc.
"""

from __future__ import annotations

import json
import logging
import time
import uuid
from pathlib import Path
from typing import Any, Callable

from butler.tools.pim_schema import (
    MAX_ACTIVE_MEMOS as _MAX_ACTIVE,
    MAX_MEMO_CONTENT_LEN as _MAX_CONTENT_LEN,
    MAX_MEMO_TAGS as _MAX_TAGS,
    MAX_TAG_LEN as _MAX_TAG_LEN,
    MEMO_CATEGORIES as _VALID_CATEGORIES,
    MEMO_CATEGORY_LABELS as _CATEGORY_LABELS,
    MEMO_PRIORITIES as _VALID_PRIORITIES,
    MEMO_PRIORITY_RANK as _PRIORITY_RANK,
    MEMO_STATUSES as _VALID_STATUSES,
)
from butler.tools.tenant_store import TenantStore

logger = logging.getLogger(__name__)

_STATUS_LABELS = {"active": "活跃", "done": "已完成", "archived": "已归档"}

_base_store = TenantStore(
    "memos", env_toggle="BUTLER_MEMO_ENABLED", skip_files=frozenset({"index.json"}),
)
_store = _base_store


def _memo_enabled() -> bool:
    return bool(_store.enabled())


def _memos_dir() -> Path:
    return Path(_base_store.storage_dir())


def _normalize_tags(raw: Any) -> list[str]:
    if not raw:
        return []
    if isinstance(raw, str):
        raw = [t.strip() for t in raw.replace(",", ",").split(",")]
    if not isinstance(raw, list):
        return []
    tags: list[str] = []
    for t in raw:
        s = str(t).strip()[:_MAX_TAG_LEN]
        if s and s not in tags:
            tags.append(s)
        if len(tags) >= _MAX_TAGS:
            break
    return tags


def _normalize_category(raw: Any) -> str:
    val = str(raw or "").strip().lower()
    return val if val in _VALID_CATEGORIES else "general"


def _normalize_status(raw: Any) -> str:
    val = str(raw or "").strip().lower()
    return val if val in _VALID_STATUSES else "active"


def _normalize_priority(raw: Any) -> str:
    val = str(raw or "").strip().lower()
    return val if val in _VALID_PRIORITIES else "normal"


def _sort_memos(memos: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return sorted(memos, key=lambda m: (
        _PRIORITY_RANK.get(m.get("priority", "normal"), 2),
        -m.get("created_at", 0),
    ))


# ── Tool Handlers ──────────────────────────────────────────────


def tool_memo_add(
    content: str,
    category: str = "",
    tags: Any = None,
    priority: str = "",
    due_date: str = "",
    **_: Any,
) -> str:
    if not _memo_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_MEMO_ENABLED=0"})

    content = (content or "").strip()
    if not content:
        return json.dumps({"ok": False, "error": "content is required"})
    content = content[:_MAX_CONTENT_LEN]

    active_count = sum(1 for m in _store.load_all() if m.get("status") == "active")
    if active_count >= _MAX_ACTIVE:
        return json.dumps({
            "ok": False,
            "error": f"Active memo limit reached ({_MAX_ACTIVE}). Complete or archive some first.",
        })

    now = time.time()
    mid = uuid.uuid4().hex[:10]
    memo: dict[str, Any] = {
        "id": mid,
        "content": content,
        "category": _normalize_category(category),
        "tags": _normalize_tags(tags),
        "status": "active",
        "priority": _normalize_priority(priority),
        "due_date": (due_date or "").strip(),
        "created_at": now,
        "updated_at": now,
        "source": "tool",
    }
    _store.save(memo)

    result: dict[str, Any] = {
        "ok": True,
        "memo_id": mid,
        "content": content,
        "category": memo["category"],
    }
    if memo["due_date"]:
        result["due_date"] = memo["due_date"]
        result["suggestion"] = (
            f"备忘已创建。建议设置提醒: "
            f"set_reminder(message='{content[:50]}', when='{memo['due_date']}')"
        )
    return json.dumps(result, ensure_ascii=False)


def tool_memo_list(
    status: str = "active",
    category: str = "",
    limit: int = 20,
    **_: Any,
) -> str:
    if not _memo_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_MEMO_ENABLED=0"})

    all_memos = _store.load_all()
    target_status = _normalize_status(status) if status else "active"

    filtered = [m for m in all_memos if m.get("status") == target_status]
    if category:
        cat = _normalize_category(category)
        if cat != "general" or category.strip().lower() == "general":
            filtered = [m for m in filtered if m.get("category") == cat]

    filtered = _sort_memos(filtered)
    limit = max(1, min(int(limit or 20), 50))
    active_total = sum(1 for m in all_memos if m.get("status") == "active")
    done_total = sum(1 for m in all_memos if m.get("status") == "done")

    return json.dumps({
        "ok": True,
        "count": len(filtered),
        "active_total": active_total,
        "done_total": done_total,
        "memos": filtered[:limit],
    }, ensure_ascii=False)


def tool_memo_search(
    query: str = "",
    limit: int = 20,
    **_: Any,
) -> str:
    if not _memo_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_MEMO_ENABLED=0"})

    q = (query or "").strip().lower()
    if not q:
        return tool_memo_list()

    all_memos = _store.load_all()
    matched: list[dict[str, Any]] = []
    for m in all_memos:
        text = (m.get("content", "") + " " + " ".join(m.get("tags", []))).lower()
        if q in text:
            matched.append(m)

    matched = _sort_memos(matched)
    limit = max(1, min(int(limit or 20), 50))

    return json.dumps({
        "ok": True,
        "query": query,
        "count": len(matched),
        "memos": matched[:limit],
    }, ensure_ascii=False)


def tool_memo_update(
    memo_id: str,
    content: str = "",
    status: str = "",
    priority: str = "",
    tags: Any = None,
    due_date: str = "",
    **_: Any,
) -> str:
    if not _memo_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_MEMO_ENABLED=0"})

    mid = (memo_id or "").strip()
    if not mid:
        return json.dumps({"ok": False, "error": "memo_id is required"})

    memo = _store.find_by_prefix(mid)
    if memo is None:
        return json.dumps({"ok": False, "error": f"Memo '{mid}' not found"})
    mid = memo["id"]

    changed = False
    if content and content.strip():
        memo["content"] = content.strip()[:_MAX_CONTENT_LEN]
        changed = True
    if status and status.strip():
        new_status = _normalize_status(status)
        if new_status != memo.get("status"):
            memo["status"] = new_status
            changed = True
    if priority and priority.strip():
        new_pri = _normalize_priority(priority)
        if new_pri != memo.get("priority"):
            memo["priority"] = new_pri
            changed = True
    if tags is not None:
        memo["tags"] = _normalize_tags(tags)
        changed = True
    if due_date is not None and due_date != "":
        memo["due_date"] = due_date.strip()
        changed = True

    if changed:
        memo["updated_at"] = time.time()
        _store.save(memo)

    return json.dumps({
        "ok": True,
        "memo_id": mid,
        "updated": changed,
        "status": memo.get("status"),
    }, ensure_ascii=False)


def tool_memo_delete(memo_id: str, **_: Any) -> str:
    if not _memo_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_MEMO_ENABLED=0"})

    mid = (memo_id or "").strip()
    if not mid:
        return json.dumps({"ok": False, "error": "memo_id is required"})

    memo = _store.find_by_prefix(mid)
    if memo is not None and _store.delete(memo["id"]):
        return json.dumps({"ok": True, "deleted": memo["id"]})

    return json.dumps({"ok": False, "error": f"Memo '{mid}' not found"})


# ── WeChat Display ─────────────────────────────────────────────


def format_memos_for_wechat(arg: str = "", *, limit: int = 15) -> str:
    if not _memo_enabled():
        return "备忘录功能未启用 (BUTLER_MEMO_ENABLED=0)"

    arg = (arg or "").strip()

    if arg.startswith("搜索 ") or arg.startswith("search "):
        query = arg.split(maxsplit=1)[1] if " " in arg else ""
        return _format_search_result(query, limit=limit)

    if arg.startswith("添加 ") or arg.startswith("add "):
        content = arg.split(maxsplit=1)[1] if " " in arg else ""
        if content:
            raw = tool_memo_add(content=content, source="slash")
            data = json.loads(raw)
            if data.get("ok"):
                return f"✅ 备忘已添加 [{data['memo_id']}]\n{content}"
            return f"❌ {data.get('error', '添加失败')}"
        return "用法: /备忘 添加 <内容>"

    if arg.startswith("完成 ") or arg.startswith("done "):
        mid = arg.split(maxsplit=1)[1].strip() if " " in arg else ""
        if mid:
            raw = tool_memo_update(memo_id=mid, status="done")
            data = json.loads(raw)
            if data.get("ok"):
                return f"✅ 备忘 [{data['memo_id']}] 已标记完成"
            return f"❌ {data.get('error', '操作失败')}"
        return "用法: /备忘 完成 <id>"

    return _format_memo_list(limit=limit)


def _format_memo_list(*, limit: int = 15) -> str:
    all_memos = _store.load_all()
    active = _sort_memos([m for m in all_memos if m.get("status") == "active"])
    done_count = sum(1 for m in all_memos if m.get("status") == "done")

    if not active:
        return "📋 备忘录为空\n\n添加: /备忘 添加 <内容>\n或对话中说「帮我记一下…」"

    lines = [f"📋 备忘录 ({len(active)}条活跃)\n"]
    pri_icons = {"urgent": "🔴", "high": "🟠", "normal": "⚪", "low": "🔵"}

    for m in active[:limit]:
        icon = pri_icons.get(m.get("priority", "normal"), "⚪")
        mid = m["id"][:4]
        content = m.get("content", "")[:60]
        line = f"{icon} [{mid}] {content}"

        meta = []
        cat = m.get("category", "general")
        if cat != "general":
            meta.append(_CATEGORY_LABELS.get(cat, cat))
        if m.get("due_date"):
            meta.append(f"截止: {m['due_date'][:10]}")
        tags = m.get("tags", [])
        if tags:
            meta.append(", ".join(tags[:3]))
        if meta:
            line += f"\n   {' | '.join(meta)}"
        lines.append(line)

    if len(active) > limit:
        lines.append(f"\n... 还有 {len(active) - limit} 条")
    footer_parts = []
    if done_count:
        footer_parts.append(f"✅ 已完成: {done_count}条")
    footer_parts.append("/备忘 搜索 <关键词>")
    lines.append(f"\n{'  |  '.join(footer_parts)}")
    return "\n".join(lines)


def _format_search_result(query: str, *, limit: int = 15) -> str:
    if not query:
        return "用法: /备忘 搜索 <关键词>"
    raw = tool_memo_search(query=query, limit=limit)
    data = json.loads(raw)
    memos = data.get("memos", [])
    if not memos:
        return f"🔍 未找到「{query}」相关备忘"

    lines = [f"🔍 搜索「{query}」找到 {len(memos)} 条\n"]
    for m in memos:
        status_icon = "✅" if m.get("status") == "done" else "⚪"
        mid = m["id"][:4]
        content = m.get("content", "")[:60]
        lines.append(f"{status_icon} [{mid}] {content}")
    return "\n".join(lines)


# ── Registration ───────────────────────────────────────────────


def register_memo_tools(register: Callable[..., None]) -> None:
    if not _memo_enabled():
        return

    register(
        name="memo_add",
        description=(
            "为主人创建备忘录条目。用于记录日常事务、约会、购物清单、健康信息等。"
            "用户说「帮我记一下…」「别忘了…」时应使用此工具。"
            "如果有明确时间点且需要推送提醒,应配合 set_reminder 使用。"
            "注意:定时推送提醒请用 set_reminder,个人偏好请用 butler_remember。"
        ),
        schema={
            "type": "object",
            "properties": {
                "content": {"type": "string", "description": "备忘内容"},
                "category": {
                    "type": "string",
                    "enum": list(_VALID_CATEGORIES),
                    "description": "分类: general/health/finance/travel/shopping/social/work",
                },
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "可选标签列表",
                },
                "priority": {
                    "type": "string",
                    "enum": list(_VALID_PRIORITIES),
                    "description": "优先级: low/normal/high/urgent",
                },
                "due_date": {
                    "type": "string",
                    "description": "可选截止日期 (ISO8601 或自然语言如 '周六 14:00')",
                },
            },
            "required": ["content"],
        },
        handler=tool_memo_add,
        toolset="memo",
    )

    register(
        name="memo_list",
        description="列出主人的备忘录。可按状态和分类筛选。",
        schema={
            "type": "object",
            "properties": {
                "status": {
                    "type": "string",
                    "enum": list(_VALID_STATUSES),
                    "description": "筛选状态: active(默认)/done/archived",
                },
                "category": {
                    "type": "string",
                    "enum": list(_VALID_CATEGORIES),
                    "description": "筛选分类",
                },
                "limit": {
                    "type": "integer",
                    "description": "最多返回条数 (默认 20, 上限 50)",
                },
            },
        },
        handler=tool_memo_list,
        toolset="memo",
    )

    register(
        name="memo_search",
        description=(
            "【read-only·query】按关键词检索备忘录正文与 tags,返回 memo_id 与摘要。"
            "场景:找备忘、不知道 id 时先搜。"
            "不写库、不改 status。"
        ),
        schema={
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "搜索关键词"},
                "limit": {"type": "integer", "description": "最多返回条数"},
            },
            "required": ["query"],
        },
        handler=tool_memo_search,
        toolset="memo",
    )

    register(
        name="memo_update",
        description=(
            "【mutation·patch】按 memo_id 更新 status/content/priority/tags。"
            "场景:标记完成、改文案。"
            "不执行关键词检索。"
        ),
        schema={
            "type": "object",
            "properties": {
                "memo_id": {"type": "string", "description": "备忘 ID(支持前缀匹配)"},
                "content": {"type": "string", "description": "新内容"},
                "status": {
                    "type": "string",
                    "enum": list(_VALID_STATUSES),
                    "description": "新状态",
                },
                "priority": {
                    "type": "string",
                    "enum": list(_VALID_PRIORITIES),
                    "description": "新优先级",
                },
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "替换标签列表",
                },
                "due_date": {"type": "string", "description": "新截止日期"},
            },
            "required": ["memo_id"],
        },
        handler=tool_memo_update,
        toolset="memo",
    )

    register(
        name="memo_delete",
        description="永久删除一条备忘录。",
        schema={
            "type": "object",
            "properties": {
                "memo_id": {"type": "string", "description": "备忘 ID(支持前缀匹配)"},
            },
            "required": ["memo_id"],
        },
        handler=tool_memo_delete,
        toolset="memo",
    )