From 80f30b705d1a3a38114f01881ae4a64b50df9885 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 31 Mar 2026 18:01:03 -0400 Subject: [PATCH] feat: Knowledge view + entity types (People, Places, Lists) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data model: - Migration 0036: adds note_type TEXT (default 'note') and metadata JSONB to the notes table; index on note_type - Note model: entity_type property, note_type/metadata in to_dict() - create_note() accepts note_type and metadata params Backend: - /api/knowledge — unified paginated endpoint: type/tag/sort/q filters, semantic search via embeddings, excludes tasks - /api/knowledge/tags — distinct tags across knowledge objects - New LLM tools: create_person, create_place, create_list, add_to_list, clear_checked_items — all wired into execute_tool() - People and places auto-injected as compact summary into LLM system prompt Frontend: - KnowledgeView replaces HomeView at /; left filter panel (type+tag), toolbar (search, sort, graph toggle), card grid with type-aware cards (indigo=note, emerald=person, amber=place, sky=list), load-more pagination - Today bar: upcoming events, overdue task count, Briefing/Chat links - Floating mini-chat sticky to bottom: creates/continues a conversation inline, message history expands upward, close button ends session - Graph panel: toggles as a 420px right panel at full viewport width - AppHeader: Knowledge, Chat, Briefing, Calendar, Tasks, Projects - Router: / → KnowledgeView; /knowledge redirect; HomeView import removed Co-Authored-By: Claude Sonnet 4.6 --- .../0036_add_note_type_and_metadata.py | 28 + frontend/src/components/AppHeader.vue | 23 +- frontend/src/router/index.ts | 9 +- frontend/src/views/KnowledgeView.vue | 1013 +++++++++++++++++ src/fabledassistant/app.py | 2 + src/fabledassistant/models/note.py | 12 + src/fabledassistant/routes/knowledge.py | 74 ++ src/fabledassistant/services/knowledge.py | 199 ++++ src/fabledassistant/services/llm.py | 5 +- src/fabledassistant/services/notes.py | 4 + src/fabledassistant/services/tools.py | 241 ++++ 11 files changed, 1593 insertions(+), 17 deletions(-) create mode 100644 alembic/versions/0036_add_note_type_and_metadata.py create mode 100644 frontend/src/views/KnowledgeView.vue create mode 100644 src/fabledassistant/routes/knowledge.py create mode 100644 src/fabledassistant/services/knowledge.py diff --git a/alembic/versions/0036_add_note_type_and_metadata.py b/alembic/versions/0036_add_note_type_and_metadata.py new file mode 100644 index 0000000..8ae48d6 --- /dev/null +++ b/alembic/versions/0036_add_note_type_and_metadata.py @@ -0,0 +1,28 @@ +"""Add note_type and metadata columns to notes table for entity types.""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0036" +down_revision = "0035" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "notes", + sa.Column("note_type", sa.Text(), nullable=False, server_default="note"), + ) + op.add_column( + "notes", + sa.Column("metadata", JSONB(), nullable=True), + ) + op.create_index("ix_notes_note_type", "notes", ["note_type"]) + + +def downgrade() -> None: + op.drop_index("ix_notes_note_type", table_name="notes") + op.drop_column("notes", "metadata") + op.drop_column("notes", "note_type") diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 7b0dd8f..62adce0 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -15,7 +15,8 @@ const chatStore = useChatStore(); const router = useRouter(); const route = useRoute(); -const isChatActive = computed(() => route.path.startsWith("/chat")); +const isChatActive = computed(() => route.path.startsWith("/chat")) +const isKnowledgeActive = computed(() => route.path === "/" || route.path === "/knowledge"); const mobileMenuOpen = ref(false); @@ -72,15 +73,12 @@ router.afterEach(() => { @@ -123,13 +121,12 @@ router.afterEach(() => {
- Notes - Projects - Tasks + Knowledge Chat - Graph - Calendar Briefing + Calendar + Tasks + Projects News Shared
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7302c5f..d6de799 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,5 +1,4 @@ import { createRouter, createWebHistory } from "vue-router"; -import HomeView from "@/views/HomeView.vue"; import { useAuthStore } from "@/stores/auth"; const router = createRouter({ @@ -7,8 +6,12 @@ const router = createRouter({ routes: [ { path: "/", - name: "home", - component: HomeView, + name: "knowledge", + component: () => import("@/views/KnowledgeView.vue"), + }, + { + path: "/knowledge", + redirect: "/", }, { path: "/login", diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue new file mode 100644 index 0000000..bda5c2e --- /dev/null +++ b/frontend/src/views/KnowledgeView.vue @@ -0,0 +1,1013 @@ + + + + + diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 13875c3..b5d45a0 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -33,6 +33,7 @@ from fabledassistant.routes.events import events_bp from fabledassistant.routes.search import search_bp from fabledassistant.routes.voice import voice_bp from fabledassistant.routes.profile import profile_bp +from fabledassistant.routes.knowledge import knowledge_bp STATIC_DIR = Path(__file__).parent / "static" logger = logging.getLogger(__name__) @@ -96,6 +97,7 @@ def create_app() -> Quart: app.register_blueprint(search_bp) app.register_blueprint(voice_bp) app.register_blueprint(profile_bp) + app.register_blueprint(knowledge_bp) @app.before_request async def before_request(): diff --git a/src/fabledassistant/models/note.py b/src/fabledassistant/models/note.py index 47635b2..cfdd318 100644 --- a/src/fabledassistant/models/note.py +++ b/src/fabledassistant/models/note.py @@ -51,6 +51,10 @@ class Note(Base, TimestampMixin): recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) + # Entity type — 'note' (default), 'person', 'place', 'list' + note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note") + # Structured metadata for entity types (person/place/list) + metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True) __table_args__ = ( Index("ix_notes_tags", "tags", postgresql_using="gin"), @@ -59,12 +63,18 @@ class Note(Base, TimestampMixin): Index("ix_notes_user_id", "user_id"), Index("ix_notes_project_id", "project_id"), Index("ix_notes_milestone_id", "milestone_id"), + Index("ix_notes_note_type", "note_type"), ) @property def is_task(self) -> bool: return self.status is not None + @property + def entity_type(self) -> str: + """Normalised type: 'note', 'person', 'place', or 'list'.""" + return self.note_type or "note" + def to_dict(self) -> dict: return { "id": self.id, @@ -86,6 +96,8 @@ class Note(Base, TimestampMixin): else None ), "is_task": self.is_task, + "note_type": self.entity_type, + "metadata": self.metadata or {}, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } diff --git a/src/fabledassistant/routes/knowledge.py b/src/fabledassistant/routes/knowledge.py new file mode 100644 index 0000000..aaa5331 --- /dev/null +++ b/src/fabledassistant/routes/knowledge.py @@ -0,0 +1,74 @@ +"""Unified Knowledge endpoint — notes, people, places, lists in one queryable feed.""" +import logging + +from quart import Blueprint, jsonify, request + +from fabledassistant.auth import get_current_user_id, login_required +from fabledassistant.routes.utils import parse_pagination + +logger = logging.getLogger(__name__) + +knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge") + +_VALID_TYPES = {"note", "person", "place", "list"} +_VALID_SORTS = {"modified", "created", "alpha", "type"} + + +@knowledge_bp.route("", methods=["GET"]) +@login_required +async def list_knowledge(): + """Return paginated knowledge objects with optional filtering. + + Query params: + type — one of note|person|place|list (omit for all, excludes tasks) + tags — comma-separated tag filter (AND logic) + sort — modified|created|alpha|type (default: modified) + q — search query (semantic when provided, keyword fallback) + page — 1-based page number (default 1) + per_page — items per page (default 24, max 100) + """ + uid = get_current_user_id() + note_type = request.args.get("type", "").strip().lower() or None + tags_raw = request.args.get("tags", "").strip() + tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else [] + sort = request.args.get("sort", "modified").strip().lower() + q = request.args.get("q", "").strip() or None + + if note_type and note_type not in _VALID_TYPES: + return jsonify({"error": f"Invalid type. Must be one of: {', '.join(sorted(_VALID_TYPES))}"}), 400 + if sort not in _VALID_SORTS: + sort = "modified" + + limit, offset = parse_pagination(default_limit=24, max_limit=100) + page = max(1, int(request.args.get("page", 1))) + + from fabledassistant.services.knowledge import query_knowledge + items, total = await query_knowledge( + user_id=uid, + note_type=note_type, + tags=tags, + sort=sort, + q=q, + limit=limit, + offset=offset, + ) + + return jsonify({ + "items": items, + "total": total, + "page": page, + "per_page": limit, + "pages": max(1, (total + limit - 1) // limit), + }) + + +@knowledge_bp.route("/tags", methods=["GET"]) +@login_required +async def list_knowledge_tags(): + """Return all tags used across knowledge objects (excludes tasks).""" + uid = get_current_user_id() + note_type = request.args.get("type", "").strip().lower() or None + + from fabledassistant.services.knowledge import get_knowledge_tags + tags = await get_knowledge_tags(uid, note_type=note_type) + return jsonify({"tags": tags}) diff --git a/src/fabledassistant/services/knowledge.py b/src/fabledassistant/services/knowledge.py new file mode 100644 index 0000000..14c22ce --- /dev/null +++ b/src/fabledassistant/services/knowledge.py @@ -0,0 +1,199 @@ +"""Knowledge service — unified query across notes, people, places, and lists.""" +import logging + +from sqlalchemy import func, select + +from fabledassistant.models import async_session +from fabledassistant.models.note import Note + +logger = logging.getLogger(__name__) + +_SNIPPET_LEN = 200 + + +def _note_to_item(note: Note) -> dict: + meta = note.metadata or {} + item: dict = { + "id": note.id, + "note_type": note.entity_type, + "title": note.title, + "snippet": (note.body or "")[:_SNIPPET_LEN], + "tags": note.tags or [], + "project_id": note.project_id, + "metadata": meta, + "created_at": note.created_at.isoformat(), + "updated_at": note.updated_at.isoformat(), + } + # Type-specific convenience fields + if note.entity_type == "person": + item["relationship"] = meta.get("relationship", "") + item["email"] = meta.get("email", "") + item["phone"] = meta.get("phone", "") + elif note.entity_type == "place": + item["address"] = meta.get("address", "") + item["phone"] = meta.get("phone", "") + item["hours"] = meta.get("hours", "") + elif note.entity_type == "list": + # Count checked / total items from markdown task list syntax + body = note.body or "" + total = body.count("- [ ]") + body.count("- [x]") + body.count("- [X]") + checked = body.count("- [x]") + body.count("- [X]") + item["item_count"] = total + item["checked_count"] = checked + return item + + +async def query_knowledge( + user_id: int, + note_type: str | None, + tags: list[str], + sort: str, + q: str | None, + limit: int, + offset: int, +) -> tuple[list[dict], int]: + """Query knowledge objects (non-task notes) with filters. + + Returns (items, total_count). + """ + # Semantic search path — scores take priority over sort + if q: + return await _semantic_knowledge_search( + user_id, q, note_type=note_type, tags=tags, limit=limit, offset=offset + ) + + async with async_session() as session: + base = ( + select(Note) + .where(Note.user_id == user_id) + .where(Note.status.is_(None)) # exclude tasks + ) + + if note_type: + base = base.where(Note.note_type == note_type) + else: + # Exclude tasks — already done above; also exclude any legacy nulls + base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) + + for tag in tags: + base = base.where(Note.tags.contains([tag])) + + # Count before pagination + count_stmt = select(func.count()).select_from(base.subquery()) + total: int = (await session.execute(count_stmt)).scalar_one() + + # Apply sort + if sort == "created": + base = base.order_by(Note.created_at.desc()) + elif sort == "alpha": + base = base.order_by(Note.title.asc()) + elif sort == "type": + base = base.order_by(Note.note_type.asc(), Note.updated_at.desc()) + else: # modified (default) + base = base.order_by(Note.updated_at.desc()) + + rows = list((await session.execute(base.limit(limit).offset(offset))).scalars().all()) + + return [_note_to_item(n) for n in rows], total + + +async def _semantic_knowledge_search( + user_id: int, + q: str, + note_type: str | None, + tags: list[str], + limit: int, + offset: int, +) -> tuple[list[dict], int]: + """Semantic search over knowledge objects, with SQL filters applied post-rank.""" + try: + from fabledassistant.services.embeddings import semantic_search_notes + # Fetch a larger candidate set to allow for filtering + candidates = await semantic_search_notes( + user_id=user_id, + query=q, + limit=min(200, limit * 8), + threshold=0.3, + is_task=False, + ) + except Exception: + logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True) + return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset) + + results = [] + for _score, note in candidates: + if note_type and note.entity_type != note_type: + continue + if tags and not all(t in (note.tags or []) for t in tags): + continue + results.append(note) + + total = len(results) + page_items = results[offset: offset + limit] + return [_note_to_item(n) for n in page_items], total + + +async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list[str]: + """Return all distinct tags used across knowledge objects for this user.""" + from sqlalchemy.dialects.postgresql import array + async with async_session() as session: + stmt = ( + select(func.unnest(Note.tags).label("tag")) + .where(Note.user_id == user_id) + .where(Note.status.is_(None)) + ) + if note_type: + stmt = stmt.where(Note.note_type == note_type) + else: + stmt = stmt.where(Note.note_type.in_(["note", "person", "place", "list"])) + + stmt = ( + select(func.unnest(Note.tags).label("tag")) + .where(Note.user_id == user_id) + .where(Note.status.is_(None)) + .distinct() + .order_by("tag") + ) + if note_type: + stmt = stmt.where(Note.note_type == note_type) + + rows = list((await session.execute(stmt)).scalars().all()) + return [r for r in rows if r] + + +async def get_people_and_places_context(user_id: int) -> str: + """Return a compact summary of known people and places for LLM system prompt injection.""" + async with async_session() as session: + stmt = ( + select(Note) + .where(Note.user_id == user_id) + .where(Note.note_type.in_(["person", "place"])) + .where(Note.status.is_(None)) + .order_by(Note.title.asc()) + .limit(50) + ) + rows = list((await session.execute(stmt)).scalars().all()) + + if not rows: + return "" + + people = [n for n in rows if n.entity_type == "person"] + places = [n for n in rows if n.entity_type == "place"] + + lines = [] + if people: + parts = [] + for p in people: + meta = p.metadata or {} + rel = meta.get("relationship", "") + parts.append(f"{p.title}" + (f" ({rel})" if rel else "")) + lines.append("Known people: " + ", ".join(parts)) + if places: + parts = [] + for p in places: + meta = p.metadata or {} + addr = meta.get("address", "") + parts.append(f"{p.title}" + (f" – {addr}" if addr else "")) + lines.append("Known places: " + "; ".join(parts)) + + return "\n".join(lines) diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 76ff0b7..9a327e4 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -515,14 +515,17 @@ async def build_context( tz_line = f" The user's timezone is {user_timezone}." if user_timezone else "" from fabledassistant.services.user_profile import build_profile_context + from fabledassistant.services.knowledge import get_people_and_places_context profile_context = await build_profile_context(user_id) profile_section = f"\n\n{profile_context}" if profile_context else "" + entities_context = await get_people_and_places_context(user_id) + entities_section = f"\n\n{entities_context}" if entities_context else "" system_parts = [ f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. " "Help users with their notes, tasks, and general questions. " "When note context is provided, use it to give relevant answers. " - f"Today's date is {today}.{tz_line}{profile_section}\n\n" + f"Today's date is {today}.{tz_line}{profile_section}{entities_section}\n\n" f"{tool_guidance}" ] diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 0af7a30..0ab3788 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -57,6 +57,8 @@ async def create_note( priority: str | None = None, due_date: date | None = None, recurrence_rule: dict | None = None, + note_type: str = "note", + metadata: dict | None = None, ) -> Note: # Auto-populate project_id from milestone when not explicitly provided if milestone_id is not None and project_id is None: @@ -82,6 +84,8 @@ async def create_note( priority=priority, due_date=due_date, recurrence_rule=recurrence_rule, + note_type=note_type, + metadata=metadata, ) session.add(note) await session.commit() diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index 3d31e38..fdfe565 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -927,11 +927,123 @@ _RAG_TOOLS = [ }, ] +_ENTITY_TOOLS = [ + { + "type": "function", + "function": { + "name": "create_person", + "description": ( + "Save a person to the user's knowledge base. Use this when the user introduces " + "someone by name or asks to remember details about a person (family member, friend, " + "contact, colleague). Stored people are used to resolve names in future prompts." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Full name of the person"}, + "relationship": {"type": "string", "description": "Relationship to the user (e.g. daughter, dentist, colleague)"}, + "phone": {"type": "string", "description": "Phone number"}, + "email": {"type": "string", "description": "Email address"}, + "birthday": {"type": "string", "description": "Birthday in YYYY-MM-DD format"}, + "address": {"type": "string", "description": "Home or mailing address"}, + "notes": {"type": "string", "description": "Any additional free-form notes about this person"}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "create_place", + "description": ( + "Save a named location to the user's knowledge base. Use this when the user wants " + "to remember a place (business, venue, address). Stored places are used to auto-fill " + "locations in calendar events and other prompts." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Name of the place (e.g. 'Dr. Smith Dental', 'Costco')"}, + "address": {"type": "string", "description": "Street address"}, + "phone": {"type": "string", "description": "Phone number"}, + "hours": {"type": "string", "description": "Opening hours (e.g. 'Mon-Fri 9am-5pm')"}, + "url": {"type": "string", "description": "Website URL"}, + "notes": {"type": "string", "description": "Any additional free-form notes about this place"}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "create_list", + "description": ( + "Create a named list (shopping list, to-do list, packing list, etc.). " + "Items are stored as a markdown task list. Use add_to_list to add items to an existing list." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "List name (e.g. 'Grocery List', 'Hardware Store')"}, + "items": { + "type": "array", + "items": {"type": "string"}, + "description": "Initial list items (unchecked)", + }, + "store": {"type": "string", "description": "Optional associated store or place name"}, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Tags for the list", + }, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "add_to_list", + "description": "Add one or more items to an existing list. Items are appended as unchecked entries.", + "parameters": { + "type": "object", + "properties": { + "list_name": {"type": "string", "description": "Name of the list to add items to"}, + "items": { + "type": "array", + "items": {"type": "string"}, + "description": "Items to add", + }, + }, + "required": ["list_name", "items"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "clear_checked_items", + "description": "Remove all checked/completed items from a list, keeping only unchecked items.", + "parameters": { + "type": "object", + "properties": { + "list_name": {"type": "string", "description": "Name of the list to clear"}, + }, + "required": ["list_name"], + }, + }, + }, +] + async def get_tools_for_user(user_id: int) -> list[dict]: """Build the tool list for a user based on their configured integrations.""" tools = list(_CORE_TOOLS) tools.extend(_RAG_TOOLS) + tools.extend(_ENTITY_TOOLS) if await is_caldav_configured(user_id): tools.extend(_CALDAV_TOOLS) if Config.searxng_enabled(): @@ -1902,6 +2014,135 @@ async def execute_tool( await update_conversation(user_id, conv_id, rag_project_id=project_id) return {"success": True, "type": "rag_scope_set", "scope_label": scope_label} + elif tool_name == "create_person": + name = str(arguments.get("name", "")).strip() + if not name: + return {"success": False, "error": "name is required"} + meta: dict = {} + for field in ("relationship", "phone", "email", "birthday", "address"): + val = arguments.get(field) + if val: + meta[field] = str(val) + extra_notes = arguments.get("notes", "") + body_lines = [] + if meta.get("relationship"): + body_lines.append(f"**Relationship:** {meta['relationship']}") + if meta.get("phone"): + body_lines.append(f"**Phone:** {meta['phone']}") + if meta.get("email"): + body_lines.append(f"**Email:** {meta['email']}") + if meta.get("birthday"): + body_lines.append(f"**Birthday:** {meta['birthday']}") + if meta.get("address"): + body_lines.append(f"**Address:** {meta['address']}") + if extra_notes: + body_lines.append(f"\n{extra_notes}") + note = await create_note( + user_id=user_id, + title=name, + body="\n".join(body_lines), + tags=["person"], + note_type="person", + metadata=meta, + ) + _schedule_embedding(note.id, user_id, name, note.body) + return {"success": True, "type": "person", "data": {"id": note.id, "name": name}} + + elif tool_name == "create_place": + name = str(arguments.get("name", "")).strip() + if not name: + return {"success": False, "error": "name is required"} + meta = {} + for field in ("address", "phone", "hours", "url"): + val = arguments.get(field) + if val: + meta[field] = str(val) + extra_notes = arguments.get("notes", "") + body_lines = [] + if meta.get("address"): + body_lines.append(f"**Address:** {meta['address']}") + if meta.get("phone"): + body_lines.append(f"**Phone:** {meta['phone']}") + if meta.get("hours"): + body_lines.append(f"**Hours:** {meta['hours']}") + if meta.get("url"): + body_lines.append(f"**Website:** {meta['url']}") + if extra_notes: + body_lines.append(f"\n{extra_notes}") + note = await create_note( + user_id=user_id, + title=name, + body="\n".join(body_lines), + tags=["place"], + note_type="place", + metadata=meta, + ) + _schedule_embedding(note.id, user_id, name, note.body) + return {"success": True, "type": "place", "data": {"id": note.id, "name": name}} + + elif tool_name == "create_list": + name = str(arguments.get("name", "")).strip() + if not name: + return {"success": False, "error": "name is required"} + items = arguments.get("items") or [] + if not isinstance(items, list): + items = [] + body = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) + meta = {} + store = arguments.get("store") + if store: + meta["store"] = str(store) + tags = arguments.get("tags") or [] + if not isinstance(tags, list): + tags = [] + note = await create_note( + user_id=user_id, + title=name, + body=body, + tags=tags, + note_type="list", + metadata=meta, + ) + _schedule_embedding(note.id, user_id, name, body) + return {"success": True, "type": "list", "data": {"id": note.id, "name": name, "item_count": len(items)}} + + elif tool_name == "add_to_list": + list_name = str(arguments.get("list_name", "")).strip() + items = arguments.get("items") or [] + if not list_name: + return {"success": False, "error": "list_name is required"} + if not isinstance(items, list) or not items: + return {"success": False, "error": "items must be a non-empty array"} + existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) + target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) + if target is None: + target = next((n for n in existing if n.note_type == "list"), None) + if target is None: + return {"success": False, "error": f"No list named '{list_name}' found. Use create_list to create it first."} + new_lines = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) + existing_body = (target.body or "").rstrip() + new_body = f"{existing_body}\n{new_lines}".lstrip() + await update_note(user_id=user_id, note_id=target.id, body=new_body) + _schedule_embedding(target.id, user_id, target.title, new_body) + return {"success": True, "type": "list_updated", "data": {"id": target.id, "name": target.title, "added": len(items)}} + + elif tool_name == "clear_checked_items": + list_name = str(arguments.get("list_name", "")).strip() + if not list_name: + return {"success": False, "error": "list_name is required"} + existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) + target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) + if target is None: + target = next((n for n in existing if n.note_type == "list"), None) + if target is None: + return {"success": False, "error": f"No list named '{list_name}' found."} + import re as _re + body = target.body or "" + cleaned = _re.sub(r"^- \[[xX]\] .+\n?", "", body, flags=_re.MULTILINE).strip() + await update_note(user_id=user_id, note_id=target.id, body=cleaned) + _schedule_embedding(target.id, user_id, target.title, cleaned) + return {"success": True, "type": "list_cleared", "data": {"id": target.id, "name": target.title}} + else: return {"success": False, "error": f"Unknown tool: {tool_name}"}