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.
- deeptutor/agents/loop/native_memory.py +24 -0
- deeptutor/api/routers/memory.py +785 -0
- deeptutor/services/memory/__init__.py +42 -0
- deeptutor/services/memory/consolidator/__init__.py +62 -0
- deeptutor/services/memory/consolidator/chunker.py +131 -0
- deeptutor/services/memory/consolidator/guards.py +104 -0
- deeptutor/services/memory/consolidator/line_doc.py +486 -0
- deeptutor/services/memory/consolidator/meta.py +194 -0
- deeptutor/services/memory/consolidator/modes/__init__.py +31 -0
- deeptutor/services/memory/consolidator/modes/_runtime.py +319 -0
- deeptutor/services/memory/consolidator/modes/_shims.py +129 -0
- deeptutor/services/memory/consolidator/modes/audit.py +481 -0
- deeptutor/services/memory/consolidator/modes/dedup.py +228 -0
- deeptutor/services/memory/consolidator/modes/merge.py +227 -0
- deeptutor/services/memory/consolidator/modes/update.py +706 -0
- deeptutor/services/memory/consolidator/parse.py +135 -0
- deeptutor/services/memory/consolidator/prompts/en/_meta.yaml +36 -0
- deeptutor/services/memory/consolidator/prompts/en/audit_l2.yaml +42 -0
- deeptutor/services/memory/consolidator/prompts/en/audit_l3.yaml +38 -0
- deeptutor/services/memory/consolidator/prompts/en/dedup.yaml +37 -0
- deeptutor/services/memory/consolidator/prompts/en/update_l2.yaml +45 -0
- deeptutor/services/memory/consolidator/prompts/en/update_l3.yaml +51 -0
- deeptutor/services/memory/consolidator/prompts/zh/_meta.yaml +35 -0
- deeptutor/services/memory/consolidator/prompts/zh/audit_l2.yaml +40 -0
- deeptutor/services/memory/consolidator/prompts/zh/audit_l3.yaml +34 -0
- deeptutor/services/memory/consolidator/prompts/zh/dedup.yaml +34 -0
- deeptutor/services/memory/consolidator/prompts/zh/update_l2.yaml +40 -0
- deeptutor/services/memory/consolidator/prompts/zh/update_l3.yaml +43 -0
- deeptutor/services/memory/consolidator/references.py +388 -0
- deeptutor/services/memory/consolidator/runs.py +405 -0
- deeptutor/services/memory/document.py +235 -0
- deeptutor/services/memory/ids.py +80 -0
- deeptutor/services/memory/ops.py +149 -0
- deeptutor/services/memory/paths.py +104 -0
- deeptutor/services/memory/prompts/en.yaml +156 -0
- deeptutor/services/memory/prompts/zh.yaml +144 -0
- deeptutor/services/memory/recall.py +237 -0
- deeptutor/services/memory/settings.py +198 -0
- deeptutor/services/memory/snapshot/__init__.py +127 -0
- deeptutor/services/memory/snapshot/adapters.py +690 -0
- deeptutor/services/memory/snapshot/diff.py +67 -0
- deeptutor/services/memory/snapshot/entity.py +92 -0
- deeptutor/services/memory/snapshot/store.py +113 -0
- deeptutor/services/memory/store.py +453 -0
- deeptutor/services/memory/trace.py +153 -0
- deeptutor_cli/memory.py +99 -0
- deeptutor_memory-1.7.0.dist-info/METADATA +6 -0
- deeptutor_memory-1.7.0.dist-info/RECORD +50 -0
- deeptutor_memory-1.7.0.dist-info/WHEEL +5 -0
- 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"]
|