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,237 @@
1
+ """Reading memory back — what the learner has been doing lately.
2
+
3
+ L1 keeps two kinds of record, and answering "what happened lately" needs both:
4
+
5
+ * the **snapshot** knows what *exists* — every chat session, notebook record,
6
+ quiz attempt, document, book and knowledge base, each with a label and a
7
+ timestamp. :func:`recent` reads that.
8
+ * the **trace** knows what was *done* — the events surfaces emit as they run.
9
+ :func:`recent_queries` reads the one kind that records the learner asking
10
+ something in their own words.
11
+
12
+ Both run on stamps: no message body, no document text, no answer is read. That
13
+ is what makes them safe to call from an interactive path rather than only from
14
+ consolidation, which is the whole reason this module exists separately from
15
+ :mod:`deeptutor.services.memory.store`.
16
+
17
+ Every hit carries ``days_ago`` rather than leaving an ISO timestamp for the
18
+ caller to subtract. A model asked to do date arithmetic on
19
+ ``2026-08-16T11:03:00+00:00`` will sometimes get it wrong, and "how long ago"
20
+ is the entire basis on which anything decides whether an item is still worth
21
+ raising.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from datetime import datetime, timedelta, timezone
28
+ import logging
29
+ from typing import Any, Iterable
30
+
31
+ from deeptutor.services.memory.paths import SURFACES, Surface
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _DEFAULT_LIMIT = 20
36
+ _MAX_LIMIT = 100
37
+ # A week: long enough to survive a couple of quiet days, short enough that what
38
+ # comes back is still recognisably "lately".
39
+ _DEFAULT_DAYS = 7
40
+ # A label is a title, a question or a filename. Anything longer is a body that
41
+ # leaked into one, and carrying it would defeat the point of stamps-only reads.
42
+ _MAX_LABEL_CHARS = 200
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class RecallHit:
47
+ """One thing the learner touched, shaped for a caller to act on."""
48
+
49
+ surface: str
50
+ label: str
51
+ ts: str
52
+ days_ago: int | None
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return {
56
+ "surface": self.surface,
57
+ "label": self.label,
58
+ "ts": self.ts,
59
+ "days_ago": self.days_ago,
60
+ }
61
+
62
+
63
+ # ── Time ─────────────────────────────────────────────────────────────────
64
+
65
+
66
+ def _now() -> datetime:
67
+ return datetime.now(tz=timezone.utc)
68
+
69
+
70
+ def _parse_ts(ts: str) -> datetime | None:
71
+ if not ts:
72
+ return None
73
+ try:
74
+ parsed = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
75
+ except (TypeError, ValueError):
76
+ return None
77
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
78
+
79
+
80
+ def days_ago(ts: str, *, now: datetime | None = None) -> int | None:
81
+ """Whole days between ``ts`` and now, or ``None`` for an unusable stamp."""
82
+ parsed = _parse_ts(ts)
83
+ if parsed is None:
84
+ return None
85
+ delta = (now or _now()) - parsed
86
+ # A future stamp (clock skew on an imported record) reads as today rather
87
+ # than as a negative age.
88
+ return max(0, delta.days)
89
+
90
+
91
+ def _cutoff(days: int | None, now: datetime) -> datetime | None:
92
+ if days is None or days < 0:
93
+ return None
94
+ return now - timedelta(days=days)
95
+
96
+
97
+ # ── Shaping ──────────────────────────────────────────────────────────────
98
+
99
+
100
+ def _clean_label(raw: str) -> str:
101
+ """One line, bounded. Empty when there is nothing worth showing."""
102
+ text = " ".join(str(raw or "").split())
103
+ return text[:_MAX_LABEL_CHARS]
104
+
105
+
106
+ def _resolve_surfaces(surfaces: Iterable[str] | None) -> tuple[Surface, ...]:
107
+ if not surfaces:
108
+ return SURFACES
109
+ wanted = {str(surface).strip().lower() for surface in surfaces}
110
+ resolved = tuple(surface for surface in SURFACES if surface in wanted)
111
+ return resolved or SURFACES
112
+
113
+
114
+ def _clamp_limit(limit: int | None) -> int:
115
+ if not limit or limit < 1:
116
+ return _DEFAULT_LIMIT
117
+ return min(int(limit), _MAX_LIMIT)
118
+
119
+
120
+ def _newest_first(hits: list[RecallHit]) -> list[RecallHit]:
121
+ """Sort newest first, dropping repeats of the same label on the same surface.
122
+
123
+ A knowledge base queried five times, or a session reopened all afternoon,
124
+ is one thing the learner is working on — not five. Keeping the duplicates
125
+ would let a single topic crowd out everything else downstream.
126
+ """
127
+ hits.sort(key=lambda hit: hit.ts, reverse=True)
128
+ seen: set[tuple[str, str]] = set()
129
+ deduped: list[RecallHit] = []
130
+ for hit in hits:
131
+ key = (hit.surface, hit.label.casefold())
132
+ if key in seen:
133
+ continue
134
+ seen.add(key)
135
+ deduped.append(hit)
136
+ return deduped
137
+
138
+
139
+ # ── What exists (snapshot) ───────────────────────────────────────────────
140
+
141
+
142
+ def recent(
143
+ *,
144
+ days: int | None = _DEFAULT_DAYS,
145
+ limit: int | None = _DEFAULT_LIMIT,
146
+ surfaces: Iterable[str] | None = None,
147
+ ) -> list[RecallHit]:
148
+ """The latest workspace activity across surfaces, newest first.
149
+
150
+ Stamps only — no content is read, so this stays cheap enough to call on a
151
+ page load.
152
+
153
+ Items without a usable timestamp are dropped rather than kept: placing an
154
+ item of unknown age inside "the last seven days" asserts something the data
155
+ does not support. This notably excludes knowledge bases, whose snapshot
156
+ timestamp is their *earliest* index time — a KB in daily use would look
157
+ stale here. What the learner has actually been asking a KB lives in the
158
+ trace instead; see :func:`recent_queries`.
159
+ """
160
+ from deeptutor.services.memory.snapshot import adapters
161
+
162
+ now = _now()
163
+ cutoff = _cutoff(days, now)
164
+ hits: list[RecallHit] = []
165
+ for surface in _resolve_surfaces(surfaces):
166
+ try:
167
+ stamps = adapters.read_stamps(surface)
168
+ except Exception:
169
+ logger.warning("recall.recent: stamps failed surface=%s", surface, exc_info=True)
170
+ continue
171
+ for stamp in stamps:
172
+ parsed = _parse_ts(stamp.ts)
173
+ if parsed is None or (cutoff is not None and parsed < cutoff):
174
+ continue
175
+ label = _clean_label(stamp.label)
176
+ if not label:
177
+ continue
178
+ hits.append(
179
+ RecallHit(
180
+ surface=surface,
181
+ label=label,
182
+ ts=stamp.ts,
183
+ days_ago=days_ago(stamp.ts, now=now),
184
+ )
185
+ )
186
+ return _newest_first(hits)[: _clamp_limit(limit)]
187
+
188
+
189
+ # ── What was asked (trace) ───────────────────────────────────────────────
190
+
191
+ # The trace kinds that carry a learner-authored question. Knowledge-base
192
+ # queries are the only ones today: the other emitters record system-side facts
193
+ # (a stated preference, a partner turn), not something the learner typed as a
194
+ # question.
195
+ _QUERY_KINDS: dict[Surface, str] = {"kb": "query"}
196
+
197
+
198
+ def recent_queries(
199
+ *,
200
+ days: int | None = _DEFAULT_DAYS,
201
+ limit: int | None = _DEFAULT_LIMIT,
202
+ ) -> list[RecallHit]:
203
+ """Searches the learner ran lately, in their own words, newest first.
204
+
205
+ Complements :func:`recent`: the snapshot can say a knowledge base exists,
206
+ but only the trace records that someone asked it about eigenvalues on
207
+ Tuesday.
208
+ """
209
+ from deeptutor.services.memory import trace
210
+
211
+ now = _now()
212
+ cutoff = _cutoff(days, now)
213
+ hits: list[RecallHit] = []
214
+ for surface, kind in _QUERY_KINDS.items():
215
+ try:
216
+ events = trace.iter_since(surface, cutoff)
217
+ except Exception:
218
+ logger.warning("recall.recent_queries: failed surface=%s", surface, exc_info=True)
219
+ continue
220
+ for event in events:
221
+ if event.kind != kind:
222
+ continue
223
+ label = _clean_label(str((event.payload or {}).get("query") or ""))
224
+ if not label:
225
+ continue
226
+ hits.append(
227
+ RecallHit(
228
+ surface=surface,
229
+ label=label,
230
+ ts=event.ts,
231
+ days_ago=days_ago(event.ts, now=now),
232
+ )
233
+ )
234
+ return _newest_first(hits)[: _clamp_limit(limit)]
235
+
236
+
237
+ __all__ = ["RecallHit", "days_ago", "recent", "recent_queries"]
@@ -0,0 +1,198 @@
1
+ """User-tunable parameters for the memory consolidator.
2
+
3
+ Single source of truth is ``data/user/settings/main.yaml`` under the
4
+ ``memory:`` subtree. Defaults live here. The frontend ``/settings/memory``
5
+ page reads/writes the same subtree via the API.
6
+
7
+ Decoupled from the algorithm code: every mode picks values up via
8
+ :func:`load_memory_settings`, never via module-level constants.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import asdict, dataclass, field, fields, is_dataclass
14
+ from typing import Any, Literal
15
+
16
+ from deeptutor.utils.config_manager import ConfigManager
17
+
18
+ _SETTINGS_KEY = "memory"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class UpdateSettings:
23
+ l2_budget: int = 20
24
+ l3_budget: int = 10
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AuditSettings:
29
+ l2_budget: int = 20
30
+ l3_budget: int = 10
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class DedupSettings:
35
+ iterations: int = 3
36
+ auto_after_update: bool = True
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class MergeSettings:
41
+ """No-LLM footnote consolidation (collapse duplicate refs into one footnote each)."""
42
+
43
+ auto_after_update: bool = True
44
+ auto_after_audit: bool = True
45
+ auto_after_dedup: bool = True
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ChunkingSettings:
50
+ overlap_ratio: float = 0.10
51
+ boundary: Literal["paragraph", "sentence"] = "paragraph"
52
+ min_chunk_chars: int = 1000
53
+ max_chunk_chars: int = 64000
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ReferenceSettings:
58
+ enforce_required: bool = True
59
+ drop_invalid_refs: bool = True
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class MemorySettings:
64
+ update: UpdateSettings = field(default_factory=UpdateSettings)
65
+ audit: AuditSettings = field(default_factory=AuditSettings)
66
+ dedup: DedupSettings = field(default_factory=DedupSettings)
67
+ merge: MergeSettings = field(default_factory=MergeSettings)
68
+ chunking: ChunkingSettings = field(default_factory=ChunkingSettings)
69
+ reference: ReferenceSettings = field(default_factory=ReferenceSettings)
70
+
71
+
72
+ def load_memory_settings() -> MemorySettings:
73
+ """Return the current ``memory:`` subtree merged on top of defaults.
74
+
75
+ Missing keys fall back to defaults. Out-of-range numeric values are
76
+ clamped to safe ranges so a malformed YAML never crashes a run.
77
+ """
78
+ raw = ConfigManager().load_config().get(_SETTINGS_KEY) or {}
79
+ return _from_dict(MemorySettings, raw)
80
+
81
+
82
+ def save_memory_settings(payload: dict[str, Any]) -> MemorySettings:
83
+ """Merge ``payload`` into the on-disk ``memory:`` subtree.
84
+
85
+ Unknown keys are dropped; values are coerced to the schema's types
86
+ so the YAML never picks up junk. Returns the post-merge settings.
87
+ """
88
+ merged = _from_dict(MemorySettings, payload)
89
+ coerced = asdict(merged)
90
+ ConfigManager().save_config({_SETTINGS_KEY: coerced})
91
+ return merged
92
+
93
+
94
+ def memory_settings_dict() -> dict[str, Any]:
95
+ """Settings as a plain dict — JSON-safe for the API response."""
96
+ return asdict(load_memory_settings())
97
+
98
+
99
+ # ── Coercion + clamping ─────────────────────────────────────────────────
100
+
101
+
102
+ _MIN_BUDGET = 1
103
+ _MAX_BUDGET = 200
104
+ _MIN_DEDUP_ITER = 1
105
+ _MAX_DEDUP_ITER = 20
106
+ _MIN_OVERLAP = 0.0
107
+ _MAX_OVERLAP = 0.5
108
+ _MIN_CHUNK_CHARS = 200
109
+ _MAX_CHUNK_CHARS = 64000
110
+ _BOUNDARIES = ("paragraph", "sentence")
111
+
112
+
113
+ def _from_dict(cls: type, raw: Any) -> Any:
114
+ """Build a frozen dataclass from a partial dict.
115
+
116
+ Strategy: walk fields, if a field is itself a dataclass and the input
117
+ has a matching dict, recurse. Otherwise coerce + clamp. Defaults fill
118
+ any missing field.
119
+ """
120
+ if not is_dataclass(cls):
121
+ raise TypeError(f"{cls!r} is not a dataclass")
122
+
123
+ instance_defaults = cls() # type: ignore[call-arg]
124
+ if not isinstance(raw, dict):
125
+ return instance_defaults
126
+
127
+ kwargs: dict[str, Any] = {}
128
+ for f in fields(cls):
129
+ provided = raw.get(f.name)
130
+ default = getattr(instance_defaults, f.name)
131
+ if isinstance(f.type, type) and is_dataclass(f.type):
132
+ kwargs[f.name] = _from_dict(f.type, provided) if provided is not None else default
133
+ continue
134
+ # nested dataclass detection through the actual default type
135
+ if is_dataclass(default):
136
+ kwargs[f.name] = (
137
+ _from_dict(type(default), provided) if provided is not None else default
138
+ )
139
+ continue
140
+ kwargs[f.name] = _coerce_scalar(f.name, provided, default)
141
+ return cls(**kwargs)
142
+
143
+
144
+ def _coerce_scalar(name: str, raw: Any, default: Any) -> Any:
145
+ if raw is None:
146
+ return default
147
+ if isinstance(default, bool):
148
+ return bool(raw)
149
+ if isinstance(default, int):
150
+ try:
151
+ int_value = int(raw)
152
+ except (TypeError, ValueError):
153
+ return default
154
+ return _clamp_int(name, int_value, default)
155
+ if isinstance(default, float):
156
+ try:
157
+ float_value = float(raw)
158
+ except (TypeError, ValueError):
159
+ return default
160
+ return _clamp_float(name, float_value, default)
161
+ if isinstance(default, str):
162
+ str_value = str(raw)
163
+ if name == "boundary" and str_value not in _BOUNDARIES:
164
+ return default
165
+ return str_value
166
+ return raw
167
+
168
+
169
+ def _clamp_int(name: str, value: int, default: int) -> int:
170
+ if name.endswith("budget"):
171
+ return max(_MIN_BUDGET, min(_MAX_BUDGET, value))
172
+ if name == "iterations":
173
+ return max(_MIN_DEDUP_ITER, min(_MAX_DEDUP_ITER, value))
174
+ if name == "min_chunk_chars":
175
+ return max(_MIN_CHUNK_CHARS, min(_MAX_CHUNK_CHARS, value))
176
+ if name == "max_chunk_chars":
177
+ return max(_MIN_CHUNK_CHARS, min(_MAX_CHUNK_CHARS, value))
178
+ return max(0, value)
179
+
180
+
181
+ def _clamp_float(name: str, value: float, default: float) -> float:
182
+ if name == "overlap_ratio":
183
+ return max(_MIN_OVERLAP, min(_MAX_OVERLAP, value))
184
+ return value
185
+
186
+
187
+ __all__ = [
188
+ "AuditSettings",
189
+ "ChunkingSettings",
190
+ "DedupSettings",
191
+ "MemorySettings",
192
+ "MergeSettings",
193
+ "ReferenceSettings",
194
+ "UpdateSettings",
195
+ "load_memory_settings",
196
+ "memory_settings_dict",
197
+ "save_memory_settings",
198
+ ]
@@ -0,0 +1,127 @@
1
+ """Workspace snapshot subsystem for L1 memory.
2
+
3
+ Public API:
4
+
5
+ - :func:`read_snapshot` — current entities for a surface (no I/O on
6
+ ``state.json`` / ``changes.jsonl``).
7
+ - :func:`read_stamps` — the same set without content: identity, label,
8
+ fingerprint, timestamp. What the diff and "what happened lately" both
9
+ actually need.
10
+ - :func:`refresh_snapshot` — re-read workspace, diff against last
11
+ persisted state, append changes, persist new state. Returns the
12
+ computed change list. Idempotent: a no-change refresh writes nothing
13
+ to the changes log.
14
+ - :func:`read_changes` — paginated history of past refreshes for one
15
+ surface (git-log-style display source).
16
+ - :func:`current_state` — the persisted ``state.json`` for a surface
17
+ (consolidator uses ``last_refresh`` to gate L2 updates).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from datetime import datetime, timezone
23
+ from typing import Iterable, Sequence
24
+
25
+ from deeptutor.services.memory.paths import Surface
26
+ from deeptutor.services.memory.snapshot import adapters, store
27
+ from deeptutor.services.memory.snapshot.diff import diff_snapshots
28
+ from deeptutor.services.memory.snapshot.entity import (
29
+ ChangeEntry,
30
+ Entity,
31
+ EntityStamp,
32
+ Stamped,
33
+ )
34
+
35
+
36
+ def read_snapshot(surface: Surface) -> list[Entity]:
37
+ return adapters.read_entities(surface)
38
+
39
+
40
+ def read_stamps(surface: Surface) -> list[EntityStamp]:
41
+ """Diff-shaped view of a surface, without its content. See :mod:`.adapters`."""
42
+ return adapters.read_stamps(surface)
43
+
44
+
45
+ def pending_changes(
46
+ surface: Surface, entities: Sequence[Stamped] | None = None
47
+ ) -> list[ChangeEntry]:
48
+ """Compute the diff between current workspace and the last persisted state.
49
+
50
+ Pure / read-only: never writes to ``state.json`` or ``changes.jsonl``.
51
+ Used by the L1 view to show "what would a refresh capture right now".
52
+
53
+ Accepts anything carrying ``id`` / ``label`` / ``fingerprint`` — a caller
54
+ that already has full :class:`Entity` objects (the L1 view, which needs
55
+ their content anyway) passes those; a caller that only needs the diff gets
56
+ the cheap :class:`EntityStamp` path by passing nothing.
57
+ """
58
+ stamps: Sequence[Stamped] = entities if entities is not None else adapters.read_stamps(surface)
59
+ curr_fp = {e.id: e.fingerprint for e in stamps}
60
+ curr_labels = {e.id: e.label for e in stamps}
61
+
62
+ prev = store.load_state(surface)
63
+ prev_fp = prev.get("fingerprints") or {}
64
+ prev_labels = prev.get("labels") or {}
65
+
66
+ return diff_snapshots(
67
+ prev_fp,
68
+ curr_fp,
69
+ label_map=curr_labels,
70
+ prev_label_map=prev_labels,
71
+ )
72
+
73
+
74
+ def refresh_snapshot(surface: Surface) -> list[ChangeEntry]:
75
+ """Reconcile persisted state with the workspace and log what moved.
76
+
77
+ Runs on stamps, not full entities: a refresh only ever writes fingerprints
78
+ and labels, so reading message bodies here would be pure waste.
79
+ """
80
+ stamps = adapters.read_stamps(surface)
81
+ changes = pending_changes(surface, stamps)
82
+ curr_fp = {e.id: e.fingerprint for e in stamps}
83
+ curr_labels = {e.id: e.label for e in stamps}
84
+ store.append_changes(surface, changes)
85
+ store.save_state(
86
+ surface,
87
+ fingerprints=curr_fp,
88
+ labels=curr_labels,
89
+ last_refresh=datetime.now(tz=timezone.utc).isoformat(),
90
+ )
91
+ return changes
92
+
93
+
94
+ def read_changes(surface: Surface, *, limit: int = 200, offset: int = 0) -> list[ChangeEntry]:
95
+ bound = max(1, min(limit, 1000))
96
+ all_changes: list[ChangeEntry] = list(store.iter_changes(surface))
97
+ # Most recent first — the file is append-order, reverse it.
98
+ all_changes.reverse()
99
+ return all_changes[offset : offset + bound]
100
+
101
+
102
+ def current_state(surface: Surface) -> dict:
103
+ return store.load_state(surface)
104
+
105
+
106
+ def clear_changes(surface: Surface) -> None:
107
+ store.clear_changes(surface)
108
+
109
+
110
+ __all__ = [
111
+ "Entity",
112
+ "EntityStamp",
113
+ "Stamped",
114
+ "ChangeEntry",
115
+ "read_snapshot",
116
+ "read_stamps",
117
+ "pending_changes",
118
+ "refresh_snapshot",
119
+ "read_changes",
120
+ "current_state",
121
+ "clear_changes",
122
+ "adapters",
123
+ ]
124
+
125
+
126
+ # Iterable kept for static-analyzer happiness when consumers import *.
127
+ _: Iterable = []