deeptutor-memory 1.7.0__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 (50) hide show
  1. deeptutor/agents/loop/native_memory.py +24 -0
  2. deeptutor/api/routers/memory.py +785 -0
  3. deeptutor/services/memory/__init__.py +42 -0
  4. deeptutor/services/memory/consolidator/__init__.py +62 -0
  5. deeptutor/services/memory/consolidator/chunker.py +131 -0
  6. deeptutor/services/memory/consolidator/guards.py +104 -0
  7. deeptutor/services/memory/consolidator/line_doc.py +486 -0
  8. deeptutor/services/memory/consolidator/meta.py +194 -0
  9. deeptutor/services/memory/consolidator/modes/__init__.py +31 -0
  10. deeptutor/services/memory/consolidator/modes/_runtime.py +319 -0
  11. deeptutor/services/memory/consolidator/modes/_shims.py +129 -0
  12. deeptutor/services/memory/consolidator/modes/audit.py +481 -0
  13. deeptutor/services/memory/consolidator/modes/dedup.py +228 -0
  14. deeptutor/services/memory/consolidator/modes/merge.py +227 -0
  15. deeptutor/services/memory/consolidator/modes/update.py +706 -0
  16. deeptutor/services/memory/consolidator/parse.py +135 -0
  17. deeptutor/services/memory/consolidator/prompts/en/_meta.yaml +36 -0
  18. deeptutor/services/memory/consolidator/prompts/en/audit_l2.yaml +42 -0
  19. deeptutor/services/memory/consolidator/prompts/en/audit_l3.yaml +38 -0
  20. deeptutor/services/memory/consolidator/prompts/en/dedup.yaml +37 -0
  21. deeptutor/services/memory/consolidator/prompts/en/update_l2.yaml +45 -0
  22. deeptutor/services/memory/consolidator/prompts/en/update_l3.yaml +51 -0
  23. deeptutor/services/memory/consolidator/prompts/zh/_meta.yaml +35 -0
  24. deeptutor/services/memory/consolidator/prompts/zh/audit_l2.yaml +40 -0
  25. deeptutor/services/memory/consolidator/prompts/zh/audit_l3.yaml +34 -0
  26. deeptutor/services/memory/consolidator/prompts/zh/dedup.yaml +34 -0
  27. deeptutor/services/memory/consolidator/prompts/zh/update_l2.yaml +40 -0
  28. deeptutor/services/memory/consolidator/prompts/zh/update_l3.yaml +43 -0
  29. deeptutor/services/memory/consolidator/references.py +388 -0
  30. deeptutor/services/memory/consolidator/runs.py +405 -0
  31. deeptutor/services/memory/document.py +235 -0
  32. deeptutor/services/memory/ids.py +80 -0
  33. deeptutor/services/memory/ops.py +149 -0
  34. deeptutor/services/memory/paths.py +104 -0
  35. deeptutor/services/memory/prompts/en.yaml +156 -0
  36. deeptutor/services/memory/prompts/zh.yaml +144 -0
  37. deeptutor/services/memory/recall.py +237 -0
  38. deeptutor/services/memory/settings.py +198 -0
  39. deeptutor/services/memory/snapshot/__init__.py +127 -0
  40. deeptutor/services/memory/snapshot/adapters.py +690 -0
  41. deeptutor/services/memory/snapshot/diff.py +67 -0
  42. deeptutor/services/memory/snapshot/entity.py +92 -0
  43. deeptutor/services/memory/snapshot/store.py +113 -0
  44. deeptutor/services/memory/store.py +453 -0
  45. deeptutor/services/memory/trace.py +153 -0
  46. deeptutor_cli/memory.py +99 -0
  47. deeptutor_memory-1.7.0.dist-info/METADATA +6 -0
  48. deeptutor_memory-1.7.0.dist-info/RECORD +50 -0
  49. deeptutor_memory-1.7.0.dist-info/WHEEL +5 -0
  50. deeptutor_memory-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,405 @@
