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,259 @@
1
+ """Build the knowledge graph from the memories table.
2
+
3
+ ``KnowledgeGraph.rebuild()`` walks every memory row, runs the
4
+ zero-dep entity extractor on its text, and:
5
+
6
+ 1. Upserts each entity row in ``entities`` (name, kind, weight, count).
7
+ 2. Computes pairwise co-occurrence within a sliding window per memory
8
+ and stores them as ``co_occurs_with`` relations in ``relations``.
9
+ 3. Records which memory id each relation is evidenced by so the UI
10
+ can highlight the underlying chunks.
11
+
12
+ Plug in an LLM extractor for higher quality by replacing the ``extract``
13
+ function via the ``KnowledgeGraph(extractor=...)`` constructor — the
14
+ function signature is ``(text: str) -> list[tuple[name, kind]]``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import time
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass
23
+
24
+ from ..storage.sqlite_store import MemoryStore
25
+ from .extract import extract_entities
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ ExtractorFn = Callable[[str], list[tuple[str, str]]]
31
+
32
+
33
+ @dataclass
34
+ class BuildReport:
35
+ entities: int = 0
36
+ relations: int = 0
37
+ memories_scanned: int = 0
38
+ elapsed_ms: float = 0.0
39
+
40
+
41
+ class KnowledgeGraph:
42
+ def __init__(
43
+ self,
44
+ store: MemoryStore,
45
+ extractor: ExtractorFn | None = None,
46
+ window: int = 6,
47
+ min_mention: int = 1,
48
+ ) -> None:
49
+ self.store = store
50
+ self.extractor = extractor or extract_entities
51
+ self.window = window
52
+ self.min_mention = min_mention
53
+
54
+ def rebuild(
55
+ self,
56
+ *,
57
+ clear: bool = False,
58
+ limit: int | None = None,
59
+ ) -> BuildReport:
60
+ t0 = time.time()
61
+ report = BuildReport()
62
+ if clear:
63
+ removed = self.store.delete_graph()
64
+ log.info("cleared graph: removed %s entities", removed)
65
+
66
+ rows = self.store.list_memories(limit=limit or 100_000)
67
+ report.memories_scanned = len(rows)
68
+ for row in rows:
69
+ ents = self.extractor(row.text or "")
70
+ ents = [(n, k) for (n, k) in ents if len(n) <= 32]
71
+ for name, kind in ents:
72
+ self.store.upsert_entity(name, kind, bump_weight=0.02)
73
+ # co-occurrence per memory text
74
+ if ents:
75
+ names = [n for (n, _) in ents]
76
+ pairs = self._pairs_in_window(names, window=self.window)
77
+ for a, b in pairs:
78
+ self.store.upsert_relation(
79
+ a, b, kind="co_occurs_with",
80
+ weight=0.5, evidence_id=row.id,
81
+ )
82
+ report.relations += 1
83
+ stats = self.store.graph_stats()
84
+ report.entities = stats["entities"]
85
+ report.relations = stats["relations"]
86
+ report.elapsed_ms = (time.time() - t0) * 1000
87
+ return report
88
+
89
+ def _pairs_in_window(self, names, *, window: int) -> list[tuple[str, str]]:
90
+ # Local co-occurrence within the same memory text.
91
+ seen = set()
92
+ out: list[tuple[str, str]] = []
93
+ for i, a in enumerate(names):
94
+ for b in names[i + 1 : i + window]:
95
+ if a == b:
96
+ continue
97
+ key = tuple(sorted([a, b]))
98
+ if key in seen:
99
+ continue
100
+ seen.add(key)
101
+ out.append((a, b))
102
+ return out
103
+
104
+ # ------------------------------------------------------------------
105
+ # Wiki-based graph: build the knowledge graph from the *distilled*
106
+ # wiki pages rather than the raw memories. The result is denser,
107
+ # cleaner, and easier to navigate because every node represents a
108
+ # topic the user has already validated through consolidation.
109
+ # ------------------------------------------------------------------
110
+
111
+ def rebuild_from_wiki(
112
+ self,
113
+ *,
114
+ clear: bool = False,
115
+ limit: int | None = None,
116
+ ) -> BuildReport:
117
+ """Build the graph from ``wiki_pages`` instead of raw memories.
118
+
119
+ Nodes created (with ``kind`` so the UI can color them):
120
+
121
+ * ``wiki:<slug>`` — one node per wiki page
122
+ * ``tag:<name>`` — one node per tag used across pages
123
+ * ``concept:<name>`` — entities extracted from the page body
124
+
125
+ Edges created:
126
+
127
+ * ``wiki --tagged_with--> tag`` (per tag in page)
128
+ * ``wiki --mentions--> concept`` (entities in body)
129
+ * ``wiki --related_to--> wiki`` (pages sharing a tag or concept)
130
+
131
+ Each relation carries the page id (and any source-memory ids)
132
+ as evidence so the UI can drill back to the original chunks.
133
+ """
134
+ t0 = time.time()
135
+ report = BuildReport()
136
+ if clear:
137
+ removed = self.store.delete_graph()
138
+ log.info("cleared graph: removed %s entities", removed)
139
+
140
+ pages = self.store.list_wiki_pages(limit=limit or 1000)
141
+ report.memories_scanned = len(pages)
142
+ if not pages:
143
+ log.info("rebuild_from_wiki: no wiki pages; skipping")
144
+ report.elapsed_ms = (time.time() - t0) * 1000
145
+ return report
146
+
147
+ # 1) Page nodes
148
+ page_node_ids: Dict[str, str] = {}
149
+ for page in pages:
150
+ slug = (page.get("slug") or "").strip()
151
+ if not slug:
152
+ continue
153
+ importance = float(page.get("importance") or 0.5)
154
+ # Wiki nodes start at importance and accumulate tiny weight
155
+ # from shared tags/concepts — they should dominate visually.
156
+ ent = self.store.upsert_entity(
157
+ f"wiki:{slug}", kind="wiki_page",
158
+ bump_weight=max(0.0, importance - 0.5),
159
+ )
160
+ page_node_ids[slug] = ent.name
161
+ # Bump by mention_count too so a page with N tags/concepts
162
+ # is more prominent than a lonely one.
163
+ for _ in range(min(8, len(page.get("tags") or []) + 1)):
164
+ self.store.upsert_entity(
165
+ f"wiki:{slug}", kind="wiki_page", bump_weight=0.01,
166
+ )
167
+
168
+ # 2) Tag nodes + tag edges
169
+ tag_to_pages: Dict[str, list[str]] = {}
170
+ for page in pages:
171
+ tags = page.get("tags") or []
172
+ slug = page.get("slug") or ""
173
+ if not slug:
174
+ continue
175
+ for raw in tags:
176
+ tag = (raw or "").strip().lower()
177
+ if not tag:
178
+ continue
179
+ self.store.upsert_entity(
180
+ f"tag:{tag}", kind="tag", bump_weight=0.05,
181
+ )
182
+ self.store.upsert_relation(
183
+ f"wiki:{slug}", f"tag:{tag}",
184
+ kind="tagged_with",
185
+ weight=0.7,
186
+ evidence_id=page.get("id"),
187
+ )
188
+ report.relations += 1
189
+ tag_to_pages.setdefault(tag, []).append(slug)
190
+
191
+ # 3) Concept entities from page body + title + summary
192
+ concept_to_pages: Dict[str, list[str]] = {}
193
+ for page in pages:
194
+ slug = page.get("slug") or ""
195
+ if not slug:
196
+ continue
197
+ text_chunks = [
198
+ page.get("title") or "",
199
+ page.get("summary") or "",
200
+ page.get("body") or "",
201
+ ]
202
+ text = chr(10).join(t for t in text_chunks if t)
203
+ ents = self.extractor(text)
204
+ seen_here: set = set()
205
+ for name, _kind in ents:
206
+ name = (name or "").strip()
207
+ if not name or len(name) > 32:
208
+ continue
209
+ if name in seen_here:
210
+ continue
211
+ seen_here.add(name)
212
+ self.store.upsert_entity(
213
+ f"concept:{name}", kind="concept", bump_weight=0.02,
214
+ )
215
+ self.store.upsert_relation(
216
+ f"wiki:{slug}", f"concept:{name}",
217
+ kind="mentions",
218
+ weight=0.4,
219
+ evidence_id=page.get("id"),
220
+ )
221
+ report.relations += 1
222
+ concept_to_pages.setdefault(name, []).append(slug)
223
+
224
+ # 4) Wiki --related_to--> wiki when pages share a tag or concept
225
+ def _relate(a: str, b: str, evidence_id: str | None) -> None:
226
+ if a == b:
227
+ return
228
+ # Insert in both directions so the UI can highlight a↔b
229
+ # without having to do its own lookup.
230
+ for x, y in ((a, b), (b, a)):
231
+ self.store.upsert_relation(
232
+ f"wiki:{x}", f"wiki:{y}",
233
+ kind="related_to",
234
+ weight=0.3,
235
+ evidence_id=evidence_id,
236
+ )
237
+ report.relations += 1
238
+
239
+ for tag, slugs in tag_to_pages.items():
240
+ slugs = list(dict.fromkeys(slugs))
241
+ for i, a in enumerate(slugs):
242
+ for b in slugs[i + 1:]:
243
+ _relate(a, b, evidence_id=None)
244
+ for _concept, slugs in concept_to_pages.items():
245
+ slugs = list(dict.fromkeys(slugs))
246
+ if len(slugs) <= 8:
247
+ for i, a in enumerate(slugs):
248
+ for b in slugs[i + 1:]:
249
+ _relate(a, b, evidence_id=None)
250
+
251
+ stats = self.store.graph_stats()
252
+ report.entities = stats["entities"]
253
+ report.relations = stats["relations"]
254
+ report.elapsed_ms = (time.time() - t0) * 1000
255
+ log.info(
256
+ "rebuild_from_wiki: %d pages -> %d entities, %d relations in %.1fms",
257
+ len(pages), report.entities, report.relations, report.elapsed_ms,
258
+ )
259
+ return report
@@ -0,0 +1,197 @@
1
+ """Lightweight entity + co-occurrence extractor (zero-dep, heuristic).
2
+
3
+ We don't pull in spaCy here. This module produces two things:
4
+
5
+ * ``extract_entities(text)`` → list of (name, kind) candidates, deduped
6
+ and filtered by stopwords. Recognised entities include Capitalised
7
+ Latin tokens, ``#hashtag`` style tokens, file paths / URLs, CamelCase
8
+ product names, and short CJK noun-like runs (after stopword filter).
9
+
10
+ * ``pair_cooccurrence(texts, window=...)`` → list of (a, b, count)
11
+ relations where ``a`` and ``b`` appear within ``window`` tokens of
12
+ each other in any of the input texts.
13
+
14
+ This is intentionally tunable — for higher quality call out to an
15
+ LLM-backed extractor and feed the output through the same API.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from collections import Counter
22
+ from collections.abc import Iterable
23
+
24
+ # --- tokenisation -----------------------------------------------------------
25
+
26
+ _TOKEN_RE = re.compile(
27
+ r"[A-Za-z][A-Za-z0-9_]+" # Latin word
28
+ r"|\#[\w\u4e00-\u9fff]+" # #hashtag (English / CJK mix)
29
+ r"|https?://[^\s]+" # URL
30
+ r"|[A-Za-z0-9_./-]+\.[A-Za-z0-9]{2,}" # file.ext or domain.tld
31
+ r"|[\u4e00-\u9fff]{2,8}" # CJK noun runs (2–8 chars)
32
+ )
33
+
34
+
35
+ def tokenize(text: str) -> list[str]:
36
+ return _TOKEN_RE.findall(text or "")
37
+
38
+
39
+ # --- stopword lists ---------------------------------------------------------
40
+
41
+ _EN_STOPWORDS = {
42
+ "the", "and", "for", "with", "from", "into", "this", "that", "these",
43
+ "those", "you", "your", "are", "was", "were", "have", "has", "had",
44
+ "but", "not", "any", "all", "can", "could", "would", "should", "will",
45
+ "shall", "may", "might", "must", "what", "why", "how", "who", "where",
46
+ "when", "then", "now", "also", "still", "just", "very", "more",
47
+ "less", "most", "least", "out", "off", "per", "via", "i", "me",
48
+ "my", "we", "us", "our", "they", "their", "them", "he", "she", "his",
49
+ "her", "is", "am", "be", "been", "being", "do", "does", "did", "done",
50
+ "doing", "of", "in", "on", "at", "to", "by", "as", "an", "or", "if",
51
+ "no", "yes", "so", "it", "its", "about", "than", "there", "here",
52
+ "above", "below", "under", "over", "again", "once", "each",
53
+ "something", "anything", "everything", "nothing", "some",
54
+ "which", "such", "after", "before",
55
+ }
56
+
57
+ # Common short CJK fragments that aren't real concepts.
58
+ _CJK_STOPWORDS = {
59
+ "的", "了", "和", "是", "在", "我", "你", "他", "她", "它",
60
+ "我们", "你们", "他们", "这个", "那个", "什么", "怎么", "为什么",
61
+ "可以", "可能", "也许", "应该", "已经", "现在", "之前", "之后",
62
+ "因为", "所以", "如果", "但是", "不过", "然后", "可是", "而且",
63
+ "或者", "还有", "也", "都", "就", "才", "只", "再", "又", "很",
64
+ "非常", "比较", "一点", "一下", "一直", "顺便", "帮我", "我用",
65
+ "你用", "对她", "我对", "是不是",
66
+ "用", "打", "做", "搞", "弄", "给", "让", "把", "被", "由",
67
+ "从", "到", "向", "对", "跟", "比", "如", "若", "虽", "除非",
68
+ "将", "会", "能", "须", "必", "得", "地", "着", "过", "如何", "怎样", "为啥", "的工具", "的项目", "的系统", "的代码", "的内容", "的功能",
69
+ "一个", "一些", "这些", "那些", "今天",
70
+ "明天", "昨天", "谢谢", "感谢", "麻烦", "请帮",
71
+ }
72
+
73
+ _STOPWORDS = _EN_STOPWORDS
74
+
75
+
76
+ # --- detection --------------------------------------------------------------
77
+
78
+
79
+
80
+
81
+ # Extra stopwords: tokens that come up very frequently as filler in
82
+ # extracted LLM-style text — they tend to drown out useful entities.
83
+ _GENERIC_TERMS = {
84
+ "User", "Users", "Assistant", "Outcome", "Task", "Description",
85
+ "Issues", "Issue", "Files", "File", "Strengths", "Added",
86
+ "Required", "Implemented", "Complete", "Check", "Review",
87
+ "Code", "Title", "Body", "Read", "Note", "Source", "Target", "Message", "Context", "Result", "Output", "Input", "Step", "Steps", "List", "Section",
88
+ "Project", "Repository", "Repo", "Doc", "Documentation",
89
+ "Docs", "URL", "Path", "Line", "Lines", "Type", "Method",
90
+ "Function", "Class", "Module", "Package", "Import", "Export",
91
+ "Example", "Examples", "Sample", "Demo", "Use", "Using", "Used",
92
+ "Make", "Made", "Run", "Running", "Create", "Creates",
93
+ "Created", "Add", "Adding", "Remove", "Removed",
94
+ "Removing", "Update", "Updated", "Updating", "Change", "Changes",
95
+ "Changed", "Show", "Showing", "Found", "Founding", "Missing",
96
+ "First", "Second", "Third",
97
+ "Test", "Tests", "Tested", "Testing", "Pass", "Passes",
98
+ "Status", "OK", "Ok", "Notes", "Comments", "Comment",
99
+ "Done", "Completed", "Completion",
100
+ }
101
+
102
+ def _looks_like_proper_noun(tok: str) -> bool:
103
+ """True for tokens that may be useful named-entity material."""
104
+ if len(tok) < 2:
105
+ return False
106
+ if tok.isupper() and len(tok) <= 5:
107
+ return tok.isalpha() # ACRONYM
108
+ if tok[0].isupper() and any(c.islower() for c in tok[1:]):
109
+ return True
110
+ if any(c.isupper() for c in tok[1:]) and any(c.islower() for c in tok):
111
+ return True # CamelCase
112
+ return False
113
+
114
+
115
+ def _kind_for(tok: str) -> str:
116
+ if tok.startswith("#"):
117
+ return "tag"
118
+ if tok.startswith("http"):
119
+ return "url"
120
+ if "." in tok and "/" in tok:
121
+ return "path"
122
+ if tok.isupper():
123
+ return "acronym"
124
+ if any(ord(c) > 127 for c in tok):
125
+ return "cjk"
126
+ return "concept"
127
+
128
+
129
+ # Heuristic disqualifiers for CJK tokens: any of these characters in
130
+ # any position is a strong signal the n-gram is not a real concept.
131
+ _CJK_FRAGMENT_MARKERS = set("的了着过得把被让给向将从跟比和或而但所以因为") | set("是吗呀啊嘛呢哦嗯哈呀嘛啊")
132
+
133
+
134
+ def _is_useful(tok: str) -> bool:
135
+ if not tok:
136
+ return False
137
+ lower = tok.lower()
138
+ if lower in _STOPWORDS:
139
+ return False
140
+ if tok in _CJK_STOPWORDS:
141
+ return False
142
+ if tok in _GENERIC_TERMS:
143
+ return False
144
+ # Latin: only proper-nounish tokens qualify.
145
+ if ord(tok[0]) < 128:
146
+ if not _looks_like_proper_noun(tok):
147
+ return False
148
+ return True
149
+ # CJK token refinement
150
+ if any(c in _CJK_FRAGMENT_MARKERS for c in tok):
151
+ return False
152
+ return True
153
+
154
+
155
+ def extract_entities(text: str, *, min_count: int = 1) -> list[tuple[str, str]]:
156
+ if not text:
157
+ return []
158
+ counts: Counter = Counter()
159
+ for tok in tokenize(text):
160
+ tok = tok.strip("'\"`")
161
+ if _is_useful(tok):
162
+ counts[tok] += 1
163
+ return [
164
+ (tok, _kind_for(tok))
165
+ for tok, n in counts.items()
166
+ if n >= min_count
167
+ ]
168
+
169
+
170
+ def pair_cooccurrence(
171
+ texts: Iterable[str],
172
+ *,
173
+ window: int = 6,
174
+ min_count: int = 1,
175
+ ) -> list[tuple[str, str, int]]:
176
+ pair_counts: Counter = Counter()
177
+ for text in texts:
178
+ toks = [tok for tok in tokenize(text) if _is_useful(tok)]
179
+ seen_local: set = set()
180
+ for i, tok in enumerate(toks):
181
+ for other in toks[i + 1 : i + window]:
182
+ if other == tok:
183
+ continue
184
+ key = tuple(sorted([tok, other]))
185
+ if key in seen_local:
186
+ continue
187
+ seen_local.add(key)
188
+ pair_counts[key] += 1
189
+ return [(a, b, c) for (a, b), c in pair_counts.items() if c >= min_count]
190
+
191
+
192
+ def canonical(name: str) -> str:
193
+ if not name:
194
+ return name
195
+ if ord(name[0]) >= 128:
196
+ return name
197
+ return name.strip()
File without changes