feat(records): every typed kind gets all five doors and a duplicate report (#4164)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped

Arising from #3731, which shipped a lesson with three of five tools and logged
the rest rather than widening its own scope. The operator's framing on reading
that: each kind deserves CRUD functions and to show up in the search and report
functions. So this fixes the property, not the two instances.

WHAT WAS MISSING FOR A LESSON: no delete, no list, and no duplicate report.
`delete_lesson` is the #2250 situation exactly — the trash is kind-agnostic so
`delete_note` always reached a lesson, but nothing said so, and a kind whose own
tools offer create/read/update reads as one you cannot retire. `list_lessons` is
the only way to ask what has been learned at all: `get_lesson` needs an id you
already have, and semantic search returns what resembles a query, never the set.

APPLYING THE RULE FOUND THE SAME REPORT GAP FOR PROCESSES, which have had full
CRUD for months and have never been in `_REPORT_KINDS` either. Both are in now,
each compared only against its own kind.

The lesson report default is 0.90 — the general semantic floor, deliberately
BELOW its own write-path bar of 0.96. The gate is permissive on purpose so it
does not refuse two genuinely different lessons whose triggers read alike, and
that tolerance is precisely what wants reviewing later, so the report looks at
the band the gate was told to let through. Safe there and not at the gate,
because a report proposes and the operator picks where the gate blocks a write.

A BUG CAUGHT BEFORE IT SHIPPED: `list_lessons` first read the trigger from
`it["data"]`, which `_note_to_item` does not carry — it projects named keys off
the mirror (`language`, `verification`) rather than the column. Every row would
have listed an empty trigger, which on a kind whose whole point is the trigger
is the failure looking like the feature. `when_to_apply` is now projected there
beside the others, so every listing surface gets it, including step 7's UI.

The guard asserts the PROPERTY rather than the instances: for each typed kind,
all five tools exist, are actually offered by register(), are classified for
auth, and the kind has a duplicate report. Derived from the kinds themselves, so
a fourth inherits the bar. A per-tool test cannot catch a missing tool, which is
why four steps of milestone 385 went green over this.

`find_duplicate_records` now validates against `_REPORT_KINDS` instead of its
own literal — the second copy is what would have refused a kind the service
already supported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-18 18:35:13 -04:00
co-authored by Claude Opus 5
parent 1d201d2ff7
commit 6a2476addb
6 changed files with 252 additions and 11 deletions
+84 -1
View File
@@ -12,8 +12,10 @@ 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 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 record_pulled
@@ -35,6 +37,55 @@ def _to_dict(note) -> dict:
}
async def list_lessons(
q: str = "", tag: str = "", limit: int = 50, offset: int = 0,
project_id: int = 0,
) -> 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.
Returns {"lessons": [{id, title, when_to_apply, tags, preview}], "total"}.
"""
uid = current_user_id()
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)
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", ""),
**({"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,
@@ -192,6 +243,38 @@ async def update_lesson(
return _to_dict(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 (create_lesson, get_lesson, update_lesson):
for fn in (list_lessons, create_lesson, get_lesson, update_lesson,
delete_lesson):
mcp.tool(name=fn.__name__)(fn)