superlocalmemory 3.4.25 → 3.4.31

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 (55) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +8 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +3 -1
  5. package/src/superlocalmemory/__init__.py +1 -1
  6. package/src/superlocalmemory/cli/daemon.py +90 -16
  7. package/src/superlocalmemory/cli/doctor_cmd.py +152 -0
  8. package/src/superlocalmemory/cli/main.py +28 -0
  9. package/src/superlocalmemory/cli/pending_store.py +55 -3
  10. package/src/superlocalmemory/cli/post_install.py +15 -0
  11. package/src/superlocalmemory/cli/setup_wizard.py +20 -0
  12. package/src/superlocalmemory/cli/version_banner.py +183 -0
  13. package/src/superlocalmemory/cli/wizard_v3426_options.py +129 -0
  14. package/src/superlocalmemory/core/clock_monitor.py +45 -0
  15. package/src/superlocalmemory/core/db_pool.py +80 -0
  16. package/src/superlocalmemory/core/engine.py +75 -30
  17. package/src/superlocalmemory/core/engine_capabilities.py +24 -0
  18. package/src/superlocalmemory/core/engine_lock.py +75 -0
  19. package/src/superlocalmemory/core/error_catalog.py +113 -0
  20. package/src/superlocalmemory/core/error_envelope.py +60 -0
  21. package/src/superlocalmemory/core/file_lock.py +92 -0
  22. package/src/superlocalmemory/core/loop_watchdog.py +56 -0
  23. package/src/superlocalmemory/core/maintenance_scheduler.py +8 -0
  24. package/src/superlocalmemory/core/priority_queue.py +61 -0
  25. package/src/superlocalmemory/core/queue_dispatcher.py +73 -0
  26. package/src/superlocalmemory/core/rate_limit.py +151 -0
  27. package/src/superlocalmemory/core/recall_queue.py +370 -0
  28. package/src/superlocalmemory/core/recall_worker.py +10 -0
  29. package/src/superlocalmemory/core/safe_fs.py +108 -0
  30. package/src/superlocalmemory/hooks/auto_capture.py +34 -12
  31. package/src/superlocalmemory/hooks/auto_recall.py +36 -9
  32. package/src/superlocalmemory/learning/signals.py +7 -1
  33. package/src/superlocalmemory/mcp/_daemon_proxy.py +107 -0
  34. package/src/superlocalmemory/mcp/_pool_adapter.py +121 -0
  35. package/src/superlocalmemory/mcp/resources.py +8 -5
  36. package/src/superlocalmemory/mcp/server.py +38 -9
  37. package/src/superlocalmemory/mcp/tools_active.py +21 -9
  38. package/src/superlocalmemory/mcp/tools_core.py +13 -9
  39. package/src/superlocalmemory/mcp/tools_evolution.py +4 -2
  40. package/src/superlocalmemory/mcp/tools_learning.py +5 -3
  41. package/src/superlocalmemory/mcp/tools_mesh.py +5 -3
  42. package/src/superlocalmemory/mcp/tools_v3.py +18 -22
  43. package/src/superlocalmemory/mcp/tools_v33.py +65 -2
  44. package/src/superlocalmemory/migrations/__init__.py +5 -0
  45. package/src/superlocalmemory/migrations/v3_4_25_to_v3_4_26.py +144 -0
  46. package/src/superlocalmemory/server/routes/data_io.py +21 -2
  47. package/src/superlocalmemory/server/routes/memories.py +91 -0
  48. package/src/superlocalmemory/server/routes/stats.py +16 -2
  49. package/src/superlocalmemory/server/unified_daemon.py +128 -12
  50. package/src/superlocalmemory/ui/index.html +35 -25
  51. package/src/superlocalmemory/ui/js/core.js +20 -4
  52. package/src/superlocalmemory/ui/js/fact-detail.js +62 -73
  53. package/src/superlocalmemory/ui/js/memories.js +34 -2
  54. package/src/superlocalmemory/ui/js/modal.js +41 -2
  55. package/src/superlocalmemory/ui/js/search.js +27 -0
package/CHANGELOG.md CHANGED
@@ -10,6 +10,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ---
12
12
 
