superlocalmemory 3.3.20 → 3.3.21

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 (78) hide show
  1. package/package.json +1 -1
  2. package/pyproject.toml +9 -1
  3. package/src/superlocalmemory/cli/commands.py +138 -22
  4. package/src/superlocalmemory/cli/daemon.py +372 -0
  5. package/src/superlocalmemory/cli/main.py +8 -0
  6. package/src/superlocalmemory/cli/pending_store.py +158 -0
  7. package/src/superlocalmemory/cli/setup_wizard.py +39 -6
  8. package/src/superlocalmemory/code_graph/__init__.py +46 -0
  9. package/src/superlocalmemory/code_graph/blast_radius.py +177 -0
  10. package/src/superlocalmemory/code_graph/bridge/__init__.py +36 -0
  11. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +464 -0
  12. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +195 -0
  13. package/src/superlocalmemory/code_graph/bridge/fact_enricher.py +159 -0
  14. package/src/superlocalmemory/code_graph/bridge/hebbian_linker.py +170 -0
  15. package/src/superlocalmemory/code_graph/bridge/temporal_checker.py +152 -0
  16. package/src/superlocalmemory/code_graph/changes.py +363 -0
  17. package/src/superlocalmemory/code_graph/communities.py +299 -0
  18. package/src/superlocalmemory/code_graph/config.py +88 -0
  19. package/src/superlocalmemory/code_graph/database.py +482 -0
  20. package/src/superlocalmemory/code_graph/extractors/__init__.py +78 -0
  21. package/src/superlocalmemory/code_graph/extractors/python.py +413 -0
  22. package/src/superlocalmemory/code_graph/extractors/typescript.py +556 -0
  23. package/src/superlocalmemory/code_graph/flows.py +350 -0
  24. package/src/superlocalmemory/code_graph/git_hooks.py +226 -0
  25. package/src/superlocalmemory/code_graph/graph_engine.py +295 -0
  26. package/src/superlocalmemory/code_graph/graph_store.py +158 -0
  27. package/src/superlocalmemory/code_graph/incremental.py +200 -0
  28. package/src/superlocalmemory/code_graph/models.py +130 -0
  29. package/src/superlocalmemory/code_graph/parser.py +507 -0
  30. package/src/superlocalmemory/code_graph/resolver.py +321 -0
  31. package/src/superlocalmemory/code_graph/search.py +460 -0
  32. package/src/superlocalmemory/code_graph/service.py +95 -0
  33. package/src/superlocalmemory/code_graph/watcher.py +207 -0
  34. package/src/superlocalmemory/core/embedding_worker.py +4 -2
  35. package/src/superlocalmemory/core/embeddings.py +8 -2
  36. package/src/superlocalmemory/core/engine.py +32 -0
  37. package/src/superlocalmemory/core/engine_wiring.py +5 -0
  38. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  39. package/src/superlocalmemory/encoding/fact_extractor.py +68 -7
  40. package/src/superlocalmemory/infra/event_bus.py +5 -0
  41. package/src/superlocalmemory/mcp/server.py +23 -0
  42. package/src/superlocalmemory/mcp/tools_code_graph.py +1592 -0
  43. package/src/superlocalmemory/retrieval/engine.py +137 -2
  44. package/src/superlocalmemory/retrieval/semantic_channel.py +6 -2
  45. package/src/superlocalmemory/retrieval/spreading_activation.py +5 -3
  46. package/src/superlocalmemory/retrieval/strategy.py +16 -0
  47. package/src/superlocalmemory/server/api.py +4 -2
  48. package/src/superlocalmemory/server/ui.py +5 -2
  49. package/src/superlocalmemory/storage/schema_code_graph.py +239 -0
  50. package/src/superlocalmemory/ui/index.html +1879 -0
  51. package/src/superlocalmemory/ui/js/agents.js +192 -0
  52. package/src/superlocalmemory/ui/js/auto-settings.js +399 -0
  53. package/src/superlocalmemory/ui/js/behavioral.js +276 -0
  54. package/src/superlocalmemory/ui/js/clusters.js +206 -0
  55. package/src/superlocalmemory/ui/js/compliance.js +252 -0
  56. package/src/superlocalmemory/ui/js/core.js +246 -0
  57. package/src/superlocalmemory/ui/js/dashboard.js +110 -0
  58. package/src/superlocalmemory/ui/js/events.js +178 -0
  59. package/src/superlocalmemory/ui/js/fact-detail.js +92 -0
  60. package/src/superlocalmemory/ui/js/feedback.js +333 -0
  61. package/src/superlocalmemory/ui/js/graph-core.js +447 -0
  62. package/src/superlocalmemory/ui/js/graph-filters.js +220 -0
  63. package/src/superlocalmemory/ui/js/graph-interactions.js +351 -0
  64. package/src/superlocalmemory/ui/js/graph-ui.js +214 -0
  65. package/src/superlocalmemory/ui/js/ide-status.js +102 -0
  66. package/src/superlocalmemory/ui/js/init.js +45 -0
  67. package/src/superlocalmemory/ui/js/learning.js +435 -0
  68. package/src/superlocalmemory/ui/js/lifecycle.js +298 -0
  69. package/src/superlocalmemory/ui/js/math-health.js +98 -0
  70. package/src/superlocalmemory/ui/js/memories.js +264 -0
  71. package/src/superlocalmemory/ui/js/modal.js +357 -0
  72. package/src/superlocalmemory/ui/js/patterns.js +93 -0
  73. package/src/superlocalmemory/ui/js/profiles.js +236 -0
  74. package/src/superlocalmemory/ui/js/recall-lab.js +292 -0
  75. package/src/superlocalmemory/ui/js/search.js +59 -0
  76. package/src/superlocalmemory/ui/js/settings.js +224 -0
  77. package/src/superlocalmemory/ui/js/timeline.js +32 -0
  78. package/src/superlocalmemory/ui/js/trust-dashboard.js +73 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.3.20",
