memleaf 0.1.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.
memleaf/compaction.py ADDED
@@ -0,0 +1,878 @@
1
+ """Local, deterministic active-memory compaction for stage B3a."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any, Iterable, Mapping
13
+
14
+ from .llm import CallableBackend, ModelError, ModelRouter, ModelUnavailable
15
+ from .locking import atomic_unlink, atomic_write_json, atomic_write_text, read_json
16
+ from .models import Memory, utc_now
17
+ from .prompts import COMPACT_SYSTEM, compact_prompt
18
+ from .validation import ModelOutputError, parse_compact_output
19
+ from .vault import safe_component
20
+
21
+
22
+ class CompactionError(RuntimeError):
23
+ """A compaction request could not be safely committed."""
24
+
25
+
26
+ _JOURNAL_VERSION = 1
27
+ _JOURNAL_PHASES = frozenset(("staged", "histories", "replacements", "sources_removed", "committed"))
28
+
29
+
30
+ def _content_mapping(value: Memory | Mapping[str, Any]) -> dict[str, Any]:
31
+ if isinstance(value, Memory):
32
+ return {
33
+ "title": value.title,
34
+ "body": value.body,
35
+ "tags": list(value.tags),
36
+ "type": value.type,
37
+ "scopes": list(value.scopes),
38
+ "scope_source": value.scope_source,
39
+ "aliases": list(value.aliases),
40
+ "keywords": list(value.keywords),
41
+ "status": value.status,
42
+ "completed_at": value.completed_at,
43
+ }
44
+ if not isinstance(value, Mapping):
45
+ raise ValueError("memory content must be a memory or mapping")
46
+ return {
47
+ "title": value.get("title", ""),
48
+ "body": value.get("body", ""),
49
+ "tags": list(value.get("tags", [])),
50
+ "type": value.get("type", "other"),
51
+ "scopes": list(value.get("scopes", [])),
52
+ "scope_source": value.get("scope_source"),
53
+ "aliases": list(value.get("aliases", [])),
54
+ "keywords": list(value.get("keywords", [])),
55
+ "status": value.get("status"),
56
+ "completed_at": value.get("completed_at"),
57
+ }
58
+
59
+
60
+ def estimate_memory_tokens(value: Memory | Mapping[str, Any]) -> int:
61
+ """Return a stable local estimate, not a model tokenizer count.
62
+
63
+ The estimate serializes active content fields and rounds UTF-8 bytes up at
64
+ four bytes per estimate unit. It is intentionally simple and is reused by
65
+ both ``stats()`` and compaction decisions.
66
+ """
67
+
68
+ payload = json.dumps(
69
+ _content_mapping(value),
70
+ ensure_ascii=False,
71
+ sort_keys=True,
72
+ separators=(",", ":"),
73
+ ).encode("utf-8")
74
+ return max(1, math.ceil(len(payload) / 4))
75
+
76
+
77
+ def estimate_active_tokens(memories: Iterable[Memory]) -> int:
78
+ return sum(estimate_memory_tokens(memory) for memory in memories)
79
+
80
+
81
+ def _sha256(text: str) -> str:
82
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
83
+
84
+
85
+ def _parse_time(value: Any) -> datetime | None:
86
+ if not isinstance(value, str) or not value.strip():
87
+ return None
88
+ try:
89
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
90
+ except ValueError:
91
+ return None
92
+ if parsed.tzinfo is None:
93
+ parsed = parsed.replace(tzinfo=timezone.utc)
94
+ return parsed.astimezone(timezone.utc)
95
+
96
+
97
+ def _clock_now(clock: Any = None) -> str:
98
+ try:
99
+ value = clock.now() if hasattr(clock, "now") and callable(clock.now) else clock() if callable(clock) else clock
100
+ except Exception as error:
101
+ raise CompactionError("compaction clock failed") from error
102
+ if value is None:
103
+ return utc_now()
104
+ if isinstance(value, datetime):
105
+ if value.tzinfo is None:
106
+ value = value.replace(tzinfo=timezone.utc)
107
+ return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
108
+ if isinstance(value, str) and value.strip():
109
+ return value
110
+ raise CompactionError("compaction clock returned an invalid timestamp")
111
+
112
+
113
+ def _error_kind(error: BaseException) -> str:
114
+ if isinstance(error, ModelUnavailable):
115
+ return "unavailable"
116
+ if isinstance(error, ModelOutputError):
117
+ return "invalid_output"
118
+ if isinstance(error, ModelError):
119
+ return "model_failed"
120
+ return "failed"
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class _Candidate:
125
+ memory: Memory
126
+ path: Path
127
+ raw: str
128
+ raw_hash: str
129
+ tokens: int
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class _Replacement:
134
+ memory: Memory
135
+ raw: str
136
+ source_ids: tuple[str, ...]
137
+ source_tokens: int
138
+ replacement_tokens: int
139
+
140
+
141
+ class Compactor:
142
+ """Snapshot, validate, and safely commit active-memory replacements."""
143
+
144
+ def __init__(self, service: Any):
145
+ self.service = service
146
+
147
+ def _config(self) -> tuple[int, float]:
148
+ try:
149
+ config = self.service.vault.config()
150
+ except (OSError, UnicodeError, TypeError, ValueError) as error:
151
+ raise CompactionError("cannot read compaction configuration") from error
152
+ process = config.get("process") if isinstance(config, Mapping) else None
153
+ threshold = process.get("memory_compact_threshold_tokens") if isinstance(process, Mapping) else None
154
+ ratio = process.get("memory_compact_candidate_ratio") if isinstance(process, Mapping) else None
155
+ if type(threshold) is not int or threshold <= 0:
156
+ raise CompactionError("invalid memory compaction threshold")
157
+ if isinstance(ratio, bool) or not isinstance(ratio, (int, float)):
158
+ raise CompactionError("invalid memory compaction ratio")
159
+ if not math.isfinite(float(ratio)) or not 0 < float(ratio) <= 1:
160
+ raise CompactionError("invalid memory compaction ratio")
161
+ return threshold, float(ratio)
162
+
163
+ def _resolve_backend(self, model: Any = None, router: Any = None) -> Any:
164
+ backend = router if router is not None else model
165
+ if backend is None:
166
+ backend = getattr(self.service, "router", None)
167
+ if backend is None:
168
+ backend = ModelRouter.from_config(self.service.vault.config())
169
+ self.service.router = backend
170
+ if callable(backend) and not hasattr(backend, "complete"):
171
+ backend = CallableBackend(backend)
172
+ if not hasattr(backend, "complete"):
173
+ raise ModelUnavailable("no model backend is configured")
174
+ return backend
175
+
176
+ @staticmethod
177
+ def _candidate_sort_key(candidate: _Candidate) -> tuple[int, datetime, int, int, str]:
178
+ hit = _parse_time(candidate.memory.last_hit_at)
179
+ # A missing or malformed hit time is treated as never hit and is
180
+ # therefore older than every valid hit time.
181
+ explicit_rank = 1 if candidate.memory.extra.get("explicit_remember") is True else 0
182
+ return (
183
+ 0 if hit is None else 1,
184
+ hit or datetime.min.replace(tzinfo=timezone.utc),
185
+ candidate.memory.hit_count,
186
+ explicit_rank,
187
+ candidate.memory.memory_id,
188
+ )
189
+
190
+ @staticmethod
191
+ def _valid_hash(value: Any) -> bool:
192
+ return isinstance(value, str) and len(value) == 64 and all(
193
+ character in "0123456789abcdef" for character in value.casefold()
194
+ )
195
+
196
+ @staticmethod
197
+ def _remove_tree(path: Path) -> None:
198
+ if not path.exists() and not path.is_symlink():
199
+ return
200
+ if path.is_symlink() or not path.is_dir():
201
+ atomic_unlink(path)
202
+ return
203
+ for child in list(path.iterdir()):
204
+ Compactor._remove_tree(child)
205
+ try:
206
+ path.rmdir()
207
+ except FileNotFoundError:
208
+ pass
209
+
210
+ def _clear_orphan_staging_unlocked(self) -> None:
211
+ root = self.service.vault.compaction_staging_root
212
+ if not root.exists():
213
+ return
214
+ if root.is_symlink():
215
+ raise CompactionError("unsafe compaction staging root")
216
+ if not root.is_dir():
217
+ raise CompactionError("invalid compaction staging root")
218
+ for child in list(root.iterdir()):
219
+ self._remove_tree(child)
220
+
221
+ def _staging_dir_unlocked(self, transaction_id: str, *, create: bool) -> Path:
222
+ safe_component(transaction_id, "compaction transaction id")
223
+ root = self.service.vault.compaction_staging_root
224
+ if root.exists() and (root.is_symlink() or not root.is_dir()):
225
+ raise CompactionError("unsafe compaction staging root")
226
+ if create:
227
+ root.mkdir(parents=True, exist_ok=True)
228
+ try:
229
+ root.chmod(0o700)
230
+ except OSError:
231
+ pass
232
+ path = self.service.vault.compaction_staging_dir(transaction_id)
233
+ if path.exists() and (path.is_symlink() or not path.is_dir()):
234
+ raise CompactionError("unsafe compaction staging directory")
235
+ if create:
236
+ path.mkdir(parents=False, exist_ok=False)
237
+ try:
238
+ path.chmod(0o700)
239
+ except OSError:
240
+ pass
241
+ return path
242
+
243
+ def _read_journal_unlocked(self) -> dict[str, Any] | None:
244
+ path = self.service.vault.compaction_journal_path
245
+ if path.is_symlink():
246
+ raise CompactionError("unsafe compaction journal path")
247
+ if not path.exists():
248
+ return None
249
+ try:
250
+ value = read_json(path)
251
+ except (OSError, UnicodeError, TypeError, ValueError) as error:
252
+ raise CompactionError("invalid compaction journal") from error
253
+ if not isinstance(value, Mapping) or value.get("version") != _JOURNAL_VERSION:
254
+ raise CompactionError("invalid compaction journal")
255
+ transaction_id = value.get("transaction_id")
256
+ phase = value.get("phase")
257
+ try:
258
+ safe_component(transaction_id, "compaction transaction id")
259
+ except (TypeError, ValueError) as error:
260
+ raise CompactionError("invalid compaction journal") from error
261
+ if phase not in _JOURNAL_PHASES:
262
+ raise CompactionError("invalid compaction journal phase")
263
+ result: dict[str, Any] = {
264
+ "version": _JOURNAL_VERSION,
265
+ "transaction_id": transaction_id,
266
+ "phase": phase,
267
+ }
268
+ for field in ("sources", "replacements", "histories"):
269
+ items = value.get(field)
270
+ if not isinstance(items, list):
271
+ raise CompactionError("invalid compaction journal entries")
272
+ normalized: list[dict[str, str]] = []
273
+ seen: set[str] = set()
274
+ for item in items:
275
+ if not isinstance(item, Mapping):
276
+ raise CompactionError("invalid compaction journal entry")
277
+ memory_id = item.get("memory_id")
278
+ digest = item.get("sha256")
279
+ try:
280
+ safe_component(memory_id, "compaction memory id")
281
+ except (TypeError, ValueError) as error:
282
+ raise CompactionError("invalid compaction journal memory id") from error
283
+ if not self._valid_hash(digest) or memory_id in seen:
284
+ raise CompactionError("invalid compaction journal hash")
285
+ seen.add(memory_id)
286
+ normalized_item = {"memory_id": memory_id, "sha256": digest.casefold()}
287
+ if field == "sources":
288
+ staging_file = item.get("staging_file")
289
+ try:
290
+ safe_component(staging_file, "compaction staging file")
291
+ except (TypeError, ValueError) as error:
292
+ raise CompactionError("invalid compaction staging file") from error
293
+ normalized_item["staging_file"] = staging_file
294
+ normalized.append(normalized_item)
295
+ result[field] = normalized
296
+ return result
297
+
298
+ def _write_journal_unlocked(self, journal: Mapping[str, Any]) -> None:
299
+ path = self.service.vault.compaction_journal_path
300
+ if path.is_symlink():
301
+ raise CompactionError("unsafe compaction journal path")
302
+ atomic_write_json(path, dict(journal))
303
+
304
+ def _clear_transaction_unlocked(self, journal: Mapping[str, Any]) -> None:
305
+ path = self.service.vault.compaction_journal_path
306
+ if path.is_symlink():
307
+ raise CompactionError("unsafe compaction journal path")
308
+ if path.exists():
309
+ atomic_unlink(path)
310
+ staging = self._staging_dir_unlocked(str(journal["transaction_id"]), create=False)
311
+ self._remove_tree(staging)
312
+
313
+ def _read_expected_file_unlocked(self, path: Path, expected_hash: str, label: str) -> str | None:
314
+ if path.is_symlink():
315
+ raise CompactionError(f"unsafe {label} path")
316
+ if not path.exists():
317
+ return None
318
+ try:
319
+ raw = path.read_text(encoding="utf-8")
320
+ except (OSError, UnicodeError) as error:
321
+ raise CompactionError(f"cannot read {label}") from error
322
+ if _sha256(raw) != expected_hash:
323
+ raise CompactionError(f"{label} changed during recovery")
324
+ return raw
325
+
326
+ def _rollback_pending_unlocked(self, journal: Mapping[str, Any]) -> None:
327
+ transaction_id = str(journal["transaction_id"])
328
+ staging = self._staging_dir_unlocked(transaction_id, create=False)
329
+ if staging.is_symlink() or not staging.exists() or not staging.is_dir():
330
+ raise CompactionError("missing compaction staging")
331
+ for entry in journal["sources"]:
332
+ source_path = self.service.vault.memory_path(entry["memory_id"], "knowledge")
333
+ if source_path.is_symlink():
334
+ raise CompactionError("unsafe active memory path during recovery")
335
+ if source_path.exists():
336
+ try:
337
+ current = source_path.read_text(encoding="utf-8")
338
+ except (OSError, UnicodeError) as error:
339
+ raise CompactionError("cannot read active memory during recovery") from error
340
+ # A user edit is preserved; the transaction additions below
341
+ # are still removed so the vault returns to a stable old view.
342
+ if _sha256(current) != entry["sha256"]:
343
+ continue
344
+ else:
345
+ staged_path = staging / entry["staging_file"]
346
+ if staged_path.is_symlink() or not staged_path.exists():
347
+ raise CompactionError("missing staged active memory")
348
+ try:
349
+ original = staged_path.read_text(encoding="utf-8")
350
+ except (OSError, UnicodeError) as error:
351
+ raise CompactionError("cannot read staged active memory") from error
352
+ if _sha256(original) != entry["sha256"]:
353
+ raise CompactionError("staged active memory hash mismatch")
354
+ atomic_write_text(source_path, original)
355
+ for entry in journal["replacements"]:
356
+ path = self.service.vault.memory_path(entry["memory_id"], "knowledge")
357
+ if self._read_expected_file_unlocked(path, entry["sha256"], "compaction replacement") is not None:
358
+ atomic_unlink(path)
359
+ for entry in journal["histories"]:
360
+ path = self.service.vault.memory_path(entry["memory_id"], "history")
361
+ if self._read_expected_file_unlocked(path, entry["sha256"], "compaction history") is not None:
362
+ atomic_unlink(path)
363
+ self.service._rebuild_index_unlocked()
364
+ self._clear_transaction_unlocked(journal)
365
+
366
+ def _recover_pending_unlocked(self) -> None:
367
+ """Rollback only the transaction named by the short-lived journal."""
368
+
369
+ journal = self._read_journal_unlocked()
370
+ if journal is None:
371
+ self._clear_orphan_staging_unlocked()
372
+ return
373
+ if journal["phase"] == "committed":
374
+ self._clear_transaction_unlocked(journal)
375
+ return
376
+ self._rollback_pending_unlocked(journal)
377
+
378
+ def _snapshot(self, threshold: int, ratio: float) -> tuple[list[_Candidate], list[_Candidate], int]:
379
+ with self.service.vault.lock():
380
+ self._recover_pending_unlocked()
381
+ candidates: list[_Candidate] = []
382
+ for record in self.service._read_memories_unlocked("knowledge"):
383
+ try:
384
+ raw = record.path.read_text(encoding="utf-8")
385
+ except (OSError, UnicodeError) as error:
386
+ raise CompactionError("cannot read active memory") from error
387
+ candidates.append(
388
+ _Candidate(
389
+ memory=record.memory,
390
+ path=record.path,
391
+ raw=raw,
392
+ raw_hash=_sha256(raw),
393
+ tokens=estimate_memory_tokens(record.memory),
394
+ )
395
+ )
396
+ active_tokens = sum(item.tokens for item in candidates)
397
+ if active_tokens < threshold or not candidates:
398
+ return [], candidates, active_tokens
399
+ ordered = sorted(candidates, key=self._candidate_sort_key)
400
+ count = max(1, math.ceil(len(ordered) * ratio))
401
+ return ordered[:count], candidates, active_tokens
402
+
403
+ @staticmethod
404
+ def _prompt_memory(candidate: _Candidate) -> dict[str, Any]:
405
+ value = _content_mapping(candidate.memory)
406
+ value["memory_id"] = candidate.memory.memory_id
407
+ return value
408
+
409
+ @staticmethod
410
+ def _replacement_id(summary: Mapping[str, Any], source_ids: Iterable[str]) -> str:
411
+ material = {
412
+ "source_memory_ids": sorted({item.casefold() for item in source_ids}),
413
+ "replacement": {
414
+ key: summary.get(key)
415
+ for key in (
416
+ "title",
417
+ "body",
418
+ "tags",
419
+ "type",
420
+ "scopes",
421
+ "scope_source",
422
+ "aliases",
423
+ "keywords",
424
+ "status",
425
+ "completed_at",
426
+ )
427
+ },
428
+ }
429
+ digest = hashlib.sha256(
430
+ json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
431
+ ).hexdigest()
432
+ return f"mem-compact-{digest[:24]}"
433
+
434
+ @staticmethod
435
+ def _history_id(source: Memory, replacement_id: str, source_hash: str) -> str:
436
+ material = {
437
+ "original_memory_id": source.memory_id,
438
+ "compacted_into": replacement_id,
439
+ "source_hash": source_hash,
440
+ }
441
+ digest = hashlib.sha256(
442
+ json.dumps(material, sort_keys=True, separators=(",", ":")).encode("utf-8")
443
+ ).hexdigest()
444
+ return f"hist-{digest[:24]}"
445
+
446
+ @staticmethod
447
+ def _merge_sources(memories: Iterable[Memory]) -> list[dict[str, Any]]:
448
+ result: list[dict[str, Any]] = []
449
+ seen: set[str] = set()
450
+ for memory in memories:
451
+ for source in memory.sources:
452
+ if not isinstance(source, Mapping):
453
+ continue
454
+ value = dict(source)
455
+ key = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
456
+ if key in seen:
457
+ continue
458
+ seen.add(key)
459
+ result.append(value)
460
+ return result
461
+
462
+ @staticmethod
463
+ def _first_created(memories: Iterable[Memory], now: str) -> str:
464
+ values = [memory.created for memory in memories if isinstance(memory.created, str) and memory.created]
465
+ return min(values) if values else now
466
+
467
+ @staticmethod
468
+ def _last_hit(memories: Iterable[Memory]) -> str | None:
469
+ values = [
470
+ (_parse_time(memory.last_hit_at), memory.last_hit_at)
471
+ for memory in memories
472
+ if isinstance(memory.last_hit_at, str) and _parse_time(memory.last_hit_at) is not None
473
+ ]
474
+ if not values:
475
+ return None
476
+ return max(values, key=lambda item: item[0])[1]
477
+
478
+ def _build_replacement(
479
+ self,
480
+ summary: Mapping[str, Any],
481
+ source_candidates: list[_Candidate],
482
+ *,
483
+ now: str,
484
+ ) -> _Replacement:
485
+ source_memories = [candidate.memory for candidate in source_candidates]
486
+ source_ids = tuple(memory.memory_id for memory in source_memories)
487
+ replacement_id = self._replacement_id(summary, source_ids)
488
+ status = summary.get("status")
489
+ if summary["type"] == "todo" and status is None:
490
+ status = "active"
491
+ extra = {
492
+ "compaction_source_ids": list(source_ids),
493
+ "compacted_at": now,
494
+ }
495
+ if any(memory.extra.get("explicit_remember") is True for memory in source_memories):
496
+ extra["explicit_remember"] = True
497
+ memory = Memory(
498
+ memory_id=replacement_id,
499
+ title=summary["title"],
500
+ body=summary["body"],
501
+ tags=list(summary["tags"]),
502
+ type=summary["type"],
503
+ scopes=list(summary["scopes"]),
504
+ scope_source=summary["scope_source"],
505
+ aliases=list(summary["aliases"]),
506
+ keywords=list(summary["keywords"]),
507
+ sources=self._merge_sources(source_memories),
508
+ created=self._first_created(source_memories, now),
509
+ updated=now,
510
+ hit_count=sum(memory.hit_count for memory in source_memories),
511
+ last_hit_at=self._last_hit(source_memories),
512
+ status=status,
513
+ completed_at=summary.get("completed_at"),
514
+ extra=extra,
515
+ )
516
+ raw = memory.to_markdown()
517
+ return _Replacement(
518
+ memory=memory,
519
+ raw=raw,
520
+ source_ids=source_ids,
521
+ source_tokens=sum(candidate.tokens for candidate in source_candidates),
522
+ replacement_tokens=estimate_memory_tokens(summary),
523
+ )
524
+
525
+ @staticmethod
526
+ def _memory_content_equal(left: Memory, right: Memory) -> bool:
527
+ ignored_extra = {"compacted_at", "archived_at"}
528
+ left_extra = {key: value for key, value in left.extra.items() if key not in ignored_extra}
529
+ right_extra = {key: value for key, value in right.extra.items() if key not in ignored_extra}
530
+ return (
531
+ left.memory_id == right.memory_id
532
+ and left.title == right.title
533
+ and left.body == right.body
534
+ and left.tags == right.tags
535
+ and left.type == right.type
536
+ and left.scopes == right.scopes
537
+ and left.scope_source == right.scope_source
538
+ and left.aliases == right.aliases
539
+ and left.keywords == right.keywords
540
+ and left.sources == right.sources
541
+ and left.status == right.status
542
+ and left.completed_at == right.completed_at
543
+ and left_extra == right_extra
544
+ )
545
+
546
+ def _preflight(
547
+ self,
548
+ selected: list[_Candidate],
549
+ all_active: list[_Candidate],
550
+ replacements: list[_Replacement],
551
+ ) -> None:
552
+ selected_by_id = {candidate.memory.memory_id: candidate for candidate in selected}
553
+ active_ids = {candidate.memory.memory_id for candidate in all_active}
554
+ replacement_ids: set[str] = set()
555
+ consumed: set[str] = set()
556
+ for replacement in replacements:
557
+ if replacement.memory.memory_id in replacement_ids:
558
+ raise CompactionError("duplicate compaction replacement id")
559
+ replacement_ids.add(replacement.memory.memory_id)
560
+ if replacement.memory.memory_id in active_ids:
561
+ raise CompactionError("compaction replacement id collides with active memory")
562
+ for source_id in replacement.source_ids:
563
+ if source_id in consumed:
564
+ raise CompactionError("compaction source memory is consumed twice")
565
+ if source_id not in selected_by_id:
566
+ raise CompactionError("compaction source is outside the snapshot")
567
+ source = selected_by_id[source_id]
568
+ history_id = self._history_id(source.memory, replacement.memory.memory_id, source.raw_hash)
569
+ history_path = self.service.vault.memory_path(history_id, "history")
570
+ if history_path.is_symlink() or history_path.exists():
571
+ raise CompactionError("compaction history exists without a pending transaction")
572
+ consumed.add(source_id)
573
+ if not replacements:
574
+ return
575
+ selected_ids = {candidate.memory.memory_id for candidate in selected}
576
+ if not consumed.issubset(selected_ids):
577
+ raise CompactionError("compaction source is outside the candidate set")
578
+ for replacement in replacements:
579
+ path = self.service.vault.memory_path(replacement.memory.memory_id, "knowledge")
580
+ if path.is_symlink() or path.exists():
581
+ raise CompactionError("compaction replacement exists without a pending transaction")
582
+
583
+ def _build_plan(
584
+ self,
585
+ output: Mapping[str, Any],
586
+ selected: list[_Candidate],
587
+ *,
588
+ now: str,
589
+ ) -> list[_Replacement]:
590
+ by_id = {candidate.memory.memory_id.casefold(): candidate for candidate in selected}
591
+ replacements: list[_Replacement] = []
592
+ for summary in output["memories"]:
593
+ source_ids = tuple(summary["source_memory_ids"])
594
+ try:
595
+ source_candidates = [by_id[source_id.casefold()] for source_id in source_ids]
596
+ except KeyError as error:
597
+ raise CompactionError("compaction source is outside the snapshot") from error
598
+ replacement = self._build_replacement(summary, source_candidates, now=now)
599
+ if replacement.replacement_tokens >= replacement.source_tokens:
600
+ raise CompactionError("compaction replacement is not smaller than its sources")
601
+ replacements.append(replacement)
602
+ return replacements
603
+
604
+ def _history_payload(
605
+ self,
606
+ source: _Candidate,
607
+ replacement: _Replacement,
608
+ *,
609
+ now: str,
610
+ ) -> tuple[str, Memory, str]:
611
+ history_id = self._history_id(source.memory, replacement.memory.memory_id, source.raw_hash)
612
+ extra = dict(source.memory.extra)
613
+ extra.update(
614
+ {
615
+ "reason": "compaction",
616
+ "original_memory_id": source.memory.memory_id,
617
+ "compacted_into": replacement.memory.memory_id,
618
+ "active_memory_id": replacement.memory.memory_id,
619
+ "archived_at": now,
620
+ "compaction_source_hash": source.raw_hash,
621
+ "compacted_into_hash": _sha256(replacement.raw),
622
+ }
623
+ )
624
+ historical = Memory(
625
+ memory_id=history_id,
626
+ title=source.memory.title,
627
+ body=source.memory.body,
628
+ tags=list(source.memory.tags),
629
+ type=source.memory.type,
630
+ scopes=list(source.memory.scopes),
631
+ scope_source=source.memory.scope_source,
632
+ aliases=list(source.memory.aliases),
633
+ keywords=list(source.memory.keywords),
634
+ sources=[dict(item) for item in source.memory.sources],
635
+ created=source.memory.created,
636
+ updated=source.memory.updated,
637
+ hit_count=source.memory.hit_count,
638
+ last_hit_at=source.memory.last_hit_at,
639
+ status=source.memory.status,
640
+ completed_at=source.memory.completed_at,
641
+ extra=extra,
642
+ )
643
+ return history_id, historical, historical.to_markdown()
644
+
645
+ def _write_history(self, source: _Candidate, replacement: _Replacement, *, now: str) -> str:
646
+ history_id, historical, raw = self._history_payload(source, replacement, now=now)
647
+ path = self.service.vault.memory_path(history_id, "history")
648
+ if path.is_symlink():
649
+ raise CompactionError("unsafe compaction history path")
650
+ if path.exists():
651
+ try:
652
+ current = Memory.from_markdown(path.read_text(encoding="utf-8"), path)
653
+ except (OSError, UnicodeError, ValueError) as error:
654
+ raise CompactionError("existing compaction history is invalid") from error
655
+ if not self._memory_content_equal(current, historical):
656
+ raise CompactionError("compaction history id collision")
657
+ else:
658
+ atomic_write_text(path, raw)
659
+ return history_id
660
+
661
+ def _commit(
662
+ self,
663
+ selected: list[_Candidate],
664
+ all_active: list[_Candidate],
665
+ replacements: list[_Replacement],
666
+ *,
667
+ now: str,
668
+ ) -> tuple[list[str], list[str]]:
669
+ replacement_ids = [replacement.memory.memory_id for replacement in replacements]
670
+ selected_by_id = {candidate.memory.memory_id: candidate for candidate in selected}
671
+ with self.service.vault.lock():
672
+ self._recover_pending_unlocked()
673
+ current_active = self.service._read_memories_unlocked("knowledge")
674
+ current_by_id = {record.memory.memory_id: record for record in current_active}
675
+ for candidate in selected:
676
+ record = current_by_id.get(candidate.memory.memory_id)
677
+ if record is None:
678
+ raise CompactionError("active memory changed during compaction")
679
+ try:
680
+ raw = record.path.read_text(encoding="utf-8")
681
+ except (OSError, UnicodeError) as error:
682
+ raise CompactionError("cannot read active memory during commit") from error
683
+ if _sha256(raw) != candidate.raw_hash:
684
+ raise CompactionError("active memory changed during compaction")
685
+ self._preflight(selected, all_active, replacements)
686
+
687
+ history_payloads: list[tuple[_Candidate, _Replacement, str, str]] = []
688
+ consumed_candidates: list[_Candidate] = []
689
+ seen_sources: set[str] = set()
690
+ for replacement in replacements:
691
+ for source_id in replacement.source_ids:
692
+ source = selected_by_id[source_id]
693
+ history_id, _historical, history_raw = self._history_payload(source, replacement, now=now)
694
+ history_payloads.append((source, replacement, history_id, history_raw))
695
+ if source_id not in seen_sources:
696
+ seen_sources.add(source_id)
697
+ consumed_candidates.append(source)
698
+
699
+ transaction_id = uuid.uuid4().hex
700
+ staging = self._staging_dir_unlocked(transaction_id, create=True)
701
+ source_entries: list[dict[str, str]] = []
702
+ for index, source in enumerate(consumed_candidates):
703
+ source_entries.append(
704
+ {
705
+ "memory_id": source.memory.memory_id,
706
+ "sha256": source.raw_hash,
707
+ "staging_file": f"{index:04d}-{_sha256(source.memory.memory_id)[:16]}.md",
708
+ }
709
+ )
710
+ journal: dict[str, Any] = {
711
+ "version": _JOURNAL_VERSION,
712
+ "transaction_id": transaction_id,
713
+ "phase": "staged",
714
+ "sources": source_entries,
715
+ "replacements": [
716
+ {"memory_id": replacement.memory.memory_id, "sha256": _sha256(replacement.raw)}
717
+ for replacement in replacements
718
+ ],
719
+ "histories": [
720
+ {"memory_id": history_id, "sha256": _sha256(history_raw)}
721
+ for _source, _replacement, history_id, history_raw in history_payloads
722
+ ],
723
+ }
724
+ try:
725
+ for source, entry in zip(consumed_candidates, source_entries):
726
+ atomic_write_text(staging / entry["staging_file"], source.raw)
727
+ self._write_journal_unlocked(journal)
728
+ for source, replacement, _history_id_value, history_raw in history_payloads:
729
+ history_id = self._history_id(source.memory, replacement.memory.memory_id, source.raw_hash)
730
+ path = self.service.vault.memory_path(history_id, "history")
731
+ if path.exists() or path.is_symlink():
732
+ raise CompactionError("compaction history exists without a pending transaction")
733
+ atomic_write_text(path, history_raw)
734
+ journal = dict(journal, phase="histories")
735
+ self._write_journal_unlocked(journal)
736
+ for replacement in replacements:
737
+ path = self.service.vault.memory_path(replacement.memory.memory_id, "knowledge")
738
+ if path.exists() or path.is_symlink():
739
+ raise CompactionError("compaction replacement exists without a pending transaction")
740
+ atomic_write_text(path, replacement.raw)
741
+ journal = dict(journal, phase="replacements")
742
+ self._write_journal_unlocked(journal)
743
+ for source in consumed_candidates:
744
+ if source.path.is_symlink():
745
+ raise CompactionError("unsafe active memory path")
746
+ try:
747
+ current = source.path.read_text(encoding="utf-8")
748
+ except (OSError, UnicodeError) as error:
749
+ raise CompactionError("cannot read active memory before removal") from error
750
+ if _sha256(current) != source.raw_hash:
751
+ raise CompactionError("active memory changed during compaction")
752
+ atomic_unlink(source.path)
753
+ journal = dict(journal, phase="sources_removed")
754
+ self._write_journal_unlocked(journal)
755
+ self.service._rebuild_index_unlocked()
756
+ journal = dict(journal, phase="committed")
757
+ self._write_journal_unlocked(journal)
758
+ self._clear_transaction_unlocked(journal)
759
+ except Exception:
760
+ try:
761
+ self._recover_pending_unlocked()
762
+ except Exception as recover_error:
763
+ raise CompactionError("compaction rollback failed") from recover_error
764
+ raise
765
+ return [item[2] for item in history_payloads], replacement_ids
766
+
767
+ def _base_result(
768
+ self,
769
+ selected: list[_Candidate],
770
+ all_active: list[_Candidate],
771
+ active_tokens: int,
772
+ threshold: int,
773
+ ratio: float,
774
+ ) -> dict[str, Any]:
775
+ return {
776
+ "status": "not_due",
777
+ "active_tokens_before": active_tokens,
778
+ "active_tokens_after": active_tokens,
779
+ "threshold": threshold,
780
+ "ratio": ratio,
781
+ "candidates": [candidate.memory.memory_id for candidate in selected],
782
+ "compacted": 0,
783
+ "replacements": [],
784
+ "history_written": [],
785
+ }
786
+
787
+ def _run(self, *, model: Any = None, router: Any = None, explicit: bool) -> dict[str, Any]:
788
+ threshold, ratio = self._config()
789
+ selected, all_active, active_tokens = self._snapshot(threshold, ratio)
790
+ result = self._base_result(selected, all_active, active_tokens, threshold, ratio)
791
+ if not selected:
792
+ return result
793
+ try:
794
+ backend = self._resolve_backend(model=model, router=router)
795
+ prompt = compact_prompt([self._prompt_memory(candidate) for candidate in selected])
796
+ try:
797
+ raw = backend.complete(
798
+ prompt,
799
+ system=COMPACT_SYSTEM,
800
+ purpose="compact",
801
+ temperature=0.0,
802
+ )
803
+ except ModelUnavailable:
804
+ raise
805
+ except ModelError:
806
+ raise ModelError("compaction model failed")
807
+ except Exception as error:
808
+ raise ModelError("compaction model failed") from error
809
+ if not isinstance(raw, str):
810
+ raise ModelError("compaction model returned non-text output")
811
+ output = parse_compact_output(raw, [candidate.memory.memory_id for candidate in selected])
812
+ if not output["memories"]:
813
+ result["status"] = "noop"
814
+ return result
815
+ now = _clock_now(getattr(self.service, "clock", None))
816
+ replacements = self._build_plan(output, selected, now=now)
817
+ self._preflight(selected, all_active, replacements)
818
+ history_ids, replacement_ids = self._commit(
819
+ selected,
820
+ all_active,
821
+ replacements,
822
+ now=now,
823
+ )
824
+ with self.service.vault.lock():
825
+ active_after = [record.memory for record in self.service._read_memories_unlocked("knowledge")]
826
+ result.update(
827
+ {
828
+ "status": "compacted",
829
+ "active_tokens_after": estimate_active_tokens(active_after),
830
+ "compacted": sum(len(replacement.source_ids) for replacement in replacements),
831
+ "replacements": replacement_ids,
832
+ "history_written": history_ids,
833
+ }
834
+ )
835
+ return result
836
+ except Exception as error:
837
+ if explicit:
838
+ if isinstance(error, CompactionError):
839
+ raise
840
+ if isinstance(error, ModelUnavailable):
841
+ raise CompactionError("compaction model is unavailable") from error
842
+ if isinstance(error, ModelOutputError):
843
+ raise CompactionError("invalid compaction model output") from error
844
+ if isinstance(error, ModelError):
845
+ raise CompactionError("compaction model failed") from error
846
+ raise CompactionError("compaction failed") from error
847
+ result["status"] = _error_kind(error)
848
+ result["error"] = {
849
+ "unavailable": "model unavailable",
850
+ "invalid_output": "invalid compaction model output",
851
+ "model_failed": "compaction model failed",
852
+ }.get(result["status"], "compaction failed")
853
+ return result
854
+
855
+ def compact(self, *, model: Any = None, router: Any = None) -> dict[str, Any]:
856
+ return self._run(model=model, router=router, explicit=True)
857
+
858
+ def auto(self, *, model: Any = None, router: Any = None) -> dict[str, Any]:
859
+ try:
860
+ return self._run(model=model, router=router, explicit=False)
861
+ except Exception as error:
862
+ status = _error_kind(error)
863
+ return {
864
+ "status": status,
865
+ "error": {
866
+ "unavailable": "model unavailable",
867
+ "invalid_output": "invalid compaction model output",
868
+ "model_failed": "compaction model failed",
869
+ }.get(status, "compaction failed"),
870
+ }
871
+
872
+
873
+ __all__ = [
874
+ "CompactionError",
875
+ "Compactor",
876
+ "estimate_active_tokens",
877
+ "estimate_memory_tokens",
878
+ ]