13
+ ## [3.4.31] - 2026-04-24
14
+
15
+ Dashboard truth, memory vs fact clarity, and self-cleaning pending queue.
16
+
17
+ ### Changed
18
+ - **Dashboard now shows both memory counts honestly.** Parent memories
19
+ (what you stored) and atomic facts (what retrieval indexes) appear as
20
+ two distinct cards with their ratio. No more "Total Memories: 6,000"
21
+ when you actually have 2,000 memories decomposed into 6,000 facts.
22
+ - **"Browse atomic facts"** relabeled for clarity — this view lists the
23
+ indexed atomic units.
24
+ - **Visible search box** in the Memories tab — previously hidden behind
25
+ the Recall Lab only. Search now debounces 280 ms on input.
26
+
27
+ ### Added
28
+ - **`/api/memories/{id}/detail`** — full memory + all child atomic facts
29
+ in one call. Powers the click-to-expand modal.
30
+ - **`/api/facts/{id}`** — single atomic fact detail with source memory
31
+ content, entities, and canonical entities.
32
+ - **Pagination UI** — Prev/Next controls show "Showing 1–50 of 6,123".
33
+ Previously hardcoded to 50 with no navigation.
34
+ - **CSV export** — new `format=csv` option on `/api/export` plus a
35
+ dedicated "Export All (CSV)" menu item. JSON and JSONL still work.
36
+ - **Export progress toast** — "Preparing JSON export…" notification
37
+ before the download starts.
38
+ - **`total_facts` + `facts_per_memory`** in `/api/stats` response.
39
+ - **Pending queue auto-cleanup** — the maintenance scheduler now sweeps
40
+ the pending queue every cycle: completed rows > 7 days, failed rows
41
+ over retry limit, and stuck rows > 7 days are removed; a 30-day hard
42
+ cap prevents runaway growth on any status.
43
+
44
+ ### Fixed
45
+ - **Test isolation** — `pending_store` now honors `SLM_DATA_DIR`. Four
46
+ MCP remember tests were writing to the live `~/.superlocalmemory/`
47
+ instead of `tmp_path`. Root conftest now forces `SLM_DATA_DIR=tmp_path`
48
+ for every test unless explicitly opted out.
49
+ - **Fact click popup** — was calling `/api/v3/recall/trace` with a text
50
+ substring (re-query by first 100 chars) and colliding with the memory
51
+ row click handler. Now scoped to `.fact-result-item` only, hits the
52
+ new `/api/facts/{fact_id}` endpoint.
53
+ - **Memory modal ID confusion** — the modal labeled `mem.id` as "ID"
54
+ regardless of whether it was a memory_id or fact_id. Now displays
55
+ both "Memory ID" and "Fact ID" when they differ.
56
+ - **Memory modal hydration** — fetches the full memory + fact list
57
+ asynchronously when opened, so source content and entity data appear
58
+ even for rows that arrived from the search endpoint.
59
+
60
+ ---
61
+
62
+ ## [3.4.30] - 2026-04-24
63
+
64
+ Multi-IDE shared worker, silent migration, and security hardening.
65
+
66
+ ### Added
67
+ - **Multi-IDE RAM sharing.** MCP processes share a single recall worker
68
+ via the daemon. Total RSS stays below 2 GB with four IDEs open.
69
+ - **Feedback and learning signals** flow from every IDE session to the
70
+ daemon, not just the first.
71
+ - **Setup wizard** validates the data directory at install time and
72
+ rejects iCloud, Dropbox, OneDrive, Box, Google Drive, and
73
+ `Library/CloudStorage` paths that silently corrupt SQLite WAL.
74
+ - **One-time upgrade banner** after `pip install -U` / `npm install -g`
75
+ points users to `slm doctor`.
76
+ - **`docs/errors.md`** — canonical error catalog with codes, recovery
77
+ steps, exit codes, and HTTP status mappings.
78
+ - **CI matrix** now runs on `ubuntu-22.04`, `macos-14` (Apple Silicon),
79
+ and `windows-latest` with `portalocker`.
80
+
81
+ ### Changed
82
+ - **Silent, atomic data migration** on upgrade — no manual steps.
83
+ - **Migration serialized via file lock** so parallel pip + npm installs
84
+ cannot race.
85
+ - **Concurrent-safe MCP engine singleton** with double-checked locking.
86
+ - Pool adapter returns frozen dataclasses instead of `SimpleNamespace`.
87
+
88
+ ### Security
89
+ - File permissions tightened: marker files written at 0600, parent
90
+ directories at 0700.
91
+ - Symlink-following blocked on version marker reads.
92
+ - Cloud-synced directory detection extended to `Library/CloudStorage`
93
+ (macOS 13+).
94
+
95
+ ### Fixed
96
+ - Silent error swallows in daemon shutdown, migration probe, and banner
97
+ emission now log at WARNING.
98
+ - Fenced-out `complete()` writes (stale worker claims) emit a WARNING
99
+ log instead of vanishing silently.
100
+ - Daemon-start migration guarded behind `is_ready` sentinel — skips
101
+ when already applied.
102
+
103
+ ---
104
+
13
105
  ## [3.4.23] - 2026-04-21
