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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
loop_memory/serve/app.py
ADDED
|
@@ -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")
|