1
+ """Persistent, cancellable consolidator runs.
2
+
3
+ A "run" is one invocation of :func:`run_update` / :func:`run_audit` /
4
+ :func:`run_dedup`. The run is owned by an asyncio task; events flow
5
+ through a buffered ring so a disconnecting client can re-attach by
6
+ posting ``since=<cursor>`` to the events endpoint and replay everything
7
+ it missed.
8
+
9
+ Why an in-memory manager instead of a DB:
10
+ - Memory consolidator runs are minutes-long at most.
11
+ - Crash / restart wipes them — that's acceptable; the docs themselves
12
+ are atomically written per step and the meta-id-diff still gives us
13
+ "what's new since last refresh" correctness on restart.
14
+
15
+ Concurrency rules
16
+ -----------------
17
+ At most one **active** run per ``(layer, key)``. Starting a second run
18
+ while the first is active returns ``RunBusyError``. Once a run reaches
19
+ a terminal status (``done`` / ``cancelled`` / ``error``) it stays in
20
+ the registry indefinitely so the UI can re-attach to see the final
21
+ trace; older runs evict on FIFO when ``_MAX_HISTORY`` is exceeded.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ from contextvars import ContextVar
28
+ from dataclasses import dataclass, field
29
+ from datetime import datetime, timezone
30
+ import logging
31
+ from pathlib import Path
32
+ from typing import Any, Awaitable, Callable, Literal
33
+ import uuid
34
+
35
+ from deeptutor.services.file_io import atomic_write_text as _atomic_write
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ RunMode = Literal["update", "audit", "dedup"]
40
+ RunStatus = Literal["queued", "running", "cancelled", "done", "error"]
41
+
42
+ _MAX_EVENTS_PER_RUN = 2000
43
+ _MAX_HISTORY = 200
44
+
45
+
46
+ @dataclass
47
+ class RunEvent:
48
+ seq: int # 0-based, monotonic per run
49
+ ts: str # ISO-8601 UTC
50
+ payload: dict[str, Any]
51
+
52
+
53
+ @dataclass
54
+ class UndoCheckpoint:
55
+ id: str
56
+ ts: str
57
+ layer: str
58
+ key: str
59
+ path: str
60
+ existed: bool
61
+ previous_content: str
62
+ action: str
63
+ turn: int | None = None
64
+ label: str | None = None
65
+
66
+
67
+ @dataclass
68
+ class Run:
69
+ id: str
70
+ layer: str
71
+ key: str
72
+ mode: RunMode
73
+ params: dict[str, Any]
74
+ language: str
75
+ user_label: str
76
+ status: RunStatus = "queued"
77
+ started_at: str = ""
78
+ ended_at: str | None = None
79
+ error: str | None = None
80
+ events: list[RunEvent] = field(default_factory=list)
81
+ undo_stack: list[UndoCheckpoint] = field(default_factory=list)
82
+ _next_seq: int = field(default=0, repr=False)
83
+ _waiters: list[asyncio.Event] = field(default_factory=list, repr=False)
84
+ _task: asyncio.Task | None = field(default=None, repr=False)
85
+ _cancel_flag: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
86
+
87
+ @property
88
+ def active(self) -> bool:
89
+ return self.status in ("queued", "running")
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ return {
93
+ "id": self.id,
94
+ "layer": self.layer,
95
+ "key": self.key,
96
+ "mode": self.mode,
97
+ "params": self.params,
98
+ "language": self.language,
99
+ "status": self.status,
100
+ "started_at": self.started_at,
101
+ "ended_at": self.ended_at,
102
+ "error": self.error,
103
+ "event_count": len(self.events),
104
+ "undo_count": len(self.undo_stack),
105
+ }
106
+
107
+
108
+ class RunBusyError(RuntimeError):
109
+ """Raised when a (layer, key) already has an active run."""
110
+
111
+
112
+ # ContextVar holds the current Run for handlers running inside the task.
113
+ # Mode code uses :func:`emit` indirectly via ``modes._runtime.emit``;
114
+ # we install our own on_event that pushes into the active run too.
115
+ _current_run: ContextVar[Run | None] = ContextVar("memory_run", default=None)
116
+
117
+
118
+ class RunManager:
119
+ """Process-wide singleton — one manager owns every consolidator run.
120
+
121
+ The instance is created on first call to :func:`get_run_manager`.
122
+ """
123
+
124
+ def __init__(self) -> None:
125
+ self._runs: dict[str, Run] = {}
126
+ self._order: list[str] = [] # FIFO for eviction
127
+ self._active: dict[tuple[str, str], str] = {}
128
+ self._lock = asyncio.Lock()
129
+
130
+ # ── Lookup ─────────────────────────────────────────────────────────
131
+
132
+ def get(self, run_id: str) -> Run | None:
133
+ return self._runs.get(run_id)
134
+
135
+ def active_for(self, layer: str, key: str) -> Run | None:
136
+ run_id = self._active.get((layer, key))
137
+ if run_id is None:
138
+ return None
139
+ run = self._runs.get(run_id)
140
+ return run if run is not None and run.active else None
141
+
142
+ def list_for(self, layer: str | None = None, key: str | None = None) -> list[Run]:
143
+ out: list[Run] = []
144
+ for rid in self._order:
145
+ run = self._runs.get(rid)
146
+ if run is None:
147
+ continue
148
+ if layer is not None and run.layer != layer:
149
+ continue
150
+ if key is not None and run.key != key:
151
+ continue
152
+ out.append(run)
153
+ return out
154
+
155
+ # ── Start ──────────────────────────────────────────────────────────
156
+
157
+ async def start(
158
+ self,
159
+ *,
160
+ layer: str,
161
+ key: str,
162
+ mode: RunMode,
163
+ runner: Callable[[Callable[[dict[str, Any]], Awaitable[None]]], Awaitable[None]],
164
+ params: dict[str, Any] | None = None,
165
+ language: str = "en",
166
+ user_label: str = "anonymous",
167
+ ) -> Run:
168
+ """Register and kick off a new run.
169
+
170
+ ``runner`` is an awaitable factory: takes a ``on_event`` callback
171
+ and runs the consolidator mode. The manager wires the callback to
172
+ the event buffer + waiter machinery.
173
+ """
174
+ async with self._lock:
175
+ if self.active_for(layer, key) is not None:
176
+ raise RunBusyError(f"a run is already in progress for {layer}/{key}")
177
+ run = Run(
178
+ id=uuid.uuid4().hex,
179
+ layer=layer,
180
+ key=key,
181
+ mode=mode,
182
+ params=dict(params or {}),
183
+ language=language,
184
+ user_label=user_label,
185
+ status="queued",
186
+ started_at=_now_iso(),
187
+ )
188
+ self._runs[run.id] = run
189
+ self._order.append(run.id)
190
+ self._active[(layer, key)] = run.id
191
+ self._evict_if_needed()
192
+
193
+ run._task = asyncio.create_task(self._drive(run, runner))
194
+ return run
195
+
196
+ async def cancel(self, run_id: str) -> bool:
197
+ run = self._runs.get(run_id)
198
+ if run is None or not run.active:
199
+ return False
200
+ run._cancel_flag.set()
201
+ if run._task is not None and not run._task.done():
202
+ run._task.cancel()
203
+ return True
204
+
205
+ async def undo_last(self, run_id: str) -> RunEvent | None:
206
+ """Restore the document snapshot before the latest run write."""
207
+ run = self._runs.get(run_id)
208
+ if run is None:
209
+ raise KeyError(run_id)
210
+ if run.active:
211
+ raise RunBusyError("cancel the active run before undoing memory edits")
212
+ if not run.undo_stack:
213
+ return None
214
+
215
+ checkpoint = run.undo_stack.pop()
216
+ path = Path(checkpoint.path)
217
+ if checkpoint.existed:
218
+ await asyncio.to_thread(_atomic_write, path, checkpoint.previous_content)
219
+ else:
220
+ await asyncio.to_thread(_remove_if_exists, path)
221
+
222
+ return await self._emit(
223
+ run,
224
+ {
225
+ "stage": "undo_applied",
226
+ "run_id": run.id,
227
+ "undo_id": checkpoint.id,
228
+ "undo_depth": len(run.undo_stack),
229
+ "layer": checkpoint.layer,
230
+ "key": checkpoint.key,
231
+ "turn": checkpoint.turn,
232
+ "label": checkpoint.label,
233
+ "action": checkpoint.action,
234
+ },
235
+ )
236
+
237
+ # ── Event subscription ─────────────────────────────────────────────
238
+
239
+ async def wait_for_events(self, run: Run, *, since: int) -> list[RunEvent]:
240
+ """Return events since cursor; block until new ones arrive or done."""
241
+ if since < 0:
242
+ since = 0
243
+ # Fast path: events already buffered past cursor.
244
+ buffered = [event for event in run.events if event.seq >= since]
245
+ if buffered:
246
+ return buffered
247
+ if not run.active:
248
+ return []
249
+ waiter = asyncio.Event()
250
+ run._waiters.append(waiter)
251
+ try:
252
+ await waiter.wait()
253
+ finally:
254
+ try:
255
+ run._waiters.remove(waiter)
256
+ except ValueError:
257
+ pass
258
+ return [event for event in run.events if event.seq >= since]
259
+
260
+ # ── Drive ──────────────────────────────────────────────────────────
261
+
262
+ async def _drive(
263
+ self,
264
+ run: Run,
265
+ runner: Callable[[Callable[[dict[str, Any]], Awaitable[None]]], Awaitable[None]],
266
+ ) -> None:
267
+ token = _current_run.set(run)
268
+ run.status = "running"
269
+ await self._emit(run, {"stage": "run_started", "run_id": run.id, "mode": run.mode})
270
+ try:
271
+
272
+ async def on_event(evt: dict[str, Any]) -> None:
273
+ await self._emit(run, evt)
274
+
275
+ await runner(on_event)
276
+ if run.status == "running":
277
+ run.status = "done"
278
+ except asyncio.CancelledError:
279
+ run.status = "cancelled"
280
+ await self._emit(run, {"stage": "cancelled"})
281
+ except Exception as exc: # noqa: BLE001
282
+ run.status = "error"
283
+ run.error = str(exc)
284
+ logger.warning(
285
+ "consolidator run failed (id=%s layer=%s key=%s mode=%s): %s",
286
+ run.id,
287
+ run.layer,
288
+ run.key,
289
+ run.mode,
290
+ exc,
291
+ exc_info=True,
292
+ )
293
+ await self._emit(run, {"stage": "error", "message": str(exc)})
294
+ finally:
295
+ run.ended_at = _now_iso()
296
+ await self._emit(run, {"stage": "run_ended", "status": run.status})
297
+ self._active.pop((run.layer, run.key), None)
298
+ # Wake any remaining waiters so they observe the terminal state.
299
+ for w in list(run._waiters):
300
+ w.set()
301
+ _current_run.reset(token)
302
+
303
+ async def _emit(self, run: Run, payload: dict[str, Any]) -> RunEvent:
304
+ event = RunEvent(seq=run._next_seq, ts=_now_iso(), payload=payload)
305
+ run._next_seq += 1
306
+ run.events.append(event)
307
+ if len(run.events) > _MAX_EVENTS_PER_RUN:
308
+ # Cursors are absolute. Never renumber retained events: a client
309
+ # may reconnect with a cursor issued before this ring wrapped.
310
+ run.events.pop(0)
311
+ for w in list(run._waiters):
312
+ w.set()
313
+ return event
314
+
315
+ def _evict_if_needed(self) -> None:
316
+ while len(self._order) > _MAX_HISTORY:
317
+ old = self._order.pop(0)
318
+ run = self._runs.pop(old, None)
319
+ if run is not None and run.active:
320
+ # Active runs are protected from eviction.
321
+ self._runs[old] = run
322
+ self._order.insert(0, old)
323
+ return
324
+
325
+
326
+ _manager: RunManager | None = None
327
+
328
+
329
+ def get_run_manager() -> RunManager:
330
+ global _manager
331
+ if _manager is None:
332
+ _manager = RunManager()
333
+ return _manager
334
+
335
+
336
+ def reset_run_manager_for_tests() -> None:
337
+ global _manager
338
+ _manager = None
339
+
340
+
341
+ def current_run() -> Run | None:
342
+ """Return the run currently driving the active task, if any.
343
+
344
+ Used by the LLM-IO event emitter so it can attach per-turn payloads
345
+ to whichever run is calling out to the model.
346
+ """
347
+ return _current_run.get()
348
+
349
+
350
+ def push_undo_checkpoint(
351
+ *,
352
+ layer: str,
353
+ key: str,
354
+ path: Path,
355
+ existed: bool,
356
+ previous_content: str,
357
+ action: str,
358
+ turn: int | None = None,
359
+ label: str | None = None,
360
+ ) -> int:
361
+ """Register a per-write rollback snapshot on the active run."""
362
+ run = _current_run.get()
363
+ if run is None:
364
+ return 0
365
+ run.undo_stack.append(
366
+ UndoCheckpoint(
367
+ id=uuid.uuid4().hex,
368
+ ts=_now_iso(),
369
+ layer=layer,
370
+ key=key,
371
+ path=str(path),
372
+ existed=existed,
373
+ previous_content=previous_content,
374
+ action=action,
375
+ turn=turn,
376
+ label=label,
377
+ )
378
+ )
379
+ return len(run.undo_stack)
380
+
381
+
382
+ def _now_iso() -> str:
383
+ return datetime.now(tz=timezone.utc).isoformat()
384
+
385
+
386
+ def _remove_if_exists(path: Path) -> None:
387
+ try:
388
+ path.unlink()
389
+ except FileNotFoundError:
390
+ return
391
+
392
+
393
+ __all__ = [
394
+ "Run",
395
+ "RunBusyError",
396
+ "RunEvent",
397
+ "RunManager",
398
+ "RunMode",
399
+ "RunStatus",
400
+ "UndoCheckpoint",
401
+ "current_run",
402
+ "get_run_manager",
403
+ "push_undo_checkpoint",
404
+ "reset_run_manager_for_tests",
405
+ ]
@@ -0,0 +1,235 @@
1
+ """Markdown documents with footnote-style citations.
2
+
3
+ Each L2/L3 file is a markdown document of the form::
4
+
5
+ # <Title>
6
+
7
+ ## <section_a>
8
+ - <text> [^1][^2] <!--m_xxx-->
9
+ - <text> [^1] <!--m_yyy-->
10
+
11
+ ## <section_b>
12
+ - <text> [^3] <!--m_zzz-->
13
+
14
+ ---
15
+
16
+ [^1]: notebook:abc
17
+ [^2]: chat:def
18
+ [^3]: chat:ghi
19
+
20
+ Footnote labels are *integers* assigned per-document in first-appearance
21
+ order over the bullet stream. Two entries citing the same source share a
22
+ label, so duplicate footnote rows disappear from the rendered view.
23
+
24
+ The HTML comment after each bullet (``<!--m_xxx-->``) is the entry id
25
+ anchor. It survives round-trips and is used by audit / dedup line views
26
+ and by ``DELETE /entry/{id}``. Parser also accepts the *legacy* format
27
+ where the bullet ends in ``[^m_xxx]`` and footnote rows are
28
+ ``[^m_xxx]: ref1, ref2`` — this lets pre-existing docs continue working
29
+ until the next save migrates them to the new layout.
30
+
31
+ Parsing and serialization are pure functions — no I/O, no LLM. The
32
+ round-trip ``serialize(parse(x))`` is idempotent for any document
33
+ produced by ``serialize``.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass, field
39
+ import re
40
+
41
+ _ENTRY_ID_RE = r"m_[0-9A-HJKMNP-TV-Z]{26}"
42
+
43
+ _TITLE_RE = re.compile(r"^#\s+(.+?)\s*$")
44
+ _SECTION_RE = re.compile(r"^##\s+(.+?)\s*$")
45
+
46
+ # New bullet: "- text [^1], [^3] <!--m_xxx-->"
47
+ # Markers are optional (an entry may cite no refs); commas + whitespace
48
+ # between markers are tolerated so the rendered superscripts read
49
+ # ``¹, ³`` instead of the visually-merged ``¹³``.
50
+ _NEW_BULLET_RE = re.compile(
51
+ rf"^\s*-\s+(?P<text>.*?)(?P<markers>(?:\s*,?\s*\[\^[^\]]+\])*)\s*<!--\s*(?P<id>{_ENTRY_ID_RE})\s*-->\s*$"
52
+ )
53
+ # Legacy bullet: "- text[^m_xxx]"
54
+ _OLD_BULLET_RE = re.compile(rf"^\s*-\s+(?P<text>.*?)\[\^(?P<id>{_ENTRY_ID_RE})\]\s*$")
55
+
56
+ # Legacy footnote def: "[^m_xxx]: ref1, ref2"
57
+ _OLD_FOOTNOTE_RE = re.compile(rf"^\[\^(?P<id>{_ENTRY_ID_RE})\]:\s*(?P<refs>.*?)\s*$")
58
+ # New footnote def: "[^1]: notebook:abc" (label is non-m_xxx)
59
+ _NEW_FOOTNOTE_RE = re.compile(r"^\[\^(?P<label>[^\]]+)\]:\s*(?P<ref>.*?)\s*$")
60
+
61
+ _MARKER_RE = re.compile(r"\[\^([^\]]+)\]")
62
+
63
+
64
+ @dataclass
65
+ class Entry:
66
+ id: str
67
+ section: str
68
+ text: str
69
+ refs: list[str] = field(default_factory=list)
70
+
71
+
72
+ @dataclass
73
+ class Document:
74
+ title: str = ""
75
+ sections: list[tuple[str, list[Entry]]] = field(default_factory=list)
76
+
77
+ def all_entries(self) -> list[Entry]:
78
+ return [e for _, entries in self.sections for e in entries]
79
+
80
+ def find(self, entry_id: str) -> Entry | None:
81
+ for _, entries in self.sections:
82
+ for entry in entries:
83
+ if entry.id == entry_id:
84
+ return entry
85
+ return None
86
+
87
+ def section_entries(self, name: str) -> list[Entry]:
88
+ """Return the entry list for ``name``, creating the section if absent."""
89
+ for section, entries in self.sections:
90
+ if section == name:
91
+ return entries
92
+ new_entries: list[Entry] = []
93
+ self.sections.append((name, new_entries))
94
+ return new_entries
95
+
96
+ def remove(self, entry_id: str) -> bool:
97
+ for _, entries in self.sections:
98
+ for i, entry in enumerate(entries):
99
+ if entry.id == entry_id:
100
+ del entries[i]
101
+ return True
102
+ return False
103
+
104
+
105
+ def parse(md: str) -> Document:
106
+ """Parse memory md in either the new (ref-keyed) or legacy (entry-keyed) format."""
107
+ raw_lines = md.splitlines()
108
+
109
+ # Pass 1 — collect every footnote definition. We accept BOTH:
110
+ # * new ref-keyed: ``[^1]: notebook:abc`` → ref by label
111
+ # * old entry-keyed: ``[^m_xxx]: r1, r2`` → refs by entry id
112
+ refs_by_entry: dict[str, list[str]] = {}
113
+ ref_by_label: dict[str, str] = {}
114
+ for raw in raw_lines:
115
+ line = raw.rstrip()
116
+ m_old_fn = _OLD_FOOTNOTE_RE.match(line)
117
+ if m_old_fn:
118
+ refs_raw = m_old_fn.group("refs")
119
+ refs_by_entry[m_old_fn.group("id")] = [
120
+ r.strip() for r in refs_raw.split(",") if r.strip()
121
+ ]
122
+ continue
123
+ m_new_fn = _NEW_FOOTNOTE_RE.match(line)
124
+ if m_new_fn:
125
+ label = m_new_fn.group("label")
126
+ if label.startswith("m_"):
127
+ # Skip — that was an entry-keyed row already handled above.
128
+ continue
129
+ ref_by_label[label] = m_new_fn.group("ref").strip()
130
+
131
+ # Pass 2 — title, sections, bullets.
132
+ doc = Document()
133
+ current_entries: list[Entry] | None = None
134
+ current_section: str | None = None
135
+ for raw in raw_lines:
136
+ line = raw.rstrip()
137
+
138
+ if not doc.title:
139
+ m_title = _TITLE_RE.match(line)
140
+ if m_title:
141
+ doc.title = m_title.group(1).strip()
142
+ continue
143
+
144
+ m_section = _SECTION_RE.match(line)
145
+ if m_section:
146
+ current_section = m_section.group(1).strip()
147
+ current_entries = []
148
+ doc.sections.append((current_section, current_entries))
149
+ continue
150
+
151
+ # New format first: bullet ends with HTML-comment entry-id anchor.
152
+ m_new_b = _NEW_BULLET_RE.match(line)
153
+ if m_new_b and current_entries is not None and current_section is not None:
154
+ entry_id = m_new_b.group("id")
155
+ text = m_new_b.group("text").rstrip()
156
+ markers = _MARKER_RE.findall(m_new_b.group("markers") or "")
157
+ entry_refs: list[str] = []
158
+ for marker in markers:
159
+ ref = ref_by_label.get(marker)
160
+ if ref is not None and ref not in entry_refs:
161
+ entry_refs.append(ref)
162
+ current_entries.append(
163
+ Entry(id=entry_id, section=current_section, text=text, refs=entry_refs)
164
+ )
165
+ continue
166
+
167
+ # Legacy bullet: refs come from refs_by_entry built in pass 1.
168
+ m_old_b = _OLD_BULLET_RE.match(line)
169
+ if m_old_b and current_entries is not None and current_section is not None:
170
+ entry_id = m_old_b.group("id")
171
+ text = m_old_b.group("text").strip()
172
+ current_entries.append(
173
+ Entry(
174
+ id=entry_id,
175
+ section=current_section,
176
+ text=text,
177
+ refs=list(refs_by_entry.get(entry_id, [])),
178
+ )
179
+ )
180
+ continue
181
+
182
+ return doc
183
+
184
+
185
+ def serialize(doc: Document) -> str:
186
+ """Render the doc in the new consolidated, ref-keyed format.
187
+
188
+ Every unique ref across all entries gets one footnote label, assigned
189
+ in first-appearance order. Bullets cite their refs as ``[^1][^3]``
190
+ inline. The entry id is preserved as a trailing HTML comment so the
191
+ round-trip ``parse(serialize(d)) == d``.
192
+ """
193
+ # 1. Build the consolidated ref → label map in first-appearance order.
194
+ ref_order: list[str] = []
195
+ ref_to_label: dict[str, int] = {}
196
+ for entry in doc.all_entries():
197
+ for ref in entry.refs:
198
+ if ref in ref_to_label:
199
+ continue
200
+ ref_to_label[ref] = len(ref_order) + 1
201
+ ref_order.append(ref)
202
+
203
+ lines: list[str] = []
204
+ if doc.title:
205
+ lines.append(f"# {doc.title}")
206
+ lines.append("")
207
+
208
+ for section, entries in doc.sections:
209
+ if not entries:
210
+ continue
211
+ lines.append(f"## {section}")
212
+ lines.append("")
213
+ for entry in entries:
214
+ # ``, ``-separate markers so the rendered superscripts read
215
+ # "¹, ²" not "¹²" — important when the same bullet cites two
216
+ # different sources.
217
+ markers = ", ".join(f"[^{ref_to_label[r]}]" for r in entry.refs if r in ref_to_label)
218
+ text = entry.text.rstrip()
219
+ if markers:
220
+ lines.append(f"- {text} {markers} <!--{entry.id}-->")
221
+ else:
222
+ lines.append(f"- {text} <!--{entry.id}-->")
223
+ lines.append("")
224
+
225
+ if ref_order:
226
+ lines.append("---")
227
+ lines.append("")
228
+ for i, ref in enumerate(ref_order, start=1):
229
+ lines.append(f"[^{i}]: {ref}")
230
+ lines.append("")
231
+
232
+ return "\n".join(lines).rstrip() + "\n"
233
+
234
+
235
+ __all__ = ["Document", "Entry", "parse", "serialize"]