14
106
 
15
107
  Critical hotfix on top of 3.4.22 for two end-user-facing regressions.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">SuperLocalMemory V3.4</h1>
6
6
  <p align="center"><strong>Every other AI forgets. Yours won't.</strong><br/><em>Infinite memory for Claude Code, Cursor, Windsurf, and any MCP-compatible AI client.</em></p>
7
- <p align="center"><code>v3.4.21</code> — Install once. Every session remembers the last. Automatically.</p>
7
+ <p align="center"><code>v3.4.25</code> — Install once. Every session remembers the last. Automatically.</p>
8
8
  <p align="center"><strong>Backed by 3 published research papers</strong> (arXiv preprints + Zenodo-archived) · <a href="https://arxiv.org/abs/2603.02240">arXiv:2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">arXiv:2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">arXiv:2604.04514</a></p>
9
9
 
10
10
  <p align="center">
@@ -20,6 +20,7 @@
20
20
  <a href="https://superlocalmemory.com"><img src="https://img.shields.io/badge/Web-superlocalmemory.com-ff6b35?style=for-the-badge" alt="Website"/></a>
21
21
  <a href="#dual-interface-mcp--cli"><img src="https://img.shields.io/badge/MCP-Native-blue?style=for-the-badge" alt="MCP Native"/></a>
22
22
  <a href="#dual-interface-mcp--cli"><img src="https://img.shields.io/badge/CLI-Agent--Native-green?style=for-the-badge" alt="CLI Agent-Native"/></a>
23
+ <a href="#multilingual-embedding-support"><img src="https://img.shields.io/badge/Multilingual-30%2B_Languages-ff69b4?style=for-the-badge" alt="Multilingual 30+ Languages"/></a>
23
24
  </p>
24
25
 
25
26
  <p align="center">
@@ -342,6 +343,12 @@ Built-in compliance tools: GDPR Article 15/17 export + complete erasure, tamper-
342
343
 
343
344
  ---
344
345
 
346
+ ## Multilingual Embedding Support
347
+
348
+ **v3.4.24+:** Plug in any OpenAI-compatible embedding endpoint — Ollama, vLLM, LiteLLM, or self-hosted models like `bge-m3`, `multilingual-e5`, `Qwen3-Embedding`. Configure from the dashboard (Settings > Step 3) or `config.json`. SLM's math layer (Fisher-Rao, Sheaf, Langevin) is language-agnostic — swap the embedding model and all 30+ languages work at full retrieval quality. No cloud dependency. No code changes. Your data, your language, your model.
349
+
350
+ ---
351
+
345
352
  ## Web Dashboard
346
353
 