3
+ "version": "3.3.21",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.3.20"
3
+ version = "3.3.21"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -44,6 +44,11 @@ dependencies = [
44
44
  "lightgbm>=4.0.0",
45
45
  "diskcache>=5.6.0",
46
46
  "orjson>=3.9.0",
47
+ # CodeGraph — code knowledge graph (v3.4)
48
+ "tree-sitter>=0.23.0,<1",
49
+ "tree-sitter-language-pack>=0.3,<2",
50
+ "rustworkx>=0.15,<1",
51
+ "watchdog>=4.0,<6",
47
52
  ]
48
53
 
49
54
  [project.optional-dependencies]
@@ -92,6 +97,9 @@ build-backend = "setuptools.build_meta"
92
97
  [tool.setuptools.packages.find]
93
98
  where = ["src"]
94
99
 
100
+ [tool.setuptools.package-data]
101
+ superlocalmemory = ["ui/**/*"]
102
+
95
103
  [tool.pytest.ini_options]
96
104
  testpaths = ["tests"]
97
105
  pythonpath = ["src"]
@@ -56,6 +56,8 @@ def dispatch(args: Namespace) -> None:
56
56
  "consolidate": cmd_consolidate,
57
57
  "soft-prompts": cmd_soft_prompts,
58
58
  "reap": cmd_reap,
59
+ # V3.3.21 daemon
60
+ "serve": cmd_serve,
59
61
  }
60
62
  handler = handlers.get(args.command)
61
63
  if handler:
@@ -65,6 +67,49 @@ def dispatch(args: Namespace) -> None:
65
67
  sys.exit(1)
66
68
 
67
69
 
