superlocalmemory 3.4.1 → 3.4.4
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.
- package/README.md +9 -12
- package/package.json +1 -1
- package/pyproject.toml +11 -2
- package/scripts/postinstall.js +26 -7
- package/src/superlocalmemory/cli/commands.py +71 -60
- package/src/superlocalmemory/cli/daemon.py +184 -64
- package/src/superlocalmemory/cli/main.py +25 -2
- package/src/superlocalmemory/cli/service_installer.py +367 -0
- package/src/superlocalmemory/cli/setup_wizard.py +150 -9
- package/src/superlocalmemory/core/config.py +28 -0
- package/src/superlocalmemory/core/consolidation_engine.py +38 -1
- package/src/superlocalmemory/core/engine.py +9 -0
- package/src/superlocalmemory/core/health_monitor.py +313 -0
- package/src/superlocalmemory/core/reranker_worker.py +19 -5
- package/src/superlocalmemory/ingestion/__init__.py +13 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +234 -0
- package/src/superlocalmemory/ingestion/base_adapter.py +177 -0
- package/src/superlocalmemory/ingestion/calendar_adapter.py +340 -0
- package/src/superlocalmemory/ingestion/credentials.py +118 -0
- package/src/superlocalmemory/ingestion/gmail_adapter.py +369 -0
- package/src/superlocalmemory/ingestion/parsers.py +100 -0
- package/src/superlocalmemory/ingestion/transcript_adapter.py +156 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +47 -1
- package/src/superlocalmemory/learning/entity_compiler.py +377 -0
- package/src/superlocalmemory/mcp/server.py +32 -3
- package/src/superlocalmemory/mcp/tools_mesh.py +249 -0
- package/src/superlocalmemory/mesh/__init__.py +12 -0
- package/src/superlocalmemory/mesh/broker.py +344 -0
- package/src/superlocalmemory/retrieval/entity_channel.py +12 -6
- package/src/superlocalmemory/server/api.py +6 -7
- package/src/superlocalmemory/server/routes/adapters.py +63 -0
- package/src/superlocalmemory/server/routes/entity.py +151 -0
- package/src/superlocalmemory/server/routes/ingest.py +110 -0
- package/src/superlocalmemory/server/routes/mesh.py +186 -0
- package/src/superlocalmemory/server/unified_daemon.py +693 -0
- package/src/superlocalmemory/storage/schema_v343.py +229 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +1588 -0
- package/src/superlocalmemory/ui/index.html +134 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +28 -1
- package/src/superlocalmemory/ui/js/ng-entities.js +272 -0
- package/src/superlocalmemory/ui/js/ng-health.js +208 -0
- package/src/superlocalmemory/ui/js/ng-ingestion.js +203 -0
- package/src/superlocalmemory/ui/js/ng-mesh.js +311 -0
- package/src/superlocalmemory/ui/js/ng-shell.js +471 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +18 -14
- package/src/superlocalmemory.egg-info/SOURCES.txt +26 -0
- package/src/superlocalmemory.egg-info/requires.txt +9 -1
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the Elastic License 2.0 - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SLM Unified Daemon — single FastAPI process for ALL routes.
|
|
6
|
+
|
|
7
|
+
Replaces the dual-process architecture (stdlib daemon + FastAPI dashboard).
|
|
8
|
+
One MemoryEngine singleton shared by CLI, MCP, Dashboard, and Mesh routes.
|
|
9
|
+
|
|
10
|
+
Architecture:
|
|
11
|
+
slm serve → starts unified daemon (uvicorn on port 8765)
|
|
12
|
+
slm remember X → HTTP POST to daemon → instant
|
|
13
|
+
slm recall X → HTTP GET from daemon → instant
|
|
14
|
+
slm dashboard → opens browser to http://localhost:8765
|
|
15
|
+
slm serve stop → POST /stop → graceful uvicorn shutdown
|
|
16
|
+
|
|
17
|
+
Port 8765: primary (dashboard + API + daemon routes)
|
|
18
|
+
Port 8767: TCP redirect for backward compat (deprecated)
|
|
19
|
+
|
|
20
|
+
24/7 by default. Opt-in auto-kill: --idle-timeout=1800
|
|
21
|
+
|
|
22
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
23
|
+
License: Elastic-2.0
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import signal
|
|
34
|
+
import sys
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from contextlib import asynccontextmanager
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Optional
|
|
41
|
+
|
|
42
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
43
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
44
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
|
45
|
+
from pydantic import BaseModel
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger("superlocalmemory.unified_daemon")
|
|
48
|
+
|
|
49
|
+
_DEFAULT_PORT = 8765
|
|
50
|
+
_LEGACY_PORT = 8767
|
|
51
|
+
_PID_FILE = Path.home() / ".superlocalmemory" / "daemon.pid"
|
|
52
|
+
_PORT_FILE = Path.home() / ".superlocalmemory" / "daemon.port"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Request models
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
class RememberRequest(BaseModel):
|
|
60
|
+
content: str
|
|
61
|
+
tags: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ObserveRequest(BaseModel):
|
|
65
|
+
content: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Observation debounce buffer (migrated from daemon.py)
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
class ObserveBuffer:
|
|
73
|
+
"""Thread-safe debounce buffer for observation processing.
|
|
74
|
+
|
|
75
|
+
Buffers observations for a configurable window, deduplicates by content
|
|
76
|
+
hash, then processes as a batch via the singleton MemoryEngine.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, debounce_sec: float = 3.0):
|
|
80
|
+
self._debounce_sec = debounce_sec
|
|
81
|
+
self._buffer: list[str] = []
|
|
82
|
+
self._seen: set[str] = set()
|
|
83
|
+
self._lock = threading.Lock()
|
|
84
|
+
self._timer: threading.Timer | None = None
|
|
85
|
+
self._engine = None
|
|
86
|
+
|
|
87
|
+
def set_engine(self, engine) -> None:
|
|
88
|
+
self._engine = engine
|
|
89
|
+
|
|
90
|
+
def enqueue(self, content: str) -> dict:
|
|
91
|
+
content_hash = hashlib.md5(content.encode()).hexdigest()
|
|
92
|
+
with self._lock:
|
|
93
|
+
if content_hash in self._seen:
|
|
94
|
+
return {"captured": False, "reason": "duplicate within debounce window"}
|
|
95
|
+
self._seen.add(content_hash)
|
|
96
|
+
self._buffer.append(content)
|
|
97
|
+
buf_size = len(self._buffer)
|
|
98
|
+
if self._timer is not None:
|
|
99
|
+
self._timer.cancel()
|
|
100
|
+
self._timer = threading.Timer(self._debounce_sec, self._flush)
|
|
101
|
+
self._timer.daemon = True
|
|
102
|
+
self._timer.start()
|
|
103
|
+
return {"captured": True, "queued": True, "buffer_size": buf_size}
|
|
104
|
+
|
|
105
|
+
def _flush(self) -> None:
|
|
106
|
+
with self._lock:
|
|
107
|
+
if not self._buffer:
|
|
108
|
+
return
|
|
109
|
+
batch = list(self._buffer)
|
|
110
|
+
self._buffer.clear()
|
|
111
|
+
self._seen.clear()
|
|
112
|
+
self._timer = None
|
|
113
|
+
|
|
114
|
+
if self._engine is None:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
from superlocalmemory.hooks.auto_capture import AutoCapture
|
|
119
|
+
auto = AutoCapture(engine=self._engine)
|
|
120
|
+
for content in batch:
|
|
121
|
+
try:
|
|
122
|
+
decision = auto.evaluate(content)
|
|
123
|
+
if decision.capture:
|
|
124
|
+
auto.capture(content, category=decision.category)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
logger.info("Observe debounce: processed %d observations", len(batch))
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
def flush_sync(self) -> None:
|
|
132
|
+
"""Force flush for shutdown."""
|
|
133
|
+
if self._timer is not None:
|
|
134
|
+
self._timer.cancel()
|
|
135
|
+
self._flush()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_observe_buffer = ObserveBuffer(
|
|
139
|
+
debounce_sec=float(os.environ.get("SLM_OBSERVE_DEBOUNCE_SEC", "3.0"))
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Idle watchdog (opt-in)
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
_last_activity = time.monotonic()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _start_idle_watchdog(timeout_sec: int) -> None:
|
|
151
|
+
"""Auto-shutdown after idle. Only if timeout > 0."""
|
|
152
|
+
if timeout_sec <= 0:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
def _watch():
|
|
156
|
+
while True:
|
|
157
|
+
time.sleep(30)
|
|
158
|
+
idle = time.monotonic() - _last_activity
|
|
159
|
+
if idle > timeout_sec:
|
|
160
|
+
logger.info("Daemon idle for %ds, shutting down", int(idle))
|
|
161
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
t = threading.Thread(target=_watch, daemon=True, name="idle-watchdog")
|
|
165
|
+
t.start()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Legacy port TCP redirect (backward compat for port 8767)
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
async def _start_legacy_redirect(primary_port: int, legacy_port: int) -> None:
|
|
173
|
+
"""Start TCP redirect from legacy_port → primary_port.
|
|
174
|
+
|
|
175
|
+
Simple byte-level proxy. No shared event loop with uvicorn — runs
|
|
176
|
+
in its own asyncio task within the same loop.
|
|
177
|
+
"""
|
|
178
|
+
_deprecation_warned = False
|
|
179
|
+
|
|
180
|
+
async def _handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
181
|
+
nonlocal _deprecation_warned
|
|
182
|
+
if not _deprecation_warned:
|
|
183
|
+
logger.warning(
|
|
184
|
+
"Request on deprecated port %d. Update config to use port %d.",
|
|
185
|
+
legacy_port, primary_port,
|
|
186
|
+
)
|
|
187
|
+
_deprecation_warned = True
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
upstream_r, upstream_w = await asyncio.open_connection("127.0.0.1", primary_port)
|
|
191
|
+
await asyncio.gather(
|
|
192
|
+
_pipe(reader, upstream_w),
|
|
193
|
+
_pipe(upstream_r, writer),
|
|
194
|
+
)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
finally:
|
|
198
|
+
writer.close()
|
|
199
|
+
|
|
200
|
+
async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
|
|
201
|
+
try:
|
|
202
|
+
while True:
|
|
203
|
+
data = await src.read(8192)
|
|
204
|
+
if not data:
|
|
205
|
+
break
|
|
206
|
+
dst.write(data)
|
|
207
|
+
await dst.drain()
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
finally:
|
|
211
|
+
try:
|
|
212
|
+
dst.close()
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
server = await asyncio.start_server(_handle_client, "127.0.0.1", legacy_port)
|
|
218
|
+
logger.info("Legacy redirect: port %d → %d (deprecated)", legacy_port, primary_port)
|
|
219
|
+
await server.serve_forever()
|
|
220
|
+
except OSError:
|
|
221
|
+
logger.info("Port %d in use (old daemon?), skipping legacy redirect", legacy_port)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Lifespan
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
@asynccontextmanager
|
|
229
|
+
async def lifespan(application: FastAPI):
|
|
230
|
+
"""Initialize engine, workers, and optional services on startup."""
|
|
231
|
+
global _last_activity
|
|
232
|
+
|
|
233
|
+
engine = None
|
|
234
|
+
config = None
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
from superlocalmemory.core.config import SLMConfig
|
|
238
|
+
from superlocalmemory.core.engine import MemoryEngine
|
|
239
|
+
|
|
240
|
+
config = SLMConfig.load()
|
|
241
|
+
engine = MemoryEngine(config)
|
|
242
|
+
engine.initialize()
|
|
243
|
+
|
|
244
|
+
# Enforce WAL mode for concurrent reads
|
|
245
|
+
db = getattr(engine, '_db', None) or getattr(engine, '_storage', None)
|
|
246
|
+
if db and hasattr(db, 'execute'):
|
|
247
|
+
try:
|
|
248
|
+
db.execute("PRAGMA journal_mode=WAL")
|
|
249
|
+
db.execute("PRAGMA synchronous=NORMAL")
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
application.state.engine = engine
|
|
254
|
+
application.state.config = config
|
|
255
|
+
logger.info("Unified daemon: MemoryEngine initialized (mode=%s)", config.mode.value)
|
|
256
|
+
|
|
257
|
+
# Set up observe buffer
|
|
258
|
+
_observe_buffer.set_engine(engine)
|
|
259
|
+
|
|
260
|
+
# Pre-warm workers (background)
|
|
261
|
+
from superlocalmemory.core.worker_pool import WorkerPool
|
|
262
|
+
WorkerPool.shared().warmup()
|
|
263
|
+
|
|
264
|
+
# Force reranker warmup
|
|
265
|
+
retrieval_eng = getattr(engine, '_retrieval_engine', None)
|
|
266
|
+
if retrieval_eng:
|
|
267
|
+
reranker = getattr(retrieval_eng, '_reranker', None)
|
|
268
|
+
if reranker and hasattr(reranker, 'warmup_sync'):
|
|
269
|
+
reranker.warmup_sync(timeout=120)
|
|
270
|
+
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
logger.warning("Engine init failed: %s", exc)
|
|
273
|
+
application.state.engine = None
|
|
274
|
+
application.state.config = None
|
|
275
|
+
|
|
276
|
+
application.state.observe_buffer = _observe_buffer
|
|
277
|
+
|
|
278
|
+
# Phase B: Start health monitor
|
|
279
|
+
try:
|
|
280
|
+
from superlocalmemory.core.health_monitor import HealthMonitor
|
|
281
|
+
health_config = getattr(config, 'health', None)
|
|
282
|
+
monitor = HealthMonitor(
|
|
283
|
+
global_rss_budget_mb=getattr(health_config, 'global_rss_budget_mb', 4096) if health_config else 4096,
|
|
284
|
+
heartbeat_timeout_sec=getattr(health_config, 'heartbeat_timeout_sec', 60) if health_config else 60,
|
|
285
|
+
check_interval_sec=getattr(health_config, 'health_check_interval_sec', 30) if health_config else 30,
|
|
286
|
+
enable_structured_logging=getattr(health_config, 'enable_structured_logging', True) if health_config else True,
|
|
287
|
+
)
|
|
288
|
+
monitor.start()
|
|
289
|
+
application.state.health_monitor = monitor
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
logger.debug("Health monitor init: %s", exc)
|
|
292
|
+
application.state.health_monitor = None
|
|
293
|
+
|
|
294
|
+
# Phase C: Start mesh broker
|
|
295
|
+
try:
|
|
296
|
+
mesh_enabled = getattr(config, 'mesh_enabled', True) if config else True
|
|
297
|
+
if mesh_enabled:
|
|
298
|
+
from superlocalmemory.mesh.broker import MeshBroker
|
|
299
|
+
db_path = config.db_path if config else Path.home() / ".superlocalmemory" / "memory.db"
|
|
300
|
+
mesh_broker = MeshBroker(str(db_path))
|
|
301
|
+
mesh_broker.start_cleanup()
|
|
302
|
+
application.state.mesh_broker = mesh_broker
|
|
303
|
+
logger.info("Mesh broker started")
|
|
304
|
+
else:
|
|
305
|
+
application.state.mesh_broker = None
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
logger.debug("Mesh broker init: %s", exc)
|
|
308
|
+
application.state.mesh_broker = None
|
|
309
|
+
|
|
310
|
+
# Start idle watchdog if configured
|
|
311
|
+
idle_timeout = int(os.environ.get("SLM_DAEMON_IDLE_TIMEOUT", "0"))
|
|
312
|
+
if config and hasattr(config, 'daemon_idle_timeout'):
|
|
313
|
+
idle_timeout = idle_timeout or config.daemon_idle_timeout
|
|
314
|
+
_start_idle_watchdog(idle_timeout)
|
|
315
|
+
|
|
316
|
+
# Start legacy port redirect
|
|
317
|
+
enable_legacy = os.environ.get("SLM_DISABLE_LEGACY_PORT", "").lower() not in ("1", "true")
|
|
318
|
+
if enable_legacy:
|
|
319
|
+
asyncio.create_task(_start_legacy_redirect(_DEFAULT_PORT, _LEGACY_PORT))
|
|
320
|
+
|
|
321
|
+
_last_activity = time.monotonic()
|
|
322
|
+
logger.info("Unified daemon ready on port %d (24/7 mode)" if idle_timeout <= 0
|
|
323
|
+
else "Unified daemon ready on port %d (idle timeout: %ds)",
|
|
324
|
+
_DEFAULT_PORT, idle_timeout)
|
|
325
|
+
|
|
326
|
+
yield
|
|
327
|
+
|
|
328
|
+
# Shutdown
|
|
329
|
+
_observe_buffer.flush_sync()
|
|
330
|
+
if engine is not None:
|
|
331
|
+
try:
|
|
332
|
+
engine.close()
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
_PID_FILE.unlink(missing_ok=True)
|
|
336
|
+
_PORT_FILE.unlink(missing_ok=True)
|
|
337
|
+
logger.info("Unified daemon shutdown complete")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# App factory
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
def create_app() -> FastAPI:
|
|
345
|
+
"""Create the unified FastAPI application."""
|
|
346
|
+
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
347
|
+
|
|
348
|
+
application = FastAPI(
|
|
349
|
+
title="SuperLocalMemory V3 — Unified Daemon",
|
|
350
|
+
description="Memory + Dashboard + Mesh — one process, one engine.",
|
|
351
|
+
version=SLM_VERSION,
|
|
352
|
+
lifespan=lifespan,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# -- Middleware --
|
|
356
|
+
from superlocalmemory.server.security_middleware import SecurityHeadersMiddleware
|
|
357
|
+
application.add_middleware(SecurityHeadersMiddleware)
|
|
358
|
+
application.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
359
|
+
application.add_middleware(
|
|
360
|
+
CORSMiddleware,
|
|
361
|
+
allow_origins=[
|
|
362
|
+
"http://localhost:8765", "http://127.0.0.1:8765",
|
|
363
|
+
"http://localhost:8767", "http://127.0.0.1:8767", # legacy compat
|
|
364
|
+
"http://localhost:8417", "http://127.0.0.1:8417",
|
|
365
|
+
],
|
|
366
|
+
allow_credentials=True,
|
|
367
|
+
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
|
368
|
+
allow_headers=["Content-Type", "Authorization", "X-SLM-API-Key"],
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# -- Register all dashboard routes (from existing api.py) --
|
|
372
|
+
_register_dashboard_routes(application)
|
|
373
|
+
|
|
374
|
+
# -- Mesh routes (Phase C) --
|
|
375
|
+
try:
|
|
376
|
+
from superlocalmemory.server.routes.mesh import router as mesh_router
|
|
377
|
+
application.include_router(mesh_router)
|
|
378
|
+
except ImportError:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
# -- Entity routes (Phase D) --
|
|
382
|
+
try:
|
|
383
|
+
from superlocalmemory.server.routes.entity import router as entity_router
|
|
384
|
+
application.include_router(entity_router)
|
|
385
|
+
except ImportError:
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
# -- Ingestion route (Phase E) --
|
|
389
|
+
try:
|
|
390
|
+
from superlocalmemory.server.routes.ingest import router as ingest_router
|
|
391
|
+
application.include_router(ingest_router)
|
|
392
|
+
except ImportError:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
# -- Daemon-specific routes --
|
|
396
|
+
_register_daemon_routes(application)
|
|
397
|
+
|
|
398
|
+
return application
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _register_dashboard_routes(application: FastAPI) -> None:
|
|
402
|
+
"""Mount all existing dashboard routes from server/routes/*.
|
|
403
|
+
|
|
404
|
+
Extracted from api.py's create_app() to avoid duplicate MemoryEngine.
|
|
405
|
+
"""
|
|
406
|
+
from superlocalmemory.server.api import UI_DIR
|
|
407
|
+
|
|
408
|
+
# Rate limiting (graceful)
|
|
409
|
+
try:
|
|
410
|
+
from superlocalmemory.infra.rate_limiter import RateLimiter
|
|
411
|
+
_write_limiter = RateLimiter(max_requests=30, window_seconds=60)
|
|
412
|
+
_read_limiter = RateLimiter(max_requests=120, window_seconds=60)
|
|
413
|
+
|
|
414
|
+
@application.middleware("http")
|
|
415
|
+
async def rate_limit_middleware(request, call_next):
|
|
416
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
417
|
+
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
418
|
+
limiter = _write_limiter if is_write else _read_limiter
|
|
419
|
+
allowed, remaining = limiter.is_allowed(client_ip)
|
|
420
|
+
if not allowed:
|
|
421
|
+
from fastapi.responses import JSONResponse
|
|
422
|
+
return JSONResponse(
|
|
423
|
+
status_code=429,
|
|
424
|
+
content={"error": "Too many requests."},
|
|
425
|
+
headers={"Retry-After": str(limiter.window_seconds)},
|
|
426
|
+
)
|
|
427
|
+
response = await call_next(request)
|
|
428
|
+
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
|
429
|
+
return response
|
|
430
|
+
except (ImportError, Exception):
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
# Auth middleware (graceful)
|
|
434
|
+
try:
|
|
435
|
+
from superlocalmemory.infra.auth_middleware import check_api_key
|
|
436
|
+
|
|
437
|
+
@application.middleware("http")
|
|
438
|
+
async def auth_middleware(request, call_next):
|
|
439
|
+
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
440
|
+
headers = dict(request.headers)
|
|
441
|
+
if not check_api_key(headers, is_write=is_write):
|
|
442
|
+
from fastapi.responses import JSONResponse
|
|
443
|
+
return JSONResponse(
|
|
444
|
+
status_code=401,
|
|
445
|
+
content={"error": "Invalid or missing API key."},
|
|
446
|
+
)
|
|
447
|
+
return await call_next(request)
|
|
448
|
+
except (ImportError, Exception):
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
# Static files
|
|
452
|
+
from fastapi.staticfiles import StaticFiles
|
|
453
|
+
UI_DIR.mkdir(exist_ok=True)
|
|
454
|
+
application.mount("/static", StaticFiles(directory=str(UI_DIR)), name="static")
|
|
455
|
+
|
|
456
|
+
# Route modules
|
|
457
|
+
from superlocalmemory.server.routes.memories import router as memories_router
|
|
458
|
+
from superlocalmemory.server.routes.stats import router as stats_router
|
|
459
|
+
from superlocalmemory.server.routes.profiles import router as profiles_router
|
|
460
|
+
from superlocalmemory.server.routes.backup import router as backup_router
|
|
461
|
+
from superlocalmemory.server.routes.data_io import router as data_io_router
|
|
462
|
+
from superlocalmemory.server.routes.events import (
|
|
463
|
+
router as events_router, register_event_listener,
|
|
464
|
+
)
|
|
465
|
+
from superlocalmemory.server.routes.agents import router as agents_router
|
|
466
|
+
from superlocalmemory.server.routes.ws import router as ws_router, manager as ws_manager
|
|
467
|
+
from superlocalmemory.server.routes.v3_api import router as v3_router
|
|
468
|
+
from superlocalmemory.server.routes.adapters import router as adapters_router
|
|
469
|
+
|
|
470
|
+
application.include_router(memories_router)
|
|
471
|
+
application.include_router(stats_router)
|
|
472
|
+
application.include_router(profiles_router)
|
|
473
|
+
application.include_router(backup_router)
|
|
474
|
+
application.include_router(data_io_router)
|
|
475
|
+
application.include_router(events_router)
|
|
476
|
+
application.include_router(agents_router)
|
|
477
|
+
application.include_router(ws_router)
|
|
478
|
+
application.include_router(v3_router)
|
|
479
|
+
application.include_router(adapters_router)
|
|
480
|
+
|
|
481
|
+
# v3.4.1 chat SSE
|
|
482
|
+
for _mod_name in ("chat",):
|
|
483
|
+
try:
|
|
484
|
+
_mod = __import__(
|
|
485
|
+
f"superlocalmemory.server.routes.{_mod_name}", fromlist=["router"],
|
|
486
|
+
)
|
|
487
|
+
application.include_router(_mod.router)
|
|
488
|
+
except (ImportError, Exception):
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
# Optional routers
|
|
492
|
+
for _mod_name in ("learning", "lifecycle", "behavioral", "compliance", "insights", "timeline"):
|
|
493
|
+
try:
|
|
494
|
+
_mod = __import__(
|
|
495
|
+
f"superlocalmemory.server.routes.{_mod_name}", fromlist=["router"],
|
|
496
|
+
)
|
|
497
|
+
application.include_router(_mod.router)
|
|
498
|
+
except (ImportError, Exception):
|
|
499
|
+
pass
|
|
500
|
+
|
|
501
|
+
# Wire WebSocket manager
|
|
502
|
+
import superlocalmemory.server.routes.profiles as _profiles_mod
|
|
503
|
+
import superlocalmemory.server.routes.data_io as _data_io_mod
|
|
504
|
+
_profiles_mod.ws_manager = ws_manager
|
|
505
|
+
_data_io_mod.ws_manager = ws_manager
|
|
506
|
+
|
|
507
|
+
# Root page
|
|
508
|
+
from fastapi.responses import HTMLResponse
|
|
509
|
+
|
|
510
|
+
@application.get("/", response_class=HTMLResponse)
|
|
511
|
+
async def root():
|
|
512
|
+
index_path = UI_DIR / "index.html"
|
|
513
|
+
if not index_path.exists():
|
|
514
|
+
return (
|
|
515
|
+
"<html><head><title>SuperLocalMemory V3</title></head>"
|
|
516
|
+
"<body style='font-family:Arial;padding:40px'>"
|
|
517
|
+
"<h1>SuperLocalMemory V3 — Unified Daemon</h1>"
|
|
518
|
+
"<p><a href='/docs'>API Documentation</a></p>"
|
|
519
|
+
"</body></html>"
|
|
520
|
+
)
|
|
521
|
+
return index_path.read_text()
|
|
522
|
+
|
|
523
|
+
# Startup event for event listener
|
|
524
|
+
@application.on_event("startup")
|
|
525
|
+
async def startup_event():
|
|
526
|
+
register_event_listener()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _register_daemon_routes(application: FastAPI) -> None:
|
|
530
|
+
"""Add daemon-specific routes for CLI integration."""
|
|
531
|
+
global _last_activity
|
|
532
|
+
|
|
533
|
+
@application.get("/health")
|
|
534
|
+
async def health():
|
|
535
|
+
_update_activity()
|
|
536
|
+
engine = application.state.engine
|
|
537
|
+
return {
|
|
538
|
+
"status": "ok",
|
|
539
|
+
"pid": os.getpid(),
|
|
540
|
+
"engine": "initialized" if engine else "unavailable",
|
|
541
|
+
"version": getattr(application, 'version', 'unknown'),
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
@application.get("/recall")
|
|
545
|
+
async def recall(q: str = "", limit: int = 20):
|
|
546
|
+
_update_activity()
|
|
547
|
+
engine = application.state.engine
|
|
548
|
+
if engine is None:
|
|
549
|
+
raise HTTPException(503, detail="Engine not initialized")
|
|
550
|
+
try:
|
|
551
|
+
response = engine.recall(q, limit=limit)
|
|
552
|
+
results = [
|
|
553
|
+
{
|
|
554
|
+
"content": r.fact.content,
|
|
555
|
+
"score": round(r.score, 4),
|
|
556
|
+
"fact_type": getattr(r.fact.fact_type, 'value', str(r.fact.fact_type)),
|
|
557
|
+
"fact_id": r.fact.fact_id,
|
|
558
|
+
}
|
|
559
|
+
for r in response.results
|
|
560
|
+
]
|
|
561
|
+
return {
|
|
562
|
+
"results": results,
|
|
563
|
+
"count": len(results),
|
|
564
|
+
"query_type": response.query_type,
|
|
565
|
+
"retrieval_time_ms": round(response.retrieval_time_ms, 1),
|
|
566
|
+
}
|
|
567
|
+
except Exception as exc:
|
|
568
|
+
raise HTTPException(500, detail=str(exc))
|
|
569
|
+
|
|
570
|
+
@application.post("/remember")
|
|
571
|
+
async def remember(req: RememberRequest):
|
|
572
|
+
_update_activity()
|
|
573
|
+
engine = application.state.engine
|
|
574
|
+
if engine is None:
|
|
575
|
+
raise HTTPException(503, detail="Engine not initialized")
|
|
576
|
+
try:
|
|
577
|
+
metadata = {"tags": req.tags} if req.tags else {}
|
|
578
|
+
fact_ids = engine.store(req.content, metadata=metadata)
|
|
579
|
+
return {"fact_ids": fact_ids, "count": len(fact_ids)}
|
|
580
|
+
except Exception as exc:
|
|
581
|
+
raise HTTPException(500, detail=str(exc))
|
|
582
|
+
|
|
583
|
+
@application.post("/observe")
|
|
584
|
+
async def observe(req: ObserveRequest):
|
|
585
|
+
_update_activity()
|
|
586
|
+
result = _observe_buffer.enqueue(req.content)
|
|
587
|
+
return result
|
|
588
|
+
|
|
589
|
+
@application.get("/status")
|
|
590
|
+
async def status():
|
|
591
|
+
_update_activity()
|
|
592
|
+
engine = application.state.engine
|
|
593
|
+
uptime = time.monotonic() - _last_activity
|
|
594
|
+
fact_count = engine.fact_count if engine else 0
|
|
595
|
+
mode = engine._config.mode.value if engine and hasattr(engine, '_config') else "unknown"
|
|
596
|
+
return {
|
|
597
|
+
"status": "running",
|
|
598
|
+
"pid": os.getpid(),
|
|
599
|
+
"uptime_s": round(time.monotonic() - (_start_time or time.monotonic())),
|
|
600
|
+
"mode": mode,
|
|
601
|
+
"fact_count": fact_count,
|
|
602
|
+
"idle_s": round(time.monotonic() - _last_activity),
|
|
603
|
+
"port": _DEFAULT_PORT,
|
|
604
|
+
"legacy_port": _LEGACY_PORT,
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
@application.get("/list")
|
|
608
|
+
async def list_facts(limit: int = 50):
|
|
609
|
+
_update_activity()
|
|
610
|
+
engine = application.state.engine
|
|
611
|
+
if engine is None:
|
|
612
|
+
raise HTTPException(503, detail="Engine not initialized")
|
|
613
|
+
try:
|
|
614
|
+
facts = engine.list_facts(limit=limit)
|
|
615
|
+
items = [
|
|
616
|
+
{
|
|
617
|
+
"content": f.content[:100],
|
|
618
|
+
"fact_type": getattr(f.fact_type, 'value', str(f.fact_type)),
|
|
619
|
+
"created_at": (f.created_at or "")[:19],
|
|
620
|
+
"fact_id": f.fact_id,
|
|
621
|
+
}
|
|
622
|
+
for f in facts
|
|
623
|
+
]
|
|
624
|
+
return {"results": items, "count": len(items)}
|
|
625
|
+
except Exception as exc:
|
|
626
|
+
raise HTTPException(500, detail=str(exc))
|
|
627
|
+
|
|
628
|
+
@application.post("/stop")
|
|
629
|
+
async def stop():
|
|
630
|
+
"""Graceful shutdown via uvicorn's mechanism."""
|
|
631
|
+
logger.info("Stop requested via API")
|
|
632
|
+
_observe_buffer.flush_sync()
|
|
633
|
+
# Signal uvicorn to shut down gracefully
|
|
634
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
635
|
+
return {"status": "stopping"}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _update_activity():
|
|
639
|
+
global _last_activity
|
|
640
|
+
_last_activity = time.monotonic()
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
_start_time: float | None = None
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# ---------------------------------------------------------------------------
|
|
647
|
+
# Server entry point
|
|
648
|
+
# ---------------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
651
|
+
"""Start the unified daemon. Blocks until stopped."""
|
|
652
|
+
global _start_time
|
|
653
|
+
import uvicorn
|
|
654
|
+
|
|
655
|
+
_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
656
|
+
_PID_FILE.write_text(str(os.getpid()))
|
|
657
|
+
_PORT_FILE.write_text(str(port))
|
|
658
|
+
_start_time = time.monotonic()
|
|
659
|
+
|
|
660
|
+
log_dir = Path.home() / ".superlocalmemory" / "logs"
|
|
661
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
662
|
+
|
|
663
|
+
config = uvicorn.Config(
|
|
664
|
+
app="superlocalmemory.server.unified_daemon:create_app",
|
|
665
|
+
factory=True,
|
|
666
|
+
host="127.0.0.1",
|
|
667
|
+
port=port,
|
|
668
|
+
log_level="warning",
|
|
669
|
+
timeout_graceful_shutdown=10,
|
|
670
|
+
)
|
|
671
|
+
server = uvicorn.Server(config)
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
server.run()
|
|
675
|
+
finally:
|
|
676
|
+
_PID_FILE.unlink(missing_ok=True)
|
|
677
|
+
_PORT_FILE.unlink(missing_ok=True)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# ---------------------------------------------------------------------------
|
|
681
|
+
# CLI entry point
|
|
682
|
+
# ---------------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
if __name__ == "__main__":
|
|
685
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
|
686
|
+
port = _DEFAULT_PORT
|
|
687
|
+
for arg in sys.argv:
|
|
688
|
+
if arg.startswith("--port="):
|
|
689
|
+
port = int(arg.split("=")[1])
|
|
690
|
+
if "--start" in sys.argv:
|
|
691
|
+
start_server(port=port)
|
|
692
|
+
else:
|
|
693
|
+
print("Usage: python -m superlocalmemory.server.unified_daemon --start [--port=8765]")
|