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,365 @@
1
+ """
2
+ Hybrid retrieval primitives.
3
+
4
+ Two building blocks used by ``MemoryStore.recall_hybrid``:
5
+
6
+ * ``bm25_search`` — runs an FTS5 MATCH against the memories or wiki
7
+ mirror, returning ranked candidates with their native BM25 score.
8
+
9
+ * ``fuse_rrf`` — Reciprocal Rank Fusion across multiple ranked lists.
10
+ Each list contributes ``1 / (k + rank_i(d))`` per document;
11
+ the document's fused score is the sum across lists. RRF is the
12
+ standard 2020+ recipe for combining heterogeneous rankers
13
+ (semantic, keyword, entity) without needing to align their raw
14
+ score distributions, which is exactly the problem we have here.
15
+
16
+ No external deps. SQLite FTS5 ships with the stdlib ``sqlite3`` module.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Iterable
21
+
22
+ # RRF constant. 60 is the value used in the original Cormack et al.
23
+ # 2009 paper and matches what Mem0/Graphiti use in 2026.
24
+ DEFAULT_RRF_K = 60
25
+
26
+
27
+ def _escape_fts(query: str) -> str:
28
+ """Wrap the query for FTS5's MATCH expression.
29
+
30
+ The pipeline is query-type-aware:
31
+
32
+ * ASCII/Latin tokens (``vue``, ``codex``) → wrapped in double
33
+ quotes so ``vue.js`` does NOT collapse into one exact-phrase.
34
+ * CJK runs (``知识图谱``, ``记忆系统``) → decomposed into character
35
+ trigrams (``知识图``, ``识图谱``) joined by ``AND``. The trigram
36
+ tokenizer otherwise needs each trigram to be a separate FTS5
37
+ token, and quoting the whole run as an exact phrase matches
38
+ only documents that contain the literal byte sequence — which
39
+ nothing does for arbitrary text.
40
+
41
+ Returns ``""`` when there is no usable content.
42
+ """
43
+ import re
44
+
45
+ s = (query or "").lower()
46
+ if not s.strip():
47
+ return ""
48
+
49
+ # 1. Split into "Latin word" tokens and "CJK runs" tokens. Any
50
+ # contiguous run of CJK ideographs is treated as one chunk.
51
+ cjk_re = re.compile(r"[一-鿿]+")
52
+ lat_re = re.compile(r"[\w]+")
53
+
54
+ parts: list[str] = []
55
+
56
+ # Walk through the string preserving order; we don't actually need
57
+ # FTS5 to honour order (RRF rescues it), so a flat list is fine.
58
+ for cjk_run in cjk_re.findall(s):
59
+ if len(cjk_run) < 3:
60
+ # too short for trigrams; quote and fall back to LIKE
61
+ parts.append(f'"{cjk_run}"')
62
+ continue
63
+ grams = [cjk_run[i:i + 3] for i in range(len(cjk_run) - 2)]
64
+ if len(grams) <= 2:
65
+ # 3-4 chars: emit them all (precise enough)
66
+ parts.extend(grams)
67
+ else:
68
+ # 5+ chars: AND only the boundary trigrams (first + last).
69
+ # The middle grams are usually present whenever the
70
+ # boundary grams are, so omitting them makes the query
71
+ # more recall-friendly without losing precision.
72
+ parts.append(grams[0])
73
+ parts.append(grams[-1])
74
+
75
+ # Also pull out Latin words (skip CJK runs).
76
+ cleaned = cjk_re.sub(" ", s)
77
+ for w in lat_re.findall(cleaned):
78
+ if len(w) >= 1:
79
+ parts.append(f'"{w}"')
80
+
81
+ if not parts:
82
+ return ""
83
+ # Default FTS5 operator between terms is AND; that's what we want.
84
+ return " ".join(parts)
85
+
86
+
87
+ def bm25_search(
88
+ store: Any,
89
+ query: str,
90
+ kind: str = "memories",
91
+ limit: int = 50,
92
+ source_filter: str | None = None,
93
+ ) -> list[dict]:
94
+ """Run an FTS5 query and return a list of ``{"id": ..., "_score": bm25}``.
95
+
96
+ ``kind`` is "memories" or "wiki". The native BM25 score from
97
+ SQLite's ``bm25(memories_fts)`` is negative (lower = better), so
98
+ we negate it for the RRF caller which expects positive scores.
99
+
100
+ For ``wiki``, the FTS query is restricted to rows whose scope
101
+ allows the caller's ``source_filter`` (when provided).
102
+
103
+ Fallback: the FTS5 trigram tokenizer requires >=3 contiguous
104
+ characters to create any token. For inputs that are all-CJK
105
+ AND total length <3, FTS5 returns nothing; we then issue a
106
+ ``LIKE '%<q>%'`` over the source table as a backstop. For our
107
+ scale (<10k memories, <1k wiki) LIKE is well under 5 ms.
108
+ """
109
+ import re as _re
110
+ fts_q = _escape_fts(query)
111
+ is_short_cjk = bool(
112
+ _re.fullmatch(r"[一-鿿]+", (query or "").strip() or "")
113
+ and len((query or "").strip()) < 3
114
+ )
115
+ rows: list = []
116
+ if fts_q and not is_short_cjk:
117
+ table = "memories_fts" if kind == "memories" else "wiki_fts"
118
+ score_col = f"bm25({table})"
119
+ with store._conn() as c:
120
+ if kind == "memories":
121
+ # Join back to memories so we can return the actual
122
+ # UUID `id` column (the FTS5 rowid is just a sqlite
123
+ # rowid, not the memory's primary key).
124
+ sql = (
125
+ f"SELECT m.id AS id, {score_col} AS s "
126
+ f"FROM {table} t "
127
+ f"JOIN memories m ON m.rowid = t.rowid "
128
+ f"WHERE {table} MATCH ? "
129
+ f"ORDER BY s LIMIT ?"
130
+ )
131
+ rows = c.execute(sql, (fts_q, limit)).fetchall()
132
+ else:
133
+ # Wiki: also join back to wiki_pages so we can apply the
134
+ # per-source scope filter at the SQL layer. We negate
135
+ # the bm25 score so the RRF caller sees positive values.
136
+ sql = (
137
+ f"SELECT w.id AS id, {score_col} AS s, w.scope AS scope "
138
+ f"FROM {table} t "
139
+ f"JOIN wiki_pages w ON w.rowid = t.rowid "
140
+ f"WHERE {table} MATCH ? "
141
+ f"ORDER BY s LIMIT ?"
142
+ )
143
+ rows = c.execute(sql, (fts_q, limit * 3)).fetchall()
144
+ # Apply scope filter in Python so we can use the same
145
+ # token-matching convention as the rest of the system.
146
+ rows = _filter_wiki_scope(rows, source_filter)[:limit]
147
+ # Always run LIKE as a backstop. Even when FTS5 returns hits,
148
+ # LIKE may surface documents the trigram tokenizer can't see
149
+ # (short CJK, OCR noise, mixed code identifiers, ...). The two
150
+ # lists are merged by id; LIKE rows get a synthetic importance
151
+ # score so the RRF caller can rank them alongside BM25.
152
+ like_rows = _like_fallback(store, query, kind=kind, limit=limit,
153
+ source_filter=source_filter)
154
+ merged: dict[str, dict] = {}
155
+ for r in (rows or []):
156
+ merged[r["id"]] = {"id": r["id"], "_score": -float(r["s"])}
157
+ for r in like_rows:
158
+ imp = r["importance"] if "importance" in r.keys() else 0.5
159
+ if r["id"] not in merged:
160
+ merged[r["id"]] = {"id": r["id"], "_score": float(imp or 0.5)}
161
+ return list(merged.values())
162
+
163
+
164
+ def _like_fallback(
165
+ store: Any,
166
+ query: str,
167
+ kind: str,
168
+ limit: int,
169
+ source_filter: str | None = None,
170
+ ) -> list:
171
+ """Brute-force LIKE fallback used when FTS5 trigram can't index
172
+ short CJK queries. Searches <substr> against the source table
173
+ directly. Returns rows in the same shape as bm25_search.
174
+ """
175
+ pat = f"%{(query or '').strip()}%"
176
+ if not query.strip():
177
+ return []
178
+ with store._conn() as c:
179
+ if kind == "memories":
180
+ sql = (
181
+ "SELECT m.id AS id, m.importance AS importance "
182
+ "FROM memories m WHERE LOWER(text) LIKE LOWER(?) "
183
+ "ORDER BY m.importance DESC, m.created_at DESC LIMIT ?"
184
+ )
185
+ return [dict(r) for r in c.execute(sql, (pat, limit)).fetchall()]
186
+ sql = (
187
+ "SELECT w.id AS id, w.importance AS importance, w.scope AS scope "
188
+ "FROM wiki_pages w "
189
+ "WHERE LOWER(w.title) LIKE LOWER(?) OR LOWER(w.body) LIKE LOWER(?) "
190
+ "ORDER BY w.importance DESC, w.updated_at DESC LIMIT ?"
191
+ )
192
+ rows = list(c.execute(sql, (pat, pat, limit * 3)).fetchall())
193
+ return _filter_wiki_scope(rows, source_filter)[:limit]
194
+
195
+
196
+ def _filter_wiki_scope(rows: Iterable, source: str | None) -> list:
197
+ if not source:
198
+ return list(rows)
199
+ tok = (source or "").strip().lower()
200
+ out = []
201
+ for r in rows:
202
+ scope = (r["scope"] if "scope" in r.keys() else "global") or "global"
203
+ if scope in {"global", "all"}:
204
+ out.append(r)
205
+ else:
206
+ # scope is "global" or a comma-list like "codex,claude"
207
+ allowed = {s.strip() for s in scope.split(",") if s.strip()}
208
+ if tok in allowed:
209
+ out.append(r)
210
+ return out
211
+
212
+
213
+ def fuse_rrf(
214
+ ranked_lists: list[list[dict]],
215
+ k: int = DEFAULT_RRF_K,
216
+ ) -> list[dict]:
217
+ """Reciprocal Rank Fusion.
218
+
219
+ Each input list is a list of ``{"id": ..., "_score": ...}``,
220
+ already sorted by descending score (best first). Documents not
221
+ present in a list contribute 0 from that list.
222
+
223
+ The fused score is::
224
+
225
+ fused(d) = sum over lists i of 1 / (k + rank_i(d))
226
+
227
+ where ``rank_i(d)`` is 1-based and ``None`` (= not in list) is
228
+ treated as 0.
229
+
230
+ Returns a list of ``{"id": ..., "_rrf": ...}`` sorted by fused
231
+ score descending. The ``_score`` field from each input is ignored
232
+ (RRF operates on ranks, not raw scores).
233
+ """
234
+ fused: dict[str, float] = {}
235
+ for lst in ranked_lists:
236
+ for rank, item in enumerate(lst, start=1):
237
+ fused[item["id"]] = fused.get(item["id"], 0.0) + 1.0 / (k + rank)
238
+ return [{"id": i, "_rrf": s} for i, s in sorted(fused.items(), key=lambda kv: -kv[1])]
239
+
240
+
241
+ # ----------------------------------------------------------------------
242
+ # Temporal reasoning layer
243
+ # ----------------------------------------------------------------------
244
+ # Mem0 (April 2026) showed that explicitly modelling the *temporal intent*
245
+ # of the query + reranking by date relevance contributes about 27 points
246
+ # on LongMemEval (94.4 vs 67.8 baseline). The intuition is simple:
247
+ # queries that say "what is the current X" should prefer the *most recent*
248
+ # memory that covers X, even if an older one is semantically closer.
249
+ # Conversely, "the project I shipped last week" should prefer the dated
250
+ # memory from that week, even if a fresher one exists. Without a temporal
251
+ # pass, RRF will happily return a newer-but-irrelevant memory.
252
+ #
253
+ # This module exposes two primitives. The store wires them in below
254
+ # the RRF fusion, so the change is opt-in for callers that already
255
+ # use ``recall_hybrid``.
256
+ # ----------------------------------------------------------------------
257
+
258
+ # Lightweight Chinese + English lexicons. We keep this in a plain
259
+ # constant (not an LLM call) so the latency cost of adding temporal
260
+ # reasoning is one regex pass per query.
261
+ _TEMPORAL_CURRENT = (
262
+ "current", "currently", "now", "today", "latest", "recent",
263
+ "现在", "当前", "目前", "今天", "此刻", "现在的", "最新的", "现在的",
264
+ )
265
+ _TEMPORAL_PAST = (
266
+ "previous", "previously", "before", "last", "ago", "yesterday",
267
+ "earlier", "originally", "initially", "at that time", "back then",
268
+ "之前", "上次", "上次", "上次", "曾经", "过去", "原来的", "当初",
269
+ "之前", "前几天", "上次", "已经", "之前",
270
+ )
271
+ _TEMPORAL_FUTURE = (
272
+ "tomorrow", "upcoming", "next", "will", "plan to", "going to",
273
+ "future", "scheduled",
274
+ "明天", "下次", "未来", "即将", "之后", "将要", "计划", "打算",
275
+ )
276
+
277
+
278
+ def detect_temporal_intent(query: str) -> tuple[str, float]:
279
+ """Return (intent, confidence) where intent is one of
280
+ 'current', 'past', 'future', 'any'.
281
+
282
+ Confidence is the rough lexical overlap with the matching lexicon,
283
+ capped at 1.0. 'any' always has confidence 0.0 (no signal).
284
+ """
285
+ import re as _re
286
+
287
+ q = (query or "").lower()
288
+ if not q.strip():
289
+ return ("any", 0.0)
290
+
291
+ def _hit(lex):
292
+ hits = 0
293
+ for w in lex:
294
+ # use a simple substring match; we deliberately avoid a
295
+ # tokenizer here because the query is short (≤ a few
296
+ # sentences) and Jinja-style tokenization would slow us
297
+ # down without measurable benefit.
298
+ if w in q:
299
+ hits += 1
300
+ return hits
301
+
302
+ n_cur = _hit(_TEMPORAL_CURRENT)
303
+ n_past = _hit(_TEMPORAL_PAST)
304
+ n_fut = _hit(_TEMPORAL_FUTURE)
305
+ counts = {"current": n_cur, "past": n_past, "future": n_fut}
306
+ intent = max(counts, key=counts.get) # type: ignore[arg-type]
307
+ total = n_cur + n_past + n_fut
308
+ if total == 0:
309
+ return ("any", 0.0)
310
+ # If the winning intent only wins by 1 over the runner-up, treat
311
+ # as 'any' (too ambiguous to do anything useful).
312
+ sorted_counts = sorted(counts.values(), reverse=True)
313
+ if sorted_counts[0] - sorted_counts[1] < 1:
314
+ return ("any", 0.0)
315
+ confidence = min(1.0, counts[intent] / 3.0) # 3 hits = full confidence
316
+ return (intent, confidence)
317
+
318
+
319
+ def temporal_score(
320
+ *,
321
+ created_at: float,
322
+ updated_at: float | None,
323
+ intent: str,
324
+ now: float,
325
+ confidence: float,
326
+ ) -> float:
327
+ """Return a multiplier in roughly [0.5, 1.5] for how well a memory's
328
+ date matches the query intent.
329
+
330
+ - intent='current': more recent = better; 30d-half-life decay.
331
+ - intent='past': memories close to "now - small_delta" get a small
332
+ boost; very recent memories are penalised so dated history wins.
333
+ - intent='future': memories with created/updated_at > now get a
334
+ strong boost (they're "upcoming plans"). We also accept memories
335
+ whose text mentions future intent (caller decides via flag).
336
+ - intent='any': returns 1.0 (no opinion).
337
+
338
+ A confidence < 1.0 softens the effect; with confidence 0.0 the
339
+ function returns 1.0 regardless of intent.
340
+ """
341
+ if intent == "any" or confidence <= 0.0:
342
+ return 1.0
343
+ import math as _math
344
+
345
+ dt = float(updated_at or created_at or now)
346
+ age_days = max(0.0, (now - dt) / 86400.0)
347
+ if intent == "current":
348
+ # 30-day half-life: fresh ≈1.5, 30d ≈1.0, 180d ≈0.65, 1y ≈0.5
349
+ base = 1.25 + 0.25 * _math.exp(-age_days / 30.0)
350
+ elif intent == "past":
351
+ # Sigmoid centred on "1 week ago" — slight penalty for very
352
+ # recent memories (they're probably the new state, not the past
353
+ # state the user asked about). Cross-over at ~7 days old.
354
+ x = (age_days - 7.0) / 7.0
355
+ base = 1.25 + 0.25 * (_math.tanh(x))
356
+ elif intent == "future":
357
+ if dt >= now:
358
+ base = 1.4 # upcoming / planned
359
+ else:
360
+ base = 0.7 # not future-dated
361
+ else:
362
+ base = 1.0
363
+ # Blend toward 1.0 by (1 - confidence) so we don't blow up a
364
+ # borderline match into a hard rule.
365
+ return 1.0 + (base - 1.0) * confidence