"""Personal memo system — cross-project structured notes for daily life. Memos are owner-level (tenant-scoped), not project-scoped. They persist under ``~/.butler/tenants//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 "用法: /备忘 完成 " 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", )