347
354
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.4.25",
3
+ "version": "3.4.31",
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.4.25"
3
+ version = "3.4.31"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -52,6 +52,8 @@ dependencies = [
52
52
  # V3.4.3: Unified Brain
53
53
  "psutil>=5.9.0",
54
54
  "structlog>=24.0.0,<27.0.0",
55
+ # Cross-platform file locking for single-daemon enforcement.
56
+ "portalocker>=2.7.0,<4.0.0",
55
57
  # V3.4.18: Semantic search + cross-encoder reranker (npm install parity).
56
58
  # Previously under [search] extra — pip users silently lost 30pp of recall
57
59
  # quality vs. npm users. Now ships by default for both install paths.
@@ -1,3 +1,3 @@
1
1
  """SuperLocalMemory — information-geometric agent memory."""
2
2
 
3
- __version__ = "3.4.25"
3
+ __version__ = "3.4.31"
@@ -199,7 +199,10 @@ def ensure_daemon() -> bool:
199
199
  return _wait_for_daemon(timeout=60)
200
200
 
201
201
  except Exception as exc:
202
- logger.debug("ensure_daemon error: %s", exc)
202
+ # Daemon auto-start is the entry point for dashboard / mesh /
203
+ # health features; failure here silently disables all of them.
204
+ # Log at WARNING so operators can see it in production logs.
205
+ logger.warning("ensure_daemon error: %s (run `slm doctor`)", exc)
203
206
  return False
204
207
  finally:
205
208
  if lock_fd:
@@ -386,8 +389,10 @@ def _flush_observe_buffer() -> None:
386
389
  decision = auto.evaluate(content)
387
390
  if decision.capture:
388
391
  auto.capture(content, category=decision.category)
389
- except Exception:
390
- pass # Don't let one bad observation kill the batch
392
+ except Exception as exc:
393
+ # Swallow per-observation to protect the batch, but log so
394
+ # a pattern of dropped observations is visible.
395
+ logger.warning("observation dropped during batch: %s", exc)
391
396
 
392
397
  logger.info("Observe debounce: processed %d observations (from buffer)", len(batch))
393
398
 
@@ -489,16 +494,55 @@ class DaemonHandler(BaseHTTPRequestHandler):
489
494
  response = engine.recall(
490
495
  query, limit=limit, session_id=session_id,
491
496
  )
492
- results = [
493
- {"content": r.fact.content, "score": round(r.score, 4),
494
- "fact_type": getattr(r.fact.fact_type, 'value', str(r.fact.fact_type)),
495
- "fact_id": r.fact.fact_id}
496
- for r in response.results
497
- ]
497
+ # Return the same field shape as recall_worker._handle_recall,
498
+ # so MCP processes that proxy through the daemon get recall_trace-
499
+ # compatible data without a second round trip.
500
+ memory_ids = list({
501
+ r.fact.memory_id for r in response.results[:limit]
502
+ if r.fact.memory_id
503
+ })
504
+ memory_map = (
505
+ engine._db.get_memory_content_batch(memory_ids)
506
+ if memory_ids else {}
507
+ )
508
+ results = []
509
+ for r in response.results[:limit]:
510
+ fact_type = getattr(r.fact, "fact_type", None)
511
+ lifecycle = getattr(r.fact, "lifecycle", None)
512
+ results.append({
513
+ "fact_id": r.fact.fact_id,
514
+ "memory_id": r.fact.memory_id,
515
+ "content": r.fact.content[:300],
516
+ "source_content": memory_map.get(r.fact.memory_id, ""),
517
+ "score": round(r.score, 4),
518
+ "confidence": round(r.confidence, 4),
519
+ "trust_score": round(r.trust_score, 4),
520
+ "channel_scores": {
521
+ k: round(v, 4)
522
+ for k, v in (r.channel_scores or {}).items()
523
+ },
524
+ "fact_type": fact_type.value
525
+ if fact_type and hasattr(fact_type, "value") else "",
526
+ "lifecycle": lifecycle.value
527
+ if lifecycle and hasattr(lifecycle, "value") else "",
528
+ "access_count": getattr(r.fact, "access_count", 0),
529
+ "evidence_chain": list(
530
+ getattr(r, "evidence_chain", []) or []
531
+ ),
532
+ })
498
533
  self._send_json(200, {
499
- "results": results, "count": len(results),
534
+ "ok": True,
535
+ "query": query,
500
536
  "query_type": response.query_type,
537
+ "result_count": len(results),
501
538
  "retrieval_time_ms": round(response.retrieval_time_ms, 1),
539
+ "channel_weights": {
540
+ k: round(v, 3)
541
+ for k, v in (response.channel_weights or {}).items()
542
+ },
543
+ "total_candidates": getattr(response, "total_candidates", 0),
544
+ "results": results,
545
+ "count": len(results), # backward compat alias
502
546
  })
503
547
  except Exception as exc:
504
548
  self._send_json(500, {"error": str(exc)})
@@ -541,14 +585,21 @@ class DaemonHandler(BaseHTTPRequestHandler):
541
585
  body = self._read_body()
542
586
  content = body.get("content", "")
543
587
  tags = body.get("tags", "")
588
+ extra_meta = body.get("metadata") or {}
544
589
  if not content:
545
590
  self._send_json(400, {"error": "content required"})
546
591
  return
547
592
 
548
593
  engine = _get_engine()
549
594
  metadata = {"tags": tags} if tags else {}
595
+ if isinstance(extra_meta, dict):
596
+ metadata.update(extra_meta)
550
597
  fact_ids = engine.store(content, metadata=metadata)
551
- self._send_json(200, {"fact_ids": fact_ids, "count": len(fact_ids)})
598
+ self._send_json(200, {
599
+ "ok": True,
600
+ "fact_ids": fact_ids,
601
+ "count": len(fact_ids),
602
+ })
552
603
  except Exception as exc:
553
604
  self._send_json(500, {"error": str(exc)})
554
605
  return
@@ -589,17 +640,16 @@ _server_start_time = time.monotonic()
589
640
 
590
641
  def _shutdown_server() -> None:
591
642
  global _engine, _server
592
- # V3.3.28: Flush any buffered observations before shutdown
593
643
  try:
594
644
  _flush_observe_buffer()
595
- except Exception:
596
- pass
645
+ except Exception as exc:
646
+ logger.warning("flush observe buffer on shutdown failed: %s", exc)
597
647
  time.sleep(0.5)
598
648
  if _engine is not None:
599
649
  try:
600
650
  _engine.close()
601
- except Exception:
602
- pass
651
+ except Exception as exc:
652
+ logger.warning("engine close on shutdown failed: %s", exc)
603
653
  _engine = None
604
654
  if _server is not None:
605
655
  _server.shutdown()
@@ -627,6 +677,30 @@ def start_server(port: int = _DEFAULT_PORT, idle_timeout: int | None = None) ->
627
677
  "SLM_DAEMON_IDLE_TIMEOUT", str(_DEFAULT_IDLE_TIMEOUT),
628
678
  ))
