b255a0f90e
Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
8.2 KiB
Python
246 lines
8.2 KiB
Python
"""Typed-entity MCP tools: person, place, list.
|
|
|
|
These are notes with a non-'note' note_type and type-specific JSON metadata
|
|
stored in the entity_meta column. Three tools per type — list, create, update.
|
|
For get and delete, use get_note / delete_note (typed entities
|
|
share the Note model).
|
|
|
|
The wrappers translate typed-field kwargs into the entity_meta dict shape that
|
|
KnowledgeView.vue and services/knowledge.py expect.
|
|
|
|
Lists: a list entity stores its items in entity_meta["list_items"] as a list
|
|
of {text, checked} dicts. The create/update tools take a simpler `items` list
|
|
of plain strings for ergonomics; checked-state is reset to False on each call.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from scribe.mcp._context import current_user_id
|
|
from scribe.services import knowledge as knowledge_svc
|
|
from scribe.services import notes as notes_svc
|
|
|
|
|
|
async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict:
|
|
"""Common list query for a typed entity."""
|
|
uid = current_user_id()
|
|
items, total = await knowledge_svc.query_knowledge(
|
|
user_id=uid,
|
|
note_type=note_type,
|
|
tags=[tag] if tag else [],
|
|
sort="modified",
|
|
q=q or None,
|
|
limit=max(1, min(limit, 100)),
|
|
offset=0,
|
|
)
|
|
# Map "items" key to a type-specific key for caller clarity.
|
|
plural = {"person": "persons", "place": "places", "list": "lists"}[note_type]
|
|
return {plural: items, "total": total}
|
|
|
|
|
|
async def _create_entity(note_type: str, name: str, entity_meta: dict,
|
|
tags: list[str] | None = None) -> dict:
|
|
"""Create a typed note with the given metadata."""
|
|
uid = current_user_id()
|
|
note = await notes_svc.create_note(
|
|
uid,
|
|
title=name,
|
|
note_type=note_type,
|
|
entity_meta=entity_meta or None,
|
|
tags=tags,
|
|
)
|
|
return note.to_dict()
|
|
|
|
|
|
async def _update_entity(entity_id: int, note_type: str, name: str,
|
|
meta_updates: dict) -> dict:
|
|
"""Merge updates into entity_meta and (optionally) update the title."""
|
|
uid = current_user_id()
|
|
note = await notes_svc.get_note(uid, entity_id)
|
|
if note is None or note.note_type != note_type:
|
|
raise ValueError(f"{note_type} {entity_id} not found")
|
|
new_meta = dict(note.entity_meta or {})
|
|
new_meta.update(meta_updates)
|
|
fields: dict = {"entity_meta": new_meta}
|
|
if name:
|
|
fields["title"] = name
|
|
updated = await notes_svc.update_note(uid, entity_id, **fields)
|
|
return updated.to_dict()
|
|
|
|
|
|
# ─── Person ──────────────────────────────────────────────────────────────────
|
|
|
|
_PERSON_FIELDS = ("relationship", "email", "phone", "birthday",
|
|
"organization", "address")
|
|
|
|
|
|
async def list_persons(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
|
"""List people in the user's knowledge base.
|
|
|
|
Args:
|
|
q: Free-text search across name + body (optional).
|
|
tag: Filter to a single tag (optional).
|
|
limit: Max results (1-100).
|
|
"""
|
|
return await _list_by_type("person", q, tag, limit)
|
|
|
|
|
|
async def create_person(
|
|
name: str,
|
|
relationship: str = "",
|
|
email: str = "",
|
|
phone: str = "",
|
|
birthday: str = "",
|
|
organization: str = "",
|
|
address: str = "",
|
|
tags: list[str] | None = None,
|
|
) -> dict:
|
|
"""Create a person in the user's knowledge base.
|
|
|
|
Args:
|
|
name: Person's name (required).
|
|
relationship: How the user knows them (e.g. "colleague", "friend").
|
|
email / phone / birthday (YYYY-MM-DD) / organization / address: optional.
|
|
tags: Plain-string tags, no # prefix.
|
|
"""
|
|
meta = {f: v for f, v in (
|
|
("relationship", relationship), ("email", email), ("phone", phone),
|
|
("birthday", birthday), ("organization", organization),
|
|
("address", address),
|
|
) if v}
|
|
return await _create_entity("person", name, meta, tags)
|
|
|
|
|
|
async def update_person(
|
|
person_id: int,
|
|
name: str = "",
|
|
relationship: str = "",
|
|
email: str = "",
|
|
phone: str = "",
|
|
birthday: str = "",
|
|
organization: str = "",
|
|
address: str = "",
|
|
) -> dict:
|
|
"""Update a person. Only explicitly provided fields are changed.
|
|
|
|
To clear a field, pass an explicit space character (this preserves the
|
|
fable-mcp empty-string-means-omit convention).
|
|
"""
|
|
meta_updates = {f: v for f, v in (
|
|
("relationship", relationship), ("email", email), ("phone", phone),
|
|
("birthday", birthday), ("organization", organization),
|
|
("address", address),
|
|
) if v}
|
|
return await _update_entity(person_id, "person", name, meta_updates)
|
|
|
|
|
|
# ─── Place ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def list_places(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
|
"""List places (cafes, offices, addresses) in the user's knowledge base."""
|
|
return await _list_by_type("place", q, tag, limit)
|
|
|
|
|
|
async def create_place(
|
|
name: str,
|
|
address: str = "",
|
|
phone: str = "",
|
|
hours: str = "",
|
|
website: str = "",
|
|
category: str = "",
|
|
tags: list[str] | None = None,
|
|
) -> dict:
|
|
"""Create a place in the user's knowledge base.
|
|
|
|
Args:
|
|
name: Place name (required).
|
|
address / phone / hours / website / category: optional.
|
|
tags: Plain-string tags, no # prefix.
|
|
"""
|
|
meta = {f: v for f, v in (
|
|
("address", address), ("phone", phone), ("hours", hours),
|
|
("website", website), ("category", category),
|
|
) if v}
|
|
return await _create_entity("place", name, meta, tags)
|
|
|
|
|
|
async def update_place(
|
|
place_id: int,
|
|
name: str = "",
|
|
address: str = "",
|
|
phone: str = "",
|
|
hours: str = "",
|
|
website: str = "",
|
|
category: str = "",
|
|
) -> dict:
|
|
"""Update a place. Only explicitly provided fields are changed."""
|
|
meta_updates = {f: v for f, v in (
|
|
("address", address), ("phone", phone), ("hours", hours),
|
|
("website", website), ("category", category),
|
|
) if v}
|
|
return await _update_entity(place_id, "place", name, meta_updates)
|
|
|
|
|
|
# ─── List ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def list_lists(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
|
"""List checklists in the user's knowledge base."""
|
|
return await _list_by_type("list", q, tag, limit)
|
|
|
|
|
|
async def create_list(
|
|
name: str,
|
|
category: str = "",
|
|
items: list[str] | None = None,
|
|
tags: list[str] | None = None,
|
|
) -> dict:
|
|
"""Create a checklist (a list-type entity).
|
|
|
|
Args:
|
|
name: List name (required).
|
|
category: Optional category label (e.g. "shopping", "packing").
|
|
items: Initial item texts. All items start unchecked.
|
|
tags: Plain-string tags, no # prefix.
|
|
"""
|
|
meta: dict = {}
|
|
if category:
|
|
meta["category"] = category
|
|
if items:
|
|
meta["list_items"] = [{"text": t, "checked": False} for t in items]
|
|
return await _create_entity("list", name, meta, tags)
|
|
|
|
|
|
async def update_list(
|
|
list_id: int,
|
|
name: str = "",
|
|
category: str = "",
|
|
items: list[str] | None = None,
|
|
) -> dict:
|
|
"""Update a checklist.
|
|
|
|
Args:
|
|
list_id: ID of the list to update.
|
|
name: New title (optional).
|
|
category: New category (optional).
|
|
items: REPLACES the entire item set with these texts (all reset to
|
|
unchecked). Omit (None) to leave items unchanged. Pass an empty
|
|
list to clear all items.
|
|
"""
|
|
meta_updates: dict = {}
|
|
if category:
|
|
meta_updates["category"] = category
|
|
if items is not None:
|
|
meta_updates["list_items"] = [
|
|
{"text": t, "checked": False} for t in items
|
|
]
|
|
return await _update_entity(list_id, "list", name, meta_updates)
|
|
|
|
|
|
def register(mcp) -> None:
|
|
for fn in (
|
|
list_persons, create_person, update_person,
|
|
list_places, create_place, update_place,
|
|
list_lists, create_list, update_list,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|