superlocalmemory 3.4.1 → 3.4.3

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 (37) hide show
  1. package/package.json +1 -1
  2. package/pyproject.toml +11 -2
  3. package/scripts/postinstall.js +26 -7
  4. package/src/superlocalmemory/cli/commands.py +42 -60
  5. package/src/superlocalmemory/cli/daemon.py +107 -47
  6. package/src/superlocalmemory/cli/main.py +10 -0
  7. package/src/superlocalmemory/cli/setup_wizard.py +137 -9
  8. package/src/superlocalmemory/core/config.py +28 -0
  9. package/src/superlocalmemory/core/consolidation_engine.py +38 -1
  10. package/src/superlocalmemory/core/engine.py +9 -0
  11. package/src/superlocalmemory/core/health_monitor.py +313 -0
  12. package/src/superlocalmemory/core/reranker_worker.py +19 -5
  13. package/src/superlocalmemory/ingestion/__init__.py +13 -0
  14. package/src/superlocalmemory/ingestion/adapter_manager.py +234 -0
  15. package/src/superlocalmemory/ingestion/base_adapter.py +177 -0
  16. package/src/superlocalmemory/ingestion/calendar_adapter.py +340 -0
  17. package/src/superlocalmemory/ingestion/credentials.py +118 -0
  18. package/src/superlocalmemory/ingestion/gmail_adapter.py +369 -0
  19. package/src/superlocalmemory/ingestion/parsers.py +100 -0
  20. package/src/superlocalmemory/ingestion/transcript_adapter.py +156 -0
  21. package/src/superlocalmemory/learning/consolidation_worker.py +47 -1
  22. package/src/superlocalmemory/learning/entity_compiler.py +377 -0
  23. package/src/superlocalmemory/mesh/__init__.py +12 -0
  24. package/src/superlocalmemory/mesh/broker.py +344 -0
  25. package/src/superlocalmemory/retrieval/entity_channel.py +12 -6
  26. package/src/superlocalmemory/server/api.py +6 -7
  27. package/src/superlocalmemory/server/routes/entity.py +95 -0
  28. package/src/superlocalmemory/server/routes/ingest.py +110 -0
  29. package/src/superlocalmemory/server/routes/mesh.py +186 -0
  30. package/src/superlocalmemory/server/unified_daemon.py +691 -0
  31. package/src/superlocalmemory/storage/schema_v343.py +229 -0
  32. package/src/superlocalmemory.egg-info/PKG-INFO +0 -597
  33. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -287
  34. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  35. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  36. package/src/superlocalmemory.egg-info/requires.txt +0 -47
  37. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,691 @@
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
+
469
+ application.include_router(memories_router)
470
+ application.include_router(stats_router)
471
+ application.include_router(profiles_router)
472
+ application.include_router(backup_router)
473
+ application.include_router(data_io_router)
474
+ application.include_router(events_router)
475
+ application.include_router(agents_router)
476
+ application.include_router(ws_router)
477
+ application.include_router(v3_router)
478
+
479
+ # v3.4.1 chat SSE
480
+ for _mod_name in ("chat",):
481
+ try:
482
+ _mod = __import__(
483
+ f"superlocalmemory.server.routes.{_mod_name}", fromlist=["router"],
484
+ )
485
+ application.include_router(_mod.router)
486
+ except (ImportError, Exception):
487
+ pass
488
+
489
+ # Optional routers
490
+ for _mod_name in ("learning", "lifecycle", "behavioral", "compliance", "insights", "timeline"):
491
+ try:
492
+ _mod = __import__(
493
+ f"superlocalmemory.server.routes.{_mod_name}", fromlist=["router"],
494
+ )
495
+ application.include_router(_mod.router)
496
+ except (ImportError, Exception):
497
+ pass
498
+
499
+ # Wire WebSocket manager
500
+ import superlocalmemory.server.routes.profiles as _profiles_mod
501
+ import superlocalmemory.server.routes.data_io as _data_io_mod
502
+ _profiles_mod.ws_manager = ws_manager
503
+ _data_io_mod.ws_manager = ws_manager
504
+
505
+ # Root page
506
+ from fastapi.responses import HTMLResponse
507
+
508
+ @application.get("/", response_class=HTMLResponse)
509
+ async def root():
510
+ index_path = UI_DIR / "index.html"
511
+ if not index_path.exists():
512
+ return (
513
+ "<html><head><title>SuperLocalMemory V3</title></head>"
514
+ "<body style='font-family:Arial;padding:40px'>"
515
+ "<h1>SuperLocalMemory V3 — Unified Daemon</h1>"
516
+ "<p><a href='/docs'>API Documentation</a></p>"
517
+ "</body></html>"
518
+ )
519
+ return index_path.read_text()
520
+
521
+ # Startup event for event listener
522
+ @application.on_event("startup")
523
+ async def startup_event():
524
+ register_event_listener()
525
+
526
+
527
+ def _register_daemon_routes(application: FastAPI) -> None:
528
+ """Add daemon-specific routes for CLI integration."""
529
+ global _last_activity
530
+
531
+ @application.get("/health")
532
+ async def health():
533
+ _update_activity()
534
+ engine = application.state.engine
535
+ return {
536
+ "status": "ok",
537
+ "pid": os.getpid(),
538
+ "engine": "initialized" if engine else "unavailable",
539
+ "version": getattr(application, 'version', 'unknown'),
540
+ }
541
+
542
+ @application.get("/recall")
543
+ async def recall(q: str = "", limit: int = 20):
544
+ _update_activity()
545
+ engine = application.state.engine
546
+ if engine is None:
547
+ raise HTTPException(503, detail="Engine not initialized")
548
+ try:
549
+ response = engine.recall(q, limit=limit)
550
+ results = [
551
+ {
552
+ "content": r.fact.content,
553
+ "score": round(r.score, 4),
554
+ "fact_type": getattr(r.fact.fact_type, 'value', str(r.fact.fact_type)),
555
+ "fact_id": r.fact.fact_id,
556
+ }
557
+ for r in response.results
558
+ ]
559
+ return {
560
+ "results": results,
561
+ "count": len(results),
562
+ "query_type": response.query_type,
563
+ "retrieval_time_ms": round(response.retrieval_time_ms, 1),
564
+ }
565
+ except Exception as exc:
566
+ raise HTTPException(500, detail=str(exc))
567
+
568
+ @application.post("/remember")
569
+ async def remember(req: RememberRequest):
570
+ _update_activity()
571
+ engine = application.state.engine
572
+ if engine is None:
573
+ raise HTTPException(503, detail="Engine not initialized")
574
+ try:
575
+ metadata = {"tags": req.tags} if req.tags else {}
576
+ fact_ids = engine.store(req.content, metadata=metadata)
577
+ return {"fact_ids": fact_ids, "count": len(fact_ids)}
578
+ except Exception as exc:
579
+ raise HTTPException(500, detail=str(exc))
580
+
581
+ @application.post("/observe")
582
+ async def observe(req: ObserveRequest):
583
+ _update_activity()
584
+ result = _observe_buffer.enqueue(req.content)
585
+ return result
586
+
587
+ @application.get("/status")
588
+ async def status():
589
+ _update_activity()
590
+ engine = application.state.engine
591
+ uptime = time.monotonic() - _last_activity
592
+ fact_count = engine.fact_count if engine else 0
593
+ mode = engine._config.mode.value if engine and hasattr(engine, '_config') else "unknown"
594
+ return {
595
+ "status": "running",
596
+ "pid": os.getpid(),
597
+ "uptime_s": round(time.monotonic() - (_start_time or time.monotonic())),
598
+ "mode": mode,
599
+ "fact_count": fact_count,
600
+ "idle_s": round(time.monotonic() - _last_activity),
601
+ "port": _DEFAULT_PORT,
602
+ "legacy_port": _LEGACY_PORT,
603
+ }
604
+
605
+ @application.get("/list")
606
+ async def list_facts(limit: int = 50):
607
+ _update_activity()
608
+ engine = application.state.engine
609
+ if engine is None:
610
+ raise HTTPException(503, detail="Engine not initialized")
611
+ try:
612
+ facts = engine.list_facts(limit=limit)
613
+ items = [
614
+ {
615
+ "content": f.content[:100],
616
+ "fact_type": getattr(f.fact_type, 'value', str(f.fact_type)),
617
+ "created_at": (f.created_at or "")[:19],
618
+ "fact_id": f.fact_id,
619
+ }
620
+ for f in facts
621
+ ]
622
+ return {"results": items, "count": len(items)}
623
+ except Exception as exc:
624
+ raise HTTPException(500, detail=str(exc))
625
+
626
+ @application.post("/stop")
627
+ async def stop():
628
+ """Graceful shutdown via uvicorn's mechanism."""
629
+ logger.info("Stop requested via API")
630
+ _observe_buffer.flush_sync()
631
+ # Signal uvicorn to shut down gracefully
632
+ os.kill(os.getpid(), signal.SIGTERM)
633
+ return {"status": "stopping"}
634
+
635
+
636
+ def _update_activity():
637
+ global _last_activity
638
+ _last_activity = time.monotonic()
639
+
640
+
641
+ _start_time: float | None = None
642
+
643
+
644
+ # ---------------------------------------------------------------------------
645
+ # Server entry point
646
+ # ---------------------------------------------------------------------------
647
+
648
+ def start_server(port: int = _DEFAULT_PORT) -> None:
649
+ """Start the unified daemon. Blocks until stopped."""
650
+ global _start_time
651
+ import uvicorn
652
+
653
+ _PID_FILE.parent.mkdir(parents=True, exist_ok=True)
654
+ _PID_FILE.write_text(str(os.getpid()))
655
+ _PORT_FILE.write_text(str(port))
656
+ _start_time = time.monotonic()
657
+
658
+ log_dir = Path.home() / ".superlocalmemory" / "logs"
659
+ log_dir.mkdir(parents=True, exist_ok=True)
660
+
661
+ config = uvicorn.Config(
662
+ app="superlocalmemory.server.unified_daemon:create_app",
663
+ factory=True,
664
+ host="127.0.0.1",
665
+ port=port,
666
+ log_level="warning",
667
+ timeout_graceful_shutdown=10,
668
+ )
669
+ server = uvicorn.Server(config)
670
+
671
+ try:
672
+ server.run()
673
+ finally:
674
+ _PID_FILE.unlink(missing_ok=True)
675
+ _PORT_FILE.unlink(missing_ok=True)
676
+
677
+
678
+ # ---------------------------------------------------------------------------
679
+ # CLI entry point
680
+ # ---------------------------------------------------------------------------
681
+
682
+ if __name__ == "__main__":
683
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
684
+ port = _DEFAULT_PORT
685
+ for arg in sys.argv:
686
+ if arg.startswith("--port="):
687
+ port = int(arg.split("=")[1])
688
+ if "--start" in sys.argv:
689
+ start_server(port=port)
690
+ else:
691
+ print("Usage: python -m superlocalmemory.server.unified_daemon --start [--port=8765]")