loop-memory 0.4.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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Memory compaction — bound the long-term store.
|
|
2
|
+
|
|
3
|
+
Codex/Claude/Hermes sessions produce a *lot* of low-signal memory rows
|
|
4
|
+
per session. Over weeks this drowns both the on-disk DB and the recall
|
|
5
|
+
context we hand back to clients (which is the bigger user-visible
|
|
6
|
+
cost: feeding hundreds of stale rows back into the assistant
|
|
7
|
+
inflates its context and slows down / crashes the conversation).
|
|
8
|
+
|
|
9
|
+
This module keeps the store bounded with three layered strategies:
|
|
10
|
+
|
|
11
|
+
1. **Heuristic compaction** — group aged memory rows by session and
|
|
12
|
+
collapse them into a single condensed "session digest" row, then
|
|
13
|
+
delete the originals. No LLM. Cheap, predictable, runs on a
|
|
14
|
+
schedule.
|
|
15
|
+
2. **Aggressive prune** — drop memories that have decayed below the
|
|
16
|
+
floor (``importance * score < floor``) and have never been
|
|
17
|
+
recalled. Belt-and-braces after (1).
|
|
18
|
+
3. **LLM compaction** *(optional)* — when a provider is wired up, ask
|
|
19
|
+
the model to fuse clusters of related memories into one dense
|
|
20
|
+
statement. Slower but higher quality; the consolidator already
|
|
21
|
+
runs LLM-driven distillation into wiki pages, so this is a
|
|
22
|
+
complementary pass on the *raw* memory layer.
|
|
23
|
+
|
|
24
|
+
The scheduler in :mod:`jobs.scheduler` calls ``Compactor.run`` on the
|
|
25
|
+
configured cadence. The dashboard exposes a manual trigger so the
|
|
26
|
+
user can force a compaction after a heavy ingest burst.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import time
|
|
34
|
+
from collections.abc import Callable, Iterable
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from ..storage.sqlite_store import MemoryStore, StoredMemory
|
|
39
|
+
|
|
40
|
+
log = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Heuristic noise patterns - common low-signal fragments we never want
|
|
44
|
+
# to keep even after distillation. Kept conservative; the LLM pass is
|
|
45
|
+
# the one that actually decides what to drop semantically.
|
|
46
|
+
_NOISE_PATTERNS = (
|
|
47
|
+
"thanks", "thank you", "ok", "okay", "好的", "是", "对", "嗯",
|
|
48
|
+
"got it", "sure", "yes", "no", "yep", "nope", "好的", "继续",
|
|
49
|
+
"继续", "好的", "明白", "知道了", "了解了", "let's continue",
|
|
50
|
+
"继续吧", "go on", "keep going", "next",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _looks_like_noise(text: str) -> bool:
|
|
55
|
+
t = (text or "").strip().lower()
|
|
56
|
+
if not t:
|
|
57
|
+
return True
|
|
58
|
+
# Single-word / very short snippets that are usually acknowledgements
|
|
59
|
+
if len(t) <= 6:
|
|
60
|
+
return any(t.startswith(p) for p in _NOISE_PATTERNS) or not t.isascii() and len(t) <= 4
|
|
61
|
+
return any(t == p or t.startswith(p + " ") for p in _NOISE_PATTERNS if len(p) >= 4)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class CompactReport:
|
|
66
|
+
"""Outcome of a single compaction run."""
|
|
67
|
+
digested_sessions: int = 0
|
|
68
|
+
deleted_memories: int = 0
|
|
69
|
+
inserted_digests: int = 0
|
|
70
|
+
pruned_noise: int = 0
|
|
71
|
+
pruned_decayed: int = 0
|
|
72
|
+
bytes_before: int = 0
|
|
73
|
+
bytes_after: int = 0
|
|
74
|
+
elapsed_ms: float = 0.0
|
|
75
|
+
mode: str = "heuristic"
|
|
76
|
+
notes: list[str] = field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
d = {
|
|
80
|
+
"digested_sessions": self.digested_sessions,
|
|
81
|
+
"deleted_memories": self.deleted_memories,
|
|
82
|
+
"inserted_digests": self.inserted_digests,
|
|
83
|
+
"pruned_noise": self.pruned_noise,
|
|
84
|
+
"pruned_decayed": self.pruned_decayed,
|
|
85
|
+
"bytes_before": self.bytes_before,
|
|
86
|
+
"bytes_after": self.bytes_after,
|
|
87
|
+
"bytes_saved": max(0, self.bytes_before - self.bytes_after),
|
|
88
|
+
"elapsed_ms": round(self.elapsed_ms, 1),
|
|
89
|
+
"mode": self.mode,
|
|
90
|
+
}
|
|
91
|
+
if self.notes:
|
|
92
|
+
d["notes"] = self.notes[:8]
|
|
93
|
+
return d
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Compactor:
|
|
97
|
+
"""Bound the memory store by digesting aged rows.
|
|
98
|
+
|
|
99
|
+
Tunables are class-level so tests can pin them. Defaults are
|
|
100
|
+
deliberately gentle — the LLM-driven wiki consolidator is the
|
|
101
|
+
primary quality lever; this module is the *safety net* that
|
|
102
|
+
keeps the DB from growing forever.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
# Memory rows older than this AND not recently recalled get folded
|
|
106
|
+
# into a session digest.
|
|
107
|
+
age_seconds: int = 60 * 60 * 24 * 14 # 14 days
|
|
108
|
+
# Memories older than this are eligible for aggressive prune even
|
|
109
|
+
# if their score is OK, as long as they have zero recalls.
|
|
110
|
+
prune_age_seconds: int = 60 * 60 * 24 * 30 # 30 days
|
|
111
|
+
# Floor: importance * score below this AND zero recalls → drop.
|
|
112
|
+
score_floor: float = 0.04
|
|
113
|
+
# Keep at least this many highest-scoring memories per session, so
|
|
114
|
+
# we never empty a session completely.
|
|
115
|
+
min_keep_per_session: int = 1
|
|
116
|
+
# Hard cap on total memories after compaction. If still over, drop
|
|
117
|
+
# the lowest-scoring rows until under the cap.
|
|
118
|
+
max_memories_after: int = 8000
|
|
119
|
+
# Max characters per inserted digest row.
|
|
120
|
+
digest_max_chars: int = 600
|
|
121
|
+
|
|
122
|
+
def __init__(self, store: MemoryStore, *, mode: str = "heuristic") -> None:
|
|
123
|
+
self.store = store
|
|
124
|
+
self.mode = mode
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Public entry point
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def run(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
progress: Callable[[int, int, str], None] | None = None,
|
|
134
|
+
force: bool = False,
|
|
135
|
+
) -> CompactReport:
|
|
136
|
+
"""Run a single compaction pass.
|
|
137
|
+
|
|
138
|
+
``force=True`` ignores the age filter (useful for a one-shot
|
|
139
|
+
"tidy up after a burst" trigger from the UI).
|
|
140
|
+
"""
|
|
141
|
+
t0 = time.time()
|
|
142
|
+
report = CompactReport(mode=self.mode)
|
|
143
|
+
report.bytes_before = self.store.db_size_bytes()
|
|
144
|
+
|
|
145
|
+
def _step(cur: int, total: int, msg: str) -> None:
|
|
146
|
+
if progress:
|
|
147
|
+
try:
|
|
148
|
+
progress(cur, total, msg)
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Step 1: heuristic per-session digest.
|
|
153
|
+
_step(0, 100, "digesting aged sessions")
|
|
154
|
+
aged_cutoff = 0.0 if force else (time.time() - self.age_seconds)
|
|
155
|
+
r1 = self._digest_aged_sessions(cutoff=aged_cutoff, force=force)
|
|
156
|
+
report.digested_sessions = r1["sessions"]
|
|
157
|
+
report.inserted_digests = r1["digests"]
|
|
158
|
+
report.deleted_memories += r1["deleted"]
|
|
159
|
+
_step(30, 100, f"digested {r1['sessions']} sessions")
|
|
160
|
+
|
|
161
|
+
# Step 2: aggressive prune of zero-recall decayed rows.
|
|
162
|
+
_step(35, 100, "pruning noise")
|
|
163
|
+
report.pruned_noise = self._prune_noise()
|
|
164
|
+
_step(50, 100, "pruning decayed")
|
|
165
|
+
report.pruned_decayed = self._prune_decayed(force=force)
|
|
166
|
+
|
|
167
|
+
# Step 3: hard cap — if we are still over the soft ceiling,
|
|
168
|
+
# drop lowest-scoring rows. This is the last resort.
|
|
169
|
+
_step(70, 100, "enforcing ceiling")
|
|
170
|
+
dropped = self._enforce_ceiling()
|
|
171
|
+
if dropped:
|
|
172
|
+
report.notes.append(f"ceiling dropped {dropped} rows")
|
|
173
|
+
report.deleted_memories += dropped
|
|
174
|
+
|
|
175
|
+
# Step 4: optional LLM compaction. Off by default — the wiki
|
|
176
|
+
# consolidator already does the high-quality work; running the
|
|
177
|
+
# LLM twice is wasteful unless explicitly requested.
|
|
178
|
+
if self.mode == "llm":
|
|
179
|
+
_step(85, 100, "LLM fusion")
|
|
180
|
+
try:
|
|
181
|
+
pass # LLM fuse optional
|
|
182
|
+
fused = llm_fuse_pass(self.store, force=force)
|
|
183
|
+
report.notes.append(f"llm-fused {fused} clusters")
|
|
184
|
+
except Exception as e:
|
|
185
|
+
report.notes.append(f"llm fuse skipped: {e}")
|
|
186
|
+
|
|
187
|
+
report.bytes_after = self.store.db_size_bytes()
|
|
188
|
+
report.elapsed_ms = (time.time() - t0) * 1000
|
|
189
|
+
_step(100, 100, "done")
|
|
190
|
+
log.info(
|
|
191
|
+
"compaction done: digested=%s deleted=%s noise=%s decayed=%s bytes=%s->%s (%.1f ms)",
|
|
192
|
+
report.digested_sessions, report.deleted_memories,
|
|
193
|
+
report.pruned_noise, report.pruned_decayed,
|
|
194
|
+
report.bytes_before, report.bytes_after, report.elapsed_ms,
|
|
195
|
+
)
|
|
196
|
+
return report
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Strategy 1: per-session digest
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def _digest_aged_sessions(self, *, cutoff: float, force: bool) -> dict[str, int]:
|
|
203
|
+
"""Group aged memory rows by session and replace them with a
|
|
204
|
+
single condensed digest row.
|
|
205
|
+
|
|
206
|
+
Sessions that already have <= ``min_keep_per_session`` aged rows
|
|
207
|
+
are skipped — we don't waste time digesting a single line.
|
|
208
|
+
"""
|
|
209
|
+
deleted = 0
|
|
210
|
+
digests = 0
|
|
211
|
+
sessions = 0
|
|
212
|
+
|
|
213
|
+
for session_id, rows in self._group_aged_by_session(cutoff=cutoff, force=force):
|
|
214
|
+
if len(rows) <= self.min_keep_per_session:
|
|
215
|
+
continue
|
|
216
|
+
# Keep the top-scoring rows, digest the rest.
|
|
217
|
+
rows.sort(key=lambda m: (m.score or 0) * (m.importance or 0), reverse=True)
|
|
218
|
+
digest_from = rows[self.min_keep_per_session:]
|
|
219
|
+
if not digest_from:
|
|
220
|
+
continue
|
|
221
|
+
digest_text = self._synthesize_digest(digest_from)
|
|
222
|
+
if not digest_text:
|
|
223
|
+
continue
|
|
224
|
+
try:
|
|
225
|
+
self.store.upsert_memory(
|
|
226
|
+
kind="digest",
|
|
227
|
+
text=digest_text,
|
|
228
|
+
importance=max((m.importance for m in digest_from), default=0.4),
|
|
229
|
+
source=digest_from[0].source,
|
|
230
|
+
session_id=session_id,
|
|
231
|
+
tags=["compacted", "session-digest"],
|
|
232
|
+
)
|
|
233
|
+
digests += 1
|
|
234
|
+
except Exception:
|
|
235
|
+
log.exception("digest upsert failed for session %s", session_id)
|
|
236
|
+
continue
|
|
237
|
+
for m in digest_from:
|
|
238
|
+
try:
|
|
239
|
+
self.store.delete_memory(m.id)
|
|
240
|
+
deleted += 1
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
sessions += 1
|
|
244
|
+
return {"sessions": sessions, "digests": digests, "deleted": deleted}
|
|
245
|
+
|
|
246
|
+
def _group_aged_by_session(
|
|
247
|
+
self, *, cutoff: float, force: bool
|
|
248
|
+
) -> Iterable[tuple[str, list[StoredMemory]]]:
|
|
249
|
+
# We pull all memories and bucket in Python — the table is small
|
|
250
|
+
# enough (<= a few thousand rows for typical users) and SQLite
|
|
251
|
+
# doesn't have an efficient "group-by session" that respects the
|
|
252
|
+
# age filter without a temp index.
|
|
253
|
+
all_rows = self.store.list_memories(limit=20_000)
|
|
254
|
+
buckets: dict[str, list[StoredMemory]] = {}
|
|
255
|
+
now = time.time()
|
|
256
|
+
for m in all_rows:
|
|
257
|
+
if m.kind == "digest":
|
|
258
|
+
continue # never re-digest a digest
|
|
259
|
+
# "Aged" = old enough AND not recently recalled. The recall
|
|
260
|
+
# gate is a cheap proxy for "the user still cares".
|
|
261
|
+
age_ok = force or (now - float(m.created_at or 0)) >= cutoff
|
|
262
|
+
if not age_ok:
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
sig = self.store.get_signal(m.id) or {}
|
|
266
|
+
recently_used = bool(sig.get("recall_count"))
|
|
267
|
+
except Exception:
|
|
268
|
+
recently_used = False
|
|
269
|
+
if recently_used and not force:
|
|
270
|
+
continue
|
|
271
|
+
buckets.setdefault(m.session_id or "_orphan", []).append(m)
|
|
272
|
+
yield from buckets.items()
|
|
273
|
+
|
|
274
|
+
def _synthesize_digest(self, rows: list[StoredMemory]) -> str:
|
|
275
|
+
"""Heuristic digest: keep one canonical line per unique
|
|
276
|
+
5-token-prefix. Cap at ``digest_max_chars`` characters.
|
|
277
|
+
"""
|
|
278
|
+
seen: set[str] = []
|
|
279
|
+
out: list[str] = []
|
|
280
|
+
for m in rows:
|
|
281
|
+
txt = (m.text or "").strip().replace("\n", " ")
|
|
282
|
+
if not txt:
|
|
283
|
+
continue
|
|
284
|
+
fp = txt[:60].lower()
|
|
285
|
+
if fp in seen:
|
|
286
|
+
continue
|
|
287
|
+
seen.append(fp)
|
|
288
|
+
out.append(f"• {txt}")
|
|
289
|
+
body = "\n".join(out)
|
|
290
|
+
if len(body) > self.digest_max_chars:
|
|
291
|
+
body = body[: self.digest_max_chars - 1].rstrip() + "…"
|
|
292
|
+
return body
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
# Strategy 2: aggressive prune
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def _prune_noise(self) -> int:
|
|
299
|
+
rows = self.store.list_memories(limit=20_000)
|
|
300
|
+
deleted = 0
|
|
301
|
+
for m in rows:
|
|
302
|
+
if _looks_like_noise(m.text):
|
|
303
|
+
try:
|
|
304
|
+
self.store.delete_memory(m.id)
|
|
305
|
+
deleted += 1
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
return deleted
|
|
309
|
+
|
|
310
|
+
def _prune_decayed(self, *, force: bool) -> int:
|
|
311
|
+
"""Drop memories that have decayed below the floor AND have
|
|
312
|
+
never been recalled. We keep them if they have any recall
|
|
313
|
+
signal — those are at least demonstrably useful.
|
|
314
|
+
"""
|
|
315
|
+
rows = self.store.list_memories(limit=20_000)
|
|
316
|
+
now = time.time()
|
|
317
|
+
deleted = 0
|
|
318
|
+
for m in rows:
|
|
319
|
+
if m.kind == "digest":
|
|
320
|
+
continue
|
|
321
|
+
score = (float(m.score or 0)) * (float(m.importance or 0))
|
|
322
|
+
age_ok = force or (now - float(m.created_at or 0)) >= self.prune_age_seconds
|
|
323
|
+
if not age_ok:
|
|
324
|
+
continue
|
|
325
|
+
if score > self.score_floor:
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
sig = self.store.get_signal(m.id) or {}
|
|
329
|
+
if int(sig.get("recall_count") or 0) > 0:
|
|
330
|
+
continue
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
try:
|
|
334
|
+
self.store.delete_memory(m.id)
|
|
335
|
+
deleted += 1
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
return deleted
|
|
339
|
+
|
|
340
|
+
# ------------------------------------------------------------------
|
|
341
|
+
# Strategy 3: hard ceiling
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
def _enforce_ceiling(self) -> int:
|
|
345
|
+
total = self.store.count_memories()
|
|
346
|
+
if total <= self.max_memories_after:
|
|
347
|
+
return 0
|
|
348
|
+
# Pull the lowest-scoring rows and drop until under cap.
|
|
349
|
+
rows = self.store.list_low_value_memories(limit=target + 50)
|
|
350
|
+
target = max(0, total - self.max_memories_after)
|
|
351
|
+
deleted = 0
|
|
352
|
+
for m in rows:
|
|
353
|
+
if deleted >= target:
|
|
354
|
+
break
|
|
355
|
+
if m.kind == "digest":
|
|
356
|
+
continue
|
|
357
|
+
try:
|
|
358
|
+
sig = self.store.get_signal(m.id) or {}
|
|
359
|
+
if int(sig.get("recall_count") or 0) > 0:
|
|
360
|
+
continue # never drop a row the user has ever used
|
|
361
|
+
except Exception:
|
|
362
|
+
pass
|
|
363
|
+
try:
|
|
364
|
+
self.store.delete_memory(m.id)
|
|
365
|
+
deleted += 1
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
return deleted
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
__all__ = ["Compactor", "CompactReport", "_looks_like_noise"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Background jobs — consolidate, rescore, GC.
|
|
2
|
+
|
|
3
|
+
In production you would run these on a cron / launchd timer. The CLI
|
|
4
|
+
exposes each on its own so you can wire them straight into your
|
|
5
|
+
scheduler of choice.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..backends.embedding import BaseEmbedder, IdentityEmbedder
|
|
14
|
+
from ..storage.sqlite_store import MemoryStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ConsolidateReport:
|
|
19
|
+
rescored: int
|
|
20
|
+
gc_removed: int
|
|
21
|
+
merged: int
|
|
22
|
+
elapsed_ms: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Consolidator:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
store: MemoryStore,
|
|
29
|
+
embedder: BaseEmbedder | None = None,
|
|
30
|
+
*,
|
|
31
|
+
half_life_days: float = 30.0,
|
|
32
|
+
merge_threshold: float = 0.92,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.store = store
|
|
35
|
+
self.embedder = embedder or IdentityEmbedder()
|
|
36
|
+
self.half_life_days = half_life_days
|
|
37
|
+
self.merge_threshold = merge_threshold
|
|
38
|
+
|
|
39
|
+
def run(self) -> ConsolidateReport:
|
|
40
|
+
t0 = time.time()
|
|
41
|
+
rescored = self.store.rescore_all(self.half_life_days)
|
|
42
|
+
gc_removed = self.store.gc()
|
|
43
|
+
merged = self.merge_near_duplicates() if self.embedder.dim else 0
|
|
44
|
+
return ConsolidateReport(
|
|
45
|
+
rescored=rescored,
|
|
46
|
+
gc_removed=gc_removed,
|
|
47
|
+
merged=merged,
|
|
48
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# --- de-duplication -----------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def merge_near_duplicates(self) -> int:
|
|
54
|
+
"""Drop near-duplicate memories, keeping the higher-importance one.
|
|
55
|
+
|
|
56
|
+
Uses cosine similarity over the stored embeddings. If no embedder
|
|
57
|
+
with ``dim > 0`` is wired in this is a no-op (returns 0).
|
|
58
|
+
"""
|
|
59
|
+
if not self.embedder.dim:
|
|
60
|
+
return 0
|
|
61
|
+
items = self.store.list_memories(limit=10_000)
|
|
62
|
+
# Group by kind to avoid merging "user-quote" with "fact".
|
|
63
|
+
by_kind: dict[str, list] = {}
|
|
64
|
+
for it in items:
|
|
65
|
+
if it.embedding is None:
|
|
66
|
+
continue
|
|
67
|
+
by_kind.setdefault(it.kind, []).append(it)
|
|
68
|
+
|
|
69
|
+
merged = 0
|
|
70
|
+
for items in by_kind.values():
|
|
71
|
+
# Sort by importance desc so winners stay.
|
|
72
|
+
items.sort(key=lambda x: x.importance, reverse=True)
|
|
73
|
+
to_delete = set()
|
|
74
|
+
for i, a in enumerate(items):
|
|
75
|
+
if a.id in to_delete:
|
|
76
|
+
continue
|
|
77
|
+
for b in items[i + 1 :]:
|
|
78
|
+
if b.id in to_delete:
|
|
79
|
+
continue
|
|
80
|
+
sim = _cosine(a.embedding, b.embedding)
|
|
81
|
+
if sim >= self.merge_threshold:
|
|
82
|
+
to_delete.add(b.id)
|
|
83
|
+
merged += 1
|
|
84
|
+
for mid in to_delete:
|
|
85
|
+
self.store.delete_memory(mid)
|
|
86
|
+
return merged
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cosine(a, b) -> float:
|
|
90
|
+
if not a or not b or len(a) != len(b):
|
|
91
|
+
return 0.0
|
|
92
|
+
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
|
93
|
+
na = sum(x * x for x in a) ** 0.5 or 1e-12
|
|
94
|
+
nb = sum(x * x for x in b) ** 0.5 or 1e-12
|
|
95
|
+
return dot / (na * nb)
|