superlocalmemory 3.6.11 → 3.6.12
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/CHANGELOG.md +32 -1
- package/README.md +2 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/cli/commands.py +1 -0
- package/src/superlocalmemory/cli/daemon.py +0 -407
- package/src/superlocalmemory/cli/main.py +3 -1
- package/src/superlocalmemory/core/context_cache.py +4 -1
- package/src/superlocalmemory/core/fact_consolidator.py +4 -1
- package/src/superlocalmemory/core/remote_mode.py +197 -0
- package/src/superlocalmemory/core/summarizer.py +4 -1
- package/src/superlocalmemory/llm/backbone.py +7 -1
- package/src/superlocalmemory/mcp/agent_context.py +7 -3
- package/src/superlocalmemory/mcp/tools_core.py +13 -1
- package/src/superlocalmemory/mcp/tools_mesh.py +14 -6
- package/src/superlocalmemory/mesh/broker.py +15 -4
- package/src/superlocalmemory/optimize/compress/router.py +9 -4
- package/src/superlocalmemory/optimize/storage/db.py +16 -2
- package/src/superlocalmemory/server/api.py +11 -3
- package/src/superlocalmemory/server/routes/mesh.py +13 -0
- package/src/superlocalmemory/server/routes/token.py +14 -2
- package/src/superlocalmemory/server/routes/v3_api.py +83 -17
- package/src/superlocalmemory/server/ui.py +15 -4
- package/src/superlocalmemory/server/unified_daemon.py +96 -160
- package/src/superlocalmemory/storage/database.py +10 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +24 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +3 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,38 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [3.6.
|
|
8
|
+
## [3.6.12] - 2026-06-14 — Distributed-ready + stability fixes
|
|
9
|
+
|
|
10
|
+
Makes SuperLocalMemory work correctly across a LAN / distributed deployment (issues #39, #40) and fixes a set of stability and security defects. Default single-machine behavior is unchanged.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`SLM_REMOTE=1` — one-switch LAN mode** (default OFF). When enabled, SLM serves the dashboard install token to allowlisted LAN clients, runs the MCP transport statelessly so a gateway/hub can forward tool calls, allows trusted LAN dashboard origins, and exempts the trusted LAN dashboard from rate limiting. LAN access stays gated by `SLM_MCP_ALLOWED_HOSTS`. Granular flags: `SLM_MCP_STATELESS=1`, and tunable rate limits `SLM_RATE_LIMIT_WRITE` / `SLM_RATE_LIMIT_READ` / `SLM_RATE_LIMIT_WINDOW`. See `docs/distributed-deployment.md`.
|
|
15
|
+
- **`slm search`** CLI command (parity with the MCP `search` tool).
|
|
16
|
+
|
|
17
|
+
### Fixed — Distributed / LAN (#39, #40)
|
|
18
|
+
|
|
19
|
+
- **The Brain page now loads from a remote browser** on the LAN (the install-token endpoint serves allowlisted LAN clients in remote mode instead of being loopback-only).
|
|
20
|
+
- **Mesh tools no longer fail with `-32600 Session not found`** when called through an MCP gateway/hub (the transport can now run stateless so the session id need not be replayed).
|
|
21
|
+
- **Custom LLM endpoints (llama.cpp / LM Studio / Azure) can now be configured from the dashboard** — the Settings page shows, sends, and saves the endpoint, and switching mode actually persists.
|
|
22
|
+
- **The dashboard rate limit (`429 Too Many Requests`) is now configurable** and trusted LAN clients are exempt in remote mode.
|
|
23
|
+
|
|
24
|
+
### Fixed — Stability & security
|
|
25
|
+
|
|
26
|
+
- **Authentication now fails closed.** A failure to install the auth gate could previously leave write endpoints unauthenticated; it now logs and denies non-loopback writes instead.
|
|
27
|
+
- **Mesh peer registration fixed** — sessions now register with the correct peer id, so heartbeat, direct messages, and inbox work reliably (previously they could silently target a non-existent peer).
|
|
28
|
+
- **`SLM_MESH_SHARED_SECRET` is now enforced** on inbound mesh requests from non-loopback callers.
|
|
29
|
+
- **The cache no longer raises on a corrupted or wrong-key entry** — it degrades to a cache miss.
|
|
30
|
+
- **SSRF protection** added to the provider connection test (cloud-metadata and internal addresses are blocked for remote callers; the local dashboard can still test local/LAN endpoints).
|
|
31
|
+
- **Mode B no longer silently falls back to Mode A** when using a keyless local LLM endpoint.
|
|
32
|
+
- **Memory search no longer errors on punctuation** (`?`, `-`, quotes, etc.).
|
|
33
|
+
- **Mode B honors the configured LLM endpoint and timeout** in summarization and consolidation (previously hardcoded to localhost).
|
|
34
|
+
- **Math-health dashboard reports real status** instead of always showing green.
|
|
35
|
+
- Mesh lock release and inbox now behave correctly (no false success, no re-listing of already-read messages); switching profile takes effect immediately for recall.
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
- Removed legacy dead code (an unused in-process daemon handler and superseded duplicate API routes) for a cleaner, more maintainable codebase. No functional change. — Optimize Everywhere: three surfaces (proxy · MCP tools · skill)
|
|
9
40
|
|
|
10
41
|
Cache + compress across **every setup** — proxy, MCP tools, or skill. Five new MCP tools land directly inside `slm mcp` (no proxy, full 1M context window preserved). A new `slm-optimize` skill makes compression and routed-result caching zero-config for Claude Code users. Overclaim in prior docs fixed; three-surfaces table added.
|
|
11
42
|
|
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
|
|
36
36
|
> V3.6 is the only local-first layer that SKIPS repeat LLM calls (cache: 100% on a hit), SHRINKS tool outputs and injected context (compress: lossless-by-default, opt-in LLMLingua-2), and DISCOUNTS prefix costs (align: native KV-cache) — and remembers everything — in one install.
|
|
37
37
|
>
|
|
38
|
+
> **v3.6.12 "Distributed-ready":** Run SLM on a server and reach it across your LAN. `SLM_REMOTE=1` (default off) lets the dashboard load from a remote browser, lets MCP gateways/hubs forward tool calls, and makes custom local LLM endpoints (llama.cpp / LM Studio / Azure) configurable right from the dashboard — plus a batch of stability and security fixes. See [`docs/distributed-deployment.md`](docs/distributed-deployment.md).
|
|
39
|
+
>
|
|
38
40
|
> **v3.6.11 "Optimize Everywhere":** Three surfaces. **Proxy** (Surface A) — full-turn cache + compress on transport; needs `ANTHROPIC_BASE_URL`, shrinks the context window. **MCP tools** (Surface B) — `slm_compress`, `slm_retrieve`, `slm_cache_set`, `slm_cache_get`, `slm_optimize_stats`; no proxy, no window shrink, works on any Claude subscription. **Skill** (Surface C) — `slm-optimize` installs in `~/.claude/skills/`; zero-config auto-compress for large tool outputs and CLAUDE.md. No proxy, full 1M window. [See Three Surfaces →](#three-surfaces-proxy--mcp-tools--skill)
|
|
39
41
|
>
|
|
40
42
|
> **v3.6.10:** cache and compression are now **independent runtime switches** (cache-only, compress-only, both, or neither — toggle live from the dashboard, no restart). Compression was rebuilt to be **lossless by default** (the old string/array/code truncation is gone); aggressive mode adds LLMLingua-2 for **prose only** — never code, numbers, structured data, or the current turn.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.6.
|
|
3
|
+
"version": "3.6.12",
|
|
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
|
@@ -114,6 +114,7 @@ def dispatch(args: Namespace) -> None:
|
|
|
114
114
|
"list": cmd_list,
|
|
115
115
|
"remember": cmd_remember,
|
|
116
116
|
"recall": cmd_recall,
|
|
117
|
+
"search": cmd_recall, # v3.6.12 (parity-3): MCP exposes a `search` verb; give the CLI parity (recall is multi-channel incl. BM25/keyword).
|
|
117
118
|
"forget": cmd_forget,
|
|
118
119
|
"delete": cmd_delete,
|
|
119
120
|
"update": cmd_update,
|
|
@@ -408,410 +408,3 @@ def _wait_for_workers_dead(timeout: int = 10) -> None:
|
|
|
408
408
|
time.sleep(0.5)
|
|
409
409
|
|
|
410
410
|
logger.warning("Some SLM workers still alive after %ds timeout", timeout)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
# ---------------------------------------------------------------------------
|
|
414
|
-
# Server: HTTP request handler with engine singleton
|
|
415
|
-
# ---------------------------------------------------------------------------
|
|
416
|
-
|
|
417
|
-
_engine = None
|
|
418
|
-
_last_activity = time.monotonic()
|
|
419
|
-
|
|
420
|
-
# ---------------------------------------------------------------------------
|
|
421
|
-
# V3.3.28: Observation debounce buffer.
|
|
422
|
-
#
|
|
423
|
-
# When 20+ file edits arrive in quick succession (from parallel AI agents,
|
|
424
|
-
# git checkout, or batch sed), we buffer observations for _OBSERVE_DEBOUNCE_SEC
|
|
425
|
-
# seconds and deduplicate by content hash. This reduces 20 observations → 1-3
|
|
426
|
-
# batches, each processed by the singleton engine (1 embedding worker).
|
|
427
|
-
# ---------------------------------------------------------------------------
|
|
428
|
-
|
|
429
|
-
_OBSERVE_DEBOUNCE_SEC = float(os.environ.get("SLM_OBSERVE_DEBOUNCE_SEC", "3.0"))
|
|
430
|
-
_observe_buffer: list[str] = []
|
|
431
|
-
_observe_seen: set[str] = set() # content hashes for dedup within window
|
|
432
|
-
_observe_lock = threading.Lock()
|
|
433
|
-
_observe_timer: threading.Timer | None = None
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def _flush_observe_buffer() -> None:
|
|
437
|
-
"""Process all buffered observations as a single batch."""
|
|
438
|
-
global _observe_timer
|
|
439
|
-
with _observe_lock:
|
|
440
|
-
if not _observe_buffer:
|
|
441
|
-
return
|
|
442
|
-
batch = list(_observe_buffer)
|
|
443
|
-
_observe_buffer.clear()
|
|
444
|
-
_observe_seen.clear()
|
|
445
|
-
_observe_timer = None
|
|
446
|
-
|
|
447
|
-
# Process each unique observation (already deduped)
|
|
448
|
-
engine = _get_engine()
|
|
449
|
-
from superlocalmemory.hooks.auto_capture import AutoCapture
|
|
450
|
-
auto = AutoCapture(engine=engine)
|
|
451
|
-
|
|
452
|
-
for content in batch:
|
|
453
|
-
try:
|
|
454
|
-
decision = auto.evaluate(content)
|
|
455
|
-
if decision.capture:
|
|
456
|
-
auto.capture(content, category=decision.category)
|
|
457
|
-
except Exception as exc:
|
|
458
|
-
# Swallow per-observation to protect the batch, but log so
|
|
459
|
-
# a pattern of dropped observations is visible.
|
|
460
|
-
logger.warning("observation dropped during batch: %s", exc)
|
|
461
|
-
|
|
462
|
-
logger.info("Observe debounce: processed %d observations (from buffer)", len(batch))
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
def _enqueue_observation(content: str) -> dict:
|
|
466
|
-
"""Add an observation to the debounce buffer. Returns immediate response."""
|
|
467
|
-
global _observe_timer
|
|
468
|
-
import hashlib
|
|
469
|
-
content_hash = hashlib.md5(content.encode()).hexdigest()
|
|
470
|
-
|
|
471
|
-
with _observe_lock:
|
|
472
|
-
if content_hash in _observe_seen:
|
|
473
|
-
return {"captured": False, "reason": "duplicate within debounce window"}
|
|
474
|
-
|
|
475
|
-
_observe_seen.add(content_hash)
|
|
476
|
-
_observe_buffer.append(content)
|
|
477
|
-
buf_size = len(_observe_buffer)
|
|
478
|
-
|
|
479
|
-
# Reset debounce timer
|
|
480
|
-
if _observe_timer is not None:
|
|
481
|
-
_observe_timer.cancel()
|
|
482
|
-
_observe_timer = threading.Timer(_OBSERVE_DEBOUNCE_SEC, _flush_observe_buffer)
|
|
483
|
-
_observe_timer.daemon = True
|
|
484
|
-
_observe_timer.start()
|
|
485
|
-
|
|
486
|
-
return {"captured": True, "queued": True, "buffer_size": buf_size,
|
|
487
|
-
"debounce_sec": _OBSERVE_DEBOUNCE_SEC}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
def _get_engine():
|
|
491
|
-
global _engine
|
|
492
|
-
if _engine is None:
|
|
493
|
-
from superlocalmemory.core.config import SLMConfig
|
|
494
|
-
from superlocalmemory.core.engine import MemoryEngine
|
|
495
|
-
|
|
496
|
-
config = SLMConfig.load()
|
|
497
|
-
_engine = MemoryEngine(config)
|
|
498
|
-
_engine.initialize()
|
|
499
|
-
|
|
500
|
-
# Force reranker warmup (blocking — daemon can afford to wait)
|
|
501
|
-
retrieval_eng = getattr(_engine, '_retrieval_engine', None)
|
|
502
|
-
if retrieval_eng:
|
|
503
|
-
reranker = getattr(retrieval_eng, '_reranker', None)
|
|
504
|
-
if reranker and hasattr(reranker, 'warmup_sync'):
|
|
505
|
-
reranker.warmup_sync(timeout=120)
|
|
506
|
-
|
|
507
|
-
logger.info("Daemon engine initialized and warm")
|
|
508
|
-
return _engine
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
class DaemonHandler(BaseHTTPRequestHandler):
|
|
512
|
-
"""Lightweight HTTP handler for daemon requests."""
|
|
513
|
-
|
|
514
|
-
def log_message(self, format, *args):
|
|
515
|
-
"""Suppress default access logging."""
|
|
516
|
-
pass
|
|
517
|
-
|
|
518
|
-
def _send_json(self, status: int, data: dict) -> None:
|
|
519
|
-
self.send_response(status)
|
|
520
|
-
self.send_header("Content-Type", "application/json")
|
|
521
|
-
self.end_headers()
|
|
522
|
-
self.wfile.write(json.dumps(data).encode())
|
|
523
|
-
|
|
524
|
-
def _read_body(self) -> dict:
|
|
525
|
-
length = int(self.headers.get("Content-Length", 0))
|
|
526
|
-
if length == 0:
|
|
527
|
-
return {}
|
|
528
|
-
return json.loads(self.rfile.read(length).decode())
|
|
529
|
-
|
|
530
|
-
def do_GET(self) -> None:
|
|
531
|
-
global _last_activity
|
|
532
|
-
_last_activity = time.monotonic()
|
|
533
|
-
|
|
534
|
-
if self.path == "/health":
|
|
535
|
-
self._send_json(200, {"status": "ok", "pid": os.getpid()})
|
|
536
|
-
return
|
|
537
|
-
|
|
538
|
-
if self.path.startswith("/recall"):
|
|
539
|
-
try:
|
|
540
|
-
# Parse query from URL params
|
|
541
|
-
from urllib.parse import urlparse, parse_qs
|
|
542
|
-
params = parse_qs(urlparse(self.path).query)
|
|
543
|
-
query = params.get("q", [""])[0]
|
|
544
|
-
limit = int(params.get("limit", ["20"])[0])
|
|
545
|
-
|
|
546
|
-
# S9-DASH-02: session_id for outcome-queue enqueue.
|
|
547
|
-
# Priority: ?session_id= query arg > X-SLM-Session-Id
|
|
548
|
-
# header > synthetic "cli:<ts>". Without any of these
|
|
549
|
-
# the recall still works — it just doesn't produce a
|
|
550
|
-
# pending_outcome (hook-based signals can't match).
|
|
551
|
-
session_id = params.get("session_id", [""])[0]
|
|
552
|
-
if not session_id:
|
|
553
|
-
session_id = self.headers.get("X-SLM-Session-Id", "")
|
|
554
|
-
if not session_id:
|
|
555
|
-
import time as _t
|
|
556
|
-
session_id = f"http:{int(_t.time() * 1000)}"
|
|
557
|
-
|
|
558
|
-
engine = _get_engine()
|
|
559
|
-
raw_fast = params.get("fast", ["false"])[0]
|
|
560
|
-
fast = raw_fast.lower() in ("true", "1")
|
|
561
|
-
response = engine.recall(
|
|
562
|
-
query, limit=limit, session_id=session_id, fast=fast,
|
|
563
|
-
)
|
|
564
|
-
# Return the same field shape as recall_worker._handle_recall,
|
|
565
|
-
# so MCP processes that proxy through the daemon get recall_trace-
|
|
566
|
-
# compatible data without a second round trip.
|
|
567
|
-
memory_ids = list({
|
|
568
|
-
r.fact.memory_id for r in response.results[:limit]
|
|
569
|
-
if r.fact.memory_id
|
|
570
|
-
})
|
|
571
|
-
memory_map = (
|
|
572
|
-
engine._db.get_memory_content_batch(memory_ids)
|
|
573
|
-
if memory_ids else {}
|
|
574
|
-
)
|
|
575
|
-
results = []
|
|
576
|
-
for r in response.results[:limit]:
|
|
577
|
-
fact_type = getattr(r.fact, "fact_type", None)
|
|
578
|
-
lifecycle = getattr(r.fact, "lifecycle", None)
|
|
579
|
-
# v3.5.1: sanitize control chars that break JSON (newlines, tabs in content).
|
|
580
|
-
clean = r.fact.content.replace("\r", " ").replace("\n", " ").replace("\t", " ")
|
|
581
|
-
sc_raw = memory_map.get(r.fact.memory_id, "")
|
|
582
|
-
sc_clean = sc_raw.replace("\r", " ").replace("\n", " ").replace("\t", " ") if sc_raw else ""
|
|
583
|
-
results.append({
|
|
584
|
-
"fact_id": r.fact.fact_id,
|
|
585
|
-
"memory_id": r.fact.memory_id,
|
|
586
|
-
"content": clean,
|
|
587
|
-
"source_content": sc_clean,
|
|
588
|
-
"score": round(r.score, 4),
|
|
589
|
-
"confidence": round(r.confidence, 4),
|
|
590
|
-
"trust_score": round(r.trust_score, 4),
|
|
591
|
-
"channel_scores": {
|
|
592
|
-
k: round(v, 4)
|
|
593
|
-
for k, v in (r.channel_scores or {}).items()
|
|
594
|
-
},
|
|
595
|
-
"fact_type": fact_type.value
|
|
596
|
-
if fact_type and hasattr(fact_type, "value") else "",
|
|
597
|
-
"lifecycle": lifecycle.value
|
|
598
|
-
if lifecycle and hasattr(lifecycle, "value") else "",
|
|
599
|
-
"access_count": getattr(r.fact, "access_count", 0),
|
|
600
|
-
"evidence_chain": list(
|
|
601
|
-
getattr(r, "evidence_chain", []) or []
|
|
602
|
-
),
|
|
603
|
-
})
|
|
604
|
-
self._send_json(200, {
|
|
605
|
-
"ok": True,
|
|
606
|
-
"query": query,
|
|
607
|
-
"query_type": response.query_type,
|
|
608
|
-
"result_count": len(results),
|
|
609
|
-
"retrieval_time_ms": round(response.retrieval_time_ms, 1),
|
|
610
|
-
"channel_weights": {
|
|
611
|
-
k: round(v, 3)
|
|
612
|
-
for k, v in (response.channel_weights or {}).items()
|
|
613
|
-
},
|
|
614
|
-
"total_candidates": getattr(response, "total_candidates", 0),
|
|
615
|
-
"results": results,
|
|
616
|
-
"count": len(results), # backward compat alias
|
|
617
|
-
})
|
|
618
|
-
except Exception as exc:
|
|
619
|
-
self._send_json(500, {"error": str(exc)})
|
|
620
|
-
return
|
|
621
|
-
|
|
622
|
-
if self.path == "/list":
|
|
623
|
-
try:
|
|
624
|
-
engine = _get_engine()
|
|
625
|
-
facts = engine.list_facts(limit=50)
|
|
626
|
-
items = [
|
|
627
|
-
{"content": f.content[:100], "fact_type": getattr(f.fact_type, 'value', str(f.fact_type)),
|
|
628
|
-
"created_at": (f.created_at or "")[:19], "fact_id": f.fact_id}
|
|
629
|
-
for f in facts
|
|
630
|
-
]
|
|
631
|
-
self._send_json(200, {"results": items, "count": len(items)})
|
|
632
|
-
except Exception as exc:
|
|
633
|
-
self._send_json(500, {"error": str(exc)})
|
|
634
|
-
return
|
|
635
|
-
|
|
636
|
-
if self.path == "/status":
|
|
637
|
-
engine = _get_engine()
|
|
638
|
-
uptime = time.monotonic() - _server_start_time
|
|
639
|
-
self._send_json(200, {
|
|
640
|
-
"status": "running", "pid": os.getpid(),
|
|
641
|
-
"uptime_s": round(uptime),
|
|
642
|
-
"mode": engine._config.mode.value,
|
|
643
|
-
"fact_count": engine.fact_count,
|
|
644
|
-
"idle_s": round(time.monotonic() - _last_activity),
|
|
645
|
-
})
|
|
646
|
-
return
|
|
647
|
-
|
|
648
|
-
self._send_json(404, {"error": "not found"})
|
|
649
|
-
|
|
650
|
-
def do_POST(self) -> None:
|
|
651
|
-
global _last_activity
|
|
652
|
-
_last_activity = time.monotonic()
|
|
653
|
-
|
|
654
|
-
if self.path == "/remember":
|
|
655
|
-
try:
|
|
656
|
-
body = self._read_body()
|
|
657
|
-
content = body.get("content", "")
|
|
658
|
-
tags = body.get("tags", "")
|
|
659
|
-
extra_meta = body.get("metadata") or {}
|
|
660
|
-
if not content:
|
|
661
|
-
self._send_json(400, {"error": "content required"})
|
|
662
|
-
return
|
|
663
|
-
|
|
664
|
-
engine = _get_engine()
|
|
665
|
-
metadata = {"tags": tags} if tags else {}
|
|
666
|
-
if isinstance(extra_meta, dict):
|
|
667
|
-
metadata.update(extra_meta)
|
|
668
|
-
fact_ids = engine.store(content, metadata=metadata)
|
|
669
|
-
self._send_json(200, {
|
|
670
|
-
"ok": True,
|
|
671
|
-
"fact_ids": fact_ids,
|
|
672
|
-
"count": len(fact_ids),
|
|
673
|
-
})
|
|
674
|
-
except Exception as exc:
|
|
675
|
-
self._send_json(500, {"error": str(exc)})
|
|
676
|
-
return
|
|
677
|
-
|
|
678
|
-
if self.path == "/observe":
|
|
679
|
-
try:
|
|
680
|
-
body = self._read_body()
|
|
681
|
-
content = body.get("content", "")
|
|
682
|
-
if not content:
|
|
683
|
-
self._send_json(400, {"error": "content required"})
|
|
684
|
-
return
|
|
685
|
-
|
|
686
|
-
# V3.3.28: Debounced observation processing.
|
|
687
|
-
# Buffers observations for 3s, deduplicates, processes as batch.
|
|
688
|
-
# Returns immediately — the actual capture happens asynchronously
|
|
689
|
-
# via the debounce timer, using the singleton engine.
|
|
690
|
-
result = _enqueue_observation(content)
|
|
691
|
-
self._send_json(200, result)
|
|
692
|
-
except Exception as exc:
|
|
693
|
-
self._send_json(500, {"error": str(exc)})
|
|
694
|
-
return
|
|
695
|
-
|
|
696
|
-
if self.path == "/stop":
|
|
697
|
-
self._send_json(200, {"status": "stopping"})
|
|
698
|
-
Thread(target=_shutdown_server, daemon=True).start()
|
|
699
|
-
return
|
|
700
|
-
|
|
701
|
-
self._send_json(404, {"error": "not found"})
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
# ---------------------------------------------------------------------------
|
|
705
|
-
# Server lifecycle
|
|
706
|
-
# ---------------------------------------------------------------------------
|
|
707
|
-
|
|
708
|
-
_server: HTTPServer | None = None
|
|
709
|
-
_server_start_time = time.monotonic()
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
def _shutdown_server() -> None:
|
|
713
|
-
global _engine, _server
|
|
714
|
-
try:
|
|
715
|
-
_flush_observe_buffer()
|
|
716
|
-
except Exception as exc:
|
|
717
|
-
logger.warning("flush observe buffer on shutdown failed: %s", exc)
|
|
718
|
-
time.sleep(0.5)
|
|
719
|
-
if _engine is not None:
|
|
720
|
-
try:
|
|
721
|
-
_engine.close()
|
|
722
|
-
except Exception as exc:
|
|
723
|
-
logger.warning("engine close on shutdown failed: %s", exc)
|
|
724
|
-
_engine = None
|
|
725
|
-
if _server is not None:
|
|
726
|
-
_server.shutdown()
|
|
727
|
-
_PID_FILE.unlink(missing_ok=True)
|
|
728
|
-
_PORT_FILE.unlink(missing_ok=True)
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
def _idle_watchdog(timeout: int) -> None:
|
|
732
|
-
"""Auto-shutdown after idle timeout."""
|
|
733
|
-
global _last_activity
|
|
734
|
-
while True:
|
|
735
|
-
time.sleep(30)
|
|
736
|
-
idle = time.monotonic() - _last_activity
|
|
737
|
-
if idle > timeout:
|
|
738
|
-
logger.info("Daemon idle for %ds, shutting down", int(idle))
|
|
739
|
-
_shutdown_server()
|
|
740
|
-
os._exit(0)
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
def start_server(port: int = _DEFAULT_PORT, idle_timeout: int | None = None) -> None:
|
|
744
|
-
"""Start the daemon HTTP server. Blocks until stopped."""
|
|
745
|
-
global _server, _server_start_time, _last_activity
|
|
746
|
-
|
|
747
|
-
idle_timeout = idle_timeout or int(os.environ.get(
|
|
748
|
-
"SLM_DAEMON_IDLE_TIMEOUT", str(_DEFAULT_IDLE_TIMEOUT),
|
|
749
|
-
))
|
|
750
|
-
|
|
751
|
-
# Banner is advisory — a broken data dir must never prevent the daemon
|
|
752
|
-
# from starting, so the swallow here is intentional.
|
|
753
|
-
try:
|
|
754
|
-
from superlocalmemory import __version__ as _slm_ver
|
|
755
|
-
from superlocalmemory.cli.version_banner import check_and_emit_upgrade_banner
|
|
756
|
-
check_and_emit_upgrade_banner(_slm_ver)
|
|
757
|
-
except Exception as exc:
|
|
758
|
-
logger.warning("upgrade banner on daemon start failed: %s", exc)
|
|
759
|
-
|
|
760
|
-
# Apply the v3.4.26 data-dir migration now — the daemon is the
|
|
761
|
-
# authoritative holder of the DB, so this is the right place to do
|
|
762
|
-
# it unconditionally (``migrate`` is idempotent).
|
|
763
|
-
try:
|
|
764
|
-
from pathlib import Path as _P
|
|
765
|
-
from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import (
|
|
766
|
-
is_ready as _is_ready, migrate as _migrate,
|
|
767
|
-
)
|
|
768
|
-
_data = _P(os.environ.get("SLM_DATA_DIR")
|
|
769
|
-
or _P.home() / ".superlocalmemory")
|
|
770
|
-
if not _is_ready(_data):
|
|
771
|
-
_migrate(_data)
|
|
772
|
-
except Exception as exc:
|
|
773
|
-
logger.warning("v3.4.26 migration on daemon start failed: %s", exc)
|
|
774
|
-
|
|
775
|
-
# Write PID + port files
|
|
776
|
-
_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
777
|
-
_PID_FILE.write_text(str(os.getpid()))
|
|
778
|
-
_PORT_FILE.write_text(str(port))
|
|
779
|
-
|
|
780
|
-
# Handle SIGTERM for graceful shutdown
|
|
781
|
-
signal.signal(signal.SIGTERM, lambda *_: _shutdown_server() or os._exit(0))
|
|
782
|
-
|
|
783
|
-
# Pre-warm engine (this is the cold start — daemon absorbs it once)
|
|
784
|
-
logger.info("Daemon starting — warming engine...")
|
|
785
|
-
_get_engine()
|
|
786
|
-
logger.info("Engine warm. Daemon ready on port %d (idle timeout: %ds)", port, idle_timeout)
|
|
787
|
-
|
|
788
|
-
_server_start_time = time.monotonic()
|
|
789
|
-
_last_activity = time.monotonic()
|
|
790
|
-
|
|
791
|
-
# Start idle watchdog
|
|
792
|
-
Thread(target=_idle_watchdog, args=(idle_timeout,), daemon=True, name="idle-watchdog").start()
|
|
793
|
-
|
|
794
|
-
# Start HTTP server
|
|
795
|
-
# SO_REUSEADDR must be set on the class BEFORE __init__ calls bind()
|
|
796
|
-
HTTPServer.allow_reuse_address = True
|
|
797
|
-
_server = HTTPServer(("127.0.0.1", port), DaemonHandler)
|
|
798
|
-
try:
|
|
799
|
-
_server.serve_forever()
|
|
800
|
-
except KeyboardInterrupt:
|
|
801
|
-
pass
|
|
802
|
-
finally:
|
|
803
|
-
_shutdown_server()
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
# ---------------------------------------------------------------------------
|
|
807
|
-
# CLI entry point
|
|
808
|
-
# ---------------------------------------------------------------------------
|
|
809
|
-
|
|
810
|
-
if __name__ == "__main__":
|
|
811
|
-
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
|
812
|
-
if "--start" in sys.argv:
|
|
813
|
-
start_server()
|
|
814
|
-
elif "--stop" in sys.argv:
|
|
815
|
-
stop_daemon()
|
|
816
|
-
else:
|
|
817
|
-
print("Usage: python -m superlocalmemory.cli.daemon --start|--stop")
|
|
@@ -183,7 +183,9 @@ def main() -> None:
|
|
|
183
183
|
help="Wait for completion (default: async background processing)",
|
|
184
184
|
)
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
# v3.6.12 (parity-3): `search` is an alias of `recall` so the CLI has the
|
|
187
|
+
# same search verb the MCP exposes (handlers dict maps both to cmd_recall).
|
|
188
|
+
recall_p = sub.add_parser("recall", aliases=["search"], help="Semantic search with 4-channel retrieval")
|
|
187
189
|
recall_p.add_argument("query", help="Search query")
|
|
188
190
|
recall_p.add_argument("--limit", type=int, default=10, help="Max results (default 10)")
|
|
189
191
|
recall_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
|
|
@@ -205,7 +205,10 @@ class ContextCache:
|
|
|
205
205
|
Does NOT run LRU sweep inline (PERF-01-07) — that's a background
|
|
206
206
|
task on the daemon.
|
|
207
207
|
"""
|
|
208
|
-
|
|
208
|
+
# v3.6.12 (redact-1): scrub dashboard/cached content at HIGH aggression
|
|
209
|
+
# so Bearer/GitHub-PAT/Anthropic/OpenAI/GENERIC_KEY patterns are caught
|
|
210
|
+
# (the default 'normal' skipped them, leaking those shapes to the UI).
|
|
211
|
+
content = redact_secrets(entry.content, aggression="high")[:MAX_CONTENT_CHARS]
|
|
209
212
|
fact_ids_json = json.dumps(list(entry.fact_ids))
|
|
210
213
|
byte_size = (
|
|
211
214
|
len(content.encode("utf-8"))
|
|
@@ -367,7 +367,10 @@ def _summarize_with_ollama(
|
|
|
367
367
|
if config and hasattr(config, 'llm'):
|
|
368
368
|
api_base = getattr(config.llm, 'api_base', api_base) or api_base
|
|
369
369
|
model = getattr(config.llm, 'model', model) or model
|
|
370
|
-
|
|
370
|
+
# v3.6.12 (modeb-4): the LLMConfig field is `timeout_seconds`, not
|
|
371
|
+
# `timeout` — the old read always missed and silently used 30s.
|
|
372
|
+
timeout = getattr(config.llm, 'timeout_seconds', None) or \
|
|
373
|
+
getattr(config.llm, 'timeout', None) or timeout
|
|
371
374
|
|
|
372
375
|
fact_texts = "\n".join(f"- {f['content']}" for f in facts[:_MAX_CLUSTER_SIZE])
|
|
373
376
|
prompt = (
|