629
679
 
680
+ # Banner is advisory — a broken data dir must never prevent the daemon
681
+ # from starting, so the swallow here is intentional.
682
+ try:
683
+ from superlocalmemory import __version__ as _slm_ver
684
+ from superlocalmemory.cli.version_banner import check_and_emit_upgrade_banner
685
+ check_and_emit_upgrade_banner(_slm_ver)
686
+ except Exception as exc:
687
+ logger.warning("upgrade banner on daemon start failed: %s", exc)
688
+
689
+ # Apply the v3.4.26 data-dir migration now — the daemon is the
690
+ # authoritative holder of the DB, so this is the right place to do
691
+ # it unconditionally (``migrate`` is idempotent).
692
+ try:
693
+ from pathlib import Path as _P
694
+ from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import (
695
+ is_ready as _is_ready, migrate as _migrate,
696
+ )
697
+ _data = _P(os.environ.get("SLM_DATA_DIR")
698
+ or _P.home() / ".superlocalmemory")
699
+ if not _is_ready(_data):
700
+ _migrate(_data)
701
+ except Exception as exc:
702
+ logger.warning("v3.4.26 migration on daemon start failed: %s", exc)
703
+
630
704
  # Write PID + port files
631
705
  _PID_FILE.parent.mkdir(parents=True, exist_ok=True)
632
706
  _PID_FILE.write_text(str(os.getpid()))
