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,481 @@
1
+ """Audit mode — line-level edits checked against raw evidence.
2
+
3
+ Algorithm
4
+ ---------
5
+ 1. Render the target md as a line-numbered, footnote-stripped view
6
+ (:func:`line_doc.render_view`).
7
+ 2. Chunk the rendered view into ≤ budget pieces; each chunk is a
8
+ contiguous slice of lines whose bullet entries get **annotated with
9
+ their full source content** (no truncation).
10
+ 3. Per chunk: LLM call → parse edits → apply against the in-memory doc
11
+ in reverse line order. All chunks use the original line view, whose
12
+ entry IDs anchor edits after earlier chunks change line positions.
13
+ 4. Atomic flush.
14
+
15
+ The chunker is deliberately char-based (same as update) so chunk size
16
+ behaves predictably alongside the annotation block (which can be much
17
+ larger than the bare md). The annotated block fed to the LLM is what
18
+ gets counted against the budget.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ import logging
25
+
26
+ from deeptutor.services.memory import paths
27
+ from deeptutor.services.memory import snapshot as snap
28
+ from deeptutor.services.memory.consolidator.chunker import (
29
+ chunk_with_boundary,
30
+ )
31
+ from deeptutor.services.memory.consolidator.line_doc import (
32
+ LineView,
33
+ apply_edits,
34
+ parse_edits_payload,
35
+ render_view,
36
+ )
37
+ from deeptutor.services.memory.consolidator.modes._runtime import (
38
+ OnEvent,
39
+ call_llm,
40
+ emit,
41
+ load_doc,
42
+ load_prompt,
43
+ slot_focus,
44
+ surface_focus,
45
+ today_iso,
46
+ write_doc_checkpoint,
47
+ )
48
+ from deeptutor.services.memory.consolidator.references import (
49
+ annotate_l2_line_with_evidence,
50
+ annotate_l3_line_with_evidence,
51
+ )
52
+ from deeptutor.services.memory.document import Document, Entry
53
+ from deeptutor.services.memory.paths import L3Slot, Surface
54
+ from deeptutor.services.memory.settings import load_memory_settings
55
+ from deeptutor.services.memory.snapshot.entity import Entity
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ @dataclass
61
+ class AuditResult:
62
+ layer: str
63
+ key: str
64
+ chunks_processed: int
65
+ edits_applied: int = 0
66
+ edits_rejected: int = 0
67
+ no_doc: bool = False
68
+
69
+
70
+ # ── Public entry ────────────────────────────────────────────────────────
71
+
72
+
73
+ async def run_audit(
74
+ layer: str,
75
+ key: str,
76
+ *,
77
+ language: str = "en",
78
+ user_label: str = "anonymous",
79
+ budget: int | None = None,
80
+ llm_selection: dict | None = None,
81
+ on_event: OnEvent | None = None,
82
+ ) -> AuditResult:
83
+ from deeptutor.services.model_selection.runtime import (
84
+ activate_llm_selection,
85
+ reset_llm_selection,
86
+ )
87
+
88
+ settings = load_memory_settings()
89
+ token = None
90
+ if llm_selection:
91
+ try:
92
+ _config, token = activate_llm_selection(llm_selection)
93
+ except Exception as exc: # noqa: BLE001
94
+ logger.warning(
95
+ "memory audit: ignoring unresolvable llm_selection %s: %s", llm_selection, exc
96
+ )
97
+ token = None
98
+ try:
99
+ if layer == "L2":
100
+ return await _run_audit_l2(
101
+ key, # type: ignore[arg-type]
102
+ language=language,
103
+ user_label=user_label,
104
+ budget=budget if budget is not None else settings.audit.l2_budget,
105
+ llm_selection=llm_selection,
106
+ on_event=on_event,
107
+ settings=settings,
108
+ )
109
+ if layer == "L3":
110
+ return await _run_audit_l3(
111
+ key, # type: ignore[arg-type]
112
+ language=language,
113
+ user_label=user_label,
114
+ budget=budget if budget is not None else settings.audit.l3_budget,
115
+ llm_selection=llm_selection,
116
+ on_event=on_event,
117
+ settings=settings,
118
+ )
119
+ raise ValueError(f"unknown layer {layer!r}")
120
+ finally:
121
+ reset_llm_selection(token)
122
+
123
+
124
+ # ── L2 ──────────────────────────────────────────────────────────────────
125
+
126
+
127
+ async def _run_audit_l2(
128
+ surface: Surface,
129
+ *,
130
+ language: str,
131
+ user_label: str,
132
+ budget: int,
133
+ llm_selection: dict | None,
134
+ on_event: OnEvent | None,
135
+ settings,
136
+ ) -> AuditResult:
137
+ l2_path = paths.l2_file(surface)
138
+ if not l2_path.exists():
139
+ await emit(on_event, {"stage": "done", "no_doc": True})
140
+ return AuditResult(layer="L2", key=surface, chunks_processed=0, no_doc=True)
141
+
142
+ doc = load_doc(l2_path, default_title=f"{surface} memory")
143
+ audit_view = render_view(doc)
144
+ entity_lookup = {ent.id: ent for ent in snap.read_snapshot(surface)}
145
+ prompt = load_prompt("audit_l2", language)
146
+ focus, _sections = surface_focus(language, surface)
147
+
148
+ annotated_text, line_ranges = _build_annotated_l2(doc, surface, entity_lookup)
149
+ chunks = chunk_with_boundary(
150
+ annotated_text,
151
+ budget=budget,
152
+ overlap_ratio=settings.chunking.overlap_ratio,
153
+ min_chunk_chars=settings.chunking.min_chunk_chars,
154
+ max_chunk_chars=settings.chunking.max_chunk_chars,
155
+ boundary=settings.chunking.boundary,
156
+ )
157
+ await emit(
158
+ on_event,
159
+ {"stage": "chunked", "chunks": len(chunks), "budget": budget, "chars": len(annotated_text)},
160
+ )
161
+
162
+ edits_applied = 0
163
+ edits_rejected = 0
164
+ for chunk in chunks:
165
+ await emit(
166
+ on_event,
167
+ {
168
+ "stage": "progress",
169
+ "mode": "audit",
170
+ "turn": chunk.index + 1,
171
+ "total": len(chunks),
172
+ },
173
+ )
174
+ system = prompt["system"].format(
175
+ user_label=user_label,
176
+ surface=surface,
177
+ focus=focus,
178
+ today=today_iso(),
179
+ )
180
+ user = prompt["user"].format(surface=surface, chunk=chunk.text)
181
+ raw = await call_llm(
182
+ system_prompt=system,
183
+ user_prompt=user,
184
+ on_event=on_event,
185
+ turn=chunk.index + 1,
186
+ chunk_index=chunk.index,
187
+ label="audit",
188
+ )
189
+ edits = parse_edits_payload(raw, layer="L2")
190
+ if not edits:
191
+ await emit(
192
+ on_event,
193
+ {"stage": "facts_extracted", "turn": chunk.index + 1, "edits": 0},
194
+ )
195
+ continue
196
+ doc, report = apply_edits(doc, edits, view=audit_view)
197
+ edits_applied += len(report.applied)
198
+ edits_rejected += len(report.rejected)
199
+ if report.applied:
200
+ await write_doc_checkpoint(
201
+ l2_path,
202
+ doc,
203
+ layer="L2",
204
+ key=surface,
205
+ on_event=on_event,
206
+ turn=chunk.index + 1,
207
+ label="audit",
208
+ action="apply_edits",
209
+ )
210
+ for res in report.applied:
211
+ await emit(
212
+ on_event,
213
+ {
214
+ "stage": "op_applied",
215
+ "turn": chunk.index + 1,
216
+ "op": res.op.op,
217
+ "detail": res.detail,
218
+ },
219
+ )
220
+ for res in report.rejected:
221
+ await emit(
222
+ on_event,
223
+ {
224
+ "stage": "op_rejected",
225
+ "turn": chunk.index + 1,
226
+ "op": res.op.op,
227
+ "detail": res.detail,
228
+ },
229
+ )
230
+
231
+ await emit(
232
+ on_event,
233
+ {
234
+ "stage": "done",
235
+ "edits_applied": edits_applied,
236
+ "edits_rejected": edits_rejected,
237
+ "chunks_processed": len(chunks),
238
+ },
239
+ )
240
+
241
+ if load_memory_settings().merge.auto_after_audit:
242
+ from deeptutor.services.memory.consolidator.modes.merge import run_merge
243
+
244
+ await run_merge(
245
+ "L2",
246
+ surface,
247
+ language=language,
248
+ user_label=user_label,
249
+ on_event=on_event,
250
+ )
251
+
252
+ return AuditResult(
253
+ layer="L2",
254
+ key=surface,
255
+ chunks_processed=len(chunks),
256
+ edits_applied=edits_applied,
257
+ edits_rejected=edits_rejected,
258
+ )
259
+
260
+
261
+ # ── L3 ──────────────────────────────────────────────────────────────────
262
+
263
+
264
+ async def _run_audit_l3(
265
+ slot: L3Slot,
266
+ *,
267
+ language: str,
268
+ user_label: str,
269
+ budget: int,
270
+ llm_selection: dict | None,
271
+ on_event: OnEvent | None,
272
+ settings,
273
+ ) -> AuditResult:
274
+ if slot == "preferences":
275
+ raise ValueError("preferences.md is not audited automatically")
276
+
277
+ l3_path = paths.l3_file(slot)
278
+ if not l3_path.exists():
279
+ await emit(on_event, {"stage": "done", "no_doc": True})
280
+ return AuditResult(layer="L3", key=slot, chunks_processed=0, no_doc=True)
281
+
282
+ doc = load_doc(l3_path, default_title=f"{slot} memory")
283
+ audit_view = render_view(doc)
284
+ l2_lookup = _build_l2_entry_lookup()
285
+ prompt = load_prompt("audit_l3", language)
286
+ focus, _sections = slot_focus(language, slot)
287
+
288
+ annotated_text, _ranges = _build_annotated_l3(doc, l2_lookup)
289
+ chunks = chunk_with_boundary(
290
+ annotated_text,
291
+ budget=budget,
292
+ overlap_ratio=settings.chunking.overlap_ratio,
293
+ min_chunk_chars=settings.chunking.min_chunk_chars,
294
+ max_chunk_chars=settings.chunking.max_chunk_chars,
295
+ boundary=settings.chunking.boundary,
296
+ )
297
+ await emit(
298
+ on_event,
299
+ {"stage": "chunked", "chunks": len(chunks), "budget": budget, "chars": len(annotated_text)},
300
+ )
301
+
302
+ edits_applied = 0
303
+ edits_rejected = 0
304
+ for chunk in chunks:
305
+ await emit(
306
+ on_event,
307
+ {
308
+ "stage": "progress",
309
+ "mode": "audit",
310
+ "turn": chunk.index + 1,
311
+ "total": len(chunks),
312
+ },
313
+ )
314
+ system = prompt["system"].format(
315
+ user_label=user_label,
316
+ slot=slot,
317
+ focus=focus,
318
+ today=today_iso(),
319
+ )
320
+ user = prompt["user"].format(slot=slot, chunk=chunk.text)
321
+ raw = await call_llm(
322
+ system_prompt=system,
323
+ user_prompt=user,
324
+ on_event=on_event,
325
+ turn=chunk.index + 1,
326
+ chunk_index=chunk.index,
327
+ label="audit",
328
+ )
329
+ edits = parse_edits_payload(raw, layer="L3")
330
+ if not edits:
331
+ await emit(
332
+ on_event,
333
+ {"stage": "facts_extracted", "turn": chunk.index + 1, "edits": 0},
334
+ )
335
+ continue
336
+ doc, report = apply_edits(doc, edits, view=audit_view)
337
+ edits_applied += len(report.applied)
338
+ edits_rejected += len(report.rejected)
339
+ if report.applied:
340
+ await write_doc_checkpoint(
341
+ l3_path,
342
+ doc,
343
+ layer="L3",
344
+ key=slot,
345
+ on_event=on_event,
346
+ turn=chunk.index + 1,
347
+ label="audit",
348
+ action="apply_edits",
349
+ )
350
+ for res in report.applied:
351
+ await emit(
352
+ on_event,
353
+ {
354
+ "stage": "op_applied",
355
+ "turn": chunk.index + 1,
356
+ "op": res.op.op,
357
+ "detail": res.detail,
358
+ },
359
+ )
360
+ for res in report.rejected:
361
+ await emit(
362
+ on_event,
363
+ {
364
+ "stage": "op_rejected",
365
+ "turn": chunk.index + 1,
366
+ "op": res.op.op,
367
+ "detail": res.detail,
368
+ },
369
+ )
370
+
371
+ await emit(
372
+ on_event,
373
+ {
374
+ "stage": "done",
375
+ "edits_applied": edits_applied,
376
+ "edits_rejected": edits_rejected,
377
+ "chunks_processed": len(chunks),
378
+ },
379
+ )
380
+
381
+ if load_memory_settings().merge.auto_after_audit:
382
+ from deeptutor.services.memory.consolidator.modes.merge import run_merge
383
+
384
+ await run_merge(
385
+ "L3",
386
+ slot,
387
+ language=language,
388
+ user_label=user_label,
389
+ on_event=on_event,
390
+ )
391
+
392
+ return AuditResult(
393
+ layer="L3",
394
+ key=slot,
395
+ chunks_processed=len(chunks),
396
+ edits_applied=edits_applied,
397
+ edits_rejected=edits_rejected,
398
+ )
399
+
400
+
401
+ # ── Annotation builders ────────────────────────────────────────────────
402
+
403
+
404
+ def _build_annotated_l2(
405
+ doc: Document, surface: str, entity_lookup: dict[str, Entity]
406
+ ) -> tuple[str, list[tuple[int, int]]]:
407
+ """Render the doc as `line N: ... ` + per-bullet source dumps.
408
+
409
+ Returns ``(text, ranges)`` where ``ranges`` is per-bullet
410
+ ``(start_char, end_char)`` for future fine-grained linking. Not
411
+ consumed by the LLM directly; reserved for the UI's diff view.
412
+ """
413
+ view = render_view(doc)
414
+ pieces: list[str] = [_render_line_index(view)]
415
+ ranges: list[tuple[int, int]] = []
416
+ cursor = len(pieces[0])
417
+ for line in view.lines:
418
+ if line.kind != "bullet" or not line.entry_id:
419
+ continue
420
+ entry = view.entry_by_id.get(line.entry_id)
421
+ if entry is None:
422
+ continue
423
+ block = annotate_l2_line_with_evidence(
424
+ line.number, entry, surface=surface, entity_lookup=entity_lookup
425
+ )
426
+ # Two newlines separate annotation blocks.
427
+ prefix = "\n\n"
428
+ start = cursor + len(prefix)
429
+ pieces.append(prefix + block)
430
+ cursor = start + len(block)
431
+ ranges.append((start, cursor))
432
+ return "".join(pieces), ranges
433
+
434
+
435
+ def _build_annotated_l3(
436
+ doc: Document, l2_lookup: dict[str, Entry]
437
+ ) -> tuple[str, list[tuple[int, int]]]:
438
+ view = render_view(doc)
439
+ pieces: list[str] = [_render_line_index(view)]
440
+ ranges: list[tuple[int, int]] = []
441
+ cursor = len(pieces[0])
442
+ for line in view.lines:
443
+ if line.kind != "bullet" or not line.entry_id:
444
+ continue
445
+ entry = view.entry_by_id.get(line.entry_id)
446
+ if entry is None:
447
+ continue
448
+ block = annotate_l3_line_with_evidence(line.number, entry, l2_entry_lookup=l2_lookup)
449
+ prefix = "\n\n"
450
+ start = cursor + len(prefix)
451
+ pieces.append(prefix + block)
452
+ cursor = start + len(block)
453
+ ranges.append((start, cursor))
454
+ return "".join(pieces), ranges
455
+
456
+
457
+ def _render_line_index(view: LineView) -> str:
458
+ width = max(2, len(str(len(view.lines))))
459
+ head = "# Line-numbered view (LLM-facing):\n"
460
+ body = "\n".join(f"{line.number:>{width}}: {line.text}" for line in view.lines)
461
+ return head + body
462
+
463
+
464
+ def _build_l2_entry_lookup() -> dict[str, Entry]:
465
+ from deeptutor.services.memory.document import parse
466
+
467
+ out: dict[str, Entry] = {}
468
+ for surface in paths.SURFACES:
469
+ path = paths.l2_file(surface)
470
+ if not path.exists():
471
+ continue
472
+ try:
473
+ doc = parse(path.read_text(encoding="utf-8"))
474
+ except Exception:
475
+ continue
476
+ for entry in doc.all_entries():
477
+ out[entry.id] = entry
478
+ return out
479
+
480
+
481
+ __all__ = ["AuditResult", "run_audit"]
@@ -0,0 +1,228 @@
1
+ """Dedup mode — iterative line-level merge / delete over the full doc.
2
+
3
+ Each iteration:
4
+ 1. Render the full md as a line-numbered view (footnote-stripped).
5
+ 2. One LLM call returns ``{"edits": [...]}`` (replace + delete only —
6
+ the dedup prompt forbids inserts).
7
+ 3. Apply in reverse line order.
8
+ 4. If the LLM returned zero edits, **stop early** (saves tokens).
9
+
10
+ The configured ``iterations`` is the *upper bound*, not a quota.
11
+
12
+ Dedup is invoked either:
13
+ - automatically after a successful :func:`run_update` (controlled by
14
+ ``memory.dedup.auto_after_update``), or
15
+ - explicitly via the workbench `[Dedup]` button.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ import logging
22
+
23
+ from deeptutor.services.memory import paths
24
+ from deeptutor.services.memory.consolidator.line_doc import (
25
+ apply_edits,
26
+ parse_edits_payload,
27
+ render_view,
28
+ )
29
+ from deeptutor.services.memory.consolidator.modes._runtime import (
30
+ OnEvent,
31
+ call_llm,
32
+ emit,
33
+ load_doc,
34
+ load_prompt,
35
+ today_iso,
36
+ write_doc_checkpoint,
37
+ )
38
+ from deeptutor.services.memory.settings import load_memory_settings
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ @dataclass
44
+ class DedupResult:
45
+ layer: str
46
+ key: str
47
+ iterations_run: int
48
+ edits_applied: int
49
+ converged_early: bool
50
+
51
+
52
+ async def run_dedup(
53
+ layer: str,
54
+ key: str,
55
+ *,
56
+ language: str = "en",
57
+ user_label: str = "anonymous",
58
+ iterations: int | None = None,
59
+ llm_selection: dict | None = None,
60
+ on_event: OnEvent | None = None,
61
+ ) -> DedupResult:
62
+ from deeptutor.services.model_selection.runtime import (
63
+ activate_llm_selection,
64
+ reset_llm_selection,
65
+ )
66
+
67
+ settings = load_memory_settings()
68
+ iters = iterations if iterations is not None else settings.dedup.iterations
69
+
70
+ token = None
71
+ if llm_selection:
72
+ try:
73
+ _config, token = activate_llm_selection(llm_selection)
74
+ except Exception as exc: # noqa: BLE001
75
+ logger.warning(
76
+ "memory dedup: ignoring unresolvable llm_selection %s: %s", llm_selection, exc
77
+ )
78
+ token = None
79
+ try:
80
+ return await _run_dedup_inner(
81
+ layer, key, iters=iters, language=language, user_label=user_label, on_event=on_event
82
+ )
83
+ finally:
84
+ reset_llm_selection(token)
85
+
86
+
87
+ async def _run_dedup_inner(
88
+ layer: str,
89
+ key: str,
90
+ *,
91
+ iters: int,
92
+ language: str,
93
+ user_label: str,
94
+ on_event: OnEvent | None,
95
+ ) -> DedupResult:
96
+ path = _path_for(layer, key)
97
+ if not path.exists():
98
+ await emit(on_event, {"stage": "done", "no_doc": True, "edits_applied": 0})
99
+ return DedupResult(
100
+ layer=layer, key=key, iterations_run=0, edits_applied=0, converged_early=True
101
+ )
102
+
103
+ doc = load_doc(path, default_title=_default_title(layer, key))
104
+ if not doc.all_entries():
105
+ await emit(on_event, {"stage": "done", "no_doc": True, "edits_applied": 0})
106
+ return DedupResult(
107
+ layer=layer, key=key, iterations_run=0, edits_applied=0, converged_early=True
108
+ )
109
+
110
+ prompt = load_prompt("dedup", language)
111
+ total_applied = 0
112
+ converged = False
113
+
114
+ for i in range(iters):
115
+ view = render_view(doc)
116
+ await emit(
117
+ on_event,
118
+ {
119
+ "stage": "progress",
120
+ "mode": "dedup",
121
+ "turn": i + 1,
122
+ "total": iters,
123
+ "lines": len(view.lines),
124
+ },
125
+ )
126
+ system = prompt["system"].format(user_label=user_label, today=today_iso())
127
+ user = prompt["user"].format(
128
+ doc=_render_with_numbers(view),
129
+ iteration=i + 1,
130
+ iterations_total=iters,
131
+ )
132
+ raw = await call_llm(
133
+ system_prompt=system,
134
+ user_prompt=user,
135
+ on_event=on_event,
136
+ turn=i + 1,
137
+ label="dedup",
138
+ )
139
+ edits = parse_edits_payload(raw, layer=layer)
140
+ if not edits:
141
+ converged = True
142
+ await emit(on_event, {"stage": "facts_extracted", "turn": i + 1, "edits": 0})
143
+ break
144
+
145
+ doc, report = apply_edits(doc, edits)
146
+ total_applied += len(report.applied)
147
+ if report.applied:
148
+ await write_doc_checkpoint(
149
+ path,
150
+ doc,
151
+ layer=layer,
152
+ key=key,
153
+ on_event=on_event,
154
+ turn=i + 1,
155
+ label="dedup",
156
+ action="apply_edits",
157
+ )
158
+ await emit(
159
+ on_event,
160
+ {
161
+ "stage": "op_applied",
162
+ "turn": i + 1,
163
+ "applied": len(report.applied),
164
+ "rejected": len(report.rejected),
165
+ },
166
+ )
167
+ if not report.applied and not report.rejected:
168
+ converged = True
169
+ break
170
+
171
+ await emit(
172
+ on_event,
173
+ {
174
+ "stage": "done",
175
+ "edits_applied": total_applied,
176
+ "iterations_run": min(iters, i + 1 if iters else 0),
177
+ "converged_early": converged,
178
+ },
179
+ )
180
+
181
+ if load_memory_settings().merge.auto_after_dedup:
182
+ from deeptutor.services.memory.consolidator.modes.merge import run_merge
183
+
184
+ await run_merge(
185
+ layer,
186
+ key,
187
+ language=language,
188
+ user_label=user_label,
189
+ on_event=on_event,
190
+ )
191
+
192
+ return DedupResult(
193
+ layer=layer,
194
+ key=key,
195
+ iterations_run=min(iters, (i + 1) if iters else 0),
196
+ edits_applied=total_applied,
197
+ converged_early=converged,
198
+ )
199
+
200
+
201
+ # ── Helpers ─────────────────────────────────────────────────────────────
202
+
203
+
204
+ def _path_for(layer: str, key: str):
205
+ if layer == "L2":
206
+ return paths.l2_file(key) # type: ignore[arg-type]
207
+ if layer == "L3":
208
+ return paths.l3_file(key) # type: ignore[arg-type]
209
+ raise ValueError(f"unknown layer {layer!r}")
210
+
211
+
212
+ def _default_title(layer: str, key: str) -> str:
213
+ if layer == "L2":
214
+ return f"{key} memory"
215
+ return {
216
+ "recent": "Recent summary",
217
+ "profile": "User profile",
218
+ "scope": "Knowledge scope",
219
+ "preferences": "Preferences",
220
+ }.get(key, f"{key} memory")
221
+
222
+
223
+ def _render_with_numbers(view) -> str:
224
+ width = max(2, len(str(len(view.lines))))
225
+ return "\n".join(f"{line.number:>{width}}: {line.text}" for line in view.lines)
226
+
227
+
228
+ __all__ = ["DedupResult", "run_dedup"]