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,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
|