superlocalmemory 3.1.0 → 3.2.0

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 CHANGED
@@ -16,6 +16,22 @@ SuperLocalMemory V3 - Intelligent local memory system for AI coding assistants.
16
16
 
17
17
  ---
18
18
 
19
+ ## [3.2.0] - 2026-03-26
20
+
21
+ ### Added
22
+ - **`slm doctor` command** — comprehensive pre-flight check: Python version, all dependency groups, embedding worker functional test, Ollama connectivity, API key validation, disk space, database integrity. Supports `--json` for agent-native output.
23
+ - **`slm hooks install`** listed in CLI reference and README.
24
+ - Dashboard, learning (lightgbm), and performance (diskcache, orjson) dependencies now install automatically during `npm install`.
25
+
26
+ ### Fixed
27
+ - **Warmup reliability** — increased subprocess timeout from 60s to 180s for first-time model download. Added step-by-step progress output and direct in-process import diagnostics when worker fails.
28
+ - **Mode B default model** — changed from `phi3:mini` to `llama3.2` to match `provider_presets()` and reduce first-time setup friction.
29
+ - **postinstall.js** — now installs all 5 dependency groups (core, search, dashboard, learning, performance) with clear status messages per group.
30
+ - **Error messages** — all embedding worker failures, engine fallbacks, and dashboard errors now suggest `slm doctor` for diagnosis.
31
+ - **pyproject.toml** — added `diskcache` and `orjson` to core dependencies; aligned optional dependency versions with core.
32
+
33
+ ---
34
+
19
35
  ## [3.0.31] - 2026-03-21
20
36
 
21
37
  ### Fixed
package/README.md CHANGED
@@ -54,6 +54,7 @@ Mathematical layers contribute **+12.7 percentage points** on average across 6 c
54
54
  ```bash
55
55
  npm install -g superlocalmemory
56
56
  slm setup # Choose mode (A/B/C)
57
+ slm doctor # Verify everything is working
57
58
  slm warmup # Pre-download embedding model (~500MB, optional)
58
59
  ```
59
60
 
@@ -84,7 +85,7 @@ slm status
84
85
  }