70
+ # -- Daemon serve mode (V3.3.21) ------------------------------------------
71
+
72
+ def cmd_serve(args: Namespace) -> None:
73
+ """Start/stop the SLM daemon for instant CLI response."""
74
+ from superlocalmemory.cli.daemon import is_daemon_running, ensure_daemon, stop_daemon
75
+
76
+ action = getattr(args, 'action', 'start')
77
+
78
+ if action == 'stop':
79
+ if stop_daemon():
80
+ print("Daemon stopped.")
81
+ else:
82
+ print("Daemon was not running.")
83
+ return
84
+
85
+ if action == 'status':
86
+ if is_daemon_running():
87
+ from superlocalmemory.cli.daemon import daemon_request
88
+ status = daemon_request("GET", "/status")
89
+ if status:
90
+ print(f"Daemon: RUNNING (PID {status['pid']}, "
91
+ f"mode={status['mode']}, facts={status['fact_count']}, "
92
+ f"uptime={status['uptime_s']}s, idle={status['idle_s']}s)")
93
+ else:
94
+ print("Daemon: RUNNING (could not get status)")
95
+ else:
96
+ print("Daemon: NOT RUNNING")
97
+ return
98
+
99
+ # Default: start
100
+ if is_daemon_running():
101
+ print("Daemon already running.")
102
+ return
103
+
104
+ print("Starting SLM daemon (engine warming up)...")
105
+ if ensure_daemon():
106
+ print("Daemon started \u2713 — CLI commands are now instant.")
107
+ print(" slm serve status — check daemon status")
108
+ print(" slm serve stop — stop daemon and free RAM")
109
+ else:
110
+ print("Failed to start daemon. Check ~/.superlocalmemory/logs/daemon.log")
111
+
112
+
68
113
  # -- Setup & Config (no --json — interactive commands) ---------------------
69
114
 
70
115
 
@@ -236,15 +281,26 @@ def cmd_list(args: Namespace) -> None:
236
281
 
237
282
  if not facts:
238
283
  print("No memories stored yet.")
239
- return
284
+ else:
285
+ print(f"Recent memories ({len(facts)}):\n")
286
+ for i, f in enumerate(facts, 1):
287
+ date = (f.created_at or "")[:19]
288
+ ftype_raw = getattr(f, "fact_type", "")
289
+ ftype = ftype_raw.value if hasattr(ftype_raw, "value") else str(ftype_raw)
290
+ content = f.content[:100] + ("..." if len(f.content) > 100 else "")
291
+ print(f" {i:3d}. [{date}] ({ftype}) {content}")
240
292
 
241
- print(f"Recent memories ({len(facts)}):\n")
242
- for i, f in enumerate(facts, 1):
243
- date = (f.created_at or "")[:19]
244
- ftype_raw = getattr(f, "fact_type", "")
245
- ftype = ftype_raw.value if hasattr(ftype_raw, "value") else str(ftype_raw)
246
- content = f.content[:100] + ("..." if len(f.content) > 100 else "")
247
- print(f" {i:3d}. [{date}] ({ftype}) {content}")
293
+ # V3.3.21: Show pending memories (store-first pattern)
294
+ try:
295
+ from superlocalmemory.cli.pending_store import get_pending
296
+ pending = get_pending(limit=10)
297
+ if pending:
298
+ print(f"\nPending (processing in background): {len(pending)}")
299
+ for p in pending:
300
+ content = p["content"][:80] + ("..." if len(p["content"]) > 80 else "")
301
+ print(f" \u23f3 [{p['created_at'][:19]}] {content}")
302
+ except Exception:
303
+ pass
248
304
 
249
305
 
250
306
  def cmd_remember(args: Namespace) -> None:
@@ -254,25 +310,56 @@ def cmd_remember(args: Namespace) -> None:
254
310
  use_json = getattr(args, 'json', False)
255
311
  sync_mode = getattr(args, 'sync_mode', False)
256
312
 
