lorebase-keeper 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,259 @@
1
+ """Change feed of a branch: Revision events as Server-Sent Events, with a polling fallback.
2
+
3
+ Events are the `revision.committed` outbox events of the branch, described by
4
+ `lorebase_store.changefeed` (Revision metadata, changes per Kind and Relation, changed keys up
5
+ to `agents.feed.changed_keys_max`). The SSE `id` of an event is its outbox sequence, so a
6
+ client reconnecting with `Last-Event-ID` resumes after the last event it saw; without it the
7
+ stream starts with the next commit. The stream sends a comment line after `heartbeat_s` of
8
+ silence and closes after `max_connection_s`.
9
+
10
+ Every `reauthorize_interval_s` the stream runs the authentication and authorization of its
11
+ request again and closes when they fail, so a revoked API key, an expired token or a withdrawn
12
+ grant ends it without waiting for `max_connection_s`. The check goes through the usual caches:
13
+ a grant changed through another Keeper process is seen after at most
14
+ `keeper.auth.authz.grants_cache_ttl_s` more, OIDC group changes after
15
+ `keeper.auth.oidc.principal_cache_ttl_s`.
16
+
17
+ `GET .../changes?since=<revision>` reads the same events from the first-parent history of the
18
+ branch instead of the outbox: the Revisions after `since`, oldest first, a page at a time,
19
+ with `next_since` to continue. Use it to catch up after a gap or where streams are not
20
+ available.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import json
27
+ import logging
28
+ from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
29
+ from dataclasses import dataclass
30
+ from typing import Any
31
+
32
+ from pydantic import BaseModel, Field, JsonValue
33
+ from sqlalchemy.ext.asyncio import AsyncSession
34
+
35
+ from lorebase_core.model import Schema
36
+ from lorebase_core.tree import kind_partition
37
+ from lorebase_keeper.agents.settings import FeedSettings
38
+ from lorebase_keeper.errors import CONFLICT, ProblemError
39
+ from lorebase_keeper.reading.points import branch_schema
40
+ from lorebase_keeper.reading.problems import invalid
41
+ from lorebase_keeper.reading.runtime import ReadRuntime, begin_read
42
+ from lorebase_store.changefeed import EventRef, describe, first_parents, matches
43
+ from lorebase_store.commit.pipeline import COMMITTED_EVENT
44
+ from lorebase_store.repositories import OutboxRepository, RevisionRow, scope_to_domain
45
+
46
+ logger = logging.getLogger("lorebase.keeper.feed")
47
+
48
+ EVENT_STREAM = "text/event-stream"
49
+ SSE_EVENT = "revision"
50
+
51
+
52
+ class RevisionEventOut(BaseModel):
53
+ """A Revision committed on a branch."""
54
+
55
+ sequence: int | None = Field(
56
+ default=None, description="Outbox sequence; the SSE id. Absent in `changes` pages."
57
+ )
58
+ type: str
59
+ domain: str
60
+ branch: str
61
+ revision: str
62
+ parents: list[str]
63
+ generation: int
64
+ author: str
65
+ on_behalf_of: str | None = None
66
+ message: str
67
+ origin: dict[str, JsonValue]
68
+ stats: dict[str, JsonValue]
69
+ created_at: str
70
+ kinds: dict[str, int] = Field(description="Changed Records per Kind api name.")
71
+ relations: dict[str, int] = Field(description="Changed Links per Relation api name.")
72
+ schema_changed: bool
73
+ keys: dict[str, list[str]] = Field(description="Changed Record keys per Kind, capped.")
74
+ keys_truncated: bool
75
+
76
+
77
+ class ChangesFeedPage(BaseModel):
78
+ branch: str
79
+ head: str
80
+ since: str | None = None
81
+ items: list[RevisionEventOut]
82
+ next_since: str = Field(description="Pass as `since` to continue.")
83
+ more: bool = Field(description="True when more Revisions follow `next_since`.")
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class KindFilter:
88
+ """Kinds an event must touch (None: every event); keys are listed for those Kinds only."""
89
+
90
+ names: frozenset[str] | None = None
91
+ partitions: frozenset[str] | None = None
92
+
93
+
94
+ def kind_filter(schema: Schema | None, values: Sequence[str] | None) -> KindFilter:
95
+ """The filter of a `kinds` query parameter (repeated or comma-separated api names)."""
96
+ if values is None:
97
+ return KindFilter()
98
+ names = [name.strip() for value in values for name in value.split(",") if name.strip()]
99
+ partitions = set()
100
+ for index, name in enumerate(names):
101
+ kind = None if schema is None else schema.kind_by_api_name(name)
102
+ if kind is None:
103
+ raise invalid(f"/query/kinds/{index}", f"unknown Kind {name!r}", "unknown_kind")
104
+ partitions.add(kind_partition(kind.id))
105
+ return KindFilter(frozenset(names), frozenset(partitions))
106
+
107
+
108
+ def parse_sequence(value: str | None) -> int | None:
109
+ """An event id given by the client (`Last-Event-ID`); 422 when it is not one."""
110
+ if value is None or value == "":
111
+ return None
112
+ try:
113
+ sequence = int(value)
114
+ except ValueError:
115
+ sequence = -1
116
+ if sequence < 0:
117
+ raise invalid("/headers/Last-Event-ID", "is not an event id", "invalid")
118
+ return sequence
119
+
120
+
121
+ async def events_after(
122
+ session: AsyncSession,
123
+ runtime: ReadRuntime,
124
+ domain_id: str,
125
+ branch: str,
126
+ after: int,
127
+ wanted: KindFilter,
128
+ settings: FeedSettings,
129
+ ) -> tuple[int, list[dict[str, Any]]]:
130
+ """Events of the branch after sequence `after` (one batch) and the last sequence read."""
131
+ rows = await OutboxRepository(session).after(
132
+ domain_id,
133
+ after,
134
+ event_types=(COMMITTED_EVENT,),
135
+ branch=branch,
136
+ limit=settings.batch_events,
137
+ )
138
+ if not rows:
139
+ return after, []
140
+ schema = await branch_schema(session, runtime, domain_id, branch)
141
+ documents = await describe(
142
+ session,
143
+ domain_id,
144
+ [EventRef(bytes.fromhex(str(r.payload["revision"])), branch, r.id) for r in rows],
145
+ schemas={branch: schema},
146
+ keys_max=settings.changed_keys_max,
147
+ kind_partitions=wanted.partitions,
148
+ )
149
+ return rows[-1].id, [d for d in documents if matches(d, wanted.names)]
150
+
151
+
152
+ def sse(document: dict[str, Any]) -> bytes:
153
+ data = json.dumps(document, ensure_ascii=False, separators=(",", ":"))
154
+ return f"id: {document['sequence']}\nevent: {SSE_EVENT}\ndata: {data}\n\n".encode()
155
+
156
+
157
+ async def stream(
158
+ runtime: ReadRuntime,
159
+ domain_id: str,
160
+ branch: str,
161
+ start: int,
162
+ wanted: KindFilter,
163
+ settings: FeedSettings,
164
+ disconnected: Any,
165
+ still_allowed: Callable[[], Awaitable[bool]],
166
+ ) -> AsyncIterator[bytes]:
167
+ """The SSE body: events as they are committed, heartbeats, closed after the max time or
168
+ once `still_allowed` (called every `reauthorize_interval_s`) answers false."""
169
+ loop = asyncio.get_running_loop()
170
+ deadline = loop.time() + settings.max_connection_s
171
+ next_check = loop.time() + settings.reauthorize_interval_s
172
+ quiet_since = loop.time()
173
+ cursor = start
174
+ yield f"retry: {settings.retry_ms}\n\n".encode()
175
+ while loop.time() < deadline:
176
+ if await disconnected():
177
+ return
178
+ if loop.time() >= next_check:
179
+ try:
180
+ allowed = await still_allowed()
181
+ except Exception:
182
+ logger.exception("change feed authorization failed", extra={"domain": domain_id})
183
+ return
184
+ if not allowed:
185
+ logger.info("change feed closed: access withdrawn", extra={"domain": domain_id})
186
+ return
187
+ next_check = loop.time() + settings.reauthorize_interval_s
188
+ try:
189
+ async with runtime.sessions() as session, session.begin():
190
+ await begin_read(session, runtime)
191
+ await scope_to_domain(session, domain_id)
192
+ cursor, documents = await events_after(
193
+ session, runtime, domain_id, branch, cursor, wanted, settings
194
+ )
195
+ except Exception:
196
+ logger.exception("change feed poll failed", extra={"domain": domain_id})
197
+ return
198
+ for document in documents:
199
+ yield sse(document)
200
+ quiet_since = loop.time()
201
+ if loop.time() - quiet_since >= settings.heartbeat_s:
202
+ yield b": keep-alive\n\n"
203
+ quiet_since = loop.time()
204
+ await asyncio.sleep(max(min(settings.poll_interval_s, deadline - loop.time()), 0))
205
+
206
+
207
+ async def changes_since(
208
+ session: AsyncSession,
209
+ runtime: ReadRuntime,
210
+ domain_id: str,
211
+ branch: str,
212
+ head: bytes,
213
+ since: RevisionRow | None,
214
+ *,
215
+ limit: int,
216
+ wanted: KindFilter,
217
+ settings: FeedSettings,
218
+ ) -> ChangesFeedPage:
219
+ """A page of the first-parent Revisions after `since` (see module doc)."""
220
+ if since is None:
221
+ segment = await first_parents(session, domain_id, head, floor=-1, max_steps=limit - 1)
222
+ newer = [revision for revision, _ in reversed(segment)]
223
+ else:
224
+ segment = await first_parents(
225
+ session, domain_id, head, floor=since.generation, max_steps=settings.max_scan
226
+ )
227
+ boundary, generation = segment[-1]
228
+ if boundary != since.id:
229
+ reason = (
230
+ "is not on the first-parent history of the branch"
231
+ if generation <= since.generation
232
+ else f"is more than {settings.max_scan} Revisions behind the head"
233
+ )
234
+ raise ProblemError(
235
+ CONFLICT,
236
+ f"Revision {since.id.hex()} {reason}; read the current state and follow the "
237
+ "branch from its head.",
238
+ extensions={"code": "since_unreachable"},
239
+ )
240
+ newer = [revision for revision, _ in reversed(segment[:-1])]
241
+ page = newer[:limit]
242
+ schema = await branch_schema(session, runtime, domain_id, branch)
243
+ documents = await describe(
244
+ session,
245
+ domain_id,
246
+ [EventRef(revision, branch) for revision in page],
247
+ schemas={branch: schema},
248
+ keys_max=settings.changed_keys_max,
249
+ kind_partitions=wanted.partitions,
250
+ )
251
+ fallback = head if since is None else since.id
252
+ return ChangesFeedPage(
253
+ branch=branch,
254
+ head=head.hex(),
255
+ since=None if since is None else since.id.hex(),
256
+ items=[RevisionEventOut.model_validate(d) for d in documents if matches(d, wanted.names)],
257
+ next_since=(page[-1] if page else fallback).hex(),
258
+ more=len(newer) > limit,
259
+ )
@@ -0,0 +1,219 @@
1
+ """Markdown rendering of context packs and Record explanations, meant to be pasted into a prompt.
2
+
3
+ The output is stable (same input, same bytes) and compact: plain ASCII markup, lists for
4
+ Records and schema, tables for neighbors and changes, empty sections left out. Revisions
5
+ inside sections are shortened to `SHORT_REVISION` hex digits; the header carries the full id.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Iterable, Mapping, Sequence
12
+ from typing import Any
13
+
14
+ from lorebase_core.model import Schema
15
+ from lorebase_keeper.agents.budget import Draft
16
+ from lorebase_keeper.agents.pack import links_json, schema_fragment
17
+
18
+ SHORT_REVISION = 12
19
+ SEPARATOR = "\n\n"
20
+
21
+
22
+ def short(revision: str | None) -> str:
23
+ return "" if revision is None else revision[:SHORT_REVISION]
24
+
25
+
26
+ def text(value: Any) -> str:
27
+ """A value on one line: strings as they are, lists comma-separated, objects as JSON."""
28
+ if value is None:
29
+ return ""
30
+ if isinstance(value, bool):
31
+ return "true" if value else "false"
32
+ if isinstance(value, list):
33
+ return ", ".join(text(v) for v in value)
34
+ if isinstance(value, dict):
35
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
36
+ return " ".join(str(value).split())
37
+
38
+
39
+ def cell(value: Any) -> str:
40
+ """A value inside a table cell."""
41
+ return text(value).replace("|", "\\|")
42
+
43
+
44
+ def pairs(values: Mapping[str, Any] | None) -> str:
45
+ return "; ".join(f"{name}={text(value)}" for name, value in (values or {}).items())
46
+
47
+
48
+ def table(header: Sequence[str], rows: Iterable[Sequence[Any]]) -> list[str]:
49
+ lines = ["| " + " | ".join(header) + " |", "|" + " --- |" * len(header)]
50
+ lines.extend("| " + " | ".join(cell(v) for v in row) + " |" for row in rows)
51
+ return lines
52
+
53
+
54
+ def heading(ref: str, title: str | None) -> str:
55
+ return f"### {ref}" + (f": {text(title)}" if title else "")
56
+
57
+
58
+ # Context pack sections ----------------------------------------------------------------------
59
+
60
+
61
+ def _seeds(draft: Draft) -> str:
62
+ lines = ["## Records"]
63
+ for seed in draft.seeds:
64
+ lines.append(heading(seed.ref, seed.title))
65
+ lines.extend(f"- {name}: {text(value)}" for name, value in seed.values.items())
66
+ if seed.updated_revision is not None:
67
+ lines.append(f"- (last changed in {short(seed.updated_revision)})")
68
+ return "\n".join(lines)
69
+
70
+
71
+ def _neighbors(draft: Draft) -> str:
72
+ if not draft.neighbors:
73
+ return ""
74
+ rows = [
75
+ (n.ref, n.title, n.depth, "(omitted)" if n.values is None else pairs(n.values))
76
+ for n in draft.neighbors
77
+ ]
78
+ return "\n".join(["## Neighbors", *table(("ref", "title", "depth", "values"), rows)])
79
+
80
+
81
+ def _links(draft: Draft, schema: Schema) -> str:
82
+ if not draft.links:
83
+ return ""
84
+ lines = ["## Links"]
85
+ for name, links in links_json(draft.links).items():
86
+ found = schema.relation_by_api_name(name)
87
+ ends = ""
88
+ if found is not None:
89
+ relation = found[0]
90
+ source, target = (
91
+ schema.kinds.get(relation.from_kind),
92
+ schema.kinds.get(relation.to_kind),
93
+ )
94
+ if source is not None and target is not None:
95
+ ends = f" ({source.api_name} -> {target.api_name})"
96
+ lines.append(f"### {name}{ends}")
97
+ for link in links:
98
+ extra = f" ({pairs(link['values'])})" if link.get("values") else ""
99
+ lines.append(f"- {link['from']} -> {link['to']}{extra}")
100
+ return "\n".join(lines)
101
+
102
+
103
+ def _attribute_line(attribute: Mapping[str, Any], indent: str = "") -> str:
104
+ flags = [attribute["type"]]
105
+ if attribute.get("required"):
106
+ flags.append("required")
107
+ if attribute.get("derived"):
108
+ flags.append("derived")
109
+ description = attribute.get("description")
110
+ return f"{indent}- {attribute['name']} ({', '.join(flags)})" + (
111
+ f": {text(description)}" if description else ""
112
+ )
113
+
114
+
115
+ def schema_markdown(fragment: Mapping[str, Any]) -> str:
116
+ lines = ["## Schema"]
117
+ for kind in fragment["kinds"]:
118
+ lines.append(f"### Kind {kind['name']}")
119
+ if kind.get("description"):
120
+ lines.append(text(kind["description"]))
121
+ keys = f"key: {', '.join(kind['key'])}"
122
+ lines.append(keys + (f"; title: {kind['title']}" if kind.get("title") else ""))
123
+ lines.extend(_attribute_line(a) for a in kind["attributes"])
124
+ if fragment["relations"]:
125
+ lines.append("### Relations")
126
+ for relation in fragment["relations"]:
127
+ line = (
128
+ f"- {relation['name']}: {relation['from']} -> {relation['to']}, "
129
+ f"{relation['cardinality']}, inverse {relation['inverse']}"
130
+ )
131
+ if relation.get("description"):
132
+ line += f". {text(relation['description'])}"
133
+ lines.append(line)
134
+ lines.extend(_attribute_line(a, " ") for a in relation.get("attributes", ()))
135
+ return "\n".join(lines)
136
+
137
+
138
+ def _changes(draft: Draft) -> str:
139
+ if not draft.changes:
140
+ return ""
141
+ rows = [
142
+ (
143
+ short(c.revision),
144
+ c.at,
145
+ c.ref,
146
+ c.change,
147
+ ", ".join(c.attributes),
148
+ c.actor,
149
+ c.origin,
150
+ c.message,
151
+ )
152
+ for c in draft.changes
153
+ ]
154
+ header = ("revision", "at", "record", "change", "attributes", "by", "origin", "message")
155
+ return "\n".join(["## Recent changes", *table(header, rows)])
156
+
157
+
158
+ def _lineage(draft: Draft) -> str:
159
+ if not draft.lineage:
160
+ return ""
161
+ lines = ["## Lineage"]
162
+ for item in draft.lineage:
163
+ source = " ".join(
164
+ part
165
+ for part in (
166
+ item.origin or "unknown origin",
167
+ f"by {item.actor}" if item.actor else "",
168
+ f"at {item.at}" if item.at else "",
169
+ )
170
+ if part
171
+ )
172
+ lines.append(f"- {item.ref}: {', '.join(item.attributes)} <- {source}")
173
+ return "\n".join(lines)
174
+
175
+
176
+ def markdown_sections(draft: Draft, schema: Schema) -> dict[str, str]:
177
+ """Text of each section, in output order; empty for sections without content."""
178
+ return {
179
+ "seeds": _seeds(draft),
180
+ "neighbors": _neighbors(draft),
181
+ "links": _links(draft, schema),
182
+ "schema": schema_markdown(schema_fragment(draft, schema)) if draft.schema else "",
183
+ "recent_changes": _changes(draft),
184
+ "lineage": _lineage(draft),
185
+ }
186
+
187
+
188
+ def markdown_measure(schema: Schema) -> Any:
189
+ """Characters of each section including its separator, a `budget.Measure`."""
190
+
191
+ def measure(draft: Draft) -> Mapping[str, int]:
192
+ return {
193
+ name: len(body) + len(SEPARATOR) if body else 0
194
+ for name, body in markdown_sections(draft, schema).items()
195
+ }
196
+
197
+ return measure
198
+
199
+
200
+ def pack_markdown(
201
+ sections: Mapping[str, str],
202
+ *,
203
+ domain: str,
204
+ branch: str,
205
+ revision: str,
206
+ budget: Mapping[str, Any],
207
+ since: str | None,
208
+ ) -> str:
209
+ """The whole pack: a header line, then the non-empty sections."""
210
+ trimmed = ", ".join(f"{t['step']}({t['removed']})" for t in budget["trimmed"]) or "none"
211
+ header = [
212
+ f"# Context: {domain}/{branch}",
213
+ f"revision {revision}; estimated {budget['used_tokens']} of "
214
+ f"{budget['limit_tokens']} tokens; trimmed: {trimmed}"
215
+ + ("; over budget" if budget["over_budget"] else "")
216
+ + (f"; changes since {short(since)}" if since else ""),
217
+ ]
218
+ body = [part for part in sections.values() if part]
219
+ return SEPARATOR.join(["\n".join(header), *body]) + "\n"