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.
- package/package.json +1 -1
- package/pyproject.toml +9 -1
- package/src/superlocalmemory/cli/commands.py +138 -22
- package/src/superlocalmemory/cli/daemon.py +372 -0
- package/src/superlocalmemory/cli/main.py +8 -0
- package/src/superlocalmemory/cli/pending_store.py +158 -0
- package/src/superlocalmemory/cli/setup_wizard.py +39 -6
- package/src/superlocalmemory/code_graph/__init__.py +46 -0
- package/src/superlocalmemory/code_graph/blast_radius.py +177 -0
- package/src/superlocalmemory/code_graph/bridge/__init__.py +36 -0
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +464 -0
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +195 -0
- package/src/superlocalmemory/code_graph/bridge/fact_enricher.py +159 -0
- package/src/superlocalmemory/code_graph/bridge/hebbian_linker.py +170 -0
- package/src/superlocalmemory/code_graph/bridge/temporal_checker.py +152 -0
- package/src/superlocalmemory/code_graph/changes.py +363 -0
- package/src/superlocalmemory/code_graph/communities.py +299 -0
- package/src/superlocalmemory/code_graph/config.py +88 -0
- package/src/superlocalmemory/code_graph/database.py +482 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +78 -0
- package/src/superlocalmemory/code_graph/extractors/python.py +413 -0
- package/src/superlocalmemory/code_graph/extractors/typescript.py +556 -0
- package/src/superlocalmemory/code_graph/flows.py +350 -0
- package/src/superlocalmemory/code_graph/git_hooks.py +226 -0
- package/src/superlocalmemory/code_graph/graph_engine.py +295 -0
- package/src/superlocalmemory/code_graph/graph_store.py +158 -0
- package/src/superlocalmemory/code_graph/incremental.py +200 -0
- package/src/superlocalmemory/code_graph/models.py +130 -0
- package/src/superlocalmemory/code_graph/parser.py +507 -0
- package/src/superlocalmemory/code_graph/resolver.py +321 -0
- package/src/superlocalmemory/code_graph/search.py +460 -0
- package/src/superlocalmemory/code_graph/service.py +95 -0
- package/src/superlocalmemory/code_graph/watcher.py +207 -0
- package/src/superlocalmemory/core/embedding_worker.py +4 -2
- package/src/superlocalmemory/core/embeddings.py +8 -2
- package/src/superlocalmemory/core/engine.py +32 -0
- package/src/superlocalmemory/core/engine_wiring.py +5 -0
- package/src/superlocalmemory/core/store_pipeline.py +23 -1
- package/src/superlocalmemory/encoding/fact_extractor.py +68 -7
- package/src/superlocalmemory/infra/event_bus.py +5 -0
- package/src/superlocalmemory/mcp/server.py +23 -0
- package/src/superlocalmemory/mcp/tools_code_graph.py +1592 -0
- package/src/superlocalmemory/retrieval/engine.py +137 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +6 -2
- package/src/superlocalmemory/retrieval/spreading_activation.py +5 -3
- package/src/superlocalmemory/retrieval/strategy.py +16 -0
- package/src/superlocalmemory/server/api.py +4 -2
- package/src/superlocalmemory/server/ui.py +5 -2
- package/src/superlocalmemory/storage/schema_code_graph.py +239 -0
- package/src/superlocalmemory/ui/index.html +1879 -0
- package/src/superlocalmemory/ui/js/agents.js +192 -0
- package/src/superlocalmemory/ui/js/auto-settings.js +399 -0
- package/src/superlocalmemory/ui/js/behavioral.js +276 -0
- package/src/superlocalmemory/ui/js/clusters.js +206 -0
- package/src/superlocalmemory/ui/js/compliance.js +252 -0
- package/src/superlocalmemory/ui/js/core.js +246 -0
- package/src/superlocalmemory/ui/js/dashboard.js +110 -0
- package/src/superlocalmemory/ui/js/events.js +178 -0
- package/src/superlocalmemory/ui/js/fact-detail.js +92 -0
- package/src/superlocalmemory/ui/js/feedback.js +333 -0
- package/src/superlocalmemory/ui/js/graph-core.js +447 -0
- package/src/superlocalmemory/ui/js/graph-filters.js +220 -0
- package/src/superlocalmemory/ui/js/graph-interactions.js +351 -0
- package/src/superlocalmemory/ui/js/graph-ui.js +214 -0
- package/src/superlocalmemory/ui/js/ide-status.js +102 -0
- package/src/superlocalmemory/ui/js/init.js +45 -0
- package/src/superlocalmemory/ui/js/learning.js +435 -0
- package/src/superlocalmemory/ui/js/lifecycle.js +298 -0
- package/src/superlocalmemory/ui/js/math-health.js +98 -0
- package/src/superlocalmemory/ui/js/memories.js +264 -0
- package/src/superlocalmemory/ui/js/modal.js +357 -0
- package/src/superlocalmemory/ui/js/patterns.js +93 -0
- package/src/superlocalmemory/ui/js/profiles.js +236 -0
- package/src/superlocalmemory/ui/js/recall-lab.js +292 -0
- package/src/superlocalmemory/ui/js/search.js +59 -0
- package/src/superlocalmemory/ui/js/settings.js +224 -0
- package/src/superlocalmemory/ui/js/timeline.js +32 -0
- 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
258
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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("
|
|
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(
|