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,506 @@
1
+ """FastAPI app — the local web UI for Loop Memory.
2
+
3
+ Endpoints:
4
+
5
+ GET / index HTML
6
+ GET /static/* static assets
7
+ GET /api/stats counters
8
+ GET /api/sessions list sessions
9
+ GET /api/sessions/{id}/memories
10
+ GET /api/memories list with filters: source, kind, since, until,
11
+ min_score, q, limit
12
+ POST /api/memories/{id}/delete
13
+ POST /api/sessions/{id}/delete
14
+ POST /api/admin/rescore
15
+ POST /api/admin/gc
16
+ POST /api/admin/consolidate
17
+ POST /api/admin/ingest body: {source, path?}
18
+
19
+ The framework stays zero-dependency; FastAPI / uvicorn live behind the
20
+ ``serve`` extra. Without them, you can drive the same store from the CLI.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ import sqlite3
28
+ import time
29
+ from pathlib import Path
30
+
31
+ from ..storage.sqlite_store import MemoryStore
32
+ from ..cli._common import DEFAULT_DB
33
+ from .handlers import (
34
+ llm_test,
35
+ memory_to_dict,
36
+ pipeline_dashboard,
37
+ pipeline_stage_items,
38
+ session_to_dict,
39
+ )
40
+
41
+ def _hint_for_llm_error(provider: str, status: int, code: str, msg: str) -> str:
42
+ """Module-level copy of handlers._hint_for_llm_error so the weekly
43
+ report endpoint can produce structured hints without a circular import.
44
+ See handlers.py for the full mapping logic.
45
+ """
46
+ from .handlers import _hint_for_llm_error as _impl
47
+ return _impl(provider, status, code, msg)
48
+
49
+ _THINK_RE = re.compile(r"<think>(.*?)</think>", re.DOTALL)
50
+
51
+ def _export_safe_segment(text: str, *, fallback: str = "", kind: str = "line") -> str:
52
+ """Sanitise a user-controlled wiki field for the markdown export.
53
+
54
+ Tiles a malicious title like ``"My\n## Pwned"`` would create a
55
+ second ``## Pwned`` heading on re-import, smuggling arbitrary
56
+ markdown into the next consumer (open redirect if rendered as
57
+ HTML, structural corruption if re-imported).
58
+
59
+ The real fix is downstream rendering as Markdown (with a
60
+ allow-list sanitizer). Until that exists, this helper:
61
+ * collapses any whitespace run into single spaces for
62
+ ``title`` / ``summary`` (they're not multi-line fields),
63
+ * strips a leading ``## `` so it cannot reopen a section,
64
+ * keeps ``body`` content untouched (body *is* markdown — it
65
+ legitimately contains ``## `` sub-headings).
66
+
67
+ ``kind`` is either ``"title"`` / ``"summary"`` / ``"body"``.
68
+ """
69
+ if not text:
70
+ return fallback
71
+ s = text
72
+ if kind in ("title", "summary"):
73
+ # Collapse any whitespace run (incl. newlines) into a single
74
+ # space and trim. Removes structural-injection vectors.
75
+ s = re.sub(r"\s+", " ", s).strip()
76
+ # Strip a leading "## " (and any case variant) that would
77
+ # reopen a section when re-imported.
78
+ s = re.sub(r"^#+\s*", "", s).strip()
79
+ return s or fallback
80
+
81
+ def _split_think(text: str) -> tuple[str, str]:
82
+ """Pull ``<think>...</think>`` blocks out of an LLM reply.
83
+
84
+ Some models (notably MiniMax-M2.7) emit a thinking trace before the
85
+ user-visible answer. We expose it under a separate field so the UI
86
+ can render the visible report as Markdown while still letting power
87
+ users peek at the chain-of-thought in a collapsed <details>.
88
+ """
89
+ if not text:
90
+ return "", ""
91
+ thinking_parts = _THINK_RE.findall(text)
92
+ cleaned = _THINK_RE.sub("", text).strip()
93
+ thinking = "\n\n".join(p.strip() for p in thinking_parts if p.strip())
94
+ return cleaned, thinking
95
+
96
+ def create_app(store: MemoryStore, static_dir: Path | None = None, scheduler=None):
97
+ """Build a FastAPI app wired to the given ``MemoryStore``.
98
+
99
+ Imports are local so the core stays importable without FastAPI.
100
+ """
101
+ try:
102
+ from fastapi import FastAPI, HTTPException # type: ignore
103
+ from fastapi.responses import FileResponse, JSONResponse # type: ignore
104
+ from fastapi.staticfiles import StaticFiles # type: ignore
105
+ except ImportError as e:
106
+ raise RuntimeError(
107
+ "FastAPI is not installed; run `pip install loop-memory[serve]`"
108
+ ) from e
109
+
110
+ app = FastAPI(title="Loop Memory", version="0.2.0")
111
+ app.state.scheduler = scheduler
112
+
113
+ # Static-asset cache policy. The default Starlette StaticFiles uses
114
+ # ``Cache-Control: max-age=3600``, which means Safari will happily
115
+ # serve a stale i18n / JS / CSS file for an hour after we push a
116
+ # fix — users click "reload", see no change, and report "the fix
117
+ # didn't take". Override per-extension so:
118
+ # * i18n json files: always revalidate (no-cache)
119
+ # * js / css / html: short max-age with revalidate (5 min)
120
+ # The browser still avoids re-downloading when the file is
121
+ # unchanged thanks to the ETag / If-Modified-Since 304 path.
122
+ import re as _re
123
+ _CACHE_LONG = {'svg', 'png', 'jpg', 'jpeg', 'webp', 'ico', 'woff', 'woff2'}
124
+ _CACHE_REVALIDATE = {'json', 'html', 'js', 'css'}
125
+ _EXT_RE = _re.compile(r"\.([a-z0-9]+)(\?|$)", _re.IGNORECASE)
126
+
127
+ # The server is local-only (127.0.0.1). We mount an explicit
128
+ # CORS policy that denies all cross-origin browser callers, even
129
+ # though the default browser policy already does so. The intent is
130
+ # to make the policy obvious in code review and to avoid accidental
131
+ # enablement via a future PR. Token-bearing requests must be
132
+ # same-origin; non-browser clients (curl, the watchdog) are not
133
+ # affected because they don't enforce CORS.
134
+ from fastapi.middleware.cors import CORSMiddleware as _CORS
135
+ app.add_middleware(
136
+ _CORS,
137
+ allow_origins=[],
138
+ allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
139
+ allow_headers=["Authorization", "Content-Type"],
140
+ allow_credentials=False,
141
+ max_age=600,
142
+ )
143
+
144
+ @app.middleware("http")
145
+ async def _security_headers(request, call_next):
146
+ response = await call_next(request)
147
+ # Security headers on all responses
148
+ response.headers["X-Content-Type-Options"] = "nosniff"
149
+ response.headers["X-Frame-Options"] = "DENY"
150
+ response.headers["Referrer-Policy"] = "no-referrer"
151
+ response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
152
+ # CSP: scripts are self-hosted; Vue's browser compiler requires unsafe-eval.
153
+ csp = (
154
+ "default-src 'self'; "
155
+ "script-src 'self' 'unsafe-eval'; "
156
+ "style-src 'self' 'unsafe-inline'; "
157
+ "img-src 'self' data:; "
158
+ "connect-src 'self'; "
159
+ "font-src 'self'; "
160
+ "frame-ancestors 'none'; "
161
+ "object-src 'none'; "
162
+ "base-uri 'self';"
163
+ )
164
+ response.headers["Content-Security-Policy"] = csp
165
+ return response
166
+
167
+ # ---- Auth middleware: Bearer token + CSRF ----
168
+ # Reads auth token from settings. Public endpoints (read-only, no auth needed):
169
+ # GET /, /api/stats, /api/sessions, /api/sessions/counts, /api/recall,
170
+ # /api/memories (list only), /api/wiki (list only), /api/graph,
171
+ # /api/pipeline, /api/weekly-report, /api/llm-audit, /api/source-health,
172
+ # /api/write-guard, /api/diag, /api/install-hooks (GET), static files.
173
+ _PUBLIC_PATHS = frozenset((
174
+ '/', '/api/stats', '/api/sessions', '/api/sessions/counts',
175
+ '/api/recall', '/api/memories', '/api/wiki', '/api/graph',
176
+ '/api/pipeline', '/api/weekly-report', '/api/llm-audit',
177
+ '/api/source-health', '/api/write-guard', '/api/diag',
178
+ '/api/install-hooks', '/api/insights',
179
+ ))
180
+
181
+ def _is_public_path(path: str) -> bool:
182
+ if path in _PUBLIC_PATHS:
183
+ return True
184
+ # Static assets must always be reachable so the SPA shell can
185
+ # boot before the user has had a chance to paste the Bearer
186
+ # token. The previous implementation folded /static/ and
187
+ # /api/memories/{id} into a single block that returned False
188
+ # for both, which broke the entire UI whenever a token was
189
+ # configured. Audit H2: keep the static dir public.
190
+ if path.startswith('/static/'):
191
+ return True
192
+ if path.startswith('/api/memories/'):
193
+ # GET /api/memories/{id} is not public (contains data).
194
+ return False
195
+ if path.startswith('/api/wiki/') and path not in (
196
+ '/api/wiki', '/api/wiki/contradictions', '/api/wiki/contradictions/scan',
197
+ ):
198
+ # Individual wiki page GETs are not public.
199
+ return False
200
+ return False
201
+
202
+ @app.middleware("http")
203
+ async def _auth_middleware(request, call_next):
204
+ # Hoist JSONResponse import out of the conditional: every
205
+ # error branch needs it and Python otherwise treats _J as
206
+ # implicit-local at the second reference.
207
+ from fastapi.responses import JSONResponse as _J
208
+ if _is_public_path(request.url.path):
209
+ return await call_next(request)
210
+ # Check Bearer token
211
+ auth_header = request.headers.get("Authorization", "")
212
+ expected = store.get_setting("loop_memory_auth_token") if hasattr(store, "get_setting") else None
213
+ if expected:
214
+ if not auth_header.startswith("Bearer "):
215
+ return _J({"error": "Missing or invalid Authorization header"}, status_code=401)
216
+ token = auth_header[7:]
217
+ # Simple constant-time comparison
218
+ import secrets as _secrets
219
+ if not _secrets.compare_digest(token, expected):
220
+ return _J({"error": "Invalid token"}, status_code=401)
221
+ # CSRF check for state-changing requests. A real browser
222
+ # always sends an ``Origin`` header on POST/PUT/DELETE/PATCH,
223
+ # and ``Sec-Fetch-Site`` is set to ``same-origin`` (or
224
+ # ``none`` for file:// or some same-origin fetches). A request
225
+ # with **no** Origin is therefore either a non-browser client
226
+ # (curl, the watchdog) or a malicious same-network caller.
227
+ # We only enforce CSRF for browser-like requests; non-browser
228
+ # callers can still use the local API, but they must opt in
229
+ # by sending the bearer token.
230
+ if request.method in ("POST", "PUT", "DELETE", "PATCH"):
231
+ origin = request.headers.get("Origin", "")
232
+ host = request.headers.get("Host", "")
233
+ if origin:
234
+ if origin not in (f"http://{host}", f"https://{host}"):
235
+ from fastapi.responses import JSONResponse as _J
236
+ return _J({"error": "Cross-origin request not allowed"}, status_code=403)
237
+ elif request.headers.get("Sec-Fetch-Site") not in (None, "same-origin", "none"):
238
+ # Browser claims a cross-site context without sending Origin — reject.
239
+ from fastapi.responses import JSONResponse as _J
240
+ return _J({"error": "Missing Origin header"}, status_code=403)
241
+ response = await call_next(request)
242
+ response.headers.setdefault("Vary", "Origin")
243
+ return response
244
+ return await call_next(request)
245
+
246
+ @app.middleware("http")
247
+ async def _static_cache_headers(request, call_next):
248
+ response = await call_next(request)
249
+ path = request.url.path
250
+ if path.startswith("/static/"):
251
+ m = _EXT_RE.search(path)
252
+ ext = m.group(1).lower() if m else ""
253
+ if ext in _CACHE_REVALIDATE:
254
+ response.headers["Cache-Control"] = "no-cache, must-revalidate"
255
+ elif ext in _CACHE_LONG:
256
+ response.headers["Cache-Control"] = "max-age=300, must-revalidate"
257
+ else:
258
+ response.headers["Cache-Control"] = "no-cache, must-revalidate"
259
+ return response
260
+
261
+ static_dir = static_dir or Path(__file__).parent / "static"
262
+ static_dir.mkdir(parents=True, exist_ok=True)
263
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
264
+
265
+ # moved to routes/system.py (register())
266
+
267
+ # moved to routes/insights.py (register())
268
+ # moved to routes/insights.py (register())
269
+ # moved to routes/system.py (register())
270
+ # moved to routes/system.py (register())
271
+
272
+ # moved to routes/system.py (register())
273
+ # moved to routes/system.py (register())
274
+
275
+ # moved to routes/system.py (register())
276
+
277
+ # moved to routes/system.py (register())
278
+
279
+ # moved to routes/system.py (register())
280
+
281
+ # moved to routes/system.py (register())
282
+
283
+ # moved to routes/system.py (register())
284
+
285
+ # moved to routes/system.py (register())
286
+
287
+ # moved to routes/memories.py (register())
288
+
289
+ # moved to routes/system.py (register())
290
+
291
+ # moved to routes/system.py (register())
292
+
293
+ # moved to routes/admin.py (register())
294
+
295
+ # moved to routes/admin.py (register())
296
+
297
+ # moved to routes/sessions.py (register())
298
+
299
+ # moved to routes/sessions.py (register())
300
+
301
+ # moved to routes/sessions.py (register())
302
+
303
+ # moved to routes/memories.py (register())
304
+
305
+ # moved to routes/memories.py (register())
306
+
307
+ # moved to routes/memories.py (register())
308
+
309
+ # moved to routes/wiki.py (register())
310
+
311
+ # moved to routes/memories.py (register())
312
+
313
+ # moved to routes/memories.py (register())
314
+
315
+ # moved to routes/memories.py (register())
316
+
317
+ # moved to routes/memories.py (register())
318
+
319
+ # moved to routes/memories.py (register())
320
+
321
+ # moved to routes/memories.py (register())
322
+
323
+ # moved to routes/memories.py (register())
324
+
325
+ # moved to routes/graph.py (register())
326
+
327
+ # moved to routes/graph.py (register())
328
+
329
+ # moved to routes/graph.py (register())
330
+
331
+ # moved to routes/cognitive.py (register())
332
+
333
+ # moved to routes/cognitive.py (register())
334
+
335
+ # moved to routes/cognitive.py (register())
336
+
337
+ # moved to routes/export.py (register())
338
+
339
+ # moved to routes/export.py (register())
340
+
341
+ # moved to routes/graph.py (register())
342
+
343
+ # moved to routes/wiki.py (register())
344
+
345
+ # moved to routes/memories.py (register())
346
+
347
+ # moved to routes/sessions.py (register())
348
+
349
+ # moved to routes/admin.py (register())
350
+
351
+ # moved to routes/admin.py (register())
352
+
353
+ # moved to routes/admin.py (register())
354
+
355
+ # moved to routes/admin.py (register())
356
+
357
+ # moved to routes/admin.py (register())
358
+
359
+ # moved to routes/admin.py (register())
360
+
361
+ # moved to routes/wiki.py (register())
362
+
363
+ # moved to routes/wiki.py (register())
364
+
365
+ # moved to routes/wiki.py (register())
366
+
367
+ # moved to routes/wiki.py (register())
368
+
369
+ # moved to routes/admin.py (register())
370
+
371
+ # moved to routes/admin.py (register())
372
+
373
+ # moved to routes/admin.py (register())
374
+
375
+ # moved to routes/admin.py (register())
376
+
377
+ # moved to routes/admin.py (register())
378
+
379
+ # moved to routes/admin.py (register())
380
+
381
+ # moved to routes/admin.py (register())
382
+
383
+ # moved to routes/admin.py (register())
384
+
385
+ # moved to routes/admin.py (register())
386
+
387
+ # moved to routes/system.py (register())
388
+
389
+ # moved to routes/graph.py (register())
390
+
391
+ # moved to routes/admin.py (register())
392
+
393
+ # moved to routes/admin.py (register())
394
+
395
+ # moved to routes/admin.py (register())
396
+
397
+ # moved to routes/admin.py (register())
398
+
399
+ # moved to routes/admin.py (register())
400
+
401
+ # moved to routes/admin.py (register())
402
+
403
+ # moved to routes/admin.py (register())
404
+
405
+ # moved to routes/admin.py (register())
406
+
407
+ # moved to routes/admin.py (register())
408
+
409
+ # moved to routes/admin.py (register())
410
+
411
+ # moved to routes/admin.py (register())
412
+
413
+ # moved to routes/admin.py (register())
414
+
415
+ # moved to routes/admin.py (register())
416
+
417
+ # moved to routes/memories.py (register())
418
+
419
+ # moved to routes/wiki.py (register())
420
+
421
+ # moved to routes/wiki.py (register())
422
+
423
+ # moved to routes/wiki.py (register())
424
+
425
+ # moved to routes/wiki.py (register())
426
+
427
+ # moved to routes/wiki.py (register())
428
+
429
+ # moved to routes/wiki.py (register())
430
+
431
+ # moved to routes/wiki.py (register())
432
+
433
+ # moved to routes/wiki.py (register())
434
+
435
+ # moved to routes/wiki.py (register())
436
+ # moved to routes/wiki.py (register())
437
+
438
+ # moved to routes/wiki.py (register())
439
+
440
+ # moved to routes/graph.py (register())
441
+ # ----- Mount route groups (audit O1: app.py was 3226 lines;
442
+ # route bodies now live under serve/routes/). -----
443
+ from .routes.system import register as _register_system
444
+ from .routes.insights import register as _register_insights
445
+ from .routes.sessions import register as _register_sessions
446
+ from .routes.memories import register as _register_memories
447
+ from .routes.wiki import register as _register_wiki
448
+ from .routes.graph import register as _register_graph
449
+ from .routes.admin import register as _register_admin
450
+ from .routes.cognitive import register as _register_cognitive
451
+ from .routes.export import register as _register_export
452
+
453
+ _register_system(app, store, scheduler, static_dir=static_dir)
454
+ _register_insights(app, store, scheduler)
455
+ _register_sessions(app, store, scheduler)
456
+ _register_memories(app, store, scheduler)
457
+ _register_wiki(app, store, scheduler)
458
+ _register_graph(app, store, scheduler)
459
+ _register_admin(app, store, scheduler)
460
+ _register_cognitive(app, store, scheduler)
461
+ _register_export(app, store, scheduler)
462
+
463
+ return app
464
+
465
+ # Backwards-compatible aliases. The implementations live in
466
+ # ``serve.handlers`` so they can be unit-tested without spinning up
467
+ # a FastAPI app. ``_memory_to_dict`` was the local alias used by the
468
+ # old monolith; route modules now import it from ``._shared``.
469
+ _memory_to_dict = memory_to_dict
470
+ _session_to_dict = session_to_dict
471
+
472
+ def serve(
473
+ db_path: str,
474
+ host: str = "127.0.0.1",
475
+ port: int = 7767,
476
+ static_dir: str | None = None,
477
+ ) -> None:
478
+ try:
479
+ import uvicorn # type: ignore
480
+ except ImportError as e:
481
+ raise RuntimeError(
482
+ "uvicorn is not installed; run `pip install loop-memory[serve]`"
483
+ ) from e
484
+
485
+ import logging
486
+ log = logging.getLogger("loop_memory.serve")
487
+
488
+ store = MemoryStore(db_path)
489
+ sd = Path(static_dir) if static_dir else None
490
+ app = create_app(store, sd)
491
+
492
+ # Start the LLM consolidator scheduler. It is a no-op until the
493
+ # user enables the schedule in /api/admin/llm/config.
494
+ try:
495
+ from ..jobs.scheduler import ConsolidatorScheduler
496
+ scheduler = ConsolidatorScheduler(store)
497
+ scheduler.reload_config()
498
+ scheduler.start()
499
+ app.state.scheduler = scheduler
500
+ log.info("LLM consolidator scheduler started (enabled=%s)",
501
+ (scheduler.status().get("schedule") or {}).get("enabled", False))
502
+ except Exception as e:
503
+ log.warning("could not start LLM consolidator scheduler: %s", e)
504
+ app.state.scheduler = None
505
+
506
+ uvicorn.run(app, host=host, port=port, log_level="info")