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,281 @@
1
+ """Wiki-page contradiction detection.
2
+
3
+ When the consolidator writes a new or updated wiki page, we don't just
4
+ stop at "it's a paragraph about X". Two pages about the *same* topic
5
+ might disagree — "user prefers tabs" vs. "user prefers spaces" — and
6
+ the UI should surface that for the user to merge. We do this cheaply
7
+ by comparing ``key_facts`` rather than full bodies: a page's key
8
+ facts are short, single-sentence bullets produced by the LLM, so
9
+ high Jaccard similarity over them strongly suggests "same topic".
10
+
11
+ Why not whole-body similarity? Bodies are long and chatty; LLM
12
+ paraphrasing inflates their divergence even when the underlying
13
+ meaning matches. ``key_facts`` are constrained to single-sentence
14
+ statements which makes them a much sharper comparable unit.
15
+
16
+ Detection runs as a post-write hook in the consolidator pipeline,
17
+ *and* on demand via the API so the UI can re-scan after the user
18
+ adds new wiki pages by hand.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import re
25
+ import time
26
+ from dataclasses import dataclass
27
+ from typing import Any, Iterable
28
+
29
+ from ..storage.sqlite_store import MemoryStore
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+
34
+ # --- tokenization ------------------------------------------------------
35
+
36
+ # We split on whitespace + ASCII punctuation, but keep CJK unigrams
37
+ # + 2-grams so "偏好 Tab 缩进" and "使用 Tab 缩进" still produce
38
+ # overlapping tokens. We lowercase ASCII; CJK is left as-is to keep
39
+ # 2-grams intact.
40
+ _TOKEN_RE = re.compile(r"[A-Za-z0-9]+|[\u4e00-\u9fff]", re.UNICODE)
41
+
42
+
43
+ def _tokenize(text: str) -> set[str]:
44
+ text = (text or "").lower()
45
+ raw = _TOKEN_RE.findall(text)
46
+ grams: set[str] = set()
47
+ for i, t in enumerate(raw):
48
+ grams.add(t)
49
+ if i + 1 < len(raw) and len(t) == 1 and len(raw[i + 1]) == 1:
50
+ # CJK bigram
51
+ grams.add(t + raw[i + 1])
52
+ return grams
53
+
54
+
55
+ def _fact_set(key_facts: list[str] | None) -> set[str]:
56
+ out: set[str] = set()
57
+ for f in key_facts or []:
58
+ out |= _tokenize(f)
59
+ return out
60
+
61
+
62
+ # --- scoring -----------------------------------------------------------
63
+
64
+ # A page is considered a contradiction candidate when its key_facts
65
+ # Jaccard overlap with another page is at or above this threshold AND
66
+ # the page shares at least one tag in common. Both gates together
67
+ # dramatically reduce false positives (the store has many overlapping
68
+ # but non-conflicting facts).
69
+ DEFAULT_THRESHOLD = 0.45
70
+
71
+
72
+ def _jaccard(a: set[str], b: set[str]) -> float:
73
+ if not a or not b:
74
+ return 0.0
75
+ inter = a & b
76
+ union = a | b
77
+ return len(inter) / len(union)
78
+
79
+
80
+ @dataclass
81
+ class ContradictionMatch:
82
+ a_id: str
83
+ b_id: str
84
+ score: float
85
+ shared_tags: list[str]
86
+ a_title: str
87
+ b_title: str
88
+
89
+ def to_dict(self) -> dict[str, Any]:
90
+ return {
91
+ "a_id": self.a_id,
92
+ "b_id": self.b_id,
93
+ "score": round(self.score, 3),
94
+ "shared_tags": self.shared_tags,
95
+ "a_title": self.a_title,
96
+ "b_title": self.b_title,
97
+ }
98
+
99
+
100
+ def detect_for_page(
101
+ store: MemoryStore,
102
+ page_id: str,
103
+ *,
104
+ threshold: float = DEFAULT_THRESHOLD,
105
+ max_candidates: int = 5,
106
+ ) -> list[ContradictionMatch]:
107
+ """Find contradiction candidates for a single wiki page.
108
+
109
+ Returns up to ``max_candidates`` matches ordered by descending
110
+ similarity. The candidates are NOT written to the store — the
111
+ caller decides whether to persist via ``write_contradicting_ids``.
112
+ """
113
+ target = store.get_wiki_page(page_id)
114
+ if not target:
115
+ return []
116
+ target_facts = _fact_set(target.get("key_facts") or [])
117
+ if not target_facts:
118
+ return []
119
+ target_tags = set(target.get("tags") or [])
120
+ target_scope = target.get("scope") or "global"
121
+
122
+ candidates: list[ContradictionMatch] = []
123
+ # Pull pages in the same scope first (cheaper and more relevant);
124
+ # fall back to global if nothing surfaces.
125
+ pool = list(store.list_wiki_pages(limit=500, scope=target_scope))
126
+ if len(pool) < 5:
127
+ pool = list(store.list_wiki_pages(limit=500))
128
+
129
+ for other in pool:
130
+ if other["id"] == page_id:
131
+ continue
132
+ if (other.get("contradicting_ids") or []):
133
+ # already has a partner — skip so we don't duplicate work
134
+ if page_id in (other.get("contradicting_ids") or []):
135
+ continue
136
+ other_facts = _fact_set(other.get("key_facts") or [])
137
+ if not other_facts:
138
+ continue
139
+ other_tags = set(other.get("tags") or [])
140
+ shared = sorted(target_tags & other_tags)
141
+ if not shared and target_scope not in {"global", "all"}:
142
+ # Different topic — skip unless we're in global scope
143
+ continue
144
+ score = _jaccard(target_facts, other_facts)
145
+ if score >= threshold:
146
+ candidates.append(ContradictionMatch(
147
+ a_id=page_id,
148
+ b_id=other["id"],
149
+ score=score,
150
+ shared_tags=shared,
151
+ a_title=target.get("title") or "",
152
+ b_title=other.get("title") or "",
153
+ ))
154
+ candidates.sort(key=lambda m: m.score, reverse=True)
155
+ return candidates[:max_candidates]
156
+
157
+
158
+ def write_contradicting_ids(
159
+ store: MemoryStore,
160
+ matches: Iterable[ContradictionMatch],
161
+ ) -> int:
162
+ """Persist the symmetric ``contradicting_ids`` columns on both
163
+ sides of every match. Returns the number of pages updated."""
164
+ by_page: dict[str, set[str]] = {}
165
+ for m in matches:
166
+ by_page.setdefault(m.a_id, set()).add(m.b_id)
167
+ by_page.setdefault(m.b_id, set()).add(m.a_id)
168
+ n = 0
169
+ for page_id, ids in by_page.items():
170
+ cur = store.get_wiki_page(page_id)
171
+ if not cur:
172
+ continue
173
+ existing = set(cur.get("contradicting_ids") or [])
174
+ merged = sorted(existing | ids)
175
+ if sorted(existing) == merged:
176
+ continue
177
+ # Re-upsert by slug so we don't depend on upsert supporting
178
+ # the page_id kwarg (it doesn't — slug is the lookup key).
179
+ store.upsert_wiki_page(
180
+ slug=cur.get("slug") or "",
181
+ title=cur.get("title") or "",
182
+ body=cur.get("body") or "",
183
+ summary=cur.get("summary") or "",
184
+ tags=cur.get("tags") or [],
185
+ importance=float(cur.get("importance") or 0.5),
186
+ evidence_ids=cur.get("evidence_ids") or [],
187
+ run_id=cur.get("run_id"),
188
+ scope=cur.get("scope") or "global",
189
+ key_facts=cur.get("key_facts") or [],
190
+ contradicting_ids=merged,
191
+ )
192
+ n += 1
193
+ return n
194
+
195
+
196
+ def scan_all(
197
+ store: MemoryStore,
198
+ *,
199
+ threshold: float = DEFAULT_THRESHOLD,
200
+ progress: Any = None,
201
+ ) -> dict[str, Any]:
202
+ """Re-scan every wiki page for contradictions.
203
+
204
+ Used after the user has manually edited pages or imported new
205
+ ones. Cheap enough to run interactively for stores with < 1000
206
+ pages.
207
+ """
208
+ t0 = time.time()
209
+ pages = store.list_wiki_pages(limit=2000)
210
+ all_matches: list[ContradictionMatch] = []
211
+ for i, p in enumerate(pages):
212
+ if progress:
213
+ try:
214
+ progress(i, len(pages), p.get("title") or p["id"])
215
+ except Exception:
216
+ pass
217
+ m = detect_for_page(store, p["id"], threshold=threshold)
218
+ all_matches.extend(m)
219
+ # Symmetric write — pass unique pair tuples (sorted ids so each
220
+ # pair only gets written once).
221
+ seen_pairs: set[tuple[str, str]] = set()
222
+ deduped: list[ContradictionMatch] = []
223
+ for m in all_matches:
224
+ key = tuple(sorted([m.a_id, m.b_id]))
225
+ if key in seen_pairs:
226
+ continue
227
+ seen_pairs.add(key)
228
+ deduped.append(m)
229
+ n_updated = write_contradicting_ids(store, deduped)
230
+ return {
231
+ "pages_scanned": len(pages),
232
+ "matches": [m.to_dict() for m in deduped],
233
+ "pages_updated": n_updated,
234
+ "elapsed_ms": round((time.time() - t0) * 1000, 1),
235
+ "threshold": threshold,
236
+ }
237
+
238
+
239
+ def list_contradictions(store: MemoryStore) -> list[dict[str, Any]]:
240
+ """Return every wiki page that has at least one contradicting id.
241
+
242
+ Each entry carries the page metadata plus a list of partner
243
+ summaries so the UI can render a one-row-per-conflict table.
244
+ """
245
+ pages = store.list_wiki_pages(limit=2000)
246
+ out: list[dict[str, Any]] = []
247
+ for p in pages:
248
+ cids = p.get("contradicting_ids") or []
249
+ if not cids:
250
+ continue
251
+ partners = []
252
+ for cid in cids:
253
+ other = store.get_wiki_page(cid)
254
+ if other:
255
+ partners.append({
256
+ "id": cid,
257
+ "title": other.get("title") or "",
258
+ "summary": (other.get("summary") or "")[:200],
259
+ "importance": float(other.get("importance") or 0),
260
+ "scope": other.get("scope") or "global",
261
+ })
262
+ out.append({
263
+ "id": p["id"],
264
+ "title": p.get("title") or "",
265
+ "summary": (p.get("summary") or "")[:200],
266
+ "importance": float(p.get("importance") or 0),
267
+ "scope": p.get("scope") or "global",
268
+ "key_facts": p.get("key_facts") or [],
269
+ "partners": partners,
270
+ })
271
+ return out
272
+
273
+
274
+ __all__ = [
275
+ "detect_for_page",
276
+ "write_contradicting_ids",
277
+ "scan_all",
278
+ "list_contradictions",
279
+ "ContradictionMatch",
280
+ "DEFAULT_THRESHOLD",
281
+ ]