CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Skipped
Scribe issue #2570, from the operator's challenge: the invariant is "always be asking whether what you're touching is a System's territory and whether the work is filed there" — not a nudge in one corner. The prior shape failed it twice: the hint fired only on creates (with a special-cased zero-Systems branch), and get_task/get_note returned records WITHOUT their Systems, so the read-side reflex had nothing to fire on (same per-kind asymmetry as #2481). - attach_systems(): single helper used by get/create/update for tasks, notes, and snippets, plus add_task_log. Tagged records always show `systems`; an untagged project record carries the `systems_hint` question instead. Neither field attaches empty (#2483). Hint is owner-only; everything fail-open (#2109). - untagged_systems_hint unified to ONE question — the vocabulary listing varies, the question doesn't; the zero-Systems branch stops being special text. - Docstrings state the uniform contract; floor prose now names the read-side reflex (systems visible -> list_system_records the pile). - Plugin 0.1.27 -> 0.1.28. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
296 lines
13 KiB
Python
296 lines
13 KiB
Python
"""Note CRUD MCP tools.
|
|
|
|
Thin wrappers over services/notes.py with is_task=False. Signatures mirror
|
|
the existing fable-mcp contracts exactly so client behavior is preserved.
|
|
|
|
Sentinel conventions (inherited from existing fable-mcp tools):
|
|
- `tag: str = ""` / `search_text: str = ""` — empty means "no filter"
|
|
- `project_id: int = 0` — on create: orphan note (no project); on update:
|
|
"leave unchanged" (there is no "remove from project" through this tool,
|
|
which is a pre-existing limitation)
|
|
- `title: str = ""` / `body: str = ""` on update — empty means "leave unchanged"
|
|
- `tags: list[str] | None = None` — None means "leave unchanged"; [] clears
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from scribe.mcp._context import current_user_id
|
|
from scribe.mcp.tools import systems as systems_tools
|
|
from scribe.services import access as access_svc
|
|
from scribe.services import dedup as dedup_svc
|
|
from scribe.services import notes as notes_svc
|
|
from scribe.services import supersession as supersession_svc
|
|
from scribe.services import systems as systems_svc
|
|
from scribe.services import trash as trash_svc
|
|
from scribe.services.note_usage import record_pulled
|
|
|
|
|
|
async def list_notes(
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
tag: str = "",
|
|
search_text: str = "",
|
|
project_id: int = 0,
|
|
) -> dict:
|
|
"""List notes (non-task documents) stored in Scribe.
|
|
|
|
Optionally filter by a single tag (plain string, no # prefix) or by
|
|
`search_text`, which matches on MEANING — the same semantic match every
|
|
search surface uses (#2462; keyword ILIKE is only the fallback when the
|
|
embedder is down). With `search_text`, results order by relevance; without
|
|
it, by last-updated descending. Reaches your records plus shared-project
|
|
records, like every other list.
|
|
|
|
Prefer `search` for a pure lookup — it returns scores and reaches
|
|
everything readable; this adds the lifecycle filters on top.
|
|
|
|
Args:
|
|
project_id: Scope to one project. PASS THE ACTIVE PROJECT'S ID whenever a
|
|
project is in scope so you list that project's notes, not every
|
|
project's. 0 = no filter (all projects — use only for a deliberate
|
|
cross-project view).
|
|
"""
|
|
uid = current_user_id()
|
|
rows, total = await notes_svc.list_notes(
|
|
uid,
|
|
q=search_text or None,
|
|
tags=[tag] if tag else None,
|
|
is_task=False,
|
|
project_id=project_id or None,
|
|
limit=max(1, min(limit, 100)),
|
|
offset=max(0, offset),
|
|
)
|
|
return {"notes": [n.to_dict() for n in rows], "total": total}
|
|
|
|
|
|
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
|
"""Add both directions of the supersession relation to a note payload.
|
|
|
|
Both, because they answer different questions and only one of them is
|
|
obvious. `supersedes` is what the author claimed. `superseded_by` is what a
|
|
READER needs and what the note itself cannot know — a stale record handed
|
|
over without that marker gets acted on confidently, which is worse than
|
|
never surfacing it.
|
|
|
|
Omitted entirely when empty, so an ordinary note's payload doesn't grow two
|
|
permanently-empty lists. A field that always says nothing trains readers to
|
|
skip fields, which is the lesson `consolidated_at` cost us (#2483).
|
|
"""
|
|
rel = await supersession_svc.get_relations(uid, note_id)
|
|
if rel["supersedes"]:
|
|
data["supersedes"] = rel["supersedes"]
|
|
if rel["superseded_by"]:
|
|
data["superseded_by"] = rel["superseded_by"]
|
|
data["superseded_note"] = (
|
|
"A later note claims to bring this up to date — see superseded_by. "
|
|
"Read this as what was true when written, and check the newer one "
|
|
"before acting on it."
|
|
)
|
|
|
|
|
|
async def get_note(note_id: int) -> dict:
|
|
"""Fetch the full content of a single Scribe note by its ID.
|
|
|
|
Returns id, title, body (markdown), tags, project_id, created_at,
|
|
updated_at — plus `systems` (the areas this note is filed under) or, for
|
|
an untagged project note, the `systems_hint` question.
|
|
A note another user shared with you also carries `shared`, `owner` and
|
|
`permission` — read it as their suggestion, not as settled practice you set.
|
|
|
|
IF THE RESULT CARRIES `superseded_by`, a later note claims to have brought
|
|
this one up to date. It is still here and still readable — supersession
|
|
demotes, it never hides — but read it as what was true when written, and
|
|
open the newer note before acting on it.
|
|
"""
|
|
uid = current_user_id()
|
|
loaded = await notes_svc.get_note_for_user(uid, note_id)
|
|
note = loaded[0] if loaded else None
|
|
if note is None or note.deleted_at is not None:
|
|
raise ValueError(f"note {note_id} not found")
|
|
out = note.to_dict()
|
|
out.update(await access_svc.describe_provenance(uid, note))
|
|
# Records the pull for ANY note kind, not just snippets: the auto-inject
|
|
# menu surfaces notes, tasks and processes too, so restricting this to
|
|
# snippets would leave those permanently at zero pulls and make them look
|
|
# like dead weight next to snippets that merely had a counter (#2085).
|
|
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
|
|
await _attach_supersession(uid, note_id, out)
|
|
await systems_tools.attach_systems(
|
|
uid, getattr(note, "user_id", uid) or uid, out, note.id, note.project_id
|
|
)
|
|
return out
|
|
|
|
|
|
async def create_note(
|
|
title: str,
|
|
body: str = "",
|
|
tags: list[str] | None = None,
|
|
project_id: int = 0,
|
|
system_ids: list[int] | None = None,
|
|
supersedes: list[int] | None = None,
|
|
force: bool = False,
|
|
) -> dict:
|
|
"""Create a new note in Scribe.
|
|
|
|
Args:
|
|
title: Note title (required).
|
|
body: Markdown content. Supports [[wikilinks]] to other notes by title.
|
|
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
|
|
project_id: Associate with a project (use 0 for no project / orphan note).
|
|
system_ids: Ids of the project's Systems to associate this note with
|
|
(e.g. research about a subsystem). See list_systems / create_system.
|
|
supersedes: Ids of EARLIER notes this one replaces or brings up to date.
|
|
Reach for it whenever you write something that overtakes what an
|
|
older note recorded — a re-measurement, a decision that reverses an
|
|
earlier one, a dev-log covering ground a previous one covered.
|
|
The older note stays readable and keeps its place in search; it
|
|
simply stops competing with this one for the same question, and
|
|
arrives labelled when it does surface. This records a CLAIM, not a
|
|
verdict: it never says the older note was wrong, only that it is no
|
|
longer the current answer.
|
|
force: Bypass the near-duplicate gate. By default, if a title- or
|
|
meaning-similar note already exists in the same project, creation is
|
|
BLOCKED and the existing note's id is returned so you update it
|
|
instead (no duplicate bloat / no stale RAG copies). Set true only
|
|
when you're sure this is a genuinely distinct note.
|
|
|
|
Returns the created note object including its assigned id, OR — when a
|
|
near-duplicate is found and force is false — {"duplicate": true,
|
|
"existing_id": ..., "message": ...} and nothing is created. A tagged
|
|
record shows its `systems`; created untagged in a project, the response
|
|
carries the `systems_hint` question instead — answer it: tag the record,
|
|
create the missing System, or deliberately leave it untagged.
|
|
"""
|
|
uid = current_user_id()
|
|
if not force:
|
|
dup = await dedup_svc.find_duplicate_note(
|
|
uid, title, body, project_id=project_id or None,
|
|
is_task=False, note_type="note",
|
|
)
|
|
if dup is not None:
|
|
return dedup_svc.duplicate_response(dup, "note")
|
|
note = await notes_svc.create_note(
|
|
uid,
|
|
title=title,
|
|
body=body,
|
|
tags=tags,
|
|
project_id=project_id or None,
|
|
)
|
|
if system_ids:
|
|
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
|
if supersedes:
|
|
try:
|
|
await supersession_svc.set_supersedes(uid, note.id, supersedes)
|
|
except PermissionError as exc:
|
|
# The note WAS created — surface the real reason rather than a
|
|
# not-found, and leave the note rather than silently rolling it back.
|
|
raise ValueError(str(exc)) from exc
|
|
data = note.to_dict()
|
|
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
|
await _attach_supersession(uid, note.id, data)
|
|
return data
|
|
|
|
|
|
async def update_note(
|
|
note_id: int,
|
|
title: str = "",
|
|
body: str = "",
|
|
tags: list[str] | None = None,
|
|
project_id: int = 0,
|
|
system_ids: list[int] | None = None,
|
|
supersedes: list[int] | None = None,
|
|
) -> dict:
|
|
"""Update an existing Scribe note. Only explicitly provided fields are changed.
|
|
|
|
Args:
|
|
note_id: ID of the note to update.
|
|
title: New title, or omit to leave unchanged.
|
|
body: New markdown body, or omit to leave unchanged.
|
|
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
|
|
project_id: New project association. Omit (or pass 0) to leave unchanged.
|
|
system_ids: Replace this note's System associations with these ids
|
|
(set-semantics). None = leave unchanged; [] = clear all.
|
|
supersedes: Replace the ids of earlier notes this one replaces
|
|
(set-semantics). None = leave unchanged; [] = clear all. See
|
|
create_note for when to reach for it.
|
|
"""
|
|
uid = current_user_id()
|
|
fields: dict = {}
|
|
if title:
|
|
fields["title"] = title
|
|
if body:
|
|
fields["body"] = body
|
|
if tags is not None:
|
|
fields["tags"] = tags
|
|
if project_id:
|
|
fields["project_id"] = project_id
|
|
note = await notes_svc.update_note(uid, note_id, **fields)
|
|
if note is None:
|
|
raise ValueError(f"note {note_id} not found")
|
|
if system_ids is not None:
|
|
await systems_svc.set_record_systems(uid, note_id, system_ids)
|
|
if supersedes is not None:
|
|
try:
|
|
await supersession_svc.set_supersedes(uid, note_id, supersedes)
|
|
except PermissionError as exc:
|
|
raise ValueError(str(exc)) from exc
|
|
data = note.to_dict()
|
|
await systems_tools.attach_systems(
|
|
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
|
|
)
|
|
await _attach_supersession(uid, note_id, data)
|
|
return data
|
|
|
|
|
|
async def find_duplicate_records(kind: str = "note", threshold: float = 0.0) -> dict:
|
|
"""Notes or tasks already recorded that closely resemble each other.
|
|
|
|
The create gate PREVENTS a duplicate arriving through an agent; the web UI
|
|
deliberately has no gate (a human mid-thought must not be blocked by a 409),
|
|
which makes YOU the corrections system — and a corrector has to be able to
|
|
SEE what needs correcting. This is the finder. Run it when tidying a
|
|
project's records, or when you suspect the same ground was covered twice.
|
|
|
|
Args:
|
|
kind: "note" (documents) or "task". Snippets have their own report,
|
|
find_duplicate_snippets, whose groups propose a lossless merge.
|
|
threshold: Similarity floor, 0-1. 0 uses the configured setting.
|
|
|
|
READ THE `suggestion` FIELD BEFORE ACTING, because the right fix differs by
|
|
what the records ARE, not how alike they score. For notes: a correction
|
|
pair → declare `supersedes` on the newer; state smeared across dated
|
|
records → extract it into the System's reference note and leave these as
|
|
history; genuinely parallel records → leave them alone. NEVER merge notes.
|
|
Each group carries `members` with dates and any `existing_supersessions`
|
|
already declared inside it — a pair someone has ruled on is not an open
|
|
question.
|
|
"""
|
|
uid = current_user_id()
|
|
if kind not in ("note", "task"):
|
|
raise ValueError('kind must be "note" or "task" — snippets have their '
|
|
"own report, find_duplicate_snippets")
|
|
return await dedup_svc.find_duplicate_records(
|
|
uid, kind=kind, threshold=threshold if threshold > 0 else None,
|
|
)
|
|
|
|
|
|
async def delete_note(note_id: int) -> dict:
|
|
"""Move a Scribe note to the trash (recoverable). Restore via restore(batch_id)."""
|
|
uid = current_user_id()
|
|
batch = await trash_svc.delete(uid, "note", note_id)
|
|
if batch is None:
|
|
raise ValueError(f"note {note_id} not found")
|
|
return {"deleted_batch_id": batch,
|
|
"message": f"Note {note_id} moved to trash. Restore with restore('{batch}')."}
|
|
|
|
|
|
def register(mcp) -> None:
|
|
for fn in (
|
|
list_notes,
|
|
get_note,
|
|
create_note,
|
|
update_note,
|
|
find_duplicate_records,
|
|
delete_note,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|