@@ -0,0 +1,152 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """slm doctor — preflight and health checks.
6
+
7
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import platform
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ _DEFAULT_DATA_DIR = Path.home() / ".superlocalmemory"
19
+
20
+
21
+ def _check_python() -> dict[str, Any]:
22
+ v = sys.version_info
23
+ ok = (v.major, v.minor) >= (3, 10)
24
+ return {
25
+ "name": "python",
26
+ "status": "ok" if ok else "error",
27
+ "detail": f"Python {v.major}.{v.minor}.{v.micro}",
28
+ "hint": None if ok else "SLM requires Python >= 3.10",
29
+ }
30
+
31
+
32
+ def _check_platform() -> dict[str, Any]:
33
+ return {
34
+ "name": "platform",
35
+ "status": "ok",
36
+ "detail": f"{platform.system()} {platform.release()} {platform.machine()}",
37
+ "hint": None,
38
+ }
39
+
40
+
41
+ def _check_portalocker() -> dict[str, Any]:
42
+ try:
43
+ import portalocker # noqa: F401
44
+ return {
45
+ "name": "portalocker",
46
+ "status": "ok",
47
+ "detail": "installed",
48
+ "hint": None,
49
+ }
50
+ except ImportError:
51
+ return {
52
+ "name": "portalocker",
53
+ "status": "error",
54
+ "detail": "not installed",
55
+ "hint": "pip install --upgrade superlocalmemory",
56
+ }
57
+
58
+
59
+ def _check_data_dir(data_dir: Path) -> dict[str, Any]:
60
+ from superlocalmemory.core import safe_fs
61
+ try:
62
+ safe_fs.validate_data_dir(data_dir)
63
+ return {
64
+ "name": "data directory",
65
+ "status": "ok",
66
+ "detail": str(data_dir),
67
+ "hint": None,
68
+ }
69
+ except safe_fs.SafeFsError as exc:
70
+ return {
71
+ "name": "data directory",
72
+ "status": "error",
73
+ "detail": str(data_dir),
74
+ "hint": str(exc),
75
+ }
76
+
77
+
78
+ def _check_migration(data_dir: Path) -> dict[str, Any]:
79
+ from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import is_ready
80
+ ok = is_ready(data_dir)
81
+ return {
82
+ "name": "v3.4.26 migration",
83
+ "status": "ok" if ok else "warn",
84
+ "detail": "ready" if ok else "not migrated yet",
85
+ "hint": None if ok else
86
+ "Run once: python -m superlocalmemory.migrations.v3_4_25_to_v3_4_26",
87
+ }
88
+
89
+
90
+ def _check_queue_db(data_dir: Path) -> dict[str, Any]:
91
+ queue = data_dir / "recall_queue.db"
92
+ if queue.exists():
93
+ return {
94
+ "name": "queue database",
95
+ "status": "ok",
96
+ "detail": f"{queue} ({queue.stat().st_size} bytes)",
97
+ "hint": None,
98
+ }
99
+ return {
100
+ "name": "queue database",
101
+ "status": "warn",
102
+ "detail": "not yet created",
103
+ "hint": "The daemon will create this on first use.",
104
+ }
105
+
106
+
107
+ def run_checks(*, data_dir: Path | None = None) -> dict[str, Any]:
108
+ dd = Path(data_dir) if data_dir is not None else _DEFAULT_DATA_DIR
109
+ checks = [
110
+ _check_python(),
111
+ _check_platform(),
112
+ _check_portalocker(),
113
+ _check_data_dir(dd),
114
+ _check_migration(dd),
115
+ _check_queue_db(dd),
116
+ ]
117
+ has_error = any(c["status"] == "error" for c in checks)
118
+ exit_code = 1 if has_error else 0
119
+ return {
120
+ "data_dir": str(dd),
121
+ "checks": checks,
122
+ "exit_code": exit_code,
123
+ }
124
+
125
+
126
+ def _print_report(report: dict[str, Any]) -> None:
127
+ glyphs = {"ok": "\u2713", "warn": "\u26a0", "error": "\u2717"}
128
+ print(f"SLM doctor — data dir: {report['data_dir']}\n")
129
+ for c in report["checks"]:
130
+ g = glyphs.get(c["status"], "?")
131
+ print(f" {g} {c['name']:<20} {c['detail']}")
132
+ if c.get("hint"):
133
+ print(f" {c['hint']}")
134
+ print(f"\nExit code: {report['exit_code']}")
135
+
136
+
137
+ def main(argv: list[str] | None = None) -> int:
138
+ import argparse
139
+ parser = argparse.ArgumentParser(prog="slm-doctor")
140
+ parser.add_argument("--json", action="store_true", help="JSON output")
141
+ parser.add_argument("--data-dir", type=Path, default=None)
142
+ args = parser.parse_args(argv)
143
+ report = run_checks(data_dir=args.data_dir)
144
+ if args.json:
145
+ print(json.dumps(report, indent=2))
146
+ else:
147
+ _print_report(report)
148
+ return int(report["exit_code"])
149
+
150
+
151
+ if __name__ == "__main__":
152
+ raise SystemExit(main())
@@ -79,6 +79,34 @@ def main() -> None:
79
79
  from superlocalmemory.cli.json_output import _get_version
80
80
  _ver = _get_version()
81
81
 
82
+ # One-time post-upgrade banner — silent for fresh installs and
83
+ # same-version runs. Guarded against I/O errors internally.
84
+ from superlocalmemory.cli.version_banner import check_and_emit_upgrade_banner
85
+ if check_and_emit_upgrade_banner(_ver):
86
+ # First post-upgrade invocation: apply the data-dir migration if
87
+ # it's safe. When the previous-version daemon is still running
88
+ # we defer — the next daemon start picks it up.
89
+ try:
90
+ import logging as _logging
91
+ from pathlib import Path as _P
92
+ from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import (
93
+ migrate_if_safe as _migrate_if_safe,
94
+ )
95
+ _data = _P(_os.environ.get("SLM_DATA_DIR")
96
+ or _P.home() / ".superlocalmemory")
97
+ _res = _migrate_if_safe(_data)
98
+ if _res.get("status") == "deferred":
99
+ print(
100
+ " note: data migration deferred — the running SLM "
101
+ "daemon will apply it on its next restart."
102
+ )
103
+ except Exception as _mig_exc:
104
+ import logging as _logging
105
+ _logging.getLogger(__name__).warning(
106
+ "v3.4.26 migrate_if_safe failed: %s — run `slm doctor`", _mig_exc,
107
+ )
108
+ print(" note: data migration check failed — run `slm doctor` to diagnose.")
109
+
82
110
  parser = argparse.ArgumentParser(
83
111
  prog="slm",
84
112
  description=f"SuperLocalMemory V3 ({_ver}) — AI agent memory with mathematical foundations",
@@ -24,12 +24,21 @@ License: Elastic-2.0
24
24
  from __future__ import annotations
25
25
 
26
26
  import json
27
+ import os
27
28
  import sqlite3
28
29
  import time
29
30
  from pathlib import Path
30
31
 
31
- _DEFAULT_DIR = Path.home() / ".superlocalmemory"
32
+
33
+ def _default_dir() -> Path:
34
+ """Honor SLM_DATA_DIR so tests can isolate via tmp_path."""
35
+ return Path(os.environ.get("SLM_DATA_DIR") or Path.home() / ".superlocalmemory")
36
+
37
+
32
38
  _PENDING_DB = "pending.db"
39
+ _MAX_RETRIES = 3
40
+ _STUCK_DAYS = 7
41
+ _DEAD_LETTER_DAYS = 30
33
42
 
34
43
  _SCHEMA = """
