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,353 @@
1
+ """Cognitive sleep — the "主动学习" / "主动修正" layer the
2
+ Universal Agent Memory contract needs.
3
+
4
+ Background: the article that prompted this work argues that real
5
+ memory systems are not databases but *cognitive processes*. They
6
+ actively:
7
+
8
+ * filter low-value noise (memories that are stale, low-importance,
9
+ and never recalled);
10
+ * detect contradictions (two memories / wiki pages that disagree);
11
+ * propose merges (near-duplicates that should be one);
12
+ * suggest forgets (memories the user almost certainly doesn't need
13
+ anymore).
14
+
15
+ This module implements that pipeline as a single ``cognitive_sleep``
16
+ call. The output is an audit report plus, when ``apply=True``, a
17
+ set of mutations on the store. Every decision is recorded in
18
+ ``cognitive_audit`` so the user / SDK can review, revert, or simply
19
+ log the result.
20
+
21
+ The job is zero-dep: it uses only the existing store + a tiny set
22
+ of heuristics. The LLM-driven distillate step in the evolution
23
+ consolidator stays the source of truth for high-level
24
+ "is this a contradiction?" questions; cognitive_sleep is the cheap,
25
+ deterministic nightly sweep that catches the obvious cases without
26
+ needing a model call.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import math
34
+ import time
35
+ from collections.abc import Iterable
36
+ from dataclasses import dataclass, field
37
+ from typing import Any
38
+
39
+ from ..storage.sqlite_store import MemoryStore
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Heuristic thresholds
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ # A memory is "stale" if it has not been recalled in this many days
50
+ # AND its score is below this threshold AND its importance is below
51
+ # the threshold. These defaults are conservative — the user can
52
+ # tighten them via the ``stale_days`` / ``min_score`` / ``min_importance``
53
+ # parameters to ``cognitive_sleep``.
54
+ DEFAULT_STALE_DAYS = 90
55
+ DEFAULT_MIN_SCORE = 0.2
56
+ DEFAULT_MIN_IMPORTANCE = 0.3
57
+
58
+ # A memory is "low value" if its score + 0.5 * importance is below
59
+ # this AND it has never been recalled. This catches the
60
+ # "auto-generated noise from a long transcript" case the LLM
61
+ # consolidator sometimes leaves behind.
62
+ DEFAULT_LOW_VALUE = 0.3
63
+
64
+ # A merge is suggested when two memories have cosine similarity above
65
+ # this threshold AND the same (agent_id, user_id) namespace.
66
+ DEFAULT_MERGE_THRESHOLD = 0.92
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Result dataclass
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ @dataclass
75
+ class CognitiveAction:
76
+ """One proposed (or applied) action from the cognitive sleep sweep."""
77
+
78
+ kind: str # forget / merge / contradict / stale / low_value
79
+ target_kind: str # memory / wiki_page
80
+ target_id: str
81
+ target_text: str
82
+ reason: str
83
+ score: float = 0.0
84
+ payload: dict = field(default_factory=dict)
85
+ action: str = "suggest" # suggest / applied / reverted
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "kind": self.kind,
90
+ "target_kind": self.target_kind,
91
+ "target_id": self.target_id,
92
+ "target_text": self.target_text,
93
+ "reason": self.reason,
94
+ "score": self.score,
95
+ "payload": self.payload,
96
+ "action": self.action,
97
+ }
98
+
99
+
100
+ @dataclass
101
+ class CognitiveReport:
102
+ """The full result of one ``cognitive_sleep`` call."""
103
+
104
+ actions: list[CognitiveAction] = field(default_factory=list)
105
+ elapsed_ms: float = 0.0
106
+ counts: dict[str, int] = field(default_factory=dict)
107
+ applied: bool = False
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ return {
111
+ "actions": [a.to_dict() for a in self.actions],
112
+ "elapsed_ms": self.elapsed_ms,
113
+ "counts": self.counts,
114
+ "applied": self.applied,
115
+ "total": len(self.actions),
116
+ }
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Sweep
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def cognitive_sleep(
125
+ store: MemoryStore,
126
+ *,
127
+ apply: bool = False,
128
+ stale_days: int = DEFAULT_STALE_DAYS,
129
+ min_score: float = DEFAULT_MIN_SCORE,
130
+ min_importance: float = DEFAULT_MIN_IMPORTANCE,
131
+ low_value: float = DEFAULT_LOW_VALUE,
132
+ merge_threshold: float = DEFAULT_MERGE_THRESHOLD,
133
+ limit: int = 1000,
134
+ record_audit: bool = True,
135
+ ) -> CognitiveReport:
136
+ """Run a single cognitive sweep.
137
+
138
+ * ``apply=False`` (default): only suggest actions, do not
139
+ mutate the store. Used by the UI's "preview" panel.
140
+ * ``apply=True``: actually delete the ``forget`` actions and
141
+ merge the ``merge`` actions, then record each applied action
142
+ in ``cognitive_audit`` with ``action='applied'``.
143
+
144
+ Every suggestion is recorded in ``cognitive_audit`` with
145
+ ``action='suggest'`` regardless of whether the user applies it,
146
+ so the trail is complete.
147
+
148
+ The sweep is bounded to ``limit`` memories per pass to keep it
149
+ cheap; for very large stores the user can call it multiple
150
+ times or wire it into a cron.
151
+ """
152
+ t0 = time.time()
153
+ actions: list[CognitiveAction] = []
154
+ counts: dict[str, int] = {
155
+ "stale": 0, "low_value": 0, "merge": 0, "contradict": 0, "forget": 0,
156
+ }
157
+ now = time.time()
158
+ stale_cutoff = now - stale_days * 86400.0
159
+
160
+ # ----- 1. Stale memories --------------------------------------
161
+ # Pull every memory below the score + importance gates. We do a
162
+ # single SQL scan to keep the pass fast.
163
+ rows = store.list_memories(limit=limit)
164
+ for r in rows:
165
+ score = float(r.score or 0)
166
+ importance = float(r.importance or 0)
167
+ created = float(r.created_at or 0)
168
+ # Stale: old + low score + low importance, regardless of recall.
169
+ if created < stale_cutoff and score < min_score and importance < min_importance:
170
+ counts["stale"] += 1
171
+ actions.append(CognitiveAction(
172
+ kind="stale", target_kind="memory", target_id=r.id,
173
+ target_text=(r.text or "")[:200],
174
+ reason=f"age>{stale_days}d & score<{min_score} & importance<{min_importance}",
175
+ score=score, payload={"importance": importance,
176
+ "age_days": int((now - created) / 86400)},
177
+ ))
178
+ continue
179
+ # Low value: never recalled, score + importance * 0.5 below
180
+ # ``low_value`` (this is the cheap "noise from a long
181
+ # transcript" filter).
182
+ # ``list_memories`` doesn't include recall_count in the
183
+ # dataclass; re-fetch via the signals table.
184
+ signals = _signals_for(store, r.id)
185
+ if signals["recall_count"] == 0 and score + 0.5 * importance < low_value:
186
+ counts["low_value"] += 1
187
+ actions.append(CognitiveAction(
188
+ kind="low_value", target_kind="memory", target_id=r.id,
189
+ target_text=(r.text or "")[:200],
190
+ reason=f"never recalled & score+0.5*importance<{low_value}",
191
+ score=score, payload={"importance": importance},
192
+ ))
193
+
194
+ # ----- 2. Near-duplicate merges ------------------------------
195
+ # Cheap O(n^2) on the first ``limit`` memories; good enough for
196
+ # nightly sweeps on a store of a few thousand rows. We use the
197
+ # ``text`` Jaccard over a small token set so the comparison
198
+ # doesn't need embeddings.
199
+ text_index = [(r.id, _token_set(r.text or "")) for r in rows]
200
+ seen_pairs: set[tuple[str, str]] = set()
201
+ for i in range(len(text_index)):
202
+ for j in range(i + 1, len(text_index)):
203
+ mid_i, ti = text_index[i]
204
+ mid_j, tj = text_index[j]
205
+ if not ti or not tj:
206
+ continue
207
+ j_sim = _jaccard(ti, tj)
208
+ # Short-text containment: when both memories are < 30
209
+ # tokens, the Jaccard threshold is too strict because one
210
+ # extra word in a paraphrase drags the score way down.
211
+ # Use a containment fallback: 80 % of A's tokens in B
212
+ # (or vice versa) counts as a merge candidate.
213
+ contain_a_in_b = (len(ti & tj) / max(1, len(ti))) >= 0.8
214
+ contain_b_in_a = (len(ti & tj) / max(1, len(tj))) >= 0.8
215
+ short_text = len(ti) <= 30 and len(tj) <= 30
216
+ if j_sim >= merge_threshold or (short_text and (contain_a_in_b or contain_b_in_a)):
217
+ pair = tuple(sorted([mid_i, mid_j]))
218
+ if pair in seen_pairs:
219
+ continue
220
+ seen_pairs.add(pair)
221
+ counts["merge"] += 1
222
+ actions.append(CognitiveAction(
223
+ kind="merge", target_kind="memory", target_id=mid_i,
224
+ target_text="(merge with " + mid_j + ")",
225
+ reason=f"Jaccard={j_sim:.3f} ≥ {merge_threshold}",
226
+ score=j_sim, payload={"other_id": mid_j, "jaccard": j_sim},
227
+ ))
228
+
229
+ # ----- 3. Contradictions -------------------------------------
230
+ # Reuse the existing wiki-page contradiction detector. It's
231
+ # cheap (key_facts Jaccard, no LLM) and already returns the
232
+ # matches we need.
233
+ from .contradiction import list_contradictions
234
+ try:
235
+ contradictions = list_contradictions(store)
236
+ except Exception as e:
237
+ log.warning("contradiction scan failed: %s", e)
238
+ contradictions = []
239
+ for c in contradictions:
240
+ # ``c`` is a dict; the page id is the *winner* and the
241
+ # partner ids are in a list.
242
+ for partner in c.get("partners", []):
243
+ counts["contradict"] += 1
244
+ actions.append(CognitiveAction(
245
+ kind="contradict", target_kind="wiki_page",
246
+ target_id=c.get("id") or "",
247
+ target_text=c.get("title", "")[:160],
248
+ reason="wiki_page contradict detected by key_facts Jaccard",
249
+ score=float(partner.get("score", 0) or 0),
250
+ payload={
251
+ "partner_id": partner.get("id"),
252
+ "partner_title": partner.get("title"),
253
+ },
254
+ ))
255
+
256
+ # ----- 4. Apply (optional) -----------------------------------
257
+ if apply:
258
+ applied_actions: list[CognitiveAction] = []
259
+ for a in actions:
260
+ if a.kind in ("stale", "low_value"):
261
+ n = store.delete_memory(a.target_id)
262
+ if n:
263
+ counts["forget"] += 1
264
+ a.action = "applied"
265
+ applied_actions.append(a)
266
+ elif a.kind == "merge":
267
+ other = a.payload.get("other_id")
268
+ if not other:
269
+ continue
270
+ result = store.merge_memories(a.target_id, other)
271
+ if result.get("merged"):
272
+ a.action = "applied"
273
+ applied_actions.append(a)
274
+ elif a.kind == "contradict":
275
+ # We don't auto-resolve contradictions — the UI
276
+ # shows them and the user clicks "merge" / "keep
277
+ # both". But we still mark the action as suggested
278
+ # so the audit trail is complete.
279
+ continue
280
+
281
+ # ----- 5. Persist to cognitive_audit -------------------------
282
+ if record_audit:
283
+ for a in actions:
284
+ store.record_audit(
285
+ kind=a.kind,
286
+ action=a.action,
287
+ target_kind=a.target_kind,
288
+ target_id=a.target_id,
289
+ target_text=a.target_text,
290
+ reason=a.reason,
291
+ score=a.score,
292
+ payload=a.payload,
293
+ )
294
+
295
+ elapsed_ms = (time.time() - t0) * 1000
296
+ return CognitiveReport(
297
+ actions=actions,
298
+ elapsed_ms=round(elapsed_ms, 1),
299
+ counts=counts,
300
+ applied=bool(apply),
301
+ )
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Internal helpers
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ def _signals_for(store: MemoryStore, memory_id: str) -> dict[str, Any]:
310
+ """Return the signal row for a memory, or zeros if missing."""
311
+ with store._conn() as c: # type: ignore[attr-defined]
312
+ row = c.execute(
313
+ "SELECT recall_count, positive, negative, last_recalled_at "
314
+ "FROM memory_signals WHERE memory_id=?",
315
+ (memory_id,),
316
+ ).fetchone()
317
+ if not row:
318
+ return {"recall_count": 0, "positive": 0, "negative": 0,
319
+ "last_recalled_at": None}
320
+ return dict(row)
321
+
322
+
323
+ def _token_set(text: str) -> set[str]:
324
+ """Cheap token set: lowercase + split on whitespace + punctuation.
325
+
326
+ CJK characters are kept as 1-grams (no bigrams) to keep the
327
+ similarity symmetric. The result is a set, not a multiset.
328
+ """
329
+ import re
330
+ if not text:
331
+ return set()
332
+ toks = re.findall(r"[A-Za-z0-9]+|[\u4e00-\u9fff]", text.lower())
333
+ return set(toks)
334
+
335
+
336
+ def _jaccard(a: set[str], b: set[str]) -> float:
337
+ if not a or not b:
338
+ return 0.0
339
+ inter = a & b
340
+ union = a | b
341
+ return len(inter) / max(1, len(union))
342
+
343
+
344
+ __all__ = [
345
+ "CognitiveAction",
346
+ "CognitiveReport",
347
+ "DEFAULT_STALE_DAYS",
348
+ "DEFAULT_MIN_SCORE",
349
+ "DEFAULT_MIN_IMPORTANCE",
350
+ "DEFAULT_LOW_VALUE",
351
+ "DEFAULT_MERGE_THRESHOLD",
352
+ "cognitive_sleep",
353
+ ]