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,453 @@
1
+ """High-level facade for the three-layer memory subsystem.
2
+
3
+ All callers — API routers, LLM tools, surface event hooks — go through
4
+ :class:`MemoryStore`. The store is stateless; per-user isolation is
5
+ inherited from :func:`paths.memory_root` which resolves :class:`PathService`
6
+ lazily via context variables.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ import logging
15
+ from pathlib import Path
16
+ import shutil
17
+ from typing import Literal
18
+
19
+ from deeptutor.services.file_io import atomic_write_text
20
+ from deeptutor.services.memory import consolidator, paths, trace
21
+ from deeptutor.services.memory.consolidator import ConsolidateResult, OnEvent
22
+ from deeptutor.services.memory.document import Document, parse, serialize
23
+ from deeptutor.services.memory.ops import AddOp, ApplyReport, EditOp, OpResult
24
+ from deeptutor.services.memory.ops import apply as ops_apply
25
+ from deeptutor.services.memory.paths import L3Slot, Surface
26
+ from deeptutor.services.memory.trace import TraceEvent
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ Layer = Literal["L2", "L3"]
31
+
32
+ _V1_FILES = ("PROFILE.md", "SOUL.md", "SUMMARY.md")
33
+
34
+
35
+ def _normalize_pref_text(text: str) -> str:
36
+ """Whitespace/case-insensitive key for duplicate-preference detection."""
37
+ return " ".join(str(text or "").split()).casefold()
38
+
39
+
40
+ def _find_duplicate_preference(doc: Document, section: str, text: str):
41
+ """Return an existing entry in ``section`` whose text matches ``text``.
42
+
43
+ Read-only — does not create the section if it is absent.
44
+ """
45
+ target = _normalize_pref_text(text)
46
+ if not target:
47
+ return None
48
+ for entry in doc.all_entries():
49
+ if entry.section == section and _normalize_pref_text(entry.text) == target:
50
+ return entry
51
+ return None
52
+
53
+
54
+ _NO_MEMORY = (
55
+ "(No memory available — interact with DeepTutor and update from the Memory page to build one.)"
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class DocOverview:
61
+ layer: Layer
62
+ key: str # surface name (L2) or slot name (L3)
63
+ exists: bool
64
+ updated_at: str | None
65
+ entry_count: int
66
+ backlog: int # L1 events since last update (L2 only; 0 for L3)
67
+
68
+
69
+ class MemoryStore:
70
+ """Stateless facade. Safe to call as a process-wide singleton."""
71
+
72
+ def __init__(self) -> None:
73
+ self._write_locks: dict[str, asyncio.Lock] = {}
74
+
75
+ # ── L1 ────────────────────────────────────────────────────────────────
76
+
77
+ async def emit(self, event: TraceEvent) -> None:
78
+ await trace.append(event)
79
+
80
+ # ── L2 / L3 read ──────────────────────────────────────────────────────
81
+
82
+ def read_doc(self, layer: Layer, key: str) -> Document:
83
+ path = self._path(layer, key)
84
+ if not path.exists():
85
+ return Document(title=_default_title(layer, key))
86
+ return parse(path.read_text(encoding="utf-8"))
87
+
88
+ def read_raw(self, layer: Layer, key: str) -> str:
89
+ path = self._path(layer, key)
90
+ if not path.exists():
91
+ return ""
92
+ return path.read_text(encoding="utf-8")
93
+
94
+ def read_l3_concat(self) -> str:
95
+ """Concatenate all four L3 docs for the ``read_memory`` tool."""
96
+ parts: list[str] = []
97
+ for slot in paths.L3_SLOTS:
98
+ body = self.read_raw("L3", slot).strip()
99
+ if body:
100
+ parts.append(body)
101
+ if not parts:
102
+ return _NO_MEMORY
103
+ return "\n\n---\n\n".join(parts) + "\n"
104
+
105
+ # ── L2 / L3 write (manual paths) ──────────────────────────────────────
106
+
107
+ async def overwrite_doc(self, layer: Layer, key: str, md: str) -> None:
108
+ """Direct user-driven save from the workbench editor."""
109
+ path = self._path(layer, key)
110
+ async with self._lock_for(path):
111
+ await asyncio.to_thread(atomic_write_text, path, md)
112
+
113
+ async def delete_entry(self, layer: Layer, key: str, entry_id: str) -> bool:
114
+ path = self._path(layer, key)
115
+ if not path.exists():
116
+ return False
117
+ async with self._lock_for(path):
118
+ doc = parse(path.read_text(encoding="utf-8"))
119
+ if not doc.remove(entry_id):
120
+ return False
121
+ await asyncio.to_thread(atomic_write_text, path, serialize(doc))
122
+ return True
123
+
124
+ # ── L2 / L3 write (consolidator paths) ────────────────────────────────
125
+
126
+ async def update_l2(
127
+ self,
128
+ surface: Surface,
129
+ *,
130
+ language: str = "en",
131
+ user_label: str = "anonymous",
132
+ on_event: OnEvent | None = None,
133
+ apply_ops: bool = True,
134
+ ) -> ConsolidateResult:
135
+ path = paths.l2_file(surface)
136
+ async with self._lock_for(path):
137
+ return await consolidator.consolidate_l2(
138
+ surface,
139
+ language=language,
140
+ user_label=user_label,
141
+ on_event=on_event,
142
+ apply_ops=apply_ops,
143
+ )
144
+
145
+ async def update_l3(
146
+ self,
147
+ slot: L3Slot,
148
+ *,
149
+ language: str = "en",
150
+ user_label: str = "anonymous",
151
+ on_event: OnEvent | None = None,
152
+ apply_ops: bool = True,
153
+ ) -> ConsolidateResult:
154
+ if slot == "preferences":
155
+ raise ValueError("preferences.md is not auto-consolidated")
156
+ path = paths.l3_file(slot)
157
+ async with self._lock_for(path):
158
+ return await consolidator.consolidate_l3(
159
+ slot,
160
+ language=language,
161
+ user_label=user_label,
162
+ on_event=on_event,
163
+ apply_ops=apply_ops,
164
+ )
165
+
166
+ async def apply_ops_payload(
167
+ self, layer: Layer, key: str, ops_payload: list[dict]
168
+ ) -> ApplyReport:
169
+ """Apply a list of ops-as-JSON to a layer doc atomically.
170
+
171
+ Used by the workbench's preview → apply two-step flow. The
172
+ payload typically comes from a previous ``apply_ops=False``
173
+ consolidate call surfaced to the user for review.
174
+ """
175
+ from deeptutor.services.memory.consolidator import _parse_ops_response
176
+
177
+ path = self._path(layer, key)
178
+ json_like = {"ops": ops_payload}
179
+ import json as _json
180
+
181
+ ops = _parse_ops_response(_json.dumps(json_like, ensure_ascii=False))
182
+ async with self._lock_for(path):
183
+ default_title = _default_title(layer, key)
184
+ doc = (
185
+ parse(path.read_text(encoding="utf-8"))
186
+ if path.exists()
187
+ else Document(title=default_title)
188
+ )
189
+ report = ops_apply(doc, ops)
190
+ if report.accepted and ops:
191
+ path.parent.mkdir(parents=True, exist_ok=True)
192
+ await asyncio.to_thread(atomic_write_text, path, serialize(doc))
193
+ return report
194
+
195
+ async def write_preference(
196
+ self,
197
+ *,
198
+ op: Literal["add", "edit"],
199
+ text: str,
200
+ target_id: str | None = None,
201
+ reason: str | None = None,
202
+ trace_id: str,
203
+ ) -> ApplyReport:
204
+ """Write the chat-mode preference signal. The ``write_memory`` tool
205
+ is the only caller; ``trace_id`` is the current chat turn's L1 id
206
+ injected by runtime."""
207
+ path = paths.l3_file("preferences")
208
+ async with self._lock_for(path):
209
+ doc = (
210
+ parse(path.read_text(encoding="utf-8"))
211
+ if path.exists()
212
+ else Document(title=_default_title("L3", "preferences"))
213
+ )
214
+ section = "Preferences"
215
+ if op == "add":
216
+ # Idempotent add: preferences.md is never auto-consolidated
217
+ # (see update_l3), so an identical bullet added again would
218
+ # persist forever as a duplicate. Guided-learning turns are
219
+ # highly tool-driven and long-running, so the model tends to
220
+ # re-issue the same write_memory across turns (issue #647).
221
+ # Short-circuit to a no-op that reports the existing entry as
222
+ # already saved instead of appending a duplicate.
223
+ duplicate = _find_duplicate_preference(doc, section, text)
224
+ if duplicate is not None:
225
+ return ApplyReport(
226
+ accepted=True,
227
+ results=[
228
+ OpResult(
229
+ op=AddOp(section=section, text=text, refs=[trace_id]),
230
+ status="applied",
231
+ entry_id=duplicate.id,
232
+ detail="duplicate",
233
+ )
234
+ ],
235
+ )
236
+ report = ops_apply(
237
+ doc,
238
+ [AddOp(section=section, text=text, refs=[trace_id])],
239
+ )
240
+ else:
241
+ if not target_id:
242
+ return ApplyReport(accepted=False, reason="edit requires target_id")
243
+ report = ops_apply(
244
+ doc,
245
+ [
246
+ EditOp(
247
+ target_id=target_id,
248
+ new_text=text,
249
+ new_refs=[trace_id],
250
+ )
251
+ ],
252
+ )
253
+ if report.accepted:
254
+ await asyncio.to_thread(atomic_write_text, path, serialize(doc))
255
+ if reason:
256
+ # Surface the reason in logs for workbench observability.
257
+ logger.info("write_memory %s id=%s reason=%s", op, target_id or "new", reason)
258
+ return report
259
+
260
+ # ── Workbench overview ────────────────────────────────────────────────
261
+
262
+ def overview(self) -> list[DocOverview]:
263
+ rows: list[DocOverview] = []
264
+ for surface in paths.SURFACES:
265
+ rows.append(self._overview_for("L2", surface))
266
+ for slot in paths.L3_SLOTS:
267
+ rows.append(self._overview_for("L3", slot))
268
+ return rows
269
+
270
+ def _overview_for(self, layer: Layer, key: str) -> DocOverview:
271
+ path = self._path(layer, key)
272
+ if not path.exists():
273
+ backlog = trace.count_since(key) if layer == "L2" else 0 # type: ignore[arg-type]
274
+ return DocOverview(
275
+ layer=layer,
276
+ key=key,
277
+ exists=False,
278
+ updated_at=None,
279
+ entry_count=0,
280
+ backlog=backlog,
281
+ )
282
+
283
+ stat = path.stat()
284
+ updated_at = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
285
+ try:
286
+ doc = parse(path.read_text(encoding="utf-8"))
287
+ entry_count = len(doc.all_entries())
288
+ except Exception:
289
+ entry_count = 0
290
+
291
+ backlog = 0
292
+ if layer == "L2":
293
+ try:
294
+ cutoff = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
295
+ backlog = trace.count_since(key, since=cutoff) # type: ignore[arg-type]
296
+ except Exception:
297
+ backlog = 0
298
+
299
+ return DocOverview(
300
+ layer=layer,
301
+ key=key,
302
+ exists=True,
303
+ updated_at=updated_at,
304
+ entry_count=entry_count,
305
+ backlog=backlog,
306
+ )
307
+
308
+ # ── Internals ─────────────────────────────────────────────────────────
309
+
310
+ def _path(self, layer: Layer, key: str) -> Path:
311
+ if layer == "L2":
312
+ if key not in paths.SURFACES:
313
+ raise ValueError(f"unknown surface {key!r}")
314
+ return paths.l2_file(key) # type: ignore[arg-type]
315
+ if key not in paths.L3_SLOTS:
316
+ raise ValueError(f"unknown L3 slot {key!r}")
317
+ return paths.l3_file(key) # type: ignore[arg-type]
318
+
319
+ def _lock_for(self, path: Path) -> asyncio.Lock:
320
+ key = str(path)
321
+ lock = self._write_locks.get(key)
322
+ if lock is None:
323
+ lock = asyncio.Lock()
324
+ self._write_locks[key] = lock
325
+ return lock
326
+
327
+
328
+ # ── v1 → v2 startup migration ─────────────────────────────────────────────
329
+
330
+
331
+ def migrate_v1_if_needed() -> Path | None:
332
+ """If any v1 memory files are present under the memory root, move the
333
+ whole memory directory's loose files into ``memory/backup/<ts>/``.
334
+
335
+ Idempotent: if there's nothing v1-shaped at the root, this is a no-op.
336
+
337
+ Returns the backup directory path on migration, or ``None`` otherwise.
338
+ """
339
+ root = paths.memory_root()
340
+ if not root.exists():
341
+ return None
342
+ v1_present = [name for name in _V1_FILES if (root / name).exists()]
343
+ if not v1_present:
344
+ return None
345
+
346
+ ts = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ")
347
+ backup_dir = paths.backup_root() / ts
348
+ backup_dir.mkdir(parents=True, exist_ok=True)
349
+ for item in list(root.iterdir()):
350
+ if item.name in {"trace", "L2", "L3", "backup"}:
351
+ continue
352
+ try:
353
+ shutil.move(str(item), str(backup_dir / item.name))
354
+ except OSError:
355
+ logger.warning("v1 memory migration: failed to move %s", item, exc_info=True)
356
+ logger.info("v1 memory migrated to %s", backup_dir)
357
+ return backup_dir
358
+
359
+
360
+ def migrate_partner_surface_if_needed() -> bool:
361
+ """Rename the legacy ``tutorbot`` memory surface to ``partner``.
362
+
363
+ The partner surface key used to be ``tutorbot`` — so footnote refs read
364
+ ``tutorbot:<id>`` and the consolidator even wrote "tutorbot" into L2
365
+ prose. It is now ``partner``. This moves any on-disk artifacts (L2 doc +
366
+ meta, snapshot dir, trace dir) to the new name, rewrites the ``tutorbot``
367
+ token to ``partner`` inside the L2 doc/meta (both the ``tutorbot:`` ref
368
+ prefix and the bare prose word), and renames the per-surface key inside
369
+ every L3 meta.
370
+
371
+ Idempotent: skips any target that already exists; a no-op when nothing
372
+ tutorbot-shaped lives under the memory root.
373
+ """
374
+ import json
375
+ import re
376
+
377
+ root = paths.memory_root()
378
+ if not root.exists():
379
+ return False
380
+
381
+ moved = False
382
+
383
+ l2 = paths.l2_dir()
384
+ old_md, new_md = l2 / "tutorbot.md", l2 / "partner.md"
385
+ if old_md.exists() and not new_md.exists():
386
+ text = old_md.read_text(encoding="utf-8")
387
+ text = text.replace("tutorbot:", "partner:") # footnote/inline refs
388
+ text = re.sub(r"\btutorbot\b", "partner", text) # bare prose word
389
+ text = re.sub(r"\bTutorbot\b", "Partner", text)
390
+ new_md.write_text(text, encoding="utf-8")
391
+ old_md.unlink()
392
+ moved = True
393
+ old_meta, new_meta = l2 / "tutorbot.meta.json", l2 / "partner.meta.json"
394
+ if old_meta.exists() and not new_meta.exists():
395
+ text = old_meta.read_text(encoding="utf-8").replace("tutorbot:", "partner:")
396
+ new_meta.write_text(text, encoding="utf-8")
397
+ old_meta.unlink()
398
+ moved = True
399
+
400
+ # snapshot/<surface>/ and trace/<surface>/ — plain directory moves
401
+ # (entity ids carry no surface prefix, so no content rewrite needed).
402
+ for sub in ("snapshot", "trace"):
403
+ old_dir, new_dir = root / sub / "tutorbot", root / sub / "partner"
404
+ if old_dir.is_dir() and not new_dir.exists():
405
+ shutil.move(str(old_dir), str(new_dir))
406
+ moved = True
407
+
408
+ # L3 metas track seen L2 entry ids per surface — rename that key.
409
+ l3 = paths.l3_dir()
410
+ if l3.is_dir():
411
+ for meta_path in l3.glob("*.meta.json"):
412
+ try:
413
+ data = json.loads(meta_path.read_text(encoding="utf-8"))
414
+ except (OSError, json.JSONDecodeError):
415
+ continue
416
+ seen = data.get("seen_l2_entry_ids")
417
+ if isinstance(seen, dict) and "tutorbot" in seen and "partner" not in seen:
418
+ seen["partner"] = seen.pop("tutorbot")
419
+ meta_path.write_text(
420
+ json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
421
+ )
422
+ moved = True
423
+
424
+ if moved:
425
+ logger.info("migrated legacy 'tutorbot' memory surface to 'partner'")
426
+ return moved
427
+
428
+
429
+ # ── Singleton accessor ────────────────────────────────────────────────────
430
+
431
+
432
+ _singleton: MemoryStore | None = None
433
+
434
+
435
+ def get_memory_store() -> MemoryStore:
436
+ global _singleton
437
+ if _singleton is None:
438
+ _singleton = MemoryStore()
439
+ return _singleton
440
+
441
+
442
+ # ── Helpers ───────────────────────────────────────────────────────────────
443
+
444
+
445
+ def _default_title(layer: Layer, key: str) -> str:
446
+ if layer == "L2":
447
+ return f"{key} memory"
448
+ return {
449
+ "recent": "Recent summary",
450
+ "profile": "User profile",
451
+ "scope": "Knowledge scope",
452
+ "preferences": "Preferences",
453
+ }.get(key, key)
@@ -0,0 +1,153 @@
1
+ """L1 raw event trace: append-only JSONL files, one per surface per UTC day.
2
+
3
+ Trace capture must never break the producing surface — every append is
4
+ wrapped and failures are logged-and-swallowed. Writes are serialized
5
+ per-surface with an asyncio lock so multiple turns in the same process
6
+ don't interleave JSON lines.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import asdict, dataclass
13
+ from datetime import datetime, timezone
14
+ import json
15
+ import logging
16
+ from pathlib import Path
17
+ from typing import Any, Iterator
18
+
19
+ from deeptutor.services.memory.ids import new_trace_id
20
+ from deeptutor.services.memory.paths import SURFACES, Surface, trace_dir, trace_file
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _locks: dict[str, asyncio.Lock] = {}
25
+
26
+
27
+ def _lock_for(surface: Surface) -> asyncio.Lock:
28
+ lock = _locks.get(surface)
29
+ if lock is None:
30
+ lock = asyncio.Lock()
31
+ _locks[surface] = lock
32
+ return lock
33
+
34
+
35
+ @dataclass
36
+ class TraceEvent:
37
+ id: str
38
+ ts: str
39
+ surface: Surface
40
+ kind: str
41
+ payload: dict[str, Any]
42
+ session_id: str | None = None
43
+ turn_id: str | None = None
44
+
45
+ @classmethod
46
+ def new(
47
+ cls,
48
+ surface: Surface,
49
+ kind: str,
50
+ payload: dict[str, Any],
51
+ *,
52
+ session_id: str | None = None,
53
+ turn_id: str | None = None,
54
+ ) -> "TraceEvent":
55
+ return cls(
56
+ id=new_trace_id(surface),
57
+ ts=datetime.now(tz=timezone.utc).isoformat(),
58
+ surface=surface,
59
+ kind=kind,
60
+ payload=payload,
61
+ session_id=session_id,
62
+ turn_id=turn_id,
63
+ )
64
+
65
+
66
+ async def append(event: TraceEvent) -> None:
67
+ """Append one event to today's surface trace file. Never raises."""
68
+ try:
69
+ path = trace_file(event.surface, datetime.now(tz=timezone.utc).date())
70
+ line = json.dumps(asdict(event), ensure_ascii=False, separators=(",", ":"))
71
+ async with _lock_for(event.surface):
72
+ await asyncio.to_thread(_append_line, path, line)
73
+ except Exception:
74
+ logger.warning(
75
+ "memory trace append failed surface=%s kind=%s",
76
+ event.surface,
77
+ event.kind,
78
+ exc_info=True,
79
+ )
80
+
81
+
82
+ def _append_line(path: Path, line: str) -> None:
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ with path.open("a", encoding="utf-8") as fh:
85
+ fh.write(line)
86
+ fh.write("\n")
87
+
88
+
89
+ def iter_since(surface: Surface, since: datetime | None = None) -> Iterator[TraceEvent]:
90
+ """Yield events for ``surface`` in chronological order, optionally
91
+ filtering to events with ``ts >= since`` (UTC)."""
92
+ files = sorted(trace_dir(surface).glob("*.jsonl"))
93
+ cutoff_iso = since.isoformat() if since else ""
94
+ cutoff_date_iso = since.date().isoformat() if since else ""
95
+ for path in files:
96
+ if cutoff_date_iso and path.stem < cutoff_date_iso:
97
+ continue
98
+ try:
99
+ with path.open("r", encoding="utf-8") as fh:
100
+ for raw in fh:
101
+ raw = raw.strip()
102
+ if not raw:
103
+ continue
104
+ try:
105
+ obj = json.loads(raw)
106
+ except json.JSONDecodeError:
107
+ continue
108
+ if cutoff_iso and obj.get("ts", "") < cutoff_iso:
109
+ continue
110
+ yield TraceEvent(**obj)
111
+ except OSError:
112
+ continue
113
+
114
+
115
+ def iter_by_ids(ids: list[str]) -> Iterator[TraceEvent]:
116
+ """Resolve trace ids back to their events. Cross-surface walk."""
117
+ wanted_by_surface: dict[str, set[str]] = {}
118
+ for tid in ids:
119
+ if ":" not in tid:
120
+ continue
121
+ surface, _ = tid.split(":", 1)
122
+ if surface in SURFACES:
123
+ wanted_by_surface.setdefault(surface, set()).add(tid)
124
+
125
+ for surface, wanted in wanted_by_surface.items():
126
+ for event in iter_since(surface): # type: ignore[arg-type]
127
+ if event.id in wanted:
128
+ yield event
129
+
130
+
131
+ def count_since(surface: Surface, since: datetime | None = None) -> int:
132
+ return sum(1 for _ in iter_since(surface, since))
133
+
134
+
135
+ def latest_ts(surface: Surface) -> str | None:
136
+ """Most recent event timestamp for ``surface``, or None."""
137
+ files = sorted(trace_dir(surface).glob("*.jsonl"), reverse=True)
138
+ for path in files:
139
+ try:
140
+ last = ""
141
+ with path.open("r", encoding="utf-8") as fh:
142
+ for raw in fh:
143
+ raw = raw.strip()
144
+ if raw:
145
+ last = raw
146
+ if last:
147
+ obj = json.loads(last)
148
+ ts = obj.get("ts")
149
+ if isinstance(ts, str):
150
+ return ts
151
+ except (OSError, json.JSONDecodeError):
152
+ continue
153
+ return None