35
44
  CREATE TABLE IF NOT EXISTS pending_memories (
@@ -47,7 +56,7 @@ CREATE TABLE IF NOT EXISTS pending_memories (
47
56
 
48
57
  def _get_db(base_dir: Path | None = None) -> sqlite3.Connection:
49
58
  """Open pending.db with WAL mode. Creates if needed."""
50
- d = base_dir or _DEFAULT_DIR
59
+ d = base_dir or _default_dir()
51
60
  d.mkdir(parents=True, exist_ok=True)
52
61
  db_path = d / _PENDING_DB
53
62
  conn = sqlite3.connect(str(db_path), timeout=5)
@@ -128,7 +137,7 @@ def mark_failed(row_id: int, error: str, base_dir: Path | None = None) -> None:
128
137
 
129
138
  def pending_count(base_dir: Path | None = None) -> int:
130
139
  """Count unprocessed pending memories."""
131
- d = base_dir or _DEFAULT_DIR
140
+ d = base_dir or _default_dir()
132
141
  db_path = d / _PENDING_DB
133
142
  if not db_path.exists():
134
143
  return 0
@@ -156,3 +165,46 @@ def cleanup_done(days: int = 7, base_dir: Path | None = None) -> int:
156
165
  return cur.rowcount
157
166
  finally:
158
167
  conn.close()
168
+
169
+
170
+ def cleanup_stale(base_dir: Path | None = None) -> dict[str, int]:
171
+ """Sweep stale rows from pending.db. Runs periodically from the daemon.
172
+
173
+ Removes:
174
+ - `done` rows older than 7 days (already processed)
175
+ - `failed` rows that exceeded max retries (moved to dead-letter via deletion)
176
+ - `pending` rows stuck more than 7 days (test pollution, crashed workers)
177
+ - Everything older than 30 days regardless of status (hard cap)
178
+ """
179
+ conn = _get_db(base_dir)
180
+ try:
181
+ done = conn.execute(
182
+ "DELETE FROM pending_memories WHERE status = 'done' "
183
+ "AND created_at < datetime('now', ?)",
184
+ (f"-{_STUCK_DAYS} days",),
185
+ ).rowcount
186
+ failed = conn.execute(
187
+ "DELETE FROM pending_memories WHERE status = 'failed' "
188
+ "AND retry_count >= ?",
189
+ (_MAX_RETRIES,),
190
+ ).rowcount
191
+ stuck = conn.execute(
192
+ "DELETE FROM pending_memories WHERE status = 'pending' "
193
+ "AND created_at < datetime('now', ?)",
194
+ (f"-{_STUCK_DAYS} days",),
195
+ ).rowcount
196
+ hard_cap = conn.execute(
197
+ "DELETE FROM pending_memories "
198
+ "WHERE created_at < datetime('now', ?)",
199
+ (f"-{_DEAD_LETTER_DAYS} days",),
200
+ ).rowcount
201
+ conn.commit()
202
+ return {
203
+ "done": done,
204
+ "failed_over_retries": failed,
205
+ "stuck_pending": stuck,
206
+ "hard_cap_expired": hard_cap,
207
+ "total": done + failed + stuck + hard_cap,
208
+ }
209
+ finally:
210
+ conn.close()
@@ -21,6 +21,21 @@ def run_post_install():
21
21
  print("=" * 30)
22
22
  print()
23
23
 
24
+ # Upgrade banner — fires when a prior-version user runs
25
+ # ``npm install -g superlocalmemory@latest``. Silent on fresh
26
+ # installs (setup wizard handles welcome).
27
+ try:
28
+ from superlocalmemory import __version__ as _slm_ver
29
+ from superlocalmemory.cli.version_banner import (
30
+ check_and_emit_upgrade_banner,
31
+ )
32
+ if check_and_emit_upgrade_banner(_slm_ver):
33
+ # Upgrade detected — the banner already covered it;
34
+ # skip the V2 path + fresh-install copy and return.
35
+ return
36
+ except Exception:
37
+ pass
38
+
24
39
  # Step 1: Check for V2 installation
25
40
  from superlocalmemory.storage.v2_migrator import V2Migrator
26
41
 
@@ -556,6 +556,26 @@ def run_wizard(auto: bool = False) -> None:
556
556
  print(" ⚠ Skipped (sentence-transformers not installed)")
557
557
  verified = False
558
558
 
559
+ # v3.4.26 options — data-dir safety + queue toggle. One prompt max.
560
+ try:
561
+ from superlocalmemory.cli.wizard_v3426_options import (
562
+ persist_v3426_options,
563
+ prompt_v3426_options,
564
+ validate_install_data_dir,
565
+ )
566
+ ok, reason = validate_install_data_dir(_SLM_HOME)
567
+ if not ok:
568
+ print()
569
+ print(" ⚠ Data directory check failed:")
570
+ for line in reason.splitlines():
571
+ print(f" {line}")
572
+ print()
573
+ v3426_opts = prompt_v3426_options(interactive=interactive)
574
+ persist_v3426_options(v3426_opts, _SLM_HOME)
575
+ except Exception as exc:
576
+ # Wizard must never crash over an advisory feature.
577
+ print(f" (v3.4.26 options step skipped: {exc})")
578
+
559
579
  # -- Done --
560
580
  _mark_complete()
561
581