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.
Files changed (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. 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)