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/__init__.py +35 -0
- memleaf/adapters/__init__.py +15 -0
- memleaf/adapters/antigravity.py +352 -0
- memleaf/adapters/base.py +746 -0
- memleaf/adapters/codex.py +460 -0
- memleaf/adapters/hermes.py +710 -0
- memleaf/budget.py +123 -0
- memleaf/capture.py +311 -0
- memleaf/cli.py +360 -0
- memleaf/compaction.py +878 -0
- memleaf/config.py +164 -0
- memleaf/frontmatter.py +403 -0
- memleaf/host_events.py +1327 -0
- memleaf/inbox.py +292 -0
- memleaf/index.py +275 -0
- memleaf/llm/__init__.py +43 -0
- memleaf/llm/base.py +349 -0
- memleaf/llm/claude_compatible.py +31 -0
- memleaf/llm/gemini.py +35 -0
- memleaf/llm/openai_compatible.py +127 -0
- memleaf/llm/router.py +172 -0
- memleaf/locking.py +134 -0
- memleaf/mcp_server.py +979 -0
- memleaf/memory_writer.py +437 -0
- memleaf/model_discovery.py +746 -0
- memleaf/models.py +285 -0
- memleaf/native_index.py +776 -0
- memleaf/processing.py +2029 -0
- memleaf/prompts.py +306 -0
- memleaf/redaction.py +41 -0
- memleaf/retrieval.py +377 -0
- memleaf/retrieval_gate.py +604 -0
- memleaf/scope_maintenance.py +480 -0
- memleaf/scope_state.py +328 -0
- memleaf/service.py +1420 -0
- memleaf/validation.py +794 -0
- memleaf/vault.py +247 -0
- memleaf-0.1.0.dist-info/METADATA +423 -0
- memleaf-0.1.0.dist-info/RECORD +43 -0
- memleaf-0.1.0.dist-info/WHEEL +5 -0
- memleaf-0.1.0.dist-info/entry_points.txt +3 -0
- memleaf-0.1.0.dist-info/licenses/LICENSE +21 -0
- memleaf-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|