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 +16 -0
- package/README.md +45 -3
- package/docs/getting-started.md +1 -1
- package/package.json +1 -1
- package/pyproject.toml +9 -7
- package/scripts/postinstall.js +35 -1
- package/src/superlocalmemory/cli/commands.py +334 -19
- package/src/superlocalmemory/cli/main.py +4 -0
- package/src/superlocalmemory/core/config.py +1 -1
- package/src/superlocalmemory/core/embeddings.py +17 -3
- package/src/superlocalmemory/core/engine.py +2 -2
- package/src/superlocalmemory/core/worker_pool.py +3 -3
- package/src/superlocalmemory/learning/feedback.py +3 -0
- package/src/superlocalmemory/mcp/tools_active.py +50 -0
- package/src/superlocalmemory/mcp/tools_core.py +30 -0
- package/src/superlocalmemory/server/routes/agents.py +14 -12
- package/src/superlocalmemory/server/routes/learning.py +4 -3
- package/src/superlocalmemory/server/routes/lifecycle.py +9 -9
- package/src/superlocalmemory/server/routes/memories.py +136 -61
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
|
-
|
|
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
|
|
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
|
|
package/docs/getting-started.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
42
|
-
"uvicorn
|
|
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
|
|
48
|
+
"lightgbm>=4.0.0",
|
|
47
49
|
]
|
|
48
50
|
performance = [
|
|
49
|
-
"diskcache>=5.6.0
|
|
50
|
-
"orjson>=3.9.0
|
|
51
|
+
"diskcache>=5.6.0",
|
|
52
|
+
"orjson>=3.9.0",
|
|
51
53
|
]
|
|
52
54
|
full = [
|
|
53
55
|
"superlocalmemory[search,ui,learning,performance]",
|
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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("\
|
|
653
|
-
|
|
940
|
+
print("\n[FAIL] Model loaded but embedding verification failed.")
|
|
941
|
+
_warmup_diagnose()
|
|
942
|
+
|
|
654
943
|
except ImportError as exc:
|
|
655
|
-
print(f"\
|
|
656
|
-
print("
|
|
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"\
|
|
659
|
-
|
|
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
|
|
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)")
|
|
@@ -164,7 +164,12 @@ class EmbeddingService:
|
|
|
164
164
|
_SUBPROCESS_RESPONSE_TIMEOUT,
|
|
165
165
|
)
|
|
166
166
|
if not resp_line:
|
|
167
|
-
logger.warning(
|
|
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(
|
|
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(
|
|
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:
|
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return {"total_agents":
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
41
|
+
# Try V3 schema first (atomic_facts with lifecycle column)
|
|
42
42
|
states = {}
|
|
43
43
|
try:
|
|
44
44
|
rows = conn.execute(
|
|
45
|
-
"SELECT
|
|
46
|
-
"FROM atomic_facts WHERE profile_id = ? GROUP BY
|
|
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['
|
|
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
|
|
58
|
-
"FROM memories WHERE profile = ? GROUP BY
|
|
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['
|
|
62
|
+
(row['lifecycle'] or 'active'): row['cnt']
|
|
63
63
|
for row in rows
|
|
64
64
|
}
|
|
65
65
|
except sqlite3.OperationalError:
|
|
66
|
-
# No
|
|
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
|
|
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
|
-
#
|
|
49
|
+
# Recency-first: get the most recent nodes, then find their edges
|
|
50
50
|
cursor.execute("""
|
|
51
|
-
SELECT
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
+
# V3 schema: memory_scenes stores fact_ids_json (JSON array)
|
|
376
|
+
if _has_table(cursor, 'memory_scenes'):
|
|
366
377
|
cursor.execute("""
|
|
367
|
-
SELECT
|
|
368
|
-
|
|
369
|
-
FROM
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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:
|
|
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, '
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|