Files
FabledScribe/src/scribe/mcp/tools/lessons.py
T
bvandeusenandClaude Opus 5.5 b3b616b20a
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 32s
fix(moments): the tools import the delivery module, and the tool-list cache leaves the swept directory (milestone 458 step 4a, #4922)
Two guards caught 7865313:

- test_mcp_tool_processes reads every coroutine in a tool module's
  namespace as a tool, and a name-imported attach_moment_rules looked
  like one. All seven modules now call moment_delivery.attach_moment_rules,
  and the parity guard accepts the attribute form.
- The session-ledger convention: a file in a swept directory must be a
  .ids ledger. The tool-list cache describes the install, not the
  context, so it moves to its own directory, scribe-moment, where a
  compaction does not sweep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:24:30 -04:00

460 lines
22 KiB
Python

"""Lesson MCP tools: a transferable insight, retrievable by situation.
Its own module rather than `create_note(note_type="lesson")`, on the precedent
of snippets and processes — and for the reason that precedent exists. A kind
whose value depends on a field being filled needs a door that ASKS for that
field by name. `create_note` would take a lesson through a generic body
parameter, and the trigger — the whole of why a lesson is findable at all —
would be something the writer had to know to include.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import knowledge as knowledge_svc
from scribe.services import lesson_rules as lesson_rules_svc
from scribe.services import lessons as lessons_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.mcp.tools import systems as systems_tools
from scribe.services.note_usage import attach_usage, record_pulled
from scribe.services import moment_delivery
# The payload shape lives in the service (`lesson_to_dict`), shared with the
# REST door — a shape spelled once per door answers the two of them
# differently the first time a field is added.
_to_dict = lessons_svc.lesson_to_dict
async def list_lessons(
q: str = "", tag: str = "", limit: int = 50, offset: int = 0,
project_id: int = 0, unjudged: bool = False,
) -> dict:
"""List lessons — the kind enumerated, rather than only what a query
resembles.
Semantic search is how a lesson REACHES a session; this is how a person or
an agent sees what exists. The two answer different questions, and without
this one there is no way to ask "what has been learned here at all".
Each entry carries its trigger, because a list of lessons sorted by title
is a list of claims with the situation — the half that says when each one
matters — left off.
Args:
q: Free-text search across title + body (optional).
tag: Filter to a single tag (optional).
limit: Max results (1-100).
offset: Skip this many before returning — page past the cap. `total`
is the unpaged count, so it says whether more remains.
project_id: Narrow to where a lesson was WRITTEN. 0 = every project.
A lesson is retrievable from anywhere regardless (step 3); this
filters the listing, not the reach.
unjudged: List only the lessons nobody has answered "which rule is
this an instance of?" for — no rule named, and no "no rule fits"
recorded. Work down this list with `update_lesson(rule_ids=…)` or
`update_lesson(no_rule="why")`. A listing rather than a search, so
it takes `tag` and `project_id` but not `q`.
Returns {"lessons": [{id, title, when_to_apply, tags, preview}], "total"}.
"""
uid = current_user_id()
if unjudged:
if q:
raise ValueError(
"unjudged lists every unjudged lesson rather than searching "
"them — call it with tag/project_id and without q."
)
items, total = await lesson_rules_svc.list_unjudged(
uid, tag=tag, project_id=project_id or None,
limit=max(1, min(limit, 100)), offset=max(0, offset),
)
else:
items, total = await knowledge_svc.query_knowledge(
user_id=uid, note_type=lessons_svc.LESSON_NOTE_TYPE,
tags=[tag] if tag else [], sort="modified", q=q or None,
limit=max(1, min(limit, 100)), offset=max(0, offset),
project_id=project_id or None,
)
labelled = await access_svc.label_shared_items(uid, items)
# One aggregate for the page, like the snippet listing — surfaced-vs-opened
# per lesson (#4196). An agent listing lessons can see which of its own
# triggers are firing and which are not, which is the reading that leads to
# `update_lesson` rather than to a second lesson about the same failure.
await attach_usage(labelled)
rows = [
{
"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
"preview": it.get("snippet", ""),
# Projected by `_note_to_item` straight off the `data` mirror —
# absent when the row carries none, rather than an empty string.
"when_to_apply": it.get("when_to_apply", ""),
"usage": it["usage"],
**({"shared": True, "owner": it.get("owner")} if it.get("shared") else {}),
}
for it in labelled
]
return {"lessons": rows, "total": total}
async def create_lesson(
what: str,
when_to_apply: str,
insight: str = "",
learned_from: list[int] | None = None,
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
rule_ids: list[int] | None = None,
no_rule: str = "",
force: bool = False,
) -> dict:
"""Record something you LEARNED, so a later session meets it at the moment
it applies — on this project or any other.
A LESSON POINTS AT THE RULE IT IS AN INSTANCE OF. While you write it, you
know the situation better than anyone will again, so that is when to say
which binding choice it falls under. Answer one of two ways, in this call
or right after it with `update_lesson`:
- `rule_ids=[…]` — the rule(s) or preference(s) it is an instance of. The
rule then becomes reachable through the situation the lesson describes,
which is closer to why it was written than its own wording often is.
- `no_rule="why"` — no rule governs this situation, in a line. That is a
real answer: a lesson that stands alone is what a rule nobody has
written yet is made from, and the reason lets it be re-judged later.
A lesson created with neither comes back with `rule_candidates` — the
rules it most resembles, each with its trigger — and `rule_judgment:
"unjudged"`. Read them against the lesson and answer; `list_lessons(
unjudged=true)` gathers any left open.
A LESSON OR A RULE? The difference is FORCE, not importance. A rule is
something that must be followed; a lesson is something worth knowing. If
ignoring it would be a mistake, it is a rule (create_rule) and needs the
operator's yes, because a rule binds every future session. If ignoring it
just means someone re-derives it the slow way, it is a lesson — write it
now, and nobody is bound by it.
That distinction is the whole reason this kind exists. Sessions holding a
transferable insight were reaching for create_rule because it was the only
surface that is both global and situation-keyed, and proposing rules for
things that should never have bound anyone.
WHAT A LESSON IS NOT: how the operator wants work DONE is a PREFERENCE
(create_preference) — it guides every session rather than informing one,
and it is kept current as they correct you. A shape to copy is a SNIPPET
(create_snippet); a procedure followed start to finish is a PROCESS
(create_process); a record of what happened, findable by topic, is a NOTE
(create_note). A lesson is the claim you would want handed to you in the
same situation next time.
`when_to_apply` IS THE RECORD. Everything else is the payload.
A lesson reaches a session by resembling the SITUATION someone is in, never
by topic — that is what separates it from a note, and it is done by putting
the trigger in the title and again at the head of the body, so the document
is dominated by when it applies. A lesson written without one still saves,
still reads correctly in every listing, and will not surface when it is
needed. There is nothing to notice afterwards: it looks exactly like a
lesson that works.
So write the SYMPTOM, in the words the situation will present itself in —
what someone would be seeing, saying or about to do. "A test fails on code
you believe is correct" is a trigger. "Testing" is a topic, and a topic
matches everything and surfaces for nothing.
Args:
what: The insight in one line — the claim itself, as you would say it.
This becomes the title, joined with the trigger. At most 240
characters and no line breaks; a longer one is refused — the
story goes in `insight`.
when_to_apply: The situation this applies in, as a symptom. Required.
insight: The body — what to do, and the incident that taught it.
Write the story here for the reader; it costs the ranking nothing,
because a long body is split into chunks that each still carry the
trigger.
learned_from: Ids of the issues, tasks or notes this was drawn from —
ALL of them. A lesson that generalises three incidents into one
claim is the good case, not the edge case, so this is a list.
tags: Optional tags.
project_id: Where it was learned. Kept as a fact, and it does not limit
reach: a lesson is retrievable from every project (that is the
point of the kind). 0 = none.
system_ids: Systems (subsystems/areas) to file it under.
rule_ids: The rule(s) or preference(s) this lesson is an instance of —
the binding choice its situation falls under. A lesson never
becomes a rule; it points at the one that governs it, and the rule
is then reachable through the situation the lesson describes
(milestone 440). Naming a rule here confirms the link.
no_rule: The reason no rule governs this situation, in a line — the
other answer to "which rule?". Give one or the other, not both.
When other lessons in the same situation also answered "no rule
fits", the response carries `convergence`: the group, and the
rule it may be missing.
force: Create even if a near-duplicate exists.
Returns the created lesson with its `rules` and `rule_judgment`, plus
`rule_candidates` when it is unjudged (absent when the rule search could
not run). On a near-duplicate, returns the existing id
instead of creating — two lessons about one failure class want to be one
lesson, so update that one rather than adding a second.
"""
uid = current_user_id()
if not when_to_apply or not when_to_apply.strip():
raise ValueError(
"when_to_apply is required: it is how a lesson is found. Say the "
"SYMPTOM — what someone would be seeing, saying or about to do "
"when this applies — not the topic it is about. Without it this "
"record saves, reads correctly, and never surfaces."
)
lessons_svc.require_claim(what)
sources = lessons_svc.normalize_sources(learned_from)
# Validated before anything is written, so a lesson naming a rule the
# caller cannot read fails whole rather than saving half-linked.
linked = await lesson_rules_svc.require_rules(uid, rule_ids)
lesson_rules_svc.require_one_answer(linked, no_rule)
title, body = lessons_svc.lesson_document(
what, when_to_apply, insight, sources,
)
if not force:
dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None,
is_task=False, note_type=lessons_svc.LESSON_NOTE_TYPE,
data=lessons_svc.compose_data(what, when_to_apply),
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "lesson")
note = await lessons_svc.create_lesson(
uid, what=what, when_to_apply=when_to_apply, insight=insight,
learned_from=sources, tags=tags, project_id=project_id or None,
)
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
if linked:
await lesson_rules_svc.set_lesson_rules(uid, note.id, linked)
elif no_rule.strip():
await lesson_rules_svc.set_no_rule(uid, note.id, no_rule)
data = _to_dict(note)
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await lesson_rules_svc.attach_lesson_rules(uid, [data])
if not linked and not no_rule.strip():
await _offer_candidates(uid, data, what, when_to_apply, project_id)
elif no_rule.strip():
await _name_convergence(uid, data, note.id)
return await moment_delivery.attach_moment_rules(uid, "create_lesson", {"project_id": project_id}, data)
async def _name_convergence(uid: int, data: dict, lesson_id: int) -> None:
"""A "no rule fits" answer is the moment to notice it is not the first
for this situation (#4634) — `convergence` names the group and the rule
it may be missing. Absent when there is no group."""
group = await lesson_rules_svc.convergence_for(uid, lesson_id)
if group:
data["convergence"] = group
async def _offer_candidates(uid: int, data: dict, what: str, trigger: str, project_id: int) -> None:
"""Put the rules an unjudged lesson resembles in front of its writer.
`rule_judgment` is set here too, because the attach above is fail-open and
may have left it off; a lesson just created with no answer IS unjudged.
"""
data["rule_judgment"] = lesson_rules_svc.UNJUDGED
candidates = await lesson_rules_svc.rule_candidates(uid, what, trigger, project_id or None)
if candidates is not None:
data["rule_candidates"] = candidates
data["rule_hint"] = (
"Which rule is this lesson an instance of? If one of rule_candidates "
"governs its situation, update_lesson(lesson_id, rule_ids=[…]) links "
"it; if none does, update_lesson(lesson_id, no_rule=\"why\") records "
"that it stands alone."
)
async def get_lesson(lesson_id: int, project_id: int = 0) -> dict:
"""Fetch one lesson by id, with its trigger and sources read back out.
IF THIS LESSON JUST PROVED ITSELF, IT IS WORTH MORE THAN IT SAYS. You are
reading it inside the situation it names, which makes you the one reader
who can tell whether its trigger is keyed to what actually fired and
whether its claim covers what you are seeing. `update_lesson` takes
another incident into `learned_from`, a claim stated more exactly, or a
re-keyed trigger — and the trigger is the edit that pays most, because a
lesson keyed to a situation nobody is in looks exactly like one nobody
needed.
`project_id` is the project you are WORKING IN, not this record's own.
Passing the active project is what makes "opened away from where it was
written" answerable; 0 leaves it unreported and the pull still counts.
"""
uid = current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
if note is None:
raise ValueError(f"lesson {lesson_id} not found")
out = _to_dict(note)
out.update(await access_svc.describe_provenance(uid, note))
# Every explicit open records the pull. A lesson is surfaced by the same
# retrieval as any other note, so a getter that records nothing would leave
# the kind permanently at zero pulls — reading as dead weight beside kinds
# that merely had a counter (#2476, the repeat of #2245).
# Read BEFORE the pull is recorded, so the number an agent is shown is the
# one that was true when it asked — otherwise every first read of a lesson
# reports a pull that is its own.
await attach_usage([out])
await lesson_rules_svc.attach_lesson_rules(uid, [out])
record_pulled(
user_id=uid, note_id=int(note.id),
source="mcp_get_lesson", project_id=project_id,
)
return out
async def update_lesson(
lesson_id: int,
what: str = "",
when_to_apply: str = "",
insight: str = "",
learned_from: list[int] | None = None,
tags: list[str] | None = None,
system_ids: list[int] | None = None,
rule_ids: list[int] | None = None,
no_rule: str = "",
) -> dict:
"""Update a lesson. Empty fields are left unchanged.
REWORDING A LESSON IS ORDINARY WORK. Understanding improves, and a trigger
that turned out to fire on the wrong situation is the single most valuable
thing to fix here — a lesson nobody is reaching is usually not wrong, it is
keyed to a situation nobody is in.
Title, body and the indexed mirror are re-composed together from the merged
fields, so a partial update cannot leave the trigger saying one thing in
the title and another in the body.
Args:
lesson_id: Lesson to update.
what: New one-line claim, at most 240 characters. Empty leaves
unchanged.
when_to_apply: New trigger, as a symptom. Empty leaves unchanged.
insight: New body. Empty leaves unchanged.
learned_from: Replace the source ids. None leaves unchanged; pass the
FULL list, including the ones already there.
tags: Replace tags. None leaves unchanged.
system_ids: Replace the Systems this lesson is filed under. None
leaves unchanged; pass the FULL list, and `[]` to clear.
Here because `create_lesson` took it and this did not, so a lesson
written without one could never be filed afterwards — and the end
of a piece of work, when a lesson is usually written, is exactly
when that argument gets dropped (#4249). A System tag is how
`list_system_records` gathers an area's pile, so an untagged
lesson is reachable by search and by nothing else.
rule_ids: Replace the rule(s) this lesson is an instance of. None
leaves unchanged; pass the FULL list. A rule that was linked and
is left out is recorded as REJECTED — "not an instance of this
one" — so the pair is not proposed again; `[]` rejects them all.
Naming a rule replaces any "no rule fits" answer.
no_rule: Record that no rule governs this lesson's situation, with the
reason in a line. Any rule still linked is rejected with that
reason. Empty leaves the answer unchanged; give this or a
non-empty `rule_ids`, not both. When other lessons in the same
situation also answered "no rule fits", the response carries
`convergence`: the group, and the rule it may be missing.
"""
uid = current_user_id()
# Only a NEW name is checked: a lesson stored with a long one must still
# take a new trigger or a rule link — and the fix for it is this call.
if what:
lessons_svc.require_claim(what)
linked = (
await lesson_rules_svc.require_rules(uid, rule_ids)
if rule_ids is not None else None
)
lesson_rules_svc.require_one_answer(linked, no_rule)
note = await lessons_svc.update_lesson(
uid, lesson_id,
what=what or None,
when_to_apply=when_to_apply or None,
insight=insight or None,
learned_from=learned_from,
tags=tags,
)
if note is None:
raise ValueError(f"lesson {lesson_id} not found")
# `is not None` rather than truthiness, so `[]` CLEARS the associations.
# `set_record_systems` is replace-semantics; treating [] as "no change"
# would make the one call that unfiles a lesson silently do nothing.
if system_ids is not None:
await systems_svc.set_record_systems(uid, lesson_id, system_ids)
note = await lessons_svc.get_lesson(uid, lesson_id) or note
if linked is not None:
await lesson_rules_svc.set_lesson_rules(uid, lesson_id, linked)
if no_rule.strip():
await lesson_rules_svc.set_no_rule(uid, lesson_id, no_rule)
out = _to_dict(note)
await lesson_rules_svc.attach_lesson_rules(uid, [out])
if no_rule.strip():
await _name_convergence(uid, out, lesson_id)
return out
async def judge_lesson_link(
lesson_id: int, rule_id: int, verdict: str, note: str = "",
) -> dict:
"""Say whether a lesson is an instance of a rule: `verdict` is "confirm"
or "reject".
Reach for this when Scribe proposes a pair — a lesson and a rule that keep
arriving together in different situations — or whenever you are reading a
lesson and recognise the rule it falls under. Confirming makes the rule
reachable through the situation the lesson describes; rejecting records
that it is not, so the pair is not proposed again. `note` is the why, and
the next reader judges the link by it.
Returns the link: {lesson_id, rule_id, state, note, evidence, judged_at}.
"""
uid = current_user_id()
return await lesson_rules_svc.judge_link(uid, lesson_id, rule_id, verdict, note)
async def delete_lesson(lesson_id: int) -> dict:
"""Retire a lesson — it moves to the trash and is recoverable.
Reach for this when a lesson turned out to be wrong, or was superseded by
a better one. A lesson that is merely NOT REACHING anyone is usually not a
deletion: its trigger is keyed to a situation nobody is in, and rewording
that with update_lesson keeps what was learned.
Deletion was always possible through `delete_note` — a lesson is a note and
the trash is kind-agnostic — but nothing said so, and a kind whose own
tools offer create/read/update reads as one you cannot retire (#2250, which
recorded exactly this for processes).
"""
uid = current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
# The KIND is checked before deleting: this tool is reached for by name, so
# letting it trash an ordinary note because the id happened to resolve
# would be a destructive action taken on a mistyped argument.
if note is None:
raise ValueError(f"lesson {lesson_id} not found")
batch = await trash_svc.delete(uid, "note", lesson_id)
if batch is None:
raise ValueError(f"lesson {lesson_id} not found")
return {
"deleted_batch_id": batch,
"message": (
f"Lesson {lesson_id} moved to trash. Restore with restore('{batch}')."
),
}
def register(mcp) -> None:
for fn in (list_lessons, create_lesson, get_lesson, update_lesson,
delete_lesson, judge_lesson_link):
mcp.tool(name=fn.__name__)(fn)