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,316 @@
1
+ """Module-level handler functions used by ``serve.app.create_app``.
2
+
3
+ These used to be inline closures inside ``create_app`` (so the file
4
+ ballooned to 960 lines and was hard to unit-test). Pulling them out
5
+ keeps the FastAPI app definition small and lets us test the logic
6
+ in isolation.
7
+
8
+ Each function is a *pure* transform: it takes a ``MemoryStore`` and
9
+ the parsed request arguments, and returns a JSON-serialisable dict
10
+ (or raises ``HTTPException``). The endpoint closure in ``app.py``
11
+ just wires the HTTP signature to the handler.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import time
19
+ import uuid
20
+ from typing import Any, Optional
21
+
22
+ from fastapi import HTTPException
23
+
24
+ PIPELINE_STAGES = ("ingest", "score", "cluster", "distill", "wiki", "memo")
25
+
26
+
27
+ def pipeline_dashboard(store) -> dict:
28
+ """5-stage data flow: ingest -> score -> cluster -> distill -> wiki -> memo."""
29
+ runs = store.latest_pipeline_runs(limit=120)
30
+ by_stage: dict = {}
31
+ for r in runs:
32
+ by_stage.setdefault(r["stage"], []).append(r)
33
+ stages_out = []
34
+ for stage in PIPELINE_STAGES:
35
+ entries = by_stage.get(stage, [])
36
+ entries.sort(key=lambda x: -x["started_at"])
37
+ stages_out.append({
38
+ "stage": stage,
39
+ "runs": entries[:5],
40
+ "last_in": entries[0]["in_count"] if entries else 0,
41
+ "last_out": entries[0]["out_count"] if entries else 0,
42
+ "last_at": entries[0]["started_at"] if entries else None,
43
+ "last_note": entries[0].get("note", "") if entries else "",
44
+ })
45
+ try:
46
+ stats = store.stats()
47
+ except Exception:
48
+ stats = {}
49
+ return {
50
+ "stages": stages_out,
51
+ "totals": {
52
+ "memories": stats.get("memories", 0),
53
+ "wiki_pages": stats.get("wiki_pages", 0),
54
+ "wiki_avg_importance": stats.get("wiki_avg_importance", 0),
55
+ "avg_score": stats.get("avg_score", 0),
56
+ },
57
+ }
58
+
59
+
60
+ def pipeline_stage_items(store, stage: str, limit: int = 50) -> dict:
61
+ """Drill-down for a single stage: latest run + touched memories."""
62
+ runs = store.latest_pipeline_runs(limit=200)
63
+ runs = [r for r in runs if r["stage"] == stage]
64
+ if not runs:
65
+ return {"stage": stage, "run": None, "items": []}
66
+ run = runs[0]
67
+ try:
68
+ stats = json.loads(run.get("stats_json") or "{}")
69
+ except Exception:
70
+ stats = {}
71
+ evidence = stats.get("evidence_ids") or []
72
+ items = []
73
+ if isinstance(evidence, list) and evidence:
74
+ for mid in evidence[:limit]:
75
+ m = store.get_memory(mid)
76
+ if m is not None:
77
+ items.append({
78
+ "id": m.id, "kind": m.kind, "text": (m.text or "")[:300],
79
+ "importance": m.importance, "score": m.score,
80
+ "tags": list(m.tags or []),
81
+ })
82
+ else:
83
+ for m in store.list_memories(limit=limit):
84
+ items.append({
85
+ "id": m.id, "kind": m.kind, "text": (m.text or "")[:300],
86
+ "importance": m.importance, "score": m.score,
87
+ "tags": list(m.tags or []),
88
+ })
89
+ return {
90
+ "stage": stage,
91
+ "run": {
92
+ "id": run["id"],
93
+ "started_at": run["started_at"],
94
+ "finished_at": run.get("finished_at"),
95
+ "in_count": run["in_count"],
96
+ "out_count": run["out_count"],
97
+ "note": run.get("note", ""),
98
+ "stats": stats,
99
+ },
100
+ "items": items,
101
+ }
102
+
103
+
104
+ def llm_test(store, body: dict, scheduler=None) -> dict:
105
+ """Smoke-test the LLM provider without writing to the store.
106
+
107
+ Accepts ``api_key`` in the body for one-off testing. The key is
108
+ stored in a *temporary* secret-backend account and deleted as soon as
109
+ the test finishes. The structured response lets the UI show *why*
110
+ a key failed (status, provider_code, hint) instead of a wall of
111
+ raw JSON.
112
+
113
+ If ``scheduler`` is passed, the test result is recorded on the
114
+ scheduler so the top-bar model chip dot can reflect connectivity
115
+ state in real time.
116
+ """
117
+ from ..llm.providers import (
118
+ build_provider, default_config, validate_config, LLMHttpError,
119
+ )
120
+ from ..security import delete_secret, set_secret
121
+ # Merge the body over the saved config so callers can override the
122
+ # model/base_url without re-saving, and pass an ephemeral api_key in
123
+ # the body to test before committing. When the body is empty, the
124
+ # test runs against whatever the user has currently saved.
125
+ saved = store.get_setting("llm_consolidator", default_config()) or {}
126
+ body = dict(body or {})
127
+ body.setdefault("provider", saved.get("provider"))
128
+ body.setdefault("model", saved.get("model"))
129
+ body.setdefault("base_url", saved.get("base_url"))
130
+ body.setdefault("api_key_account", saved.get("api_key_account"))
131
+ body.setdefault("api_key_set", saved.get("api_key_set"))
132
+ provider = (body.get("provider") or "echo").lower()
133
+ raw_key = body.pop("api_key", None)
134
+ ephemeral_account = None
135
+ if raw_key:
136
+ ephemeral_account = f"llm-test/{provider}/{uuid.uuid4().hex[:8]}"
137
+ set_secret(ephemeral_account, raw_key)
138
+ body["api_key_account"] = ephemeral_account
139
+ body["api_key_set"] = True
140
+ cfg, warnings = validate_config(body or default_config())
141
+ provider_obj = build_provider(cfg)
142
+ real_key = getattr(provider_obj, "api_key", None) or ""
143
+ base_info = {
144
+ "provider": cfg.get("provider"),
145
+ "model": cfg.get("model"),
146
+ "base_url": getattr(provider_obj, "base_url", None),
147
+ "key_prefix": (real_key[:10] + "...") if len(real_key) > 10 else real_key,
148
+ "key_len": len(real_key),
149
+ "warnings": warnings,
150
+ }
151
+ # Placeholder detection before we even hit the network.
152
+ # Only flag *clearly fake* placeholders, not real keys that happen to
153
+ # contain 'x'. Common patterns: "sk-xxxx...", "your-api-key",
154
+ # "REPLACE_ME", "<API_KEY>", or fewer than 8 non-whitespace chars.
155
+ if (not real_key
156
+ or len(real_key.strip()) < 8
157
+ or re.search(r"x{4,}", real_key)
158
+ or re.search(r"(your[-_ ]?(api[-_ ]?)?key|replace[-_ ]?me|<api[-_ ]?key>|placeholder)", real_key, re.I)):
159
+ return {
160
+ **base_info,
161
+ "ok": False,
162
+ "elapsed_ms": 0,
163
+ "error": {
164
+ "status": 0,
165
+ "provider_code": None,
166
+ "provider_message": "API key not set or looks like a placeholder",
167
+ "hint": "Paste a real API key in the field above and try again.",
168
+ },
169
+ }
170
+ from ..llm.base import ChatHistory, Message
171
+ t0 = time.time()
172
+ try:
173
+ reply = provider_obj.complete(
174
+ ChatHistory(
175
+ system="You are a connectivity probe. Reply with one word: ok",
176
+ messages=[Message(role="user", content="Reply with the single word: ok")],
177
+ ),
178
+ temperature=0.0,
179
+ max_tokens=10,
180
+ )
181
+ ok = bool((reply or "").strip())
182
+ if scheduler is not None:
183
+ try:
184
+ scheduler.record_test_result(ok, "ok" if ok else "empty reply")
185
+ except Exception:
186
+ pass
187
+ return {
188
+ **base_info,
189
+ "ok": ok,
190
+ "elapsed_ms": round((time.time() - t0) * 1000, 1),
191
+ "reply": (reply or "")[:200],
192
+ }
193
+ except LLMHttpError as e:
194
+ hint = _hint_for_llm_error(
195
+ base_info["provider"] or "?",
196
+ e.status,
197
+ e.provider_code or "",
198
+ e.provider_message or "",
199
+ )
200
+ if scheduler is not None:
201
+ try:
202
+ scheduler.record_test_result(False, e.provider_message or hint)
203
+ except Exception:
204
+ pass
205
+ return {
206
+ **base_info,
207
+ "ok": False,
208
+ "elapsed_ms": round((time.time() - t0) * 1000, 1),
209
+ "error": {
210
+ "status": e.status,
211
+ "provider_code": e.provider_code,
212
+ "provider_message": e.provider_message,
213
+ "hint": hint,
214
+ },
215
+ }
216
+ except Exception as e:
217
+ if scheduler is not None:
218
+ try:
219
+ scheduler.record_test_result(False, str(e)[:200])
220
+ except Exception:
221
+ pass
222
+ return {
223
+ **base_info,
224
+ "ok": False,
225
+ "elapsed_ms": round((time.time() - t0) * 1000, 1),
226
+ "error": {
227
+ "status": 0,
228
+ "provider_code": None,
229
+ "provider_message": f"{type(e).__name__}: {e}"[:200],
230
+ "hint": "Could not reach the provider. Check network connectivity.",
231
+ },
232
+ }
233
+ finally:
234
+ if ephemeral_account:
235
+ try:
236
+ delete_secret(ephemeral_account)
237
+ except Exception:
238
+ pass
239
+
240
+
241
+ def _hint_for_llm_error(provider: str, status: int, code: str, msg: str) -> str:
242
+ """Map an LLM HTTP error to a user-facing hint.
243
+
244
+ MiniMax codes: 1004 missing auth header, 2049 invalid api key,
245
+ 1002 rate limit, 1008 insufficient balance, 1026 model not found.
246
+ OpenAI / Anthropic surface standard HTTP statuses. We map both so
247
+ the dashboard banner reads the same regardless of provider.
248
+ """
249
+ code = (code or "").strip()
250
+ m = (msg or "").lower()
251
+ if status in (401, 403) or "2049" in code or "1004" in code \
252
+ or "unauthorized" in m or "authorized_error" in m \
253
+ or "invalid api key" in m:
254
+ return (
255
+ f"{provider} rejected the API key. "
256
+ "Verify it in your provider console (MiniMax: console.minimaxi.chat), "
257
+ "then re-paste it here. The key prefix in the test result should match what you copied."
258
+ )
259
+ if status == 404 or ("model" in m and "not found" in m) or "1026" in code:
260
+ return (
261
+ f"The model name is wrong or not available on your {provider} plan. "
262
+ "Pick another model from the dropdown."
263
+ )
264
+ if status == 429 or "rate" in m or "1002" in code:
265
+ return (
266
+ f"{provider} is rate-limiting. Wait a minute and retry, or upgrade your plan."
267
+ )
268
+ if status == 402 or "balance" in m or "1008" in code:
269
+ return f"{provider} says your account is out of credit. Top up and retry."
270
+ if status >= 500:
271
+ return f"{provider} server error ({status}). Retry in a few seconds."
272
+ if status == 0:
273
+ return "Network error reaching the provider. Check connectivity."
274
+ return f"{provider} call failed (HTTP {status}" + (f", code {code}" if code else "") + ")."
275
+
276
+
277
+ def memory_to_dict(m) -> dict:
278
+ return {
279
+ "id": m.id,
280
+ "kind": m.kind,
281
+ "text": m.text,
282
+ "importance": round(m.importance, 3),
283
+ "score": round(m.score, 4),
284
+ "source": m.source,
285
+ "session_id": m.session_id,
286
+ "created_at": m.created_at,
287
+ "updated_at": getattr(m, "updated_at", None) or m.created_at,
288
+ "tags": m.tags,
289
+ "agent_id": getattr(m, "agent_id", None),
290
+ "user_id": getattr(m, "user_id", None),
291
+ "external_id": getattr(m, "external_id", None),
292
+ }
293
+
294
+
295
+ def session_to_dict(s) -> dict:
296
+ return {
297
+ "id": s.id,
298
+ "source": s.source,
299
+ "external_id": s.external_id,
300
+ "title": s.title,
301
+ "started_at": s.started_at,
302
+ "ended_at": s.ended_at,
303
+ "message_count": s.message_count,
304
+ }
305
+
306
+
307
+ def require_scheduler(app) -> Any:
308
+ """Return the running scheduler or raise 503 if it isn't attached.
309
+
310
+ Used by endpoints that *only* make sense when the serve process
311
+ has booted with a scheduler (most admin actions).
312
+ """
313
+ sched = getattr(app.state, "scheduler", None)
314
+ if sched is None:
315
+ raise HTTPException(503, "scheduler not running")
316
+ return sched
@@ -0,0 +1,59 @@
1
+ """Helpers shared across route modules.
2
+
3
+ The original ``serve/app.py`` (3226 lines) had every route defined as a
4
+ closure inside ``create_app``. To keep the central file small (audit O1)
5
+ we lifted each route group into its own ``routes/<bucket>.py`` module
6
+ and emit ``register(app, store, scheduler=None)``. Closures over
7
+ ``_memory_to_dict`` and ``_export_safe_segment`` still work because
8
+ both are imported here under the same names.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any, Optional
14
+
15
+ from ..handlers import (
16
+ memory_to_dict as _memory_to_dict, # noqa: F401
17
+ session_to_dict as _session_to_dict, # noqa: F401
18
+ )
19
+
20
+ __all__ = ["_memory_to_dict", "_session_to_dict", "_export_safe_segment"]
21
+
22
+
23
+ _THINK_RE = re.compile(r"<(think|reasoning)>(.*?)</\1>", re.DOTALL)
24
+
25
+
26
+ def _split_think(text: str) -> tuple[str, str]:
27
+ """Pull LLM thinking/reasoning blocks out of a model reply.
28
+
29
+ Several providers (MiniMax reasoning, Anthropic extended thinking)
30
+ emit a ``<think>...</think>`` or ``<reasoning>...</reasoning>``
31
+ block before the user-visible answer. We surface the block under a
32
+ separate field so the UI can render the report as Markdown while
33
+ still letting power users peek at the chain-of-thought in a
34
+ collapsed ``<details>``.
35
+ """
36
+ if not text:
37
+ return "", ""
38
+ thinking_parts = _THINK_RE.findall(text)
39
+ cleaned = _THINK_RE.sub("", text).strip()
40
+ thinking = "\n\n".join(p[1].strip() for p in thinking_parts if p[1].strip())
41
+ return cleaned, thinking
42
+
43
+
44
+ def _export_safe_segment(text: str, *, fallback: str = "", kind: str = "line") -> str:
45
+ """Sanitise a user-controlled wiki field for the markdown export.
46
+
47
+ Audit M2: a malicious title ``"My\\n## Pwned"`` would otherwise
48
+ create a second ``## Pwned`` heading on re-import. ``title`` /
49
+ ``summary`` fields are collapsed to a single line and stripped of
50
+ any leading ``## `` prefix; body content (legitimate markdown) is
51
+ left alone.
52
+ """
53
+ if not text:
54
+ return fallback
55
+ s = text
56
+ if kind in ("title", "summary"):
57
+ s = re.sub(r"\s+", " ", s).strip()
58
+ s = re.sub(r"^#+\s*", "", s).strip()
59
+ return s or fallback