257
- # V3.3.19: Async by default return instantly, process in background.
258
- # Use --sync to wait for completion (e.g., when you need fact_ids back).
313
+ # V3.3.21: Route through daemon for instant remember (no cold start).
314
+ # If daemon is running, send request directly (~0.1s).
315
+ # If not, use store-first pattern (pending.db) as fallback.
259
316
  if not sync_mode:
317
+ # Try daemon first
318
+ try:
319
+ from superlocalmemory.cli.daemon import is_daemon_running, daemon_request, ensure_daemon
320
+ if is_daemon_running() or ensure_daemon():
321
+ result = daemon_request("POST", "/remember", {
322
+ "content": args.content,
323
+ "tags": args.tags or "",
324
+ })
325
+ if result and "fact_ids" in result:
326
+ if use_json:
327
+ from superlocalmemory.cli.json_output import json_print
328
+ json_print("remember", data=result)
329
+ else:
330
+ print(f"Stored \u2713 {result['count']} facts (via daemon).")
331
+ return
332
+ except Exception:
333
+ pass # Fall through to pending store
334
+
335
+ # Fallback: store-first pattern (Option C — zero data loss)
260
336
  import subprocess
261
- cmd = [sys.executable, "-m", "superlocalmemory.cli.main", "remember", args.content]
337
+ from superlocalmemory.cli.pending_store import store_pending
338
+
339
+ row_id = store_pending(
340
+ content=args.content,
341
+ tags=args.tags or "",
342
+ )
343
+
344
+ cmd = [sys.executable, "-m", "superlocalmemory.cli.main",
345
+ "remember", args.content, "--sync"]
262
346
  if args.tags:
263
347
  cmd.extend(["--tags", args.tags])
264
- if use_json:
265
- cmd.append("--json")
266
- # Spawn detached subprocess — parent exits immediately
267
- subprocess.Popen(
268
- cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
269
- start_new_session=True,
270
- )
348
+ log_dir = __import__("pathlib").Path.home() / ".superlocalmemory" / "logs"
349
+ log_dir.mkdir(parents=True, exist_ok=True)
350
+ log_file = log_dir / "async-remember.log"
351
+ with open(log_file, "a") as lf:
352
+ subprocess.Popen(
353
+ cmd, stdout=subprocess.DEVNULL, stderr=lf,
354
+ start_new_session=True,
355
+ )
356
+
271
357
  if use_json:
272
358
  from superlocalmemory.cli.json_output import json_print
273
- json_print("remember", data={"queued": True, "async": True})
359
+ json_print("remember", data={"queued": True, "async": True,
360
+ "pending_id": row_id, "safe": True})
274
361
  else:
275
- print("Queued for background processing.")
362
+ print(f"Stored \u2713 (pending_id={row_id}) \u2014 processing in background.")
276
363
  return
277
364
 
278
365
  from superlocalmemory.core.engine import MemoryEngine
@@ -304,11 +391,40 @@ def cmd_remember(args: Namespace) -> None:
304
391
 
305
392
 
306
393
  def cmd_recall(args: Namespace) -> None:
307
- """Search memories via the engine."""
394
+ """Search memories via the engine — routes through daemon if available."""
395
+ use_json = getattr(args, 'json', False)
396
+
397
+ # V3.3.21: Route through daemon for instant response (no cold start).
398
+ # Falls back to direct engine if daemon not running.
399
+ try:
400
+ from superlocalmemory.cli.daemon import is_daemon_running, daemon_request, ensure_daemon
401
+ if is_daemon_running() or ensure_daemon():
402
+ from urllib.parse import quote
403
+ result = daemon_request(
404
+ "GET", f"/recall?q={quote(args.query)}&limit={args.limit}",
405
+ )
406
+ if result and "results" in result:
407
+ # Format daemon response same as engine response
408
+ if use_json:
409
+ from superlocalmemory.cli.json_output import json_print
410
+ json_print("recall", data=result, next_actions=[
411
+ {"command": "slm list --json", "description": "List recent memories"},
412
+ ])
413
+ return
414
+ if not result["results"]:
415
+ print("No matching memories found.")
416
+ return
417
+ # Text output
418
+ print(f"SpreadingActivation.search completed via daemon ({result.get('retrieval_time_ms', 0):.0f}ms)")
419
+ for i, r in enumerate(result["results"], 1):
420
+ print(f" {i}. [{r['score']:.2f}] {r['content']}")
421
+ return
422
+ except Exception:
423
+ pass # Fall through to direct engine
424
+
308
425
  from superlocalmemory.core.config import SLMConfig
