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,395 @@
1
+ """Graph memory + 3D adaptive scoring for the Universal Agent Memory
2
+ contract.
3
+
4
+ This module has three responsibilities, all additive on top of the
5
+ existing ``KnowledgeGraph`` in ``graph/build.py``:
6
+
7
+ 1. **Semantic graph edges** — ``upsert_semantic_edge(src, dst,
8
+ kind, weight, evidence_id)`` lets any Agent push high-value
9
+ relationships like ``(User)-[LIVES_IN]->(Hangzhou)`` that
10
+ vector search can never reconstruct on its own. Mem0's
11
+ differentiator; loop-memory now has it.
12
+
13
+ 2. **Graph-aware recall** — ``graph_boost(store, query, memory_ids)``
14
+ returns a per-memory ``[0, 1.5]`` multiplier based on the density
15
+ of the subgraph the memory shares with the query's entities. The
16
+ hybrid recall path multiplies its RRF score by this to push
17
+ connected memories to the top.
18
+
19
+ 3. **3D adaptive scoring** — ``adaptive_score(recall_count, last_recalled,
20
+ importance, graph_degree)`` blends usage / importance / graph
21
+ connectivity into a single [0, 1] number that callers can use to
22
+ rank "what's worth surfacing right now". Article 7 calls this
23
+ "the third dimension" Mem0 added on top of plain vector recall.
24
+
25
+ The module is zero-dep and only uses ``MemoryStore``; it can be
26
+ called from the SDK, the HTTP route, the CLI, or the MCP server
27
+ without any extra setup.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import math
34
+ import time
35
+ from collections.abc import Iterable
36
+ from dataclasses import dataclass
37
+ from typing import Any
38
+
39
+ from ..storage.sqlite_store import MemoryStore
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Semantic graph edges
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ # Relation kinds we care about. Kept short so the SQL filter stays
48
+ # cheap. ``co_occurs_with`` is the legacy kind the original
49
+ # ``KnowledgeGraph`` writes; the new ones below are the "high-signal"
50
+ # predicates an Agent pushes explicitly.
51
+ HIGH_SIGNAL_KINDS: frozenset[str] = frozenset({
52
+ "lives_in", "works_on", "uses", "prefers", "decided",
53
+ "depends_on", "owns", "manages", "blocks", "replaces",
54
+ "caused", "fixed_by", "documented_in", "person",
55
+ "项目", "决策", "偏好", "工具", "技术栈",
56
+ })
57
+
58
+
59
+ def upsert_semantic_edge(
60
+ store: MemoryStore,
61
+ src: str,
62
+ dst: str,
63
+ *,
64
+ kind: str = "relates_to",
65
+ weight: float = 0.5,
66
+ evidence_id: str | None = None,
67
+ ) -> dict[str, Any]:
68
+ """Push a high-signal relation between two entities.
69
+
70
+ ``src`` and ``dst`` are entity names (not ids). The store upserts
71
+ the underlying ``entities`` rows and the ``relations`` row in
72
+ one call so the graph stays consistent even if the entities
73
+ have never been seen before.
74
+
75
+ Returns a small dict describing what was written so the caller
76
+ can log it via ``record_audit``.
77
+ """
78
+ src = (src or "").strip()
79
+ dst = (dst or "").strip()
80
+ if not src or not dst or src == dst:
81
+ raise ValueError("upsert_semantic_edge needs two distinct non-empty names")
82
+ # Ensure both entities exist
83
+ store.upsert_entity(src, _kind_for(src), bump_weight=weight)
84
+ store.upsert_entity(dst, _kind_for(dst), bump_weight=weight)
85
+ store.upsert_relation(src, dst, kind=kind, weight=weight, evidence_id=evidence_id)
86
+ return {
87
+ "src": src,
88
+ "dst": dst,
89
+ "kind": kind,
90
+ "weight": weight,
91
+ "evidence_id": evidence_id,
92
+ }
93
+
94
+
95
+ def _kind_for(name: str) -> str:
96
+ """Best-effort guess at the entity ``kind`` for a name.
97
+
98
+ The legacy ``extract_entities`` only emits a small set of kinds
99
+ (``concept``, ``person``, ``tag``, ``project``, ``tool``,
100
+ ``company``). When the Agent pushes a name it didn't extract
101
+ from text we want it stored under the most useful existing
102
+ kind so the UI can colour it correctly.
103
+ """
104
+ n = (name or "").lower()
105
+ if any(tok in n for tok in ("项目", "project", "repo", "service", "系统", "模块")):
106
+ return "project"
107
+ if any(tok in n for tok in ("工具", "tool", "framework", "库", "lib")):
108
+ return "tool"
109
+ if any(tok in n for tok in ("公司", "company", "inc", "团队", "team")):
110
+ return "company"
111
+ if n[:1].isupper() and n[1:2].islower():
112
+ return "concept"
113
+ return "concept"
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Graph-aware recall boost
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ @dataclass
122
+ class GraphBoost:
123
+ """Per-memory graph connectivity score in [0, 1.5]."""
124
+
125
+ memory_id: str
126
+ boost: float
127
+ matched_entities: list[str]
128
+
129
+
130
+ def graph_boost(
131
+ store: MemoryStore,
132
+ query: str,
133
+ memory_ids: list[str],
134
+ *,
135
+ max_per_memory: int = 8,
136
+ ) -> dict[str, GraphBoost]:
137
+ """Return a ``{memory_id: GraphBoost}`` map for the given candidates.
138
+
139
+ The boost is the density of the subgraph the memory shares with
140
+ the query's entities. A memory whose entities connect to the
141
+ query's entities (either directly or via one hop) gets a boost;
142
+ isolated memories get 0. The maximum is 1.5 so it can dominate
143
+ the RRF score when the connection is strong.
144
+
145
+ The implementation is deliberately simple:
146
+
147
+ 1. Extract entities from the query text (using the same
148
+ ``extract_entities`` the graph builder uses).
149
+ 2. Find every relation (in either direction) where one endpoint
150
+ is a query entity.
151
+ 3. Look up which memory ids back each matched relation via the
152
+ ``entity_mentions`` table.
153
+ 4. Count matches per memory id; map count → boost with a
154
+ saturating log curve.
155
+
156
+ The lookup is bounded to ``max_per_memory`` hits per memory so
157
+ a runaway relation doesn't lock the score to its cap.
158
+ """
159
+ if not memory_ids or not query.strip():
160
+ return {}
161
+ from ..graph.extract import extract_entities
162
+ query_entities = [n for (n, _) in extract_entities(query) if n]
163
+ if not query_entities:
164
+ return {}
165
+ # All related entities (1-hop away from query entities)
166
+ related: dict[str, list[str]] = {n: [] for n in query_entities}
167
+ for ent in query_entities:
168
+ rels = store.search_entities_by_names([ent], limit=64)
169
+ for r in rels:
170
+ # ``search_entities_by_names`` returns canonical name in
171
+ # ``name``; we want the *other* side of each relation.
172
+ others = store.related_entities(r["name"], limit=64)
173
+ for o in others:
174
+ if o != ent:
175
+ related.setdefault(ent, []).append(o)
176
+ if not any(related.values()):
177
+ return {}
178
+ # Map: memory_id -> set of matched query entities (de-duplicated)
179
+ mem_to_matches: dict[str, set[str]] = {m: set() for m in memory_ids}
180
+ for qent, others in related.items():
181
+ if not others:
182
+ continue
183
+ # Which memory ids back `qent`?
184
+ mems_for_qent = store.memory_ids_for_entity(qent, limit=512)
185
+ # Which memory ids back each of the 1-hop neighbours?
186
+ for nb in others:
187
+ mems_for_nb = store.memory_ids_for_entity(nb, limit=512)
188
+ for mid in set(mems_for_qent) & set(mems_for_nb) & set(memory_ids):
189
+ mem_to_matches[mid].add(qent)
190
+ if len(mem_to_matches[mid]) >= max_per_memory:
191
+ break
192
+ out: dict[str, GraphBoost] = {}
193
+ for mid, matches in mem_to_matches.items():
194
+ if not matches:
195
+ continue
196
+ # Saturating log so 1 hit = 0.45, 2 = 0.7, 4+ → 1.0+, capped
197
+ # at 1.5.
198
+ n = len(matches)
199
+ boost = min(1.5, 0.45 + 0.25 * math.log1p(n))
200
+ out[mid] = GraphBoost(memory_id=mid, boost=round(boost, 3),
201
+ matched_entities=sorted(matches))
202
+ return out
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # 3D adaptive scoring
207
+ # ---------------------------------------------------------------------------
208
+
209
+
210
+ @dataclass
211
+ class AdaptiveScore:
212
+ """The three dimensions Mem0 fuses, plus the blended score.
213
+
214
+ All numbers in [0, 1]. ``blended`` is a weighted average with
215
+ weights that bias toward ``importance`` so the highest-quality
216
+ memories still dominate even when their usage is low.
217
+ """
218
+
219
+ importance: float
220
+ recency: float
221
+ usage: float
222
+ graph: float
223
+ blended: float
224
+
225
+ def to_dict(self) -> dict[str, float]:
226
+ return {
227
+ "importance": round(self.importance, 4),
228
+ "recency": round(self.recency, 4),
229
+ "usage": round(self.usage, 4),
230
+ "graph": round(self.graph, 4),
231
+ "blended": round(self.blended, 4),
232
+ }
233
+
234
+
235
+ def adaptive_score(
236
+ *,
237
+ importance: float,
238
+ created_at: float,
239
+ now: float | None = None,
240
+ recall_count: int = 0,
241
+ last_recalled_at: float | None = None,
242
+ graph_degree: int = 0,
243
+ half_life_days: float = 30.0,
244
+ weights: dict[str, float] | None = None,
245
+ ) -> AdaptiveScore:
246
+ """Compute the 3D adaptive score.
247
+
248
+ The four components are:
249
+
250
+ * ``importance`` — the LLM/original importance (already [0, 1]).
251
+ * ``recency`` — half-life decay on the memory's age.
252
+ * ``usage`` — log-saturated recall_count, further discounted by
253
+ the age of the most recent recall (a memory that was hot two
254
+ months ago is not as hot as one that was hot yesterday).
255
+ * ``graph`` — log-saturated graph degree, capped at 1.0 (a
256
+ memory connected to 8+ entities is a "hub" worth promoting).
257
+
258
+ ``blended`` = w_i*importance + w_r*recency + w_u*usage + w_g*graph,
259
+ with default weights ``{importance: 0.40, recency: 0.20, usage:
260
+ 0.25, graph: 0.15}``. Callers can override via ``weights=``.
261
+
262
+ Pure function — no DB access — so the unit tests can hammer it
263
+ with crafted inputs.
264
+ """
265
+ w = weights or {"importance": 0.40, "recency": 0.20, "usage": 0.25, "graph": 0.15}
266
+ now = now if now is not None else time.time()
267
+ age = max(0.0, now - created_at)
268
+ half_life = half_life_days * 86400.0
269
+ recency = (0.5 ** (age / half_life)) if half_life else 1.0
270
+ recency = max(0.0, min(1.0, recency))
271
+
272
+ if recall_count > 0:
273
+ log_recall = math.log1p(recall_count) / math.log1p(100)
274
+ log_recall = max(0.0, min(1.0, log_recall))
275
+ if last_recalled_at:
276
+ age_recall = max(0.0, now - last_recalled_at)
277
+ usage_recency = (0.5 ** (age_recall / half_life)) if half_life else 1.0
278
+ else:
279
+ usage_recency = 1.0
280
+ usage = log_recall * (0.25 + 0.75 * usage_recency)
281
+ else:
282
+ usage = 0.0
283
+ usage = max(0.0, min(1.0, usage))
284
+
285
+ graph = 0.0
286
+ if graph_degree > 0:
287
+ graph = math.log1p(graph_degree) / math.log1p(8) # 8 hops = full credit
288
+ graph = max(0.0, min(1.0, graph))
289
+
290
+ imp = max(0.0, min(1.0, float(importance or 0)))
291
+ blended = (
292
+ w.get("importance", 0.40) * imp
293
+ + w.get("recency", 0.20) * recency
294
+ + w.get("usage", 0.25) * usage
295
+ + w.get("graph", 0.15) * graph
296
+ )
297
+ blended = max(0.0, min(1.0, blended))
298
+ return AdaptiveScore(
299
+ importance=imp,
300
+ recency=recency,
301
+ usage=usage,
302
+ graph=graph,
303
+ blended=blended,
304
+ )
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Convenience: small subgraph snapshot
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ @dataclass
313
+ class Subgraph:
314
+ """A trimmed-down view of the graph for an Agent prompt."""
315
+
316
+ nodes: list[dict[str, Any]]
317
+ edges: list[dict[str, Any]]
318
+ memory_ids: list[str]
319
+
320
+ def to_dict(self) -> dict[str, Any]:
321
+ return {
322
+ "nodes": self.nodes,
323
+ "edges": self.edges,
324
+ "memory_ids": self.memory_ids,
325
+ "node_count": len(self.nodes),
326
+ "edge_count": len(self.edges),
327
+ }
328
+
329
+
330
+ def subgraph_for(
331
+ store: MemoryStore,
332
+ query: str,
333
+ *,
334
+ max_hops: int = 1,
335
+ max_nodes: int = 32,
336
+ max_edges: int = 64,
337
+ ) -> Subgraph:
338
+ """Build a small subgraph relevant to ``query``.
339
+
340
+ 1. Extract entities from the query.
341
+ 2. Pull every relation (1 hop) that touches a query entity.
342
+ 3. Look up the backing memory ids for each entity.
343
+ 4. Trim to ``max_nodes`` / ``max_edges``.
344
+
345
+ Returns a :class:`Subgraph` dataclass so callers can render it
346
+ however they like (Mermaid, JSON for the SDK, etc.).
347
+ """
348
+ from ..graph.extract import extract_entities
349
+ ents = [n for (n, _) in extract_entities(query) if n]
350
+ if not ents:
351
+ return Subgraph(nodes=[], edges=[], memory_ids=[])
352
+ nodes: dict[str, dict[str, Any]] = {}
353
+ edges: list[dict[str, Any]] = []
354
+ memory_ids: set[str] = set()
355
+ for ent in ents:
356
+ info = store.entity_by_name(ent)
357
+ if info:
358
+ nodes[ent] = {
359
+ "name": ent,
360
+ "kind": info.get("kind") or "concept",
361
+ "weight": float(info.get("weight") or 0),
362
+ "mention_count": int(info.get("mention_count") or 0),
363
+ }
364
+ else:
365
+ nodes[ent] = {"name": ent, "kind": "concept", "weight": 0.5, "mention_count": 0}
366
+ for nb in store.related_entities(ent, limit=32):
367
+ if nb not in nodes:
368
+ nb_info = store.entity_by_name(nb) or {}
369
+ nodes[nb] = {
370
+ "name": nb,
371
+ "kind": nb_info.get("kind") or "concept",
372
+ "weight": float(nb_info.get("weight") or 0),
373
+ "mention_count": int(nb_info.get("mention_count") or 0),
374
+ }
375
+ edge = {"src": ent, "dst": nb, "kind": "related"}
376
+ if edge not in edges:
377
+ edges.append(edge)
378
+ for mid in store.memory_ids_for_entity(ent, limit=32):
379
+ memory_ids.add(mid)
380
+ # Trim
381
+ n_nodes = list(nodes.values())[:max_nodes]
382
+ n_edges = edges[:max_edges]
383
+ return Subgraph(nodes=n_nodes, edges=n_edges, memory_ids=sorted(memory_ids))
384
+
385
+
386
+ __all__ = [
387
+ "AdaptiveScore",
388
+ "GraphBoost",
389
+ "HIGH_SIGNAL_KINDS",
390
+ "Subgraph",
391
+ "adaptive_score",
392
+ "graph_boost",
393
+ "subgraph_for",
394
+ "upsert_semantic_edge",
395
+ ]
@@ -0,0 +1,24 @@
1
+ """Optional LLM-driven memory fusion.
2
+
3
+ Placeholder for the moment — the wiki consolidator already produces
4
+ high-quality distilled pages. Re-implementing memory-layer fusion here
5
+ would burn the same context twice. Keep this module around so the
6
+ ``Compactor`` config knob can switch modes without an import error.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ..storage.sqlite_store import MemoryStore
13
+
14
+
15
+ def llm_fuse_pass(store: MemoryStore, *, force: bool = False) -> int:
16
+ """Stub. Returns 0. The real implementation lives in
17
+ :mod:`jobs.evolution` — this pass is intentionally a no-op until
18
+ we have evidence the heuristic layer is leaving valuable signal
19
+ on the floor.
20
+ """
21
+ return 0
22
+
23
+
24
+ __all__ = ["llm_fuse_pass"]