rename(547): the server is grimoire — package, image, DB defaults, backups
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s

Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:50:45 -04:00
co-authored by Claude Opus 5.5
parent e99f1169f7
commit 0f5ef0be63
447 changed files with 3611 additions and 3558 deletions
+651
View File
@@ -0,0 +1,651 @@
"""Lesson service — a transferable insight, retrievable by situation.
A *lesson* is a Note with ``note_type='lesson'``: a better way to think about a
problem, or a solution that transfers, recorded so a later session meets it at
the moment it applies — and **without binding the reader**.
WHY THE KIND EXISTS
Without it, rules are the only surface that is global AND situation-keyed, so
an agent holding a transferable insight has one door, and that door binds —
and sessions propose rules for things that should not be rules.
The gap is a document-shape fact, not a threshold:
- a note is embedded as ``title\\nbody`` and is findable by **what it is
about**;
- a rule is embedded as ``{title} — {trigger}`` with ``When to apply:``
repeated at the head of the body, so the trigger appears twice in a short
document and dominates the vector — findable by **when it applies**.
No tuning reaches across that: the field the query would match on is simply not
in a note's document. So a lesson carries a trigger and is embedded like a rule,
while staying a note in every other respect.
WHERE THE TRIGGER LIVES
In ``notes.data`` under ``when_to_apply``, written through a named parameter and
mirrored into the head of the body — the shape snippets use for
``when_to_use``. Not a column on ``notes``. It is NOT in the stored title: the
title is the lesson's subject, and the trigger joins it only in the embedded
document (``embeddings.document_title``).
A named structured field rather than a column: snippets show such a field gets
filled reliably without a guard, and a column would decide enforceability for
every note kind at once.
The trigger in the document is what makes the vector sharp. It reaches it
twice — in the embedded title, joined at embed time, and in the body's first
line.
WHAT A LESSON INHERITS, AND THE CELLS LEFT EMPTY ON PURPOSE
A new kind inherits the note machinery wholesale. Which parts it should NOT get
is stated here, so that an empty cell is a decision rather than an oversight.
Inherited, all deliberately:
- **versions** — a lesson is reworded as understanding improves, and what it
used to say is worth as much as any note's history.
- **supersession** — the event this most needs. A lesson replaced by a better
lesson is precisely what ``note_supersessions`` models, and the demotion
penalty already exists.
- **trash**, **the share ACL**, **tags**, **project and System tagging**,
**chunked embeddings**, **the near-duplicate gate**.
NOT inherited, and each for a stated reason:
- **status / task_kind / milestone_id** — a lesson is not work. ``is_task`` is
``status is not None``, so a lesson that acquired a status would become a
task and appear in open-work listings. This is the one cell where filling it
in by accident silently changes what the record IS.
- **recurrence** — task-only, and a lesson does not recur.
- **verify_with / expires_when** — available, because they are generic note
fields, but not part of a lesson's contract and not asked for on create.
Those mark a record that asserts a FACT about someone else's software and
can go false unwatched. A lesson is closer
to a norm — "a better way to think about this" has no truth value that rots
on its own. A lesson that does assert such a fact can still carry them.
WHY A KIND NEEDS NO MIGRATION
``note_type`` carries **no CHECK constraint** — only ``task_kind`` does
(``notes_task_kind_check``). It is plain ``Text`` with a server default, so a
value the database refuses on an un-migrated instance cannot arise for this
column.
The real vocabulary is ``services.knowledge._FACETS``, which is where a kind
becomes reachable on the browse surface and validated at the door. That is one
table feeding both dialects of the type filter, so adding a kind there is a
single edit.
"""
from __future__ import annotations
import re
from grimoire.models import async_session
from grimoire.models.note import Note
LESSON_NOTE_TYPE = "lesson"
# The key in `notes.data`. Named for the field it mirrors on `rules`, because it
# answers the same question and a reader who knows one should not have to learn
# a second word for it.
TRIGGER_KEY = "when_to_apply"
# What taught this lesson: the ids of the issues, tasks or notes it was drawn
# from. A LIST, and that is the whole decision — see `normalize_sources`.
SOURCES_KEY = "taught_by"
# The body's trigger line, and the pattern that reads it back. The body is the
# readable form and the thing that gets embedded; `data` is the queryable
# mirror. Reads prefer the mirror and fall back to this, which is the discipline
# `snippet_fields` follows and the reason a row written before the mirror
# existed is still readable.
_BODY_TRIGGER_RE = re.compile(r"^\*\*When to apply:\*\*\s*(.+?)\s*$", re.M)
# The readable mirror of `data[SOURCES_KEY]`, and the pattern that reads it
# back — the shape snippets use for `**Merged from:** #ids`, for the same
# reason: the body is what a human sees and what survives a row with no `data`.
_BODY_SOURCES_RE = re.compile(r"^\*\*Learned from:\*\*\s*(.+?)\s*$", re.M)
_ID_RE = re.compile(r"#(\d+)")
def lesson_trigger(note) -> str:
"""When this lesson applies, or "" — the mirror first, then the body.
Prefers `data` for the same reason every snippet read does: it is indexed,
and parsing a body to answer a question the database can answer is how a
hot path ends up regexing markdown. The fallback is not dead code — it is
what makes a lesson readable if the mirror is ever absent, and an absent
mirror must degrade to the right answer rather than to silence.
"""
data = getattr(note, "data", None) or {}
from_mirror = (data.get(TRIGGER_KEY) or "").strip() if isinstance(data, dict) else ""
if from_mirror:
return from_mirror
match = _BODY_TRIGGER_RE.search(getattr(note, "body", None) or "")
return match.group(1).strip() if match else ""
def normalize_sources(entries: list | None) -> list[int]:
"""The ids that taught this lesson — ints, de-duplicated, in the order given.
THE CARDINALITY DECISION, and why it is a list.
`arose_from_id` already exists and holds ONE id, which is the obvious first
answer and the wrong one. A good lesson often generalises several
incidents — a badge collision, an un-backfilled column, a duplicated const
— into one claim about a failure class. That is the shape a good lesson
HAS, not an edge case. A single
id would keep the first and silently drop the rest, and a record that drops
two of its three sources is worse than one that names none, because it reads
as complete.
It lives in `notes.data` rather than a join table for the reason the
trigger lives there: a join table would settle, for every note kind at
once, whether provenance is multi-valued. `data` is JSONB with a GIN
index, so the list is
queryable today and a table can be migrated to later if the need is shown.
Order is history, not sorting: the incidents stay in the sequence the writer
named them, which is the order they were learned in.
"""
out: list[int] = []
seen: set[int] = set()
for raw in entries or []:
ident = raw.get("id") if isinstance(raw, dict) else raw
try:
i = int(ident)
except (TypeError, ValueError):
continue
if i > 0 and i not in seen:
seen.add(i)
out.append(i)
return out
def lesson_sources(note) -> list[int]:
"""What taught this lesson — the mirror first, then the body, then
`arose_from_id`.
Three fallbacks rather than two, because the third is what keeps this
honest on a record written before the kind existed: a note carrying only
`arose_from_id` has exactly one source and this returns it, so a caller
never has to ask which field to read.
"""
data = getattr(note, "data", None) or {}
if isinstance(data, dict):
from_mirror = normalize_sources(data.get(SOURCES_KEY))
if from_mirror:
return from_mirror
match = _BODY_SOURCES_RE.search(getattr(note, "body", None) or "")
if match:
found = normalize_sources(_ID_RE.findall(match.group(1)))
if found:
return found
single = getattr(note, "arose_from_id", None)
return normalize_sources([single]) if single else []
def sole_source(sources: list[int] | None) -> int | None:
"""`arose_from_id` for this lesson: the id when there is exactly ONE.
Left NULL for a lesson drawn from several, deliberately. Every existing
surface that renders provenance reads `arose_from_id` and renders it as
THE origin; handing it one of three would make those surfaces state
something false. Showing nothing there is accurate — there is no single
origin — and `data[SOURCES_KEY]` carries all of them for the surfaces that
know to ask.
"""
ids = normalize_sources(sources)
return ids[0] if len(ids) == 1 else None
def compose_body(
insight: str, when_to_apply: str = "", learned_from: list[int] | None = None,
) -> str:
"""The lesson body — the trigger line first, the insight after.
The trigger's home in the text of the document, and half of what makes a
lesson findable: `chunk_document` joins `{embedded title}\\n{body}`, and
the embedded title is `what — when it applies` (`embeddings.document_title`),
so the document states WHEN IT APPLIES in the title and again in the first
line of the body. That twice-in-a-short-document shape is what measures
sharp in the corpus.
`**When to apply:**` rather than plain text: the body is the READABLE
form, `data` is the queryable mirror, and `_BODY_TRIGGER_RE` reads this
line back when the mirror is missing. Its markdown must therefore match
what that pattern expects, which is why neither is written by hand
anywhere else.
The insight goes in the body rather than being held out of the document.
`rule_document` excludes a rule's `why` because long narrative in ONE
vector lands on the centroid of "development" — but a body over the
budget is split into several chunks, EACH prefixed with the title, so a
lesson's story does not average itself into its trigger: it occupies its
own vectors, and every
one of them still carries the trigger in its prefix. Holding it out would
cost the reader the only part that explains the insight and would buy a
sharpness the chunker already provides.
"""
lines = []
trigger = (when_to_apply or "").strip()
if trigger:
lines.append(f"**When to apply:** {trigger}")
insight = (insight or "").strip()
if insight:
lines.append(insight)
sources = normalize_sources(learned_from)
if sources:
# LAST, not beside the trigger. The first line has to be what this
# lesson is FOR; a provenance line above the insight would push the
# thing the reader came for below a list of ids, and would put
# numbers where the trigger's second appearance does its work.
lines.append("**Learned from:** " + ", ".join(f"#{i}" for i in sources))
return "\n\n".join(lines)
# A lesson's NAME is its claim, and the claim is one line. `what` is the title
# every listing, menu and search row prints, so a story written there instead
# of in `insight` turns each of those lines into a document, with the claim it
# exists to transfer somewhere in the middle. It also ranks badly: the embedded title
# is `what — when_to_apply`, so a narrative crowds out the trigger.
#
# 240 is a long sentence. The bound is on what a NAME can be, not on what a
# lesson can say — `insight` is the body, unbounded, chunked like any record,
# and costs the ranking nothing.
WHAT_MAX_CHARS = 240
def require_claim(what: str | None) -> None:
"""Refuse a `what` that is not one line — before anything is written.
A refusal rather than a cut, because a cut name reads as complete: the
writer would never learn the story went to the wrong field, and the
reader would get its first 240 characters as though they were the claim.
"""
text = (what or "").strip()
multiline = "\n" in text
if not multiline and len(text) <= WHAT_MAX_CHARS:
return
shape = "runs over several lines" if multiline else f"is {len(text)} characters"
raise ValueError(
f"`what` {shape}; it is the lesson's NAME — one claim, one line, at "
f"most {WHAT_MAX_CHARS} characters — and every listing and menu prints "
"it whole. Nothing was written. Put the claim in `what`, said as the "
"sentence someone should come away with, and the incident that taught "
"it in `insight`, which is the body and is not bounded."
)
def claim_line(what: str | None) -> str:
"""A stored name as one menu line, whatever was stored.
The display half of `require_claim`, for names written before it existed:
a guard at the door does not undo a value already stored, and on an
install nobody has repaired, those rows would still print their whole
story on every menu. Over the bound, the name's first sentence is shown
and marked as cut — the record itself is unchanged, and opening it shows
the rest.
"""
text = " ".join((what or "").split())
if len(text) <= WHAT_MAX_CHARS:
return text
first = re.split(r"(?<=[.!?])\s", text, maxsplit=1)[0]
if len(first) > WHAT_MAX_CHARS:
first = first[:WHAT_MAX_CHARS].rsplit(" ", 1)[0]
return first + " …"
def lesson_document(
what: str, when_to_apply: str = "", insight: str = "",
learned_from: list[int] | None = None,
) -> tuple[str, str]:
"""The (title, body) a lesson is STORED as.
One call so the two halves cannot be composed apart. The body's first line
carries the trigger; a lesson without it (and without the `data` mirror)
would embed as an ordinary note wearing a label, and nothing would report
it: the record would look right in every listing and simply never be
retrieved at the moment it applies.
The title is the SUBJECT alone. A stored title carrying the trigger would
make every listing, menu and search row show a title that runs to
kilobytes. The trigger joins the title at embed time
(`embeddings.document_title`, reading `data`).
"""
return (
(what or "").strip(),
compose_body(insight, when_to_apply, learned_from),
)
def compose_data(
what: str, when_to_apply: str = "", learned_from: list[int] | None = None,
) -> dict:
"""The indexed mirror of the same fields the body renders (0070).
Written together with the body by the one caller that composes both, so the
two can never describe different things — the discipline `compose_data`
follows for snippets, and the reason `lesson_trigger` can prefer `data`
without checking whether it agrees with the prose.
Empty values are omitted so the column stays sparse: a lesson with no
sources has no `taught_by` key rather than an empty list, which keeps a
`?` containment query honest.
"""
data: dict = {"what": what.strip()} if what and what.strip() else {}
trigger = (when_to_apply or "").strip()
if trigger:
data[TRIGGER_KEY] = trigger
sources = normalize_sources(learned_from)
if sources:
data[SOURCES_KEY] = sources
return data
def recompose_data(note) -> dict:
"""Rebuild a lesson's `data` mirror from its own title and body.
For the GENERIC note door. `update_lesson` composes the mirror itself from
the merged field set and never needs this; a plain `update_note(body=...)`
has no idea the mirror exists and would leave it behind.
THE COST OF LEAVING IT BEHIND IS HIGHER HERE THAN FOR A SNIPPET. A stale
snippet mirror reports the wrong path. A stale lesson mirror reports the
wrong TRIGGER — and `lesson_trigger` prefers the mirror, so the lesson goes
on being retrieved for the situation it used to name while displaying the
one it now names. The trigger is the entire retrieval story, so
that is not a degraded record; it is a record that fires at the wrong
moment and looks right when it does.
The body is the authority and the mirror is derived — already this file's
rule. This is its enforcement on the path that bypasses `update_lesson`.
The subject comes back out of the title through `untrigger_title`, the
inverse of the join that composed it, rather than by splitting on a
separator spelled a second time here.
"""
from grimoire.services.embeddings import untrigger_title
body = getattr(note, "body", None) or ""
trigger_match = _BODY_TRIGGER_RE.search(body)
trigger = trigger_match.group(1).strip() if trigger_match else ""
what = untrigger_title(getattr(note, "title", None), trigger)
# Sources through the normal read, which already falls back body →
# arose_from_id. A body edit that drops the provenance line should drop
# the mirror's copy too: the body is the authority, and carrying a value
# the reader just deleted is the failure this function exists to prevent.
sources_match = _BODY_SOURCES_RE.search(body)
sources = (
normalize_sources(_ID_RE.findall(sources_match.group(1)))
if sources_match else []
)
return compose_data(what, trigger, sources)
async def create_lesson(
user_id: int,
*,
what: str,
when_to_apply: str = "",
insight: str = "",
learned_from: list[int] | None = None,
tags: list[str] | None = None,
project_id: int | None = None,
):
"""Create a lesson note. Returns the created Note.
The title, the body and the `data` mirror are composed HERE from named
parameters rather than asked of the caller. That is the design, not a
convenience: a named structured field that a service composes is filled
reliably, where a convention an agent must type correctly is not.
`project_id` is accepted and kept, even though a lesson is reachable from
every project. Where it was learned is a fact worth keeping; it
simply stops being the limit of where it can be found.
"""
from grimoire.services import notes as notes_svc
sources = normalize_sources(learned_from)
title, body = lesson_document(what, when_to_apply, insight, sources)
return await notes_svc.create_note(
user_id,
title=title,
body=body,
note_type=LESSON_NOTE_TYPE,
tags=tags,
project_id=project_id,
# NULL unless there is exactly one source — see `sole_source`.
arose_from_id=sole_source(sources),
data=compose_data(what, when_to_apply, sources),
)
async def get_lesson(user_id: int, lesson_id: int):
"""Fetch a lesson by id, or None if it isn't one / isn't readable.
Share-aware (services/access.py): a fetch by id is an explicit act, so it
resolves the caller's full read scope rather than ownership alone —
without this, a lesson a search legitimately surfaced could not then be
opened.
"""
from grimoire.services import notes as notes_svc
result = await notes_svc.get_note_for_user(user_id, lesson_id)
if result is None:
return None
note, _permission = result
if note.note_type != LESSON_NOTE_TYPE or note.deleted_at is not None:
return None
return note
def lesson_to_dict(note) -> dict:
"""A lesson as either door returns it — the composed fields read back out,
not the raw row, so a caller sees the same vocabulary it wrote with.
In the SERVICE rather than in each door, on the `snippet_to_dict`
precedent: the REST route feeds the web UI and the MCP tools feed an
agent, and a shape spelled once per door is a shape that answers the two
of them differently the first time a field is added.
"""
return {
"id": note.id,
"title": note.title,
"body": note.body,
# The composed vocabulary, not the storage: a caller that wrote
# `when_to_apply` reads `when_to_apply` back.
"what": (note.data or {}).get("what", "") if isinstance(note.data, dict) else "",
"when_to_apply": lesson_trigger(note),
"learned_from": lesson_sources(note),
"insight": lesson_insight(note),
"tags": list(note.tags or []),
"project_id": note.project_id,
"note_type": note.note_type,
"created_at": note.created_at.isoformat() if note.created_at else None,
"updated_at": note.updated_at.isoformat() if note.updated_at else None,
}
async def source_records(user_id: int, ids: list[int]) -> list[dict]:
"""The records that taught a lesson, resolved to something linkable.
`learned_from` is a list of bare ids, which is right for storage and
useless on a page: "#4181" tells a reader nothing about whether it is
worth opening. This resolves each to its title and kind so the UI can
label the link, and so a source that has been deleted simply drops out
rather than rendering a link to nothing.
One query for the whole list, not one per id — a lesson with six sources
would otherwise be six round trips to draw one panel.
Share-aware (services/access.py). Order follows `ids`, because that order is the
writer's: the first source is the one they reached for first.
"""
from sqlalchemy import select
from grimoire.services.access import readable_notes_clause
wanted = normalize_sources(ids)
if not wanted:
return []
async with async_session() as session:
result = await session.execute(
select(Note)
.where(Note.id.in_(wanted))
.where(Note.deleted_at.is_(None))
.where(readable_notes_clause(user_id))
)
found = {n.id: n for n in result.scalars().all()}
return [
{
"id": n.id,
"title": n.title,
"note_type": n.note_type,
"is_task": n.status is not None,
"task_kind": n.task_kind,
"status": n.status,
}
for i in wanted
if (n := found.get(i)) is not None
]
async def lessons_taught_by(user_id: int, record_id: int, limit: int = 20):
"""The lessons drawn FROM one record — the reverse of `learned_from`.
THE DIRECTION THAT GETS FORGOTTEN, and arguably the more useful one: a
reader opening an old issue wants to know what was learned from it, and
without this the relation is only navigable from the lesson's side. A
record that taught something should say so on its own page.
Queried through `data[SOURCES_KEY]` rather than by scanning bodies: the
mirror is JSONB with a GIN index, which is the whole reason the list
lives there. `path_exists` is the same dialect the snippet location
lookup uses, so both reverse lookups read the index the same way.
Share-aware via `readable_notes_clause`: this renders beside a
record the caller can already see, and a lesson someone shared with them
belongs in that list exactly as their own does.
"""
from sqlalchemy import select
from grimoire.services.access import readable_notes_clause
try:
wanted = int(record_id)
except (TypeError, ValueError):
return []
if wanted <= 0:
return []
# The id is an int we just validated, never caller text, so it cannot
# break out of the expression — the same guarantee `location_jsonpath`
# gets from JSON-quoting its values.
jsonpath = f"$.{SOURCES_KEY}[*] ? (@ == {wanted})"
async with async_session() as session:
result = await session.execute(
select(Note)
.where(Note.note_type == LESSON_NOTE_TYPE)
.where(Note.deleted_at.is_(None))
.where(Note.data.path_exists(jsonpath))
.where(readable_notes_clause(user_id))
.order_by(Note.updated_at.desc())
.limit(max(1, min(limit, 100)))
)
return list(result.scalars().all())
async def update_lesson(
user_id: int,
lesson_id: int,
*,
what: str | None = None,
when_to_apply: str | None = None,
insight: str | None = None,
learned_from: list[int] | None = None,
tags: list[str] | None = None,
):
"""Update a lesson, re-composing title, body and mirror from the merged
fields. Returns the updated Note, or None if it isn't a readable lesson.
READ-MODIFY-WRITE over the whole record rather than patching one half.
The three fields are not independent: the trigger appears in the title AND
at the head of the body, so editing it in place would need two edits that
a caller could do one of. Re-composing from the merged values means a
partial update cannot leave the halves disagreeing — which, because the
document is what ranks, would be a lesson that still reads correctly and
quietly stops being retrievable.
"""
from grimoire.services import notes as notes_svc
note = await get_lesson(user_id, lesson_id)
if note is None:
return None
current = note.data if isinstance(note.data, dict) else {}
merged_what = current.get("what") or "" if what is None else what
merged_trigger = lesson_trigger(note) if when_to_apply is None else when_to_apply
merged_sources = (
lesson_sources(note) if learned_from is None
else normalize_sources(learned_from)
)
if insight is None:
insight = lesson_insight(note)
title, body = lesson_document(
merged_what, merged_trigger, insight, merged_sources,
)
fields: dict = {
"title": title,
"body": body,
"arose_from_id": sole_source(merged_sources),
"data": compose_data(merged_what, merged_trigger, merged_sources),
}
if tags is not None:
fields["tags"] = tags
return await notes_svc.update_note(user_id, lesson_id, **fields)
def lesson_insight(note) -> str:
"""The authored insight alone — the body with what `compose_body` added removed.
An update that keeps the insight hands it back to `compose_body`, which
re-adds the trigger and provenance. So this has to remove EXACTLY what was
added; anything it leaves behind is re-prepended and the body grows by that
much on every edit.
TAKES THE NOTE, NOT THE BODY. Filtering out lines matching
`_BODY_TRIGGER_RE` is not enough: that pattern is `re.M` and matches ONE
LINE, while `when_to_apply` is free text and routinely several paragraphs
— `compose_body` writes all of it after the marker. Paragraphs 2..n would
come back as part of "the insight", the full trigger would be prepended
again, and the record would gain (n-1) paragraphs per update. Silent, and
compounding.
What makes an exact strip possible is that the trigger is stored: the
mirror in `data` holds its full text, so `composed` below is
byte-for-byte what `compose_body` put at the head, and removing it is
subtraction rather than pattern-matching.
The fallback is not dead code. A row whose mirror is missing degrades to
line-wise removal, which is right for a one-paragraph trigger and is the
best that can be done without knowing where the trigger ends — there is
no marker for its last line.
The provenance line needs no such care: `compose_body` builds it by
joining ids with ", ", so it cannot contain a newline.
"""
body = getattr(note, "body", None) or ""
trigger = lesson_trigger(note).strip()
head = body.lstrip("\n")
composed = f"**When to apply:** {trigger}" if trigger else ""
if composed and head.startswith(composed):
lines = head[len(composed):].splitlines()
else:
lines = [ln for ln in body.splitlines() if not _BODY_TRIGGER_RE.match(ln)]
kept = [ln for ln in lines if not _BODY_SOURCES_RE.match(ln)]
return "\n".join(kept).strip()