309
426
  from superlocalmemory.core.engine import MemoryEngine
310
427
 
311
- use_json = getattr(args, 'json', False)
312
428
  try:
313
429
  config = SLMConfig.load()
314
430
  engine = MemoryEngine(config)
@@ -0,0 +1,372 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SLM Daemon — keeps engine warm for instant CLI/MCP response.
6
+
7
+ Problem: CLI cold start is 23s (embedding worker spawn + model load).
8
+ Solution: Background daemon keeps MemoryEngine warm. CLI commands route
9
+ requests through the daemon via localhost HTTP (~10ms overhead).
10
+
11
+ Architecture:
12
+ slm serve → starts daemon (engine init, workers warm, ~600MB RAM)
13
+ slm remember X → HTTP POST to daemon → instant (no cold start)
14
+ slm recall X → HTTP GET from daemon → instant
15
+ slm serve stop → graceful shutdown, workers killed, RAM freed
16
+
17
+ Auto-start: if daemon not running on CLI use, starts it automatically.
18
+ Auto-shutdown: after 30 min idle (configurable via SLM_DAEMON_IDLE_TIMEOUT).
19
+
20
+ Memory safety:
21
+ - RSS watchdog on embedding worker (2.5GB cap)
22
+ - Worker recycling every 5000 requests
23
+ - Parent watchdog kills workers if daemon dies
24
+ - SQLite WAL mode for concurrent access
25
+
26
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
27
+ License: MIT
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import logging
34
+ import os
35
+ import signal
36
+ import sys
37
+ import time
38
+ from http.server import HTTPServer, BaseHTTPRequestHandler
39
+ from pathlib import Path
40
+ from threading import Thread
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ _DEFAULT_PORT = 8767
45
+ _DEFAULT_IDLE_TIMEOUT = 1800 # 30 min
46
+ _PID_FILE = Path.home() / ".superlocalmemory" / "daemon.pid"
47
+ _PORT_FILE = Path.home() / ".superlocalmemory" / "daemon.port"
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Client: check if daemon running + send requests
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def is_daemon_running() -> bool:
55
+ """Check if daemon is alive via PID file + HTTP health check."""
56
+ if not _PID_FILE.exists():
57
+ return False
58
+ try:
59
+ pid = int(_PID_FILE.read_text().strip())
60
+ os.kill(pid, 0) # Check if process exists
61
+ except (ValueError, ProcessLookupError, PermissionError):
62
+ _PID_FILE.unlink(missing_ok=True)
63
+ return False
64
+
65
+ # PID exists — verify HTTP health
66
+ port = _get_port()
67
+ try:
68
+ import urllib.request
69
+ resp = urllib.request.urlopen(
70
+ f"http://127.0.0.1:{port}/health", timeout=2,
71
+ )
72
+ return resp.status == 200
73
+ except Exception:
74
+ return False
75
+
76
+
77
+ def _get_port() -> int:
78
+ if _PORT_FILE.exists():
79
+ try:
80
+ return int(_PORT_FILE.read_text().strip())
81
+ except ValueError:
82
+ pass
83
+ return _DEFAULT_PORT
84
+
85
+
86
+ def daemon_request(method: str, path: str, body: dict | None = None) -> dict | None:
87
+ """Send request to daemon. Returns parsed JSON or None on failure."""
88
+ port = _get_port()
89
+ try:
90
+ import urllib.request
91
+ url = f"http://127.0.0.1:{port}{path}"
92
+ data = json.dumps(body).encode() if body else None
93
+ headers = {"Content-Type": "application/json"} if data else {}
94
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
95
+ resp = urllib.request.urlopen(req, timeout=30)
96
+ return json.loads(resp.read().decode())
97
+ except Exception:
98
+ return None
99
+
100
+
101
+ def ensure_daemon() -> bool:
102
+ """Start daemon if not running. Returns True if daemon is ready."""
103
+ if is_daemon_running():
104
+ return True
105
+
106
+ # Start daemon in background
107
+ import subprocess
108
+ cmd = [sys.executable, "-m", "superlocalmemory.cli.daemon", "--start"]
109
+ log_dir = Path.home() / ".superlocalmemory" / "logs"
110
+ log_dir.mkdir(parents=True, exist_ok=True)
111
+ log_file = log_dir / "daemon.log"
112
+
113
+ with open(log_file, "a") as lf:
114
+ subprocess.Popen(
115
+ cmd, stdout=lf, stderr=lf,
116
+ start_new_session=True,
117
+ )
118
+
119
+ # Wait for daemon to become ready (max 30s for cold start)
120
+ for _ in range(60):
121
+ time.sleep(0.5)
122
+ if is_daemon_running():
123
+ return True
124
+
125
+ return False
126
+
127
+
128
+ def stop_daemon() -> bool:
129
+ """Stop the running daemon gracefully."""
130
+ if not _PID_FILE.exists():
131
+ return True
132
+ try:
133
+ pid = int(_PID_FILE.read_text().strip())
134
+ os.kill(pid, signal.SIGTERM)
135
+ # Wait for cleanup
136
+ for _ in range(20):
137
+ time.sleep(0.5)
138
+ try:
139
+ os.kill(pid, 0)
140
+ except ProcessLookupError:
141
+ break
142
+ _PID_FILE.unlink(missing_ok=True)
143
+ _PORT_FILE.unlink(missing_ok=True)
144
+ return True
145
+ except Exception:
146
+ return False
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Server: HTTP request handler with engine singleton
151
+ # ---------------------------------------------------------------------------
152
+
153
+ _engine = None
154
+ _last_activity = time.monotonic()
155
+
156
+
157
+ def _get_engine():
158
+ global _engine
159
+ if _engine is None:
160
+ from superlocalmemory.core.config import SLMConfig
161
+ from superlocalmemory.core.engine import MemoryEngine
162
+
163
+ config = SLMConfig.load()
164
+ _engine = MemoryEngine(config)
165
+ _engine.initialize()
166
+
167
+ # Force reranker warmup (blocking — daemon can afford to wait)
168
+ retrieval_eng = getattr(_engine, '_retrieval_engine', None)
169
+ if retrieval_eng:
170
+ reranker = getattr(retrieval_eng, '_reranker', None)
171
+ if reranker and hasattr(reranker, 'warmup_sync'):
172
+ reranker.warmup_sync(timeout=120)
173
+
174
+ logger.info("Daemon engine initialized and warm")
175
+ return _engine
176
+
177
+
178
+ class DaemonHandler(BaseHTTPRequestHandler):
179
+ """Lightweight HTTP handler for daemon requests."""
180
+
181
+ def log_message(self, format, *args):
182
+ """Suppress default access logging."""
183
+ pass
184
+
185
+ def _send_json(self, status: int, data: dict) -> None:
186
+ self.send_response(status)
187
+ self.send_header("Content-Type", "application/json")
188
+ self.end_headers()
189
+ self.wfile.write(json.dumps(data).encode())
190
+
191
+ def _read_body(self) -> dict:
192
+ length = int(self.headers.get("Content-Length", 0))
193
+ if length == 0:
194
+ return {}
195
+ return json.loads(self.rfile.read(length).decode())
196
+
197
+ def do_GET(self) -> None:
198
+ global _last_activity
199
+ _last_activity = time.monotonic()
200
+
201
+ if self.path == "/health":
202
+ self._send_json(200, {"status": "ok", "pid": os.getpid()})
203
+ return
204
+
205
+ if self.path.startswith("/recall"):
206
+ try:
207
+ # Parse query from URL params
208
+ from urllib.parse import urlparse, parse_qs
209
+ params = parse_qs(urlparse(self.path).query)
210
+ query = params.get("q", [""])[0]
211
+ limit = int(params.get("limit", ["20"])[0])
212
+
213
+ engine = _get_engine()
214
+ response = engine.recall(query, limit=limit)
215
+ results = [
216
+ {"content": r.fact.content, "score": round(r.score, 4),
217
+ "fact_type": getattr(r.fact.fact_type, 'value', str(r.fact.fact_type)),
218
+ "fact_id": r.fact.fact_id}
219
+ for r in response.results
220
+ ]
221
+ self._send_json(200, {
222
+ "results": results, "count": len(results),
223
+ "query_type": response.query_type,
224
+ "retrieval_time_ms": round(response.retrieval_time_ms, 1),
225
+ })
226
+ except Exception as exc:
227
+ self._send_json(500, {"error": str(exc)})
228
+ return
229
+
230
+ if self.path == "/list":
231
+ try:
232
+ engine = _get_engine()
233
+ facts = engine.list_facts(limit=50)
234
+ items = [
235
+ {"content": f.content[:100], "fact_type": getattr(f.fact_type, 'value', str(f.fact_type)),
236
+ "created_at": (f.created_at or "")[:19], "fact_id": f.fact_id}
237
+ for f in facts
238
+ ]
239
+ self._send_json(200, {"results": items, "count": len(items)})
240
+ except Exception as exc:
241
+ self._send_json(500, {"error": str(exc)})
242
+ return
243
+
244
+ if self.path == "/status":
245
+ engine = _get_engine()
246
+ uptime = time.monotonic() - _server_start_time
247
+ self._send_json(200, {
248
+ "status": "running", "pid": os.getpid(),
249
+ "uptime_s": round(uptime),
250
+ "mode": engine._config.mode.value,
251
+ "fact_count": engine.fact_count,
252
+ "idle_s": round(time.monotonic() - _last_activity),
253
+ })
254
+ return
255
+
256
+ self._send_json(404, {"error": "not found"})
257
+
258
+ def do_POST(self) -> None:
259
+ global _last_activity
260
+ _last_activity = time.monotonic()
261
+
262
+ if self.path == "/remember":
263
+ try:
264
+ body = self._read_body()
265
+ content = body.get("content", "")
266
+ tags = body.get("tags", "")
267
+ if not content:
268
+ self._send_json(400, {"error": "content required"})
269
+ return
270
+
271
+ engine = _get_engine()
272
+ metadata = {"tags": tags} if tags else {}
273
+ fact_ids = engine.store(content, metadata=metadata)
274
+ self._send_json(200, {"fact_ids": fact_ids, "count": len(fact_ids)})
275
+ except Exception as exc:
276
+ self._send_json(500, {"error": str(exc)})
277
+ return
278
+
279
+ if self.path == "/stop":
280
+ self._send_json(200, {"status": "stopping"})
281
+ Thread(target=_shutdown_server, daemon=True).start()
282
+ return
283
+
284
+ self._send_json(404, {"error": "not found"})
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Server lifecycle
289
+ # ---------------------------------------------------------------------------
290
+
291
+ _server: HTTPServer | None = None
292
+ _server_start_time = time.monotonic()
293
+
294
+
295
+ def _shutdown_server() -> None:
296
+ global _engine, _server
297
+ time.sleep(0.5)
298
+ if _engine is not None:
299
+ try:
300
+ _engine.close()
301
+ except Exception:
302
+ pass
303
+ _engine = None
304
+ if _server is not None:
305
+ _server.shutdown()
306
+ _PID_FILE.unlink(missing_ok=True)
307
+ _PORT_FILE.unlink(missing_ok=True)
308
+
309
+
310
+ def _idle_watchdog(timeout: int) -> None:
311
+ """Auto-shutdown after idle timeout."""
312
+ global _last_activity
313
+ while True:
314
+ time.sleep(30)
315
+ idle = time.monotonic() - _last_activity
316
+ if idle > timeout:
317
+ logger.info("Daemon idle for %ds, shutting down", int(idle))
318
+ _shutdown_server()
319
+ os._exit(0)
320
+
321
+
322
+ def start_server(port: int = _DEFAULT_PORT, idle_timeout: int | None = None) -> None:
323
+ """Start the daemon HTTP server. Blocks until stopped."""
324
+ global _server, _server_start_time, _last_activity
325
+
326
+ idle_timeout = idle_timeout or int(os.environ.get(
327
+ "SLM_DAEMON_IDLE_TIMEOUT", str(_DEFAULT_IDLE_TIMEOUT),
328
+ ))
329
+
330
+ # Write PID + port files
331
+ _PID_FILE.parent.mkdir(parents=True, exist_ok=True)
332
+ _PID_FILE.write_text(str(os.getpid()))
333
+ _PORT_FILE.write_text(str(port))
334
+
335
+ # Handle SIGTERM for graceful shutdown
336
+ signal.signal(signal.SIGTERM, lambda *_: _shutdown_server() or os._exit(0))
337
+
338
+ # Pre-warm engine (this is the cold start — daemon absorbs it once)
339
+ logger.info("Daemon starting — warming engine...")
340
+ _get_engine()
341
+ logger.info("Engine warm. Daemon ready on port %d (idle timeout: %ds)", port, idle_timeout)
342
+
343
+ _server_start_time = time.monotonic()
344
+ _last_activity = time.monotonic()
345
+
346
+ # Start idle watchdog
347
+ Thread(target=_idle_watchdog, args=(idle_timeout,), daemon=True, name="idle-watchdog").start()
348
+
349
+ # Start HTTP server
350
+ # SO_REUSEADDR must be set on the class BEFORE __init__ calls bind()
351
+ HTTPServer.allow_reuse_address = True
352
+ _server = HTTPServer(("127.0.0.1", port), DaemonHandler)
353
+ try:
354
+ _server.serve_forever()
355
+ except KeyboardInterrupt:
356
+ pass
357
+ finally:
358
+ _shutdown_server()
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # CLI entry point
363
+ # ---------------------------------------------------------------------------
364
+
365
+ if __name__ == "__main__":
366
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
367
+ if "--start" in sys.argv:
368
+ start_server()
369
+ elif "--stop" in sys.argv:
370
+ stop_daemon()
371
+ else:
372
+ print("Usage: python -m superlocalmemory.cli.daemon --start|--stop")
@@ -191,6 +191,14 @@ def main() -> None:
191
191
  "--port", type=int, default=8765, help="Port (default 8765)",
192
192
  )
193
193
 
194
+ # V3.3.21: Daemon serve mode
195
+ serve_p = sub.add_parser("serve", help="Start/stop daemon for instant CLI response (~600MB RAM)")
196
+ serve_p.add_argument(
197
+ "action", nargs="?", default="start",
198
+ choices=["start", "stop", "status"],
199
+ help="start (default), stop, or status",
200
+ )
201
+
194
202
  # -- Profiles ------------------------------------------------------
195
203
  profile_p = sub.add_parser("profile", help="Profile management (list/switch/create)")
196
204
  profile_p.add_argument(