85
86
  ```
86
87
 
87
- 24 MCP tools available. Works with Claude Code, Cursor, Windsurf, VS Code Copilot, Continue, Cody, ChatGPT Desktop, Gemini CLI, JetBrains, Zed, and 17+ AI tools.
88
+ 27 MCP tools + 7 resources available. Works with Claude Code, Cursor, Windsurf, VS Code Copilot, Continue, Cody, ChatGPT Desktop, Gemini CLI, JetBrains, Zed, and 17+ AI tools. **V3.1: Active Memory tools auto-learn your patterns.**
88
89
 
89
90
  ### Dual Interface: MCP + CLI
90
91
 
@@ -247,6 +248,42 @@ slm dashboard # Opens at http://localhost:8765
247
248
 
248
249
  ---
249
250
 
251
+ ## Active Memory (V3.1) — Memory That Learns
252
+
253
+ Most AI memory systems are passive databases — you store, you search, you get results. **SuperLocalMemory learns.**
254
+
255
+ Every recall you make generates learning signals. Over time, the system adapts to your patterns:
256
+
257
+ | Phase | Signals | What Happens |
258
+ |-------|---------|-------------|
259
+ | **Baseline** | 0-19 | Cross-encoder ranking (default behavior) |
260
+ | **Rule-Based** | 20+ | Heuristic boosts: recency, access count, trust score |
261
+ | **ML Model** | 200+ | LightGBM model trained on YOUR usage patterns |
262
+
263
+ ### Zero-Cost Learning Signals
264
+ No LLM tokens spent. Four mathematical signals computed locally:
265
+ - **Co-Retrieval** — memories retrieved together strengthen their connections
266
+ - **Confidence Lifecycle** — accessed facts get boosted, unused facts decay
267
+ - **Channel Performance** — tracks which retrieval channel works best for your queries
268
+ - **Entropy Gap** — surprising content gets prioritized for deeper indexing
269
+
270
+ ### Auto-Capture & Auto-Recall
271
+ ```bash
272
+ slm hooks install # Install Claude Code hooks for invisible injection
273
+ slm observe "We decided to use PostgreSQL" # Auto-detects decisions, bugs, preferences
274
+ slm session-context # Get relevant context at session start
275
+ ```
276
+
277
+ ### MCP Active Memory Tools
278
+ Three new tools for AI assistants:
279
+ - `session_init` — call at session start, get relevant project context automatically
280
+ - `observe` — send conversation content, auto-captures decisions/bugs/preferences
281
+ - `report_feedback` — explicit feedback for faster learning
282
+
283
+ **No competitor learns at zero token cost.** Mem0, Zep, and Letta all require cloud LLM calls for their learning loops. SLM learns through mathematics.
284
+
285
+ ---
286
+
250
287
  ## Features
251
288
 
252
289
  ### Retrieval
@@ -287,13 +324,15 @@ slm dashboard # Opens at http://localhost:8765
287
324
  | `slm trace "..."` | Recall with per-channel score breakdown |
288
325
  | `slm status` | System status |
289
326
  | `slm health` | Math layer health (Fisher, Sheaf, Langevin) |
327
+ | `slm doctor` | Pre-flight check (deps, worker, Ollama, database) |
290
328
  | `slm mode a/b/c` | Switch operating mode |
291
329
  | `slm setup` | Interactive first-time wizard |
292
330
  | `slm warmup` | Pre-download embedding model |
293
331
  | `slm migrate` | V2 to V3 migration |
294
- | `slm dashboard` | Launch web dashboard |
332
+ | `slm dashboard` | Launch 17-tab web dashboard |
295
333
  | `slm mcp` | Start MCP server (for IDE integration) |
296
334
  | `slm connect` | Configure IDE integrations |
335
+ | `slm hooks install` | Wire auto-memory into Claude Code hooks |
297
336
  | `slm profile list/create/switch` | Profile management |
298
337
 
299
338
  ---
@@ -331,13 +370,16 @@ slm dashboard # Opens at http://localhost:8765
331
370
  | **Node.js** | 14+ | npm package manager |
332
371
  | **Python** | 3.11+ | V3 engine runtime |
333
372
 
334
- All Python dependencies install automatically during `npm install`. If anything fails, the installer shows exact fix commands. BM25 keyword search works even without embeddings — you're never fully blocked.
373
+ All Python dependencies install automatically during `npm install` — core math, dashboard server, learning engine, and performance optimizations. If anything fails, the installer shows exact fix commands. Run `slm doctor` after install to verify everything works. BM25 keyword search works even without embeddings — you're never fully blocked.
335
374
 
336
375
  | Component | Size | When |
337
376
  |:----------|:-----|:-----|
338
377
  | Core libraries (numpy, scipy, networkx) | ~50MB | During install |
378
+ | Dashboard & MCP server (fastapi, uvicorn) | ~20MB | During install |
379
+ | Learning engine (lightgbm) | ~10MB | During install |
339
380
  | Search engine (sentence-transformers, torch) | ~200MB | During install |
340
381
  | Embedding model (nomic-embed-text-v1.5, 768d) | ~500MB | First use or `slm warmup` |
382
+ | **Mode B** requires [Ollama](https://ollama.com) + a model (`ollama pull llama3.2`) | ~2GB | Manual |
341
383
 
342
384
  ---
343
385
 
@@ -2,7 +2,7 @@
2
2
  > SuperLocalMemory V3 Documentation
3
3
  > https://superlocalmemory.com | Part of Qualixar
4
4
 
5
- Get your AI's memory system running in under 5 minutes.
5
+ Get your AI's memory system running in under 5 minutes. **V3.1: Now with Active Memory — your memory learns from your usage and gets smarter over time, at zero token cost.**
6
6
 
7
7
  ---
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
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.1.0"
3
+ version = "3.2.0"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -27,27 +27,29 @@ dependencies = [
27
27
  "uvicorn>=0.42.0",
28
28
  "websockets>=16.0",
29
29
  "lightgbm>=4.0.0",
30
+ "diskcache>=5.6.0",
31
+ "orjson>=3.9.0",
30
32
  ]
31
33
 
32
34
  [project.optional-dependencies]
33
35
  search = [
34
36
  "sentence-transformers>=2.5.0,<4.0.0",
35
- "einops>=0.7.0,<1.0.0",
37
+ "einops>=0.8.2",
36
38
  "torch>=2.2.0",
37
39
  "scikit-learn>=1.3.0,<2.0.0",
38
40
  "geoopt>=0.5.0",
39
41
  ]
40
42
  ui = [
41
- "fastapi>=0.109.0,<1.0.0",
42
- "uvicorn[standard]>=0.27.0,<1.0.0",
43
+ "fastapi[all]>=0.135.1",
44
+ "uvicorn>=0.42.0",
43
45
  "python-multipart>=0.0.6,<1.0.0",
44
46
  ]
45
47
  learning = [
46
- "lightgbm>=4.0.0,<5.0.0",
48
+ "lightgbm>=4.0.0",
47
49
  ]
48
50
  performance = [
49
- "diskcache>=5.6.0,<6.0.0",
50
- "orjson>=3.9.0,<4.0.0",
51
+ "diskcache>=5.6.0",
52
+ "orjson>=3.9.0",
51
53
  ]
52
54
  full = [
53
55
  "superlocalmemory[search,ui,learning,performance]",
@@ -102,6 +102,7 @@ const coreDeps = [
102
102
  'numpy>=1.26.0', 'scipy>=1.12.0', 'networkx>=3.0',
103
103
  'httpx>=0.24.0', 'python-dateutil>=2.9.0',
104
104
  'rank-bm25>=0.2.2', 'vaderSentiment>=3.3.2',
105
+ 'einops>=0.8.2', 'mcp>=1.0.0',
105
106
  ];
106
107
 
107
108
  if (pipInstall(coreDeps, 'core')) {
@@ -127,6 +128,35 @@ if (pipInstall(searchDeps, 'search')) {
127
128
  console.log(' pip install sentence-transformers einops geoopt');
128
129
  }
129
130
 
131
+ // Dashboard dependencies (IMPORTANT — enables web dashboard + MCP server)
132
+ const dashboardDeps = ['fastapi[all]>=0.135.1', 'uvicorn>=0.42.0', 'websockets>=16.0'];
133
+ console.log('\nInstalling dashboard & server dependencies...');
134
+ if (pipInstall(dashboardDeps, 'dashboard')) {
135
+ console.log('✓ Dashboard & MCP server dependencies installed (fastapi + uvicorn)');
136
+ } else {
137
+ console.log('⚠ Dashboard installation failed.');
138
+ console.log(' Run manually: pip install \'fastapi[all]\' uvicorn websockets');
139
+ }
140
+
141
+ // Learning dependencies (enables adaptive retrieval after 200+ signals)
142
+ const learningDeps = ['lightgbm>=4.0.0'];
143
+ console.log('\nInstalling learning engine...');
144
+ if (pipInstall(learningDeps, 'learning')) {
145
+ console.log('✓ Learning engine installed (lightgbm — adaptive ranking)');
146
+ } else {
147
+ console.log('⚠ Learning installation failed (retrieval still works without it).');
148
+ console.log(' Run manually: pip install lightgbm');
149
+ }
150
+
151
+ // Performance dependencies (optional — improves caching and JSON speed)
152
+ const perfDeps = ['diskcache>=5.6.0', 'orjson>=3.9.0'];
153
+ console.log('\nInstalling performance optimizations...');
154
+ if (pipInstall(perfDeps, 'performance')) {
155
+ console.log('✓ Performance optimizations installed (diskcache + orjson)');
156
+ } else {
157
+ console.log('⚠ Performance deps skipped (system works fine without them).');
158
+ }
159
+
130
160
  // --- Step 4: Detect V2 installation ---
131
161
  const V2_HOME = path.join(os.homedir(), '.claude-memory');
132
162
  if (fs.existsSync(V2_HOME) && fs.existsSync(path.join(V2_HOME, 'memory.db'))) {
@@ -149,13 +179,17 @@ console.log(' ✓ SuperLocalMemory V3 installed successfully!');
149
179
  console.log('');
150
180
  console.log(' Quick start:');
151
181
  console.log(' slm setup # First-time configuration');
152
- console.log(' slm status # Check system status');
182
+ console.log(' slm doctor # Pre-flight check (verify everything works)');
183
+ console.log(' slm warmup # Pre-download embedding model (~500MB)');
153
184
  console.log(' slm remember "..." # Store a memory');
154
185
  console.log(' slm recall "..." # Search memories');
186
+ console.log(' slm dashboard # Open 17-tab web dashboard');
155
187
  console.log('');
156
188
  console.log(' Prerequisites satisfied:');
157
189
  console.log(' ✓ Python 3.11+');
158
190
  console.log(' ✓ Core math & search libraries');
191
+ console.log(' ✓ Dashboard server (fastapi, uvicorn)');
192
+ console.log(' ✓ Learning engine (lightgbm)');
159
193
  console.log(' ✓ Data directory (~/.superlocalmemory/)');
160
194
  console.log('');
161
195
  console.log(' Docs: https://github.com/qualixar/superlocalmemory/wiki');
@@ -32,6 +32,7 @@ def dispatch(args: Namespace) -> None:
32
32
  "update": cmd_update,
33
33
  "status": cmd_status,
34
34
  "health": cmd_health,
35
+ "doctor": cmd_doctor,
35
36
  "trace": cmd_trace,
36
37
  "mcp": cmd_mcp,
37
38
  "warmup": cmd_warmup,
@@ -291,6 +292,12 @@ def cmd_recall(args: Namespace) -> None:
291
292
  ])
292
293
  return
293
294
 
295
+ # Record learning signals (CLI path — works without MCP)
296
+ try:
297
+ _cli_record_signals(config, args.query, response.results)
298
+ except Exception:
299
+ pass
300
+
294
301
  if not response.results:
295
302
  print("No memories found.")
296
303
  return
@@ -298,6 +305,26 @@ def cmd_recall(args: Namespace) -> None:
298
305
  print(f" {i}. [{r.score:.2f}] {r.fact.content[:120]}")
299
306
 
300
307
 
308
+ def _cli_record_signals(config, query, results):
309
+ """Record learning signals from CLI recall (no MCP dependency)."""
310
+ from pathlib import Path
311
+ from superlocalmemory.learning.feedback import FeedbackCollector
312
+ from superlocalmemory.learning.signals import LearningSignals
313
+ slm_dir = Path.home() / ".superlocalmemory"
314
+ pid = config.active_profile
315
+ fact_ids = [r.fact.fact_id for r in results[:10]]
316
+ if not fact_ids:
317
+ return
318
+ FeedbackCollector(slm_dir / "learning.db").record_implicit(
319
+ profile_id=pid, query=query,
320
+ fact_ids_returned=fact_ids, fact_ids_available=fact_ids,
321
+ )
322
+ signals = LearningSignals(slm_dir / "learning.db")
323
+ signals.record_co_retrieval(pid, fact_ids)
324
+ for fid in fact_ids[:5]:
325
+ LearningSignals.boost_confidence(str(slm_dir / "memory.db"), fid)
326
+
327
+
301
328
  def cmd_forget(args: Namespace) -> None:
302
329
  """Delete memories matching a query."""
303
330
  from superlocalmemory.core.engine import MemoryEngine
@@ -566,6 +593,254 @@ def cmd_health(args: Namespace) -> None:
566
593
  print(f" Mode: {config.mode.value.upper()}")
567
594
 
568
595
 
596
+ def cmd_doctor(args: Namespace) -> None:
597
+ """Comprehensive pre-flight check — verify everything works."""
598
+ import shutil
599
+ from pathlib import Path
600
+
601
+ use_json = getattr(args, "json", False)
602
+ checks: list[dict] = []
603
+ passed = warned = failed = 0
604
+
605
+ def _check(name: str, status: str, detail: str, fix: str = ""):
606
+ nonlocal passed, warned, failed
607
+ checks.append({"name": name, "status": status, "detail": detail, "fix": fix})
608
+ if status == "PASS":
609
+ passed += 1
610
+ elif status == "WARN":
611
+ warned += 1
612
+ else:
613
+ failed += 1
614
+ if not use_json:
615
+ tag = {"PASS": "[PASS]", "WARN": "[WARN]", "FAIL": "[FAIL]"}[status]
616
+ line = f" {tag} {name}: {detail}"
617
+ if fix:
618
+ line += f"\n Fix: {fix}"
619
+ print(line)
620
+
621
+ if not use_json:
622
+ print("SuperLocalMemory V3 — Doctor (Pre-flight Check)")
623
+ print("=" * 50)
624
+ print()
625
+
626
+ # 1. Python version
627
+ v = sys.version_info
628
+ if v >= (3, 11):
629
+ _check("Python", "PASS", f"{v.major}.{v.minor}.{v.micro} (>= 3.11)")
630
+ else:
631
+ _check("Python", "FAIL", f"{v.major}.{v.minor}.{v.micro} (need >= 3.11)",
632
+ "Install Python 3.11+ from https://python.org/downloads/")
633
+
634
+ # 2. Core deps
635
+ core_modules = {
636
+ "numpy": "numpy", "scipy": "scipy", "networkx": "networkx",
637
+ "httpx": "httpx", "dateutil": "python-dateutil",
638
+ "rank_bm25": "rank-bm25", "vaderSentiment": "vadersentiment",
639
+ "einops": "einops",
640
+ }
641
+ core_ok, core_versions = [], []
642
+ for mod, pkg in core_modules.items():
643
+ try:
644
+ m = __import__(mod)
645
+ ver = getattr(m, "__version__", "?")
646
+ core_ok.append(mod)
647
+ core_versions.append(f"{mod} {ver}")
648
+ except ImportError:
649
+ pass
650
+ if len(core_ok) == len(core_modules):
651
+ _check("Core deps", "PASS", ", ".join(core_versions[:4]) + "...")
652
+ else:
653
+ missing = set(core_modules) - set(core_ok)
654
+ _check("Core deps", "FAIL", f"Missing: {', '.join(missing)}",
655
+ "pip install " + " ".join(core_modules[m] for m in missing))
656
+
657
+ # 3. Search deps
658
+ search_mods = {"sentence_transformers": "sentence-transformers", "torch": "torch",
659
+ "sklearn": "scikit-learn", "geoopt": "geoopt"}
660
+ search_ok = []
661
+ for mod, pkg in search_mods.items():
662
+ try:
663
+ __import__(mod)
664
+ search_ok.append(mod)
665
+ except ImportError:
666
+ pass
667
+ if len(search_ok) == len(search_mods):
668
+ _check("Search deps", "PASS", "sentence-transformers, torch, sklearn, geoopt")
669
+ else:
670
+ missing = set(search_mods) - set(search_ok)
671
+ _check("Search deps", "WARN", f"Missing: {', '.join(missing)}",
672
+ "pip install 'superlocalmemory[search]'")
673
+
674
+ # 4. Dashboard deps
675
+ dash_ok = True
676
+ for mod in ["fastapi", "uvicorn", "websockets"]:
677
+ try:
678
+ __import__(mod)
679
+ except ImportError:
680
+ dash_ok = False
681
+ break
682
+ if dash_ok:
683
+ _check("Dashboard deps", "PASS", "fastapi, uvicorn, websockets")
684
+ else:
685
+ _check("Dashboard deps", "WARN", "Missing dashboard deps",
686
+ "pip install 'fastapi[all]' uvicorn websockets")
687
+
688
+ # 5. Learning deps
689
+ try:
690
+ import lightgbm
691
+ _check("Learning deps", "PASS", f"lightgbm {lightgbm.__version__}")
692
+ except ImportError:
693
+ _check("Learning deps", "WARN", "lightgbm not installed",
694
+ "pip install lightgbm")
695
+ except OSError as exc:
696
+ _check("Learning deps", "WARN", f"lightgbm installed but broken: {exc}",
697
+ "brew install libomp && pip install --force-reinstall lightgbm")
698
+
699
+ # 6. Performance deps
700
+ perf_ok = []
701
+ for mod in ["diskcache", "orjson"]:
702
+ try:
703
+ __import__(mod)
704
+ perf_ok.append(mod)
705
+ except ImportError:
706
+ pass
707
+ if len(perf_ok) == 2:
708
+ _check("Performance deps", "PASS", "diskcache, orjson")
709
+ else:
710
+ missing = {"diskcache", "orjson"} - set(perf_ok)
711
+ _check("Performance deps", "WARN", f"Missing: {', '.join(missing)}",
712
+ "pip install diskcache orjson")
713
+
714
+ # 7. Embedding worker functional test
715
+ try:
716
+ import subprocess as _sp
717
+ import json as _json
718
+
719
+ env = {
720
+ **__import__("os").environ,
721
+ "CUDA_VISIBLE_DEVICES": "",
722
+ "PYTORCH_MPS_HIGH_WATERMARK_RATIO": "0.0",
723
+ "TOKENIZERS_PARALLELISM": "false",
724
+ "TORCH_DEVICE": "cpu",
725
+ }
726
+ proc = _sp.Popen(
727
+ [sys.executable, "-m", "superlocalmemory.core.embedding_worker"],
728
+ stdin=_sp.PIPE, stdout=_sp.PIPE, stderr=_sp.DEVNULL,
729
+ text=True, bufsize=1, env=env,
730
+ )
731
+ proc.stdin.write(_json.dumps({"cmd": "ping"}) + "\n")
732
+ proc.stdin.flush()
733
+
734
+ import select as _sel
735
+ ready, _, _ = _sel.select([proc.stdout], [], [], 30)
736
+ if ready:
737
+ resp = _json.loads(proc.stdout.readline())
738
+ if resp.get("ok"):
739
+ _check("Embedding worker", "PASS",
740
+ f"responsive (PID {proc.pid}, Python {sys.executable})")
741
+ else:
742
+ _check("Embedding worker", "FAIL",
743
+ f"error: {resp.get('error', 'unknown')}",
744
+ "pip install sentence-transformers einops torch")
745
+ else:
746
+ _check("Embedding worker", "FAIL", "timed out (30s)",
747
+ "slm warmup")
748
+ proc.stdin.write(_json.dumps({"cmd": "quit"}) + "\n")
749
+ proc.stdin.flush()
750
+ proc.wait(timeout=5)
751
+ except FileNotFoundError:
752
+ _check("Embedding worker", "FAIL", "embedding_worker module not found",
753
+ "Reinstall: npm install -g superlocalmemory")
754
+ except Exception as exc:
755
+ _check("Embedding worker", "FAIL", str(exc),
756
+ "slm warmup")
757
+
758
+ # 8. Ollama connectivity (Mode B only)
759
+ try:
760
+ from superlocalmemory.core.config import SLMConfig
761
+ config = SLMConfig.load()
762
+ if config.mode.value == "b":
763
+ import httpx
764
+ try:
765
+ resp = httpx.get(
766
+ f"{config.llm.api_base}/api/tags", timeout=5.0,
767
+ )
768
+ if resp.status_code == 200:
769
+ models = [m["name"].split(":")[0] for m in resp.json().get("models", [])]
770
+ has_llm = config.llm.model.split(":")[0] in models
771
+ if has_llm:
772
+ _check("Ollama", "PASS",
773
+ f"running, {len(models)} models, '{config.llm.model}' available")
774
+ else:
775
+ _check("Ollama", "WARN",
776
+ f"running but '{config.llm.model}' not pulled",
777
+ f"ollama pull {config.llm.model}")
778
+ else:
779
+ _check("Ollama", "WARN", f"HTTP {resp.status_code}",
780
+ "brew services start ollama")
781
+ except Exception:
782
+ _check("Ollama", "WARN", "not reachable at " + config.llm.api_base,
783
+ "brew services start ollama")
784
+ elif config.mode.value == "c":
785
+ # Mode C — check API key
786
+ if config.llm.api_key:
787
+ _check("API key", "PASS",
788
+ f"provider={config.llm.provider}, key=***{config.llm.api_key[-4:]}")
789
+ else:
790
+ _check("API key", "WARN", "no API key configured",
791
+ "slm provider set")
792
+ except Exception:
793
+ pass # Config load failed — already caught above
794
+
795
+ # 9. Disk space
796
+ slm_home = Path.home() / ".superlocalmemory"
797
+ try:
798
+ usage = shutil.disk_usage(slm_home if slm_home.exists() else Path.home())
799
+ free_gb = usage.free / (1024 ** 3)
800
+ if free_gb >= 2.0:
801
+ _check("Disk space", "PASS", f"{free_gb:.1f} GB free")
802
+ else:
803
+ _check("Disk space", "WARN", f"{free_gb:.1f} GB free (< 2 GB)",
804
+ "Free up disk space")
805
+ except Exception:
806
+ pass
807
+
808
+ # 10. Database integrity
809
+ db_path = slm_home / "memory.db"
810
+ if db_path.exists():
811
+ try:
812
+ import sqlite3
813
+ conn = sqlite3.connect(str(db_path))
814
+ result = conn.execute("PRAGMA integrity_check").fetchone()
815
+ conn.close()
816
+ if result and result[0] == "ok":
817
+ size_mb = db_path.stat().st_size / (1024 * 1024)
818
+ _check("Database", "PASS", f"OK ({size_mb:.2f} MB)")
819
+ else:
820
+ _check("Database", "FAIL", f"integrity check: {result}",
821
+ "Backup and recreate database")
822
+ except Exception as exc:
823
+ _check("Database", "FAIL", str(exc))
824
+ else:
825
+ _check("Database", "PASS", "not yet created (will initialize on first use)")
826
+
827
+ # Summary
828
+ if use_json:
829
+ from superlocalmemory.cli.json_output import json_print
830
+ next_actions = []
831
+ for c in checks:
832
+ if c["fix"]:
833
+ next_actions.append({"command": c["fix"], "description": f"Fix {c['name']}"})
834
+ json_print("doctor", data={
835
+ "checks": checks,
836
+ "summary": {"passed": passed, "warned": warned, "failed": failed},
837
+ }, next_actions=next_actions)
838
+ else:
839
+ print(f"\nSummary: {passed} passed, {warned} warnings, {failed} failed")
840
+ if failed > 0:
841
+ print("Run the suggested fix commands above, then re-run: slm doctor")
842
+
843
+
569
844
  def cmd_trace(args: Namespace) -> None:
570
845
  """Recall with per-channel score breakdown."""
571
846
  from superlocalmemory.core.engine import MemoryEngine
@@ -628,35 +903,74 @@ def cmd_mcp(_args: Namespace) -> None:
628
903
 
629
904
  def cmd_warmup(_args: Namespace) -> None:
630
905
  """Pre-download the embedding model so first use is instant."""
631
- print("Downloading embedding model (nomic-ai/nomic-embed-text-v1.5)...")
632
- print("This is ~500MB and only needed once.\n")
906
+ import superlocalmemory.core.embeddings as _emb_mod
907
+
908
+ print("SuperLocalMemory V3 — Embedding Model Warmup")
909
+ print("=" * 50)
910
+ print(f" Python: {sys.executable}")
911
+ print(f" Model: nomic-ai/nomic-embed-text-v1.5 (~500MB)")
912
+ print()
913
+
914
+ # Increase timeout for first-time download
915
+ original_timeout = _emb_mod._SUBPROCESS_RESPONSE_TIMEOUT
916
+ _emb_mod._SUBPROCESS_RESPONSE_TIMEOUT = 180 # 3 min for cold start
633
917
 
634
918
  try:
635
919
  from superlocalmemory.core.config import EmbeddingConfig
636
920
  from superlocalmemory.core.embeddings import EmbeddingService
637
921
 
638
922
  config = EmbeddingConfig()
923
+
924
+ print("Step 1/3: Spawning embedding worker subprocess...")
639
925
  svc = EmbeddingService(config)
640
926
 
641
- # Force model load (triggers download)
642
- if svc.is_available:
643
- # Verify it works
644
- emb = svc.embed("warmup test")
645
- if emb and len(emb) == config.dimension:
646
- print(f"\nModel ready: {config.model_name} ({config.dimension}-dim)")
647
- print("Semantic search is fully operational.")
648
- else:
649
- print("\nModel loaded but embedding verification failed.")
650
- print("Run: pip install sentence-transformers einops")
927
+ if not svc.is_available:
928
+ print("\n[FAIL] Embedding service not available.")
929
+ _warmup_diagnose()
930
+ return
931
+
932
+ print("Step 2/3: Loading model (may download ~500MB on first run)...")
933
+ emb = svc.embed("warmup test")
934
+
935
+ if emb and len(emb) == config.dimension:
936
+ print("Step 3/3: Verifying embedding output...")
937
+ print(f"\n[PASS] Model ready: {config.model_name} ({config.dimension}-dim)")
938
+ print("Semantic search is fully operational.")
651
939
  else:
652
- print("\nModel could not load.")
653
- print("Install dependencies: pip install sentence-transformers einops torch")
940
+ print("\n[FAIL] Model loaded but embedding verification failed.")
941
+ _warmup_diagnose()
942
+
654
943
  except ImportError as exc:
655
- print(f"\nMissing dependency: {exc}")
656
- print("Install with: pip install sentence-transformers einops torch")
944
+ print(f"\n[FAIL] Missing dependency: {exc}")
945
+ print("Fix: pip install sentence-transformers einops torch")
657
946
  except Exception as exc:
658
- print(f"\nWarmup failed: {exc}")
659
- print("Check your internet connection and try again.")
947
+ print(f"\n[FAIL] Warmup failed: {exc}")
948
+ _warmup_diagnose()
949
+ finally:
950
+ _emb_mod._SUBPROCESS_RESPONSE_TIMEOUT = original_timeout
951
+
952
+
953
+ def _warmup_diagnose() -> None:
954
+ """Diagnostic helper when warmup fails."""
955
+ print("\nDiagnosing...")
956
+ print(f" Python executable: {sys.executable}")
957
+ try:
958
+ from sentence_transformers import SentenceTransformer
959
+ print(" sentence-transformers: importable")
960
+ m = SentenceTransformer(
961
+ "nomic-ai/nomic-embed-text-v1.5", trust_remote_code=True, device="cpu",
962
+ )
963
+ v = m.encode(["test"], normalize_embeddings=True)
964
+ print(f" Direct embed: OK (dim={v.shape[1]})")
965
+ print("\n Issue: Subprocess worker failed but direct import works.")
966
+ print(" This is likely a Python path mismatch between Node.js wrapper")
967
+ print(" and your current shell. Run: slm doctor")
968
+ except ImportError as ie:
969
+ print(f" sentence-transformers: NOT importable ({ie})")
970
+ print(" Fix: pip install sentence-transformers einops torch")
971
+ except Exception as de:
972
+ print(f" Direct embed failed: {de}")
973
+ print(" Run: slm doctor")
660
974
 
661
975
 
662
976
  def cmd_dashboard(args: Namespace) -> None:
@@ -664,7 +978,8 @@ def cmd_dashboard(args: Namespace) -> None:
664
978
  try:
665
979
  import uvicorn
666
980
  except ImportError:
667
- print("Dashboard requires: pip install 'fastapi[all]' uvicorn")
981
+ print("Dashboard requires additional deps. Run: slm doctor")
982
+ print("Or install manually: pip install 'fastapi[all]' uvicorn")
668
983
  sys.exit(1)
669
984
 
670
985
  import socket
@@ -153,6 +153,10 @@ def main() -> None:
153
153
  trace_p.add_argument("query", help="Search query")
154
154
  trace_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
155
155
 
156
+ # -- Diagnostics (continued) ----------------------------------------
157
+ doctor_p = sub.add_parser("doctor", help="Pre-flight check: deps, embedding worker, connectivity")
158
+ doctor_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
159
+
156
160
  # -- Services ------------------------------------------------------
157
161
  sub.add_parser("mcp", help="Start MCP server (stdio transport for IDE integration)")
158
162
  sub.add_parser("warmup", help="Pre-download embedding model (~500MB, one-time)")
@@ -366,7 +366,7 @@ class SLMConfig:
366
366
  ),
367
367
  llm=LLMConfig(
368
368
  provider=llm_provider or "ollama",
369
- model=llm_model or "phi3:mini",
369
+ model=llm_model or "llama3.2",
370
370
  api_base=llm_api_base or "http://localhost:11434",
371
371
  api_key=llm_api_key or "",
372
372
  ),
@@ -164,7 +164,12 @@ class EmbeddingService:
164
164
  _SUBPROCESS_RESPONSE_TIMEOUT,
165
165
  )
166
166
  if not resp_line:
167
- logger.warning("Worker returned empty or timed out, restarting")
167
+ logger.warning(
168
+ "Embedding worker timed out after %ds. On first run, model "
169
+ "download can take several minutes. Run 'slm doctor' to "
170
+ "diagnose or 'slm warmup' to pre-download the model.",
171
+ _SUBPROCESS_RESPONSE_TIMEOUT,
172
+ )
168
173
  self._kill_worker()
169
174
  return None
170
175
  resp = json.loads(resp_line)
@@ -174,7 +179,11 @@ class EmbeddingService:
174
179
  self._reset_idle_timer()
175
180
  return resp["vectors"]
176
181
  except (BrokenPipeError, OSError, json.JSONDecodeError) as exc:
177
- logger.warning("Worker communication failed: %s", exc)
182
+ logger.warning(
183
+ "Embedding worker communication failed: %s. "
184
+ "Run 'slm doctor' to check dependencies and Python version.",
185
+ exc,
186
+ )
178
187
  self._kill_worker()
179
188
  return None
180
189
 
@@ -231,7 +240,12 @@ class EmbeddingService:
231
240
  logger.info("Embedding worker spawned (PID %d)", self._worker_proc.pid)
232
241
  self._worker_ready = True
233
242
  except Exception as exc:
234
- logger.warning("Failed to spawn embedding worker: %s", exc)
243
+ logger.warning(
244
+ "Failed to spawn embedding worker: %s. "
245
+ "Run 'slm doctor' to verify your Python environment. "
246
+ "Using Python: %s",
247
+ exc, sys.executable,
248
+ )
235
249
  self._available = False
236
250
  self._worker_proc = None
237
251
 
@@ -175,9 +175,9 @@ class MemoryEngine:
175
175
  emb = cls(emb_cfg)
176
176
  if emb.is_available:
177
177
  return emb
178
- logger.warning("EmbeddingService not available. BM25-only mode.")
178
+ logger.warning("EmbeddingService not available. BM25-only mode. Run 'slm doctor' to diagnose.")
179
179
  except Exception as exc:
180
- logger.warning("Embeddings unavailable (%s). BM25-only mode.", exc)
180
+ logger.warning("Embeddings unavailable (%s). BM25-only mode. Run 'slm doctor' to diagnose.", exc)
181
181
  return None
182
182
 
183
183
  def store(
@@ -169,7 +169,7 @@ class WorkerPool:
169
169
 
170
170
  resp_line = self._proc.stdout.readline()
171
171
  if not resp_line:
172
- logger.warning("Worker returned empty, restarting")
172
+ logger.warning("Worker returned empty, restarting. Run 'slm doctor' to diagnose.")
173
173
  self._kill()
174
174
  return {"ok": False, "error": "Worker died"}
175
175
 
@@ -177,7 +177,7 @@ class WorkerPool:
177
177
  return json.loads(resp_line)
178
178
 
179
179
  except (BrokenPipeError, OSError, json.JSONDecodeError) as exc:
180
- logger.warning("Worker communication failed: %s", exc)
180
+ logger.warning("Worker communication failed: %s. Run 'slm doctor' to diagnose.", exc)
181
181
  self._kill()
182
182
  return {"ok": False, "error": str(exc)}
183
183
 
@@ -207,7 +207,7 @@ class WorkerPool:
207
207
  )
208
208
  logger.info("Recall worker spawned (PID %d)", self._proc.pid)
209
209
  except Exception as exc:
210
- logger.error("Failed to spawn recall worker: %s", exc)
210
+ logger.error("Failed to spawn recall worker: %s. Run 'slm doctor' to diagnose. Python: %s", exc, sys.executable)
211
211
  self._proc = None
212
212
 
213
213
  def _kill(self) -> None:
@@ -314,3 +314,6 @@ class FeedbackCollector:
314
314
  }
315
315
  finally:
316
316
  conn.close()
317
+
318
+ # Alias used by dashboard routes
319
+ get_feedback_summary = get_summary
@@ -17,10 +17,37 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
17
17
  from __future__ import annotations
18
18
 
19
19
  import logging
20
+ from pathlib import Path
20
21
  from typing import Callable
21
22
 
22
23
  logger = logging.getLogger(__name__)
23
24
 
25
+ MEMORY_DIR = Path.home() / ".superlocalmemory"
26
+ DB_PATH = MEMORY_DIR / "memory.db"
27
+
28
+
29
+ def _emit_event(event_type: str, payload: dict | None = None,
30
+ source_agent: str = "mcp_client") -> None:
31
+ """Emit an event to the EventBus (best-effort, never raises)."""
32
+ try:
33
+ from superlocalmemory.infra.event_bus import EventBus
34
+ bus = EventBus.get_instance(str(DB_PATH))
35
+ bus.emit(event_type, payload=payload, source_agent=source_agent,
36
+ source_protocol="mcp")
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ def _register_agent(agent_id: str, profile_id: str) -> None:
42
+ """Register an agent in the AgentRegistry (best-effort)."""
43
+ try:
44
+ from superlocalmemory.core.registry import AgentRegistry
45
+ registry_path = MEMORY_DIR / "agents.json"
46
+ registry = AgentRegistry(persist_path=registry_path)
47
+ registry.register_agent(agent_id, profile_id)
48
+ except Exception:
49
+ pass
50
+
24
51
 
25
52
  def register_active_tools(server, get_engine: Callable) -> None:
26
53
  """Register 3 active memory tools on *server*."""
@@ -78,6 +105,14 @@ def register_active_tools(server, get_engine: Callable) -> None:
78
105
  except Exception:
79
106
  pass
80
107
 
108
+ # Register agent + emit event
109
+ _register_agent("mcp_client", pid)
110
+ _emit_event("agent.connected", {
111
+ "agent_id": "mcp_client",
112
+ "project_path": project_path,
113
+ "memory_count": len(memories),
114
+ })
115
+
81
116
  return {
82
117
  "success": True,
83
118
  "context": context,
@@ -148,6 +183,14 @@ def register_active_tools(server, get_engine: Callable) -> None:
148
183
  metadata={"agent_id": agent_id, "source": "auto-observe"},
149
184
  )
150
185
 
186
+ if stored:
187
+ _emit_event("memory.created", {
188
+ "agent_id": agent_id,
189
+ "category": decision.category,
190
+ "content_preview": content[:80],
191
+ "source": "auto-observe",
192
+ }, source_agent=agent_id)
193
+
151
194
  return {
152
195
  "captured": stored,
153
196
  "category": decision.category,
@@ -191,6 +234,13 @@ def register_active_tools(server, get_engine: Callable) -> None:
191
234
 
192
235
  count = engine._adaptive_learner.get_feedback_count(pid)
193
236
 
237
+ _emit_event("pattern.learned", {
238
+ "fact_id": fact_id,
239
+ "feedback": feedback,
240
+ "total_signals": count,
241
+ "phase": 1 if count < 50 else (2 if count < 200 else 3),
242
+ })
243
+
194
244
  return {
195
245
  "success": True,
196
246
  "feedback_id": record.feedback_id,
@@ -15,10 +15,25 @@ from __future__ import annotations
15
15
 
16
16
  import json
17
17
  import logging
18
+ from pathlib import Path
18
19
  from typing import Any, Callable
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
23
+ _DB_PATH = str(Path.home() / ".superlocalmemory" / "memory.db")
24
+
25
+
26
+ def _emit_event(event_type: str, payload: dict | None = None,
27
+ source_agent: str = "mcp_client") -> None:
28
+ """Emit an event to the EventBus (best-effort, never raises)."""
29
+ try:
30
+ from superlocalmemory.infra.event_bus import EventBus
31
+ bus = EventBus.get_instance(_DB_PATH)
32
+ bus.emit(event_type, payload=payload, source_agent=source_agent,
33
+ source_protocol="mcp")
34
+ except Exception:
35
+ pass
36
+
22
37
 
23
38
  def _record_recall_hits(get_engine: Callable, query: str, results: list[dict]) -> None:
24
39
  """Record implicit feedback + learning signals for each recall.
@@ -89,6 +104,11 @@ def register_core_tools(server, get_engine: Callable) -> None:
89
104
  "session_id": session_id,
90
105
  })
91
106
  if result.get("ok"):
107
+ _emit_event("memory.created", {
108
+ "content_preview": content[:80],
109
+ "agent_id": agent_id,
110
+ "fact_count": result.get("count", 0),
111
+ }, source_agent=agent_id)
92
112
  return {"success": True, "fact_ids": result.get("fact_ids", []), "count": result.get("count", 0)}
93
113
  return {"success": False, "error": result.get("error", "Store failed")}
94
114
  except Exception as exc:
@@ -108,6 +128,12 @@ def register_core_tools(server, get_engine: Callable) -> None:
108
128
  _record_recall_hits(get_engine, query, result.get("results", []))
109
129
  except Exception:
110
130
  pass # Feedback is non-critical, never block recall
131
+ _emit_event("memory.recalled", {
132
+ "query": query[:80],
133
+ "result_count": result.get("result_count", 0),
134
+ "query_type": result.get("query_type", "unknown"),
135
+ "agent_id": agent_id,
136
+ }, source_agent=agent_id)
111
137
  return {
112
138
  "success": True,
113
139
  "results": result.get("results", []),
@@ -362,6 +388,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
362
388
  })
363
389
  if result.get("ok"):
364
390
  logger.info("Memory deleted: %s by agent: %s", fact_id[:16], agent_id)
391
+ _emit_event("memory.deleted", {
392
+ "fact_id": fact_id,
393
+ "agent_id": agent_id,
394
+ }, source_agent=agent_id)
365
395
  return {"success": True, "deleted": fact_id, "agent_id": agent_id}
366
396
  return {"success": False, "error": result.get("error", "Delete failed")}
367
397
  except Exception as exc:
@@ -43,13 +43,15 @@ async def get_agents(
43
43
  if not REGISTRY_AVAILABLE:
44
44
  return {"agents": [], "count": 0, "message": "Agent registry not available"}
45
45
  try:
46
- engine = getattr(request.app.state, "engine", None)
47
- if engine and hasattr(engine, '_db'):
48
- registry = AgentRegistry(engine._db)
49
- agents = registry.list_agents(protocol=protocol, limit=limit)
50
- stats = registry.get_stats()
51
- return {"agents": agents, "count": len(agents), "stats": stats}
52
- return {"agents": [], "count": 0, "message": "Engine not initialized"}
46
+ from pathlib import Path
47
+ registry_path = Path.home() / ".superlocalmemory" / "agents.json"
48
+ registry = AgentRegistry(persist_path=registry_path)
49
+ agents = registry.list_agents()
50
+ return {
51
+ "agents": agents,
52
+ "count": len(agents),
53
+ "stats": {"total_agents": len(agents)},
54
+ }
53
55
  except Exception as e:
54
56
  raise HTTPException(status_code=500, detail=f"Agent registry error: {str(e)}")
55
57
 
@@ -60,11 +62,11 @@ async def get_agent_stats(request: Request):
60
62
  if not REGISTRY_AVAILABLE:
61
63
  return {"total_agents": 0, "message": "Agent registry not available"}
62
64
  try:
63
- engine = getattr(request.app.state, "engine", None)
64
- if engine and hasattr(engine, '_db'):
65
- registry = AgentRegistry(engine._db)
66
- return registry.get_stats()
67
- return {"total_agents": 0, "message": "Engine not initialized"}
65
+ from pathlib import Path
66
+ registry_path = Path.home() / ".superlocalmemory" / "agents.json"
67
+ registry = AgentRegistry(persist_path=registry_path)
68
+ agents = registry.list_agents()
69
+ return {"total_agents": len(agents)}
68
70
  except Exception as e:
69
71
  raise HTTPException(status_code=500, detail=f"Agent stats error: {str(e)}")
70
72
 
@@ -104,7 +104,7 @@ async def learning_status():
104
104
  feedback = _get_feedback()
105
105
  if feedback:
106
106
  try:
107
- old_stats = feedback.get_feedback_summary()
107
+ old_stats = feedback.get_feedback_summary(active_profile)
108
108
  if isinstance(old_stats, dict):
109
109
  old_stats["feedback_count"] = signal_count
110
110
  old_stats["active_profile"] = active_profile
@@ -274,8 +274,9 @@ async def feedback_stats():
274
274
  by_type = {}
275
275
 
276
276
  if feedback:
277
- summary = feedback.get_feedback_summary()
278
- total = summary.get("total_signals", 0)
277
+ profile = get_active_profile()
278
+ summary = feedback.get_feedback_summary(profile)
279
+ total = summary.get("total", summary.get("total_signals", 0))
279
280
  by_channel = summary.get("by_channel", {})
280
281
  by_type = summary.get("by_type", {})
281
282
 
@@ -38,32 +38,32 @@ async def lifecycle_status():
38
38
  conn = sqlite3.connect(str(DB_PATH))
39
39
  conn.row_factory = sqlite3.Row
40
40
 
41
- # Try V3 schema first (atomic_facts with lifecycle_state)
41
+ # Try V3 schema first (atomic_facts with lifecycle column)
42
42
  states = {}
43
43
  try:
44
44
  rows = conn.execute(
45
- "SELECT lifecycle_state, COUNT(*) as cnt "
46
- "FROM atomic_facts WHERE profile_id = ? GROUP BY lifecycle_state",
45
+ "SELECT lifecycle, COUNT(*) as cnt "
46
+ "FROM atomic_facts WHERE profile_id = ? GROUP BY lifecycle",
47
47
  (profile,),
48
48
  ).fetchall()
49
49
  states = {
50
- (row['lifecycle_state'] or 'active'): row['cnt']
50
+ (row['lifecycle'] or 'active'): row['cnt']
51
51
  for row in rows
52
52
  }
53
53
  except sqlite3.OperationalError:
54
54
  # V2 fallback: memories table
55
55
  try:
56
56
  rows = conn.execute(
57
- "SELECT lifecycle_state, COUNT(*) as cnt "
58
- "FROM memories WHERE profile = ? GROUP BY lifecycle_state",
57
+ "SELECT lifecycle, COUNT(*) as cnt "
58
+ "FROM memories WHERE profile = ? GROUP BY lifecycle",
59
59
  (profile,),
60
60
  ).fetchall()
61
61
  states = {
62
- (row['lifecycle_state'] or 'active'): row['cnt']
62
+ (row['lifecycle'] or 'active'): row['cnt']
63
63
  for row in rows
64
64
  }
65
65
  except sqlite3.OperationalError:
66
- # No lifecycle_state column at all
66
+ # No lifecycle column at all — count everything as active
67
67
  total = conn.execute(
68
68
  "SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
69
69
  (profile,),
@@ -80,7 +80,7 @@ async def lifecycle_status():
80
80
  "SELECT AVG(julianday('now') - julianday(created_at)) as avg_age, "
81
81
  "MIN(julianday('now') - julianday(created_at)) as min_age, "
82
82
  "MAX(julianday('now') - julianday(created_at)) as max_age "
83
- "FROM atomic_facts WHERE profile_id = ? AND lifecycle_state = ?",
83
+ "FROM atomic_facts WHERE profile_id = ? AND lifecycle = ?",
84
84
  (profile, state),
85
85
  ).fetchone()
86
86
  if row and row['avg_age'] is not None:
@@ -46,53 +46,37 @@ def _fetch_graph_data(
46
46
  ) -> tuple[list, list, list]:
47
47
  """Fetch graph nodes, links, clusters from V3 or V2 schema."""
48
48
  if use_v3:
49
- # Graph-first: fetch edges, then get connected nodes, then fill slots
49
+ # Recency-first: get the most recent nodes, then find their edges
50
50
  cursor.execute("""
51
- SELECT source_id as source, target_id as target,
52
- weight, edge_type as relationship_type
53
- FROM graph_edges WHERE profile_id = ?
54
- ORDER BY weight DESC
55
- """, (profile,))
56
- all_links = cursor.fetchall()
51
+ SELECT fact_id as id, content, fact_type as category,
52
+ confidence as importance, session_id as project_name,
53
+ created_at
54
+ FROM atomic_facts
55
+ WHERE profile_id = ? AND confidence >= ?
56
+ ORDER BY created_at DESC
57
+ LIMIT ?
58
+ """, (profile, min_importance / 10.0, max_nodes))
59
+ nodes = cursor.fetchall()
57
60
 
58
- connected_ids = set()
59
- for lk in all_links:
60
- connected_ids.add(lk['source'])
61
- connected_ids.add(lk['target'])
61
+ node_ids = {n['id'] for n in nodes}
62
62
 
63
- # Fetch connected nodes first (these have edges to display)
64
- connected_nodes: list = []
65
- if connected_ids:
66
- ph = ','.join('?' * len(connected_ids))
63
+ # Fetch edges between these nodes
64
+ if node_ids:
65
+ ph = ','.join('?' * len(node_ids))
66
+ id_list = list(node_ids)
67
67
  cursor.execute(f"""
68
- SELECT fact_id as id, content, fact_type as category,
69
- confidence as importance, session_id as project_name,
70
- created_at
71
- FROM atomic_facts
72
- WHERE profile_id = ? AND fact_id IN ({ph})
73
- """, [profile] + list(connected_ids))
74
- connected_nodes = cursor.fetchall()
75
-
76
- # Fill remaining slots with top-confidence unconnected nodes
77
- remaining = max_nodes - len(connected_nodes)
78
- if remaining > 0:
79
- existing = {n['id'] for n in connected_nodes}
80
- cursor.execute("""
81
- SELECT fact_id as id, content, fact_type as category,
82
- confidence as importance, session_id as project_name,
83
- created_at
84
- FROM atomic_facts
85
- WHERE profile_id = ? AND confidence >= ?
86
- ORDER BY confidence DESC, created_at DESC
87
- LIMIT ?
88
- """, (profile, min_importance / 10.0, remaining + len(existing)))
89
- for n in cursor.fetchall():
90
- if n['id'] not in existing:
91
- connected_nodes.append(n)
92
- if len(connected_nodes) >= max_nodes:
93
- break
94
-
95
- nodes = connected_nodes[:max_nodes]
68
+ SELECT source_id as source, target_id as target,
69
+ weight, edge_type as relationship_type
70
+ FROM graph_edges
71
+ WHERE profile_id = ?
72
+ AND source_id IN ({ph}) AND target_id IN ({ph})
73
+ ORDER BY weight DESC
74
+ """, [profile] + id_list + id_list)
75
+ all_links = cursor.fetchall()
76
+ else:
77
+ all_links = []
78
+
79
+ links = all_links
96
80
  for n in nodes:
97
81
  n['entities'] = []
98
82
  n['content_preview'] = _preview(n.get('content'))
@@ -101,7 +85,33 @@ def _fetch_graph_data(
101
85
  node_ids = {n['id'] for n in nodes}
102
86
  links = [lk for lk in all_links
103
87
  if lk['source'] in node_ids and lk['target'] in node_ids]
104
- return nodes, links, []
88
+
89
+ # Compute clusters from memory_scenes
90
+ clusters = []
91
+ try:
92
+ cursor.execute("""
93
+ SELECT scene_id, theme, fact_ids_json
94
+ FROM memory_scenes WHERE profile_id = ?
95
+ """, (profile,))
96
+ for row in cursor.fetchall():
97
+ fact_ids = []
98
+ try:
99
+ fact_ids = json.loads(row.get('fact_ids_json', '[]') or '[]')
100
+ except (json.JSONDecodeError, TypeError):
101
+ pass
102
+ # Only include clusters that overlap with displayed nodes
103
+ overlap = [fid for fid in fact_ids if fid in node_ids]
104
+ if overlap:
105
+ clusters.append({
106
+ 'cluster_id': row['scene_id'],
107
+ 'size': len(fact_ids),
108
+ 'visible_size': len(overlap),
109
+ 'theme': row.get('theme', ''),
110
+ })
111
+ except Exception:
112
+ pass
113
+
114
+ return nodes, links, clusters
105
115
 
106
116
  # V2 fallback
107
117
  try:
@@ -362,15 +372,54 @@ async def get_clusters(request: Request):
362
372
  profile = get_active_profile()
363
373
  unclustered = 0
364
374
 
365
- if _has_table(cursor, 'scene_facts'):
375
+ # V3 schema: memory_scenes stores fact_ids_json (JSON array)
376
+ if _has_table(cursor, 'memory_scenes'):
366
377
  cursor.execute("""
367
- SELECT s.scene_id as cluster_id, COUNT(sf.fact_id) as member_count,
368
- s.summary, s.created_at as first_memory
369
- FROM scenes s JOIN scene_facts sf ON s.scene_id = sf.scene_id
370
- WHERE s.profile_id = ? GROUP BY s.scene_id ORDER BY member_count DESC
378
+ SELECT scene_id as cluster_id, theme, fact_ids_json,
379
+ entity_ids_json, created_at as first_memory
380
+ FROM memory_scenes WHERE profile_id = ?
381
+ ORDER BY created_at DESC
371
382
  """, (profile,))
372
- clusters = [dict(r, top_entities=[]) for r in cursor.fetchall()]
383
+ raw_scenes = cursor.fetchall()
384
+ clusters = []
385
+ for scene in raw_scenes:
386
+ fact_ids = []
387
+ try:
388
+ fact_ids = json.loads(scene.get('fact_ids_json', '[]') or '[]')
389
+ except (json.JSONDecodeError, TypeError):
390
+ pass
391
+ entity_ids = []
392
+ try:
393
+ entity_ids = json.loads(scene.get('entity_ids_json', '[]') or '[]')
394
+ except (json.JSONDecodeError, TypeError):
395
+ pass
396
+ clusters.append({
397
+ 'cluster_id': scene['cluster_id'],
398
+ 'member_count': len(fact_ids),
399
+ 'categories': scene.get('theme', ''),
400
+ 'summary': scene.get('theme', ''),
401
+ 'first_memory': scene.get('first_memory', ''),
402
+ 'top_entities': entity_ids[:5],
403
+ })
404
+ # Filter out empty clusters
405
+ clusters = [c for c in clusters if c['member_count'] > 0]
406
+ clusters.sort(key=lambda c: c['member_count'], reverse=True)
407
+
408
+ # Count facts not in any scene
409
+ all_scene_fact_ids = set()
410
+ for scene in raw_scenes:
411
+ try:
412
+ ids = json.loads(scene.get('fact_ids_json', '[]') or '[]')
413
+ all_scene_fact_ids.update(ids)
414
+ except (json.JSONDecodeError, TypeError):
415
+ pass
416
+ total_facts = cursor.execute(
417
+ "SELECT COUNT(*) as c FROM atomic_facts WHERE profile_id = ?",
418
+ (profile,),
419
+ ).fetchone()['c']
420
+ unclustered = total_facts - len(all_scene_fact_ids)
373
421
  else:
422
+ # V2 fallback
374
423
  try:
375
424
  cursor.execute("""
376
425
  SELECT cluster_id, COUNT(*) as member_count,
@@ -382,8 +431,14 @@ async def get_clusters(request: Request):
382
431
  clusters = [dict(r, top_entities=[]) for r in cursor.fetchall()]
383
432
  except Exception:
384
433
  clusters = []
385
- cursor.execute("SELECT COUNT(*) as c FROM memories WHERE cluster_id IS NULL AND profile = ?", (profile,))
386
- unclustered = cursor.fetchone()['c']
434
+ try:
435
+ cursor.execute(
436
+ "SELECT COUNT(*) as c FROM memories WHERE cluster_id IS NULL AND profile = ?",
437
+ (profile,),
438
+ )
439
+ unclustered = cursor.fetchone()['c']
440
+ except Exception:
441
+ unclustered = 0
387
442
 
388
443
  conn.close()
389
444
  return {"clusters": clusters, "total_clusters": len(clusters), "unclustered_count": unclustered}
@@ -392,21 +447,41 @@ async def get_clusters(request: Request):
392
447
 
393
448
 
394
449
  @router.get("/api/clusters/{cluster_id}")
395
- async def get_cluster_detail(request: Request, cluster_id: int, limit: int = Query(50, ge=1, le=200)):
396
- """Get detailed view of a specific cluster."""
450
+ async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Query(50, ge=1, le=200)):
451
+ """Get detailed view of a specific cluster (scene)."""
397
452
  try:
398
453
  conn = get_db_connection()
399
454
  conn.row_factory = dict_factory
400
455
  cursor = conn.cursor()
401
456
  profile = get_active_profile()
402
457
 
403
- if _has_table(cursor, 'scene_facts'):
404
- cursor.execute("""
405
- SELECT f.fact_id as id, f.content, f.fact_type as category,
406
- f.confidence as importance, f.created_at
407
- FROM atomic_facts f JOIN scene_facts sf ON f.fact_id = sf.fact_id
408
- WHERE sf.scene_id = ? AND f.profile_id = ? ORDER BY f.confidence DESC LIMIT ?
409
- """, (str(cluster_id), profile, limit))
458
+ if _has_table(cursor, 'memory_scenes'):
459
+ # Get fact IDs from the scene's JSON array
460
+ cursor.execute(
461
+ "SELECT fact_ids_json, theme FROM memory_scenes "
462
+ "WHERE scene_id = ? AND profile_id = ?",
463
+ (cluster_id, profile),
464
+ )
465
+ scene_row = cursor.fetchone()
466
+ if scene_row:
467
+ fact_ids = []
468
+ try:
469
+ fact_ids = json.loads(scene_row.get('fact_ids_json', '[]') or '[]')
470
+ except (json.JSONDecodeError, TypeError):
471
+ pass
472
+ if fact_ids:
473
+ ph = ','.join('?' * min(len(fact_ids), limit))
474
+ cursor.execute(f"""
475
+ SELECT fact_id as id, content, fact_type as category,
476
+ confidence as importance, created_at
477
+ FROM atomic_facts
478
+ WHERE profile_id = ? AND fact_id IN ({ph})
479
+ ORDER BY confidence DESC
480
+ """, [profile] + fact_ids[:limit])
481
+ else:
482
+ cursor.execute("SELECT 1 WHERE 0") # empty result
483
+ else:
484
+ cursor.execute("SELECT 1 WHERE 0") # empty result
410
485
  else:
411
486
  cursor.execute("""
412
487
  SELECT id, content, summary, category, project_name, importance, created_at, tags