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,458 @@
1
+ """Ingest pipeline.
2
+
3
+ ``MemoryPipeline.run(session)`` writes a **single, summarised memory
4
+ record per conversation** rather than one row per turn. This keeps
5
+ the long-term store from drowning in chat noise — a typical 50-turn
6
+ session becomes 3–5 high-signal entries.
7
+
8
+ The summarisation extracts:
9
+
10
+ 1. Session title (first user prompt, capped to 80 chars)
11
+ 2. The user's stated intent (first user turn verbatim)
12
+ 3. Up to ``max_facts`` durable facts extracted from user turns
13
+ (delegates to the configured ``Reflector`` — heuristic by default
14
+ and pluggable to an LLM-backed one)
15
+ 4. The session outcome — last assistant turn verbatim (truncated)
16
+
17
+ If the conversation carries no extractable fact (pure chitchat), only
18
+ the title is written so the loop memory still records *that the session
19
+ happened* without bloating the store with low-signal noise.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import re
26
+ import time
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass
29
+
30
+ from ..backends.embedding import BaseEmbedder, IdentityEmbedder
31
+ from ..memory.types import MemoryItem
32
+ from ..storage.sqlite_store import MemoryStore, StoredMemory, StoredSession
33
+ from ..privacy import redact_text, strip_private_spans, RedactionSummary
34
+ from .loader import IngestedSession, IngestedTurn
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class IngestResult:
41
+ session: StoredSession
42
+ summary_items: list[StoredMemory]
43
+ facts_count: int
44
+ outcome_written: bool
45
+ dropped: list[DroppedItem] = None # items the WriteGuard rejected
46
+
47
+
48
+ @dataclass
49
+ class DroppedItem:
50
+ """A pre-write check that vetoed a candidate memory."""
51
+ kind: str # "duplicate" | "too_short" | "too_long" | "low_signal"
52
+ text_preview: str # first 80 chars
53
+ reason: str
54
+ matched_id: str | None = None # when kind=="duplicate"
55
+ matched_score: float = 0.0
56
+
57
+
58
+ class WriteGuard:
59
+ """Pre-write filters run before a candidate memory hits the store.
60
+
61
+ Goal: stop the long-term store from drowning in chat noise / duplicates
62
+ that the LLM-driven consolidator would have to clean up later anyway.
63
+ Each check returns either ``None`` (accept) or a ``DroppedItem``
64
+ describing the rejection.
65
+
66
+ Thresholds are deliberately conservative — the consolidator is still
67
+ the source of truth for *semantic* filtering; the guard only blocks
68
+ patterns we know are pure waste (whitespace, near-duplicates).
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ store: MemoryStore,
74
+ *,
75
+ min_chars: int = 25,
76
+ max_chars: int = 1200,
77
+ duplicate_threshold: float = 0.85,
78
+ duplicate_window: int = 600,
79
+ ) -> None:
80
+ self.store = store
81
+ self.min_chars = min_chars
82
+ self.max_chars = max_chars
83
+ self.duplicate_threshold = duplicate_threshold
84
+ self.duplicate_window = duplicate_window
85
+
86
+ def check(self, *, kind: str, text: str, importance: float, source: str | None) -> DroppedItem | None:
87
+ text = (text or "").strip()
88
+ if not text:
89
+ return DroppedItem(kind="too_short", text_preview="", reason="empty text")
90
+ if len(text) < self.min_chars:
91
+ return DroppedItem(
92
+ kind="too_short",
93
+ text_preview=text[:80],
94
+ reason=f"text shorter than {self.min_chars} chars",
95
+ )
96
+ # Low-signal short episode with very low importance
97
+ if kind == "episode" and importance < 0.4 and len(text) < 60:
98
+ return DroppedItem(
99
+ kind="low_signal",
100
+ text_preview=text[:80],
101
+ reason="episode with importance<0.4 and <60 chars",
102
+ )
103
+ # Length cap
104
+ if len(text) > self.max_chars:
105
+ return DroppedItem(
106
+ kind="too_long",
107
+ text_preview=text[:80],
108
+ reason=f"text longer than {self.max_chars} chars — wikify first",
109
+ )
110
+ # Duplicate check via cheap shingle overlap
111
+ dup = self._find_duplicate(text, source)
112
+ if dup:
113
+ return DroppedItem(
114
+ kind="duplicate",
115
+ text_preview=text[:80],
116
+ reason=f"≥{int(self.duplicate_threshold*100)}% shingle overlap with existing memory",
117
+ matched_id=dup["id"],
118
+ matched_score=dup["score"],
119
+ )
120
+ return None
121
+
122
+ def _shingles(self, text: str, n: int = 5) -> set[str]:
123
+ text = re.sub(r"\s+", " ", text.lower()).strip()
124
+ if len(text) < n:
125
+ return {text}
126
+ return {text[i:i+n] for i in range(0, len(text) - n + 1)}
127
+
128
+ def _find_duplicate(self, text: str, source: str | None) -> dict | None:
129
+ """Cheap near-duplicate detector: 5-char shingle Jaccard vs the
130
+ last ``duplicate_window`` memories of the same source. We use
131
+ Python sets rather than SQLite FTS because most memory tables
132
+ are < 10k rows and this runs once per write — keeping it
133
+ in-process avoids a roundtrip.
134
+ """
135
+ try:
136
+ recent = self.store.list_memories(limit=self.duplicate_window, source=source)
137
+ except Exception:
138
+ return None
139
+ if not recent:
140
+ return None
141
+ cand = self._shingles(text)
142
+ if not cand:
143
+ return None
144
+ best = None
145
+ for m in recent:
146
+ existing = self._shingles(m.text or "")
147
+ if not existing:
148
+ continue
149
+ inter = len(cand & existing)
150
+ union = len(cand | existing)
151
+ if union == 0:
152
+ continue
153
+ score = inter / union
154
+ if score >= self.duplicate_threshold:
155
+ if best is None or score > best["score"]:
156
+ best = {"id": m.id, "score": score}
157
+ return best
158
+
159
+
160
+ ExtractFn = Callable[[list[IngestedTurn]], list[MemoryItem]]
161
+
162
+
163
+ def _norm(text: str) -> str:
164
+ return re.sub(r"\s+", " ", text or "").strip()
165
+
166
+
167
+ class MemoryPipeline:
168
+ """Pipeline: ``IngestedSession`` → ``(StoredSession, [summary items])``.
169
+
170
+ Compared to v0.2 the per-turn ``MemoryItem`` rows are gone. The
171
+ SQLite store's ``memories`` table is unchanged; we just feed it
172
+ fewer, denser rows. Set ``max_facts=0`` to keep only the title + outcome.
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ store: MemoryStore,
178
+ embedder: BaseEmbedder | None = None,
179
+ extractor: ExtractFn | None = None,
180
+ half_life_days: float = 30.0,
181
+ max_facts: int = 3,
182
+ max_chars: int = 480,
183
+ guard: WriteGuard | None = None,
184
+ redact_enabled: bool | None = None,
185
+ ) -> None:
186
+ self.store = store
187
+ self.embedder = embedder or IdentityEmbedder()
188
+ self.half_life_days = half_life_days
189
+ self.extractor = extractor or self._default_extractor
190
+ self.max_facts = max(0, max_facts)
191
+ self.max_chars = max_chars
192
+ self.guard = guard or WriteGuard(store)
193
+ # Privacy redaction: ON by default. ``LOOP_MEMORY_REDACT=0``
194
+ # disables it for debugging. Explicit ``redact_enabled=`` arg
195
+ # in the constructor overrides the env var. The summary
196
+ # counter is per-pipeline-run and is read by the /admin
197
+ # endpoints for live observability.
198
+ import os as _os
199
+ if redact_enabled is None:
200
+ redact_enabled = _os.environ.get("LOOP_MEMORY_REDACT", "1") != "0"
201
+ self.redact_enabled = bool(redact_enabled)
202
+ self.redact_summary = RedactionSummary()
203
+ # Lazy-initialised on first drop so the import path stays optional
204
+ # for environments that don't pull sqlite_store.
205
+ self._drop_store = None
206
+
207
+ def _get_drop_store(self):
208
+ if self._drop_store is None:
209
+ try:
210
+ from ..storage.sqlite_store import WriteGuardDropStore
211
+ self._drop_store = WriteGuardDropStore(self.store)
212
+ except Exception:
213
+ self._drop_store = False # sentinel: already attempted
214
+ return self._drop_store or None
215
+
216
+ # --- public -----------------------------------------------------------
217
+
218
+ def run(self, session: IngestedSession) -> IngestResult:
219
+ if not session.turns:
220
+ raise ValueError("session has no turns")
221
+
222
+ # Always recompute a clean title from the user turns; ignore
223
+ # any system-preamble metadata the loader may have surfaced.
224
+ clean_title = self._title_text(session) or f"{session.source} session"
225
+ sis = self.store.upsert_session(
226
+ source=session.source,
227
+ external_id=session.external_id,
228
+ title=clean_title,
229
+ started_at=session.started_at,
230
+ ended_at=session.ended_at,
231
+ message_count=session.message_count,
232
+ metadata={"ingested_at": time.time(), "kind": "summary"},
233
+ )
234
+ summary_items: list[StoredMemory] = []
235
+
236
+ # Attach holder for the guard to record drops into
237
+ result_holder = IngestResult(
238
+ session=sis, summary_items=summary_items,
239
+ facts_count=0, outcome_written=False, dropped=[],
240
+ )
241
+ self._in_flight_result = result_holder
242
+ try:
243
+ title_text = self._title_text(session)
244
+ if title_text:
245
+ m = self._write(
246
+ kind="episode",
247
+ text=f"[{session.source}] {title_text}",
248
+ importance=0.55,
249
+ session_id=sis.id,
250
+ created_at=session.started_at,
251
+ tags=[session.source, "title"],
252
+ source=session.source,
253
+ skip_guard=True,
254
+ )
255
+ if m is not None:
256
+ summary_items.append(m)
257
+
258
+ first_user = self._first_user(session)
259
+ if first_user and _norm(first_user) != _norm(title_text):
260
+ m = self._write(
261
+ kind="fact",
262
+ text=f"User intent: {_norm(first_user)[:self.max_chars]}",
263
+ importance=0.7,
264
+ session_id=sis.id,
265
+ created_at=session.started_at,
266
+ tags=[session.source, "intent"],
267
+ source=session.source,
268
+ )
269
+ if m is not None:
270
+ summary_items.append(m)
271
+
272
+ facts = self._safe_extract(session.turns)
273
+ facts = facts[: self.max_facts] if self.max_facts else []
274
+ for f in facts:
275
+ m = self._write(
276
+ kind="fact",
277
+ text=_norm(f.text)[: self.max_chars],
278
+ importance=max(0.3, min(1.0, f.importance)),
279
+ session_id=sis.id,
280
+ created_at=f.created_at or session.ended_at or session.started_at,
281
+ tags=list({*f.tags, session.source, "extracted"}),
282
+ source=session.source,
283
+ )
284
+ if m is not None:
285
+ summary_items.append(m)
286
+ finally:
287
+ self._in_flight_result = None
288
+ result_holder.facts_count = len(facts)
289
+ result_holder.summary_items = summary_items
290
+
291
+ outcome_written = False
292
+ last_assistant = self._last_assistant(session)
293
+ # Re-attach the same holder so the outcome write can record drops
294
+ self._in_flight_result = result_holder
295
+ try:
296
+ if last_assistant:
297
+ m = self._write(
298
+ kind="episode",
299
+ text=f"Outcome: {_norm(last_assistant)[:self.max_chars]}",
300
+ importance=0.55,
301
+ session_id=sis.id,
302
+ created_at=session.ended_at or time.time(),
303
+ tags=[session.source, "outcome"],
304
+ source=session.source,
305
+ skip_guard=True,
306
+ )
307
+ if m is not None:
308
+ summary_items.append(m)
309
+ outcome_written = True
310
+ finally:
311
+ self._in_flight_result = None
312
+ result_holder.outcome_written = outcome_written
313
+ result_holder.summary_items = summary_items
314
+ result_holder.facts_count = len(facts)
315
+ return result_holder
316
+
317
+ # --- helpers ----------------------------------------------------------
318
+
319
+ def _write(
320
+ self,
321
+ *,
322
+ kind: str,
323
+ text: str,
324
+ importance: float,
325
+ session_id: str,
326
+ created_at: float,
327
+ tags: list,
328
+ source: str,
329
+ skip_guard: bool = False,
330
+ ) -> StoredMemory | None:
331
+ # Pre-write guard. Structural writes (title / outcome / intent) are
332
+ # always allowed through so the long-term store still records *that*
333
+ # a session happened, even if the body is short.
334
+ if self.guard is not None and not skip_guard:
335
+ drop = self.guard.check(kind=kind, text=text, importance=importance, source=source)
336
+ if drop is not None:
337
+ log.info(
338
+ "WriteGuard rejected %s (%s): %s — %r",
339
+ drop.kind, source, drop.reason, drop.text_preview,
340
+ )
341
+ # Stash the drop on the in-flight result if available
342
+ res = getattr(self, "_in_flight_result", None)
343
+ if res is not None and res.dropped is not None:
344
+ res.dropped.append(drop)
345
+ ds = self._get_drop_store()
346
+ if ds is not None:
347
+ try:
348
+ ds.record(source=source, kind=drop.kind,
349
+ text_preview=drop.text_preview,
350
+ matched_id=drop.matched_id,
351
+ matched_score=drop.matched_score)
352
+ except Exception:
353
+ pass
354
+ return None
355
+ # Privacy redaction. Runs before embedding so the vector
356
+ # stored in SQLite never sees a leaked secret. We also
357
+ # honour ``<private>...</private>`` user markers — the body
358
+ # becomes a single ``[PRIVATE:redacted]`` token.
359
+ if self.redact_enabled:
360
+ text = strip_private_spans(text)
361
+ if text.strip():
362
+ text = redact_text(text, summary=self.redact_summary)
363
+ emb = None
364
+ if self.embedder.dim:
365
+ try:
366
+ emb = self.embedder.embed_query(text)
367
+ except Exception:
368
+ emb = None
369
+ return self.store.upsert_memory(
370
+ kind=kind,
371
+ text=text,
372
+ importance=importance,
373
+ source=source,
374
+ session_id=session_id,
375
+ created_at=created_at,
376
+ tags=tags,
377
+ embedding=emb,
378
+ )
379
+
380
+ def _safe_extract(self, turns) -> list[MemoryItem]:
381
+ try:
382
+ return self.extractor(list(turns)) or []
383
+ except Exception:
384
+ log.exception("extractor failed; continuing without facts")
385
+ return []
386
+
387
+ _PREAMBLE_RE = re.compile(r"<\s*(environment_context|system-prompt|instructions)[^>]*>", re.I)
388
+
389
+ def _first_user(self, session: IngestedSession) -> str | None:
390
+ """First *substantive* user turn — skip empty / XML preambles."""
391
+ for t in session.turns:
392
+ if t.role != "user":
393
+ continue
394
+ txt = _norm(t.text)
395
+ if not txt:
396
+ continue
397
+ if len(txt) < 6:
398
+ continue
399
+ if self._PREAMBLE_RE.match(txt):
400
+ continue
401
+ return txt
402
+ # fall back to whatever came first
403
+ for t in session.turns:
404
+ if t.role == "user" and t.text:
405
+ return _norm(t.text)
406
+ return None
407
+
408
+ def _last_assistant(self, session: IngestedSession) -> str | None:
409
+ for t in reversed(session.turns):
410
+ if t.role == "assistant" and t.text:
411
+ txt = _norm(t.text)
412
+ if txt:
413
+ return txt
414
+ return None
415
+
416
+ def _title_text(self, session: IngestedSession) -> str:
417
+ if session.title and not self._PREAMBLE_RE.match(_norm(session.title)):
418
+ return _norm(session.title)[:80]
419
+ first = self._first_user(session)
420
+ if first:
421
+ return first[:80]
422
+ return f"{session.source} session"
423
+
424
+ # --- default extractor ----------------------------------------------
425
+
426
+ def _default_extractor(self, turns: list[IngestedTurn]) -> list[MemoryItem]:
427
+ """Pull *durable*, *specific* facts from user turns.
428
+
429
+ Heuristic: skip turns shorter than 12 chars or longer than 240,
430
+ skip greetings, de-duplicate by fingerprint, cap at 6 candidates.
431
+ Replace with an LLM-backed reflector via the constructor for
432
+ higher quality.
433
+ """
434
+ GREETING = re.compile(r"^(hi|hey|hello|thanks|thank you|ok|okay|好的|是|对|嗯)[.! ]*$", re.I)
435
+ candidates: list[MemoryItem] = []
436
+ seen: set[str] = set()
437
+ for turn in turns:
438
+ if turn.role != "user":
439
+ continue
440
+ text = _norm(turn.text)
441
+ if len(text) < 12 or len(text) > 240:
442
+ continue
443
+ if GREETING.match(text):
444
+ continue
445
+ fp = text.lower()[:60]
446
+ if fp in seen:
447
+ continue
448
+ seen.add(fp)
449
+ candidates.append(MemoryItem(
450
+ text=f"User said: {text}",
451
+ importance=0.55,
452
+ kind="fact",
453
+ created_at=turn.created_at or time.time(),
454
+ tags=["user-quote"],
455
+ ))
456
+ if len(candidates) >= 6:
457
+ break
458
+ return candidates
File without changes