superlocalmemory 3.8.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +2 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +134 -7
  34. package/src/superlocalmemory/cli/daemon.py +7 -0
  35. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  36. package/src/superlocalmemory/cli/main.py +10 -4
  37. package/src/superlocalmemory/cli/version_banner.py +17 -3
  38. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  39. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  40. package/src/superlocalmemory/core/embeddings.py +119 -45
  41. package/src/superlocalmemory/core/engine.py +24 -21
  42. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  43. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  44. package/src/superlocalmemory/core/injection.py +12 -7
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  46. package/src/superlocalmemory/core/recall_pipeline.py +5 -3
  47. package/src/superlocalmemory/core/store_pipeline.py +153 -20
  48. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  49. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  50. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  51. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  52. package/src/superlocalmemory/learning/reward.py +50 -0
  53. package/src/superlocalmemory/learning/source_quality.py +523 -1
  54. package/src/superlocalmemory/loops/ledger.py +25 -5
  55. package/src/superlocalmemory/mcp/server.py +11 -30
  56. package/src/superlocalmemory/mcp/tools_core.py +4 -3
  57. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  58. package/src/superlocalmemory/retrieval/engine.py +53 -21
  59. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  60. package/src/superlocalmemory/server/config_file.py +90 -0
  61. package/src/superlocalmemory/server/origin.py +50 -0
  62. package/src/superlocalmemory/server/routes/backup.py +293 -70
  63. package/src/superlocalmemory/server/routes/behavioral.py +336 -59
  64. package/src/superlocalmemory/server/routes/brain.py +57 -16
  65. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  66. package/src/superlocalmemory/server/routes/entity.py +100 -23
  67. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  68. package/src/superlocalmemory/server/routes/learning.py +276 -105
  69. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  70. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  71. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  72. package/src/superlocalmemory/server/routes/stats.py +93 -155
  73. package/src/superlocalmemory/server/routes/token.py +3 -13
  74. package/src/superlocalmemory/server/routes/v3_api.py +64 -20
  75. package/src/superlocalmemory/server/unified_daemon.py +467 -40
  76. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  77. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  78. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  79. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  80. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  81. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  82. package/src/superlocalmemory/storage/schema.py +49 -1
  83. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  84. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  85. package/src/superlocalmemory/ui/index.html +3 -6
  86. package/src/superlocalmemory/ui/js/core.js +52 -9
  87. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  88. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  89. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  90. package/src/superlocalmemory/ui/js/od-entities.js +22 -22
  91. package/src/superlocalmemory/ui/js/od-graph.js +11 -4
  92. package/src/superlocalmemory/ui/js/od-memories.js +47 -5
  93. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  94. package/src/superlocalmemory/ui/js/od-settings.js +113 -59
  95. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  96. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  97. package/src/superlocalmemory/ui/js/settings.js +15 -1
  98. package/plugin-src/.mcp.json +0 -12
  99. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  100. package/plugin-src/agents/slm-loop-runner.md +0 -71
  101. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  102. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  103. package/plugin-src/commands/slm-loop.md +0 -31
  104. package/plugin-src/hooks/.gitkeep +0 -0
  105. package/plugin-src/hooks/hooks.json +0 -102
  106. package/plugin-src/manifest.json +0 -30
  107. package/plugin-src/requirements.txt +0 -1
  108. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  109. package/plugin-src/scripts/ensure-venv.bat +0 -122
  110. package/plugin-src/scripts/ensure-venv.sh +0 -105
  111. package/plugin-src/scripts/slm-launch +0 -62
  112. package/plugin-src/scripts/slm-launch.bat +0 -23
  113. package/plugin-src/settings.json +0 -25
  114. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  115. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  116. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  117. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  118. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -4,21 +4,78 @@
4
4
  """SuperLocalMemory V3 - Stats Routes
5
5
  - AGPL-3.0-or-later
6
6
 
7
- Routes: /api/stats, /api/timeline, /api/patterns
7
+ Routes: /api/stats, /api/timeline
8
8
  """
9
- import json
10
9
  import logging
11
- from collections import defaultdict
10
+ import sqlite3
12
11
  from typing import Optional
13
12
 
14
13
  from fastapi import APIRouter, HTTPException, Query
15
14
 
16
- from .helpers import get_db_connection, dict_factory, get_active_profile, DB_PATH, MEMORY_DIR
15
+ from .helpers import get_db_connection, dict_factory, get_active_profile, DB_PATH
17
16
 
18
17
  logger = logging.getLogger("superlocalmemory.routes.stats")
19
18
  router = APIRouter()
20
19
 
21
20
 
21
+ def _query_ingestion_sources(cursor, profile_id: str) -> list[dict]:
22
+ """Query profile-scoped ingestion provenance without double-counting facts."""
23
+ cursor.execute(
24
+ """
25
+ SELECT COALESCE(NULLIF(TRIM(source_type), ''), 'unknown') AS source_type,
26
+ COUNT(DISTINCT fact_id) AS count
27
+ FROM provenance
28
+ WHERE profile_id = ?
29
+ GROUP BY COALESCE(NULLIF(TRIM(source_type), ''), 'unknown')
30
+ ORDER BY count DESC, source_type ASC
31
+ """,
32
+ (profile_id,),
33
+ )
34
+ return [dict(row) for row in cursor.fetchall()]
35
+
36
+
37
+ def _load_ingestion_sources_with_status(
38
+ cursor, profile_id: str,
39
+ ) -> tuple[list[dict], dict]:
40
+ """Return provenance plus a truthful availability state.
41
+
42
+ Older databases may not have the provenance table. In that case the
43
+ dashboard must report an empty/unknown state rather than inventing a split.
44
+ Lock or I/O failures are distinct from a legitimate legacy-empty state.
45
+ """
46
+ try:
47
+ return _query_ingestion_sources(cursor, profile_id), {
48
+ "available": True,
49
+ "state": "recorded",
50
+ "source": "memory.db:provenance",
51
+ }
52
+ except sqlite3.OperationalError as exc:
53
+ if "no such table" in str(exc).lower():
54
+ return [], {
55
+ "available": True,
56
+ "state": "legacy_not_recorded",
57
+ "source": "memory.db:provenance",
58
+ }
59
+ logger.warning("provenance metrics temporarily unavailable: %s", exc)
60
+ return [], {
61
+ "available": False,
62
+ "state": "temporarily_unavailable",
63
+ "source": "memory.db:provenance",
64
+ }
65
+ except sqlite3.Error as exc:
66
+ logger.warning("provenance metrics unavailable: %s", exc)
67
+ return [], {
68
+ "available": False,
69
+ "state": "temporarily_unavailable",
70
+ "source": "memory.db:provenance",
71
+ }
72
+
73
+
74
+ def _load_ingestion_sources(cursor, profile_id: str) -> list[dict]:
75
+ """Compatibility helper returning only the provenance rows."""
76
+ return _load_ingestion_sources_with_status(cursor, profile_id)[0]
77
+
78
+
22
79
  def _internal_error(detail: str = "Internal server error") -> HTTPException:
23
80
  """SEC-H-02: log full traceback server-side; return a generic message to the client."""
24
81
  logger.exception("stats route error")
@@ -26,7 +83,7 @@ def _internal_error(detail: str = "Internal server error") -> HTTPException:
26
83
 
27
84
 
28
85
  @router.get("/api/stats")
29
- async def get_stats():
86
+ def get_stats():
30
87
  """Get comprehensive system statistics."""
31
88
  try:
32
89
  conn = get_db_connection()
@@ -205,6 +262,9 @@ async def get_stats():
205
262
  importance_dist = cursor.fetchall()
206
263
 
207
264
  db_size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
265
+ ingestion_sources, ingestion_sources_status = (
266
+ _load_ingestion_sources_with_status(cursor, active_profile)
267
+ )
208
268
 
209
269
  if total_graph_nodes > 1:
210
270
  max_edges = (total_graph_nodes * (total_graph_nodes - 1)) / 2
@@ -241,6 +301,8 @@ async def get_stats():
241
301
  if total_graph_nodes > 0 else 0
242
302
  ),
243
303
  },
304
+ "ingestion_sources": ingestion_sources,
305
+ "ingestion_sources_status": ingestion_sources_status,
244
306
  }
245
307
 
246
308
  except Exception:
@@ -248,11 +310,12 @@ async def get_stats():
248
310
 
249
311
 
250
312
  @router.get("/api/timeline")
251
- async def get_timeline(
313
+ def get_timeline(
252
314
  days: int = Query(30, ge=1, le=365),
253
315
  group_by: str = Query("day", pattern="^(day|week|month)$"),
316
+ include_categories: bool = Query(True),
254
317
  ):
255
- """Get temporal view of memory creation with flexible grouping."""
318
+ """Get temporal memory creation; dashboard callers may skip category scans."""
256
319
  try:
257
320
  conn = get_db_connection()
258
321
  conn.row_factory = dict_factory
@@ -286,22 +349,29 @@ async def get_timeline(
286
349
  """, (days, active_profile))
287
350
  timeline = cursor.fetchall()
288
351
 
289
- cursor.execute(f"""
290
- SELECT {date_group} as period, {cat_col} as category, COUNT(*) as count
291
- FROM {table}
292
- WHERE created_at >= datetime('now', '-' || ? || ' days')
293
- AND {cat_col} IS NOT NULL AND {profile_col} = ?
294
- GROUP BY {date_group}, {cat_col} ORDER BY period DESC, count DESC
295
- """, (days, active_profile))
296
- category_trend = cursor.fetchall()
297
-
298
- cursor.execute(f"""
299
- SELECT COUNT(*) as total_memories,
300
- COUNT(DISTINCT {cat_col}) as categories_used
301
- FROM {table}
302
- WHERE created_at >= datetime('now', '-' || ? || ' days') AND {profile_col} = ?
303
- """, (days, active_profile))
304
- period_stats = cursor.fetchone()
352
+ if include_categories:
353
+ cursor.execute(f"""
354
+ SELECT {date_group} as period, {cat_col} as category, COUNT(*) as count
355
+ FROM {table}
356
+ WHERE created_at >= datetime('now', '-' || ? || ' days')
357
+ AND {cat_col} IS NOT NULL AND {profile_col} = ?
358
+ GROUP BY {date_group}, {cat_col} ORDER BY period DESC, count DESC
359
+ """, (days, active_profile))
360
+ category_trend = cursor.fetchall()
361
+
362
+ cursor.execute(f"""
363
+ SELECT COUNT(*) as total_memories,
364
+ COUNT(DISTINCT {cat_col}) as categories_used
365
+ FROM {table}
366
+ WHERE created_at >= datetime('now', '-' || ? || ' days') AND {profile_col} = ?
367
+ """, (days, active_profile))
368
+ period_stats = cursor.fetchone()
369
+ else:
370
+ category_trend = []
371
+ period_stats = {
372
+ "total_memories": sum(int(row["count"] or 0) for row in timeline),
373
+ "categories_used": None,
374
+ }
305
375
 
306
376
  conn.close()
307
377
 
@@ -313,135 +383,3 @@ async def get_timeline(
313
383
 
314
384
  except Exception:
315
385
  raise _internal_error("Timeline error")
316
-
317
-
318
- @router.get("/api/patterns")
319
- async def get_patterns():
320
- """Get learned patterns."""
321
- try:
322
- conn = get_db_connection()
323
- conn.row_factory = dict_factory
324
- cursor = conn.cursor()
325
- active_profile = get_active_profile()
326
-
327
- # Check for V3 learning tables or V2 identity_patterns
328
- patterns = []
329
- table_name = None
330
- for candidate in ('learned_patterns', 'identity_patterns'):
331
- cursor.execute(
332
- "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
333
- (candidate,),
334
- )
335
- if cursor.fetchone():
336
- table_name = candidate
337
- break
338
-
339
- if not table_name:
340
- conn.close()
341
- # Fall through to V3.1 behavioral pattern store
342
- try:
343
- from superlocalmemory.learning.behavioral import BehavioralPatternStore
344
- store = BehavioralPatternStore(str(MEMORY_DIR / "learning.db"))
345
- raw = store.get_patterns(profile_id=active_profile)
346
- # v3.4.7: Map all pattern types to frontend categories
347
- type_map = {
348
- "tech_preference": "preference",
349
- "interest": "preference",
350
- "entity_preferences": "preference",
351
- "style": "style",
352
- "fact_type_distribution": "style",
353
- "knowledge_structure": "style",
354
- "terminology": "terminology",
355
- "temporal": "workflow",
356
- "session_activity": "workflow",
357
- "workflow": "workflow",
358
- "co_retrieval_clusters": "workflow",
359
- "channel_performance": "performance",
360
- }
361
- grouped = defaultdict(list)
362
- for p in raw:
363
- meta = p.get("metadata", {})
364
- data = meta # metadata IS the data dict
365
- frontend_key = type_map.get(p.get("pattern_type", ""), "preference")
366
- # Extract human-readable value from data fields
367
- readable_value = (
368
- data.get("value")
369
- or data.get("topic")
370
- or data.get("pattern_key", "")
371
- or p.get("pattern_key", "")
372
- )
373
- readable_key = (
374
- data.get("pattern_key")
375
- or data.get("key")
376
- or p.get("pattern_key", "")
377
- )
378
- grouped[frontend_key].append({
379
- "pattern_type": p.get("pattern_type", ""),
380
- "key": readable_key,
381
- "value": readable_value,
382
- "confidence": p.get("confidence", 0),
383
- "evidence_count": data.get("evidence", p.get("evidence_count", 0)),
384
- })
385
- all_patterns = [p for ps in grouped.values() for p in ps]
386
- confs = [p["confidence"] for p in all_patterns if p.get("confidence")]
387
- return {
388
- "patterns": dict(grouped),
389
- "total_patterns": len(all_patterns),
390
- "pattern_types": list(grouped.keys()),
391
- "confidence_stats": {
392
- "avg": sum(confs) / len(confs) if confs else 0,
393
- "min": min(confs) if confs else 0,
394
- "max": max(confs) if confs else 0,
395
- },
396
- }
397
- except Exception:
398
- return {
399
- "patterns": {}, "total_patterns": 0, "pattern_types": [],
400
- "message": "Pattern learning not initialized.",
401
- }
402
-
403
- if table_name == 'identity_patterns':
404
- cursor.execute("""
405
- SELECT pattern_type, key, value, confidence, evidence_count,
406
- updated_at as last_updated
407
- FROM identity_patterns WHERE profile = ?
408
- ORDER BY confidence DESC, evidence_count DESC
409
- """, (active_profile,))
410
- else:
411
- cursor.execute("""
412
- SELECT pattern_type, key, value, confidence, evidence_count,
413
- last_updated
414
- FROM learned_patterns WHERE is_active = 1
415
- ORDER BY confidence DESC, evidence_count DESC
416
- """)
417
-
418
- patterns = cursor.fetchall()
419
-
420
- for pattern in patterns:
421
- if pattern.get('value'):
422
- try:
423
- pattern['value'] = json.loads(pattern['value'])
424
- except Exception:
425
- pass
426
-
427
- grouped = defaultdict(list)
428
- for pattern in patterns:
429
- grouped[pattern['pattern_type']].append(pattern)
430
-
431
- confidences = [p['confidence'] for p in patterns if p.get('confidence')]
432
- confidence_stats = {
433
- "avg": sum(confidences) / len(confidences) if confidences else 0,
434
- "min": min(confidences) if confidences else 0,
435
- "max": max(confidences) if confidences else 0,
436
- }
437
-
438
- conn.close()
439
-
440
- return {
441
- "patterns": dict(grouped), "total_patterns": len(patterns),
442
- "pattern_types": list(grouped.keys()), "confidence_stats": confidence_stats,
443
- }
444
-
445
- except Exception:
446
- logger.exception("patterns route error")
447
- return {"patterns": {}, "total_patterns": 0, "error": "Internal server error"}
@@ -31,21 +31,11 @@ logger = logging.getLogger(__name__)
31
31
  router = APIRouter(tags=["internal"])
32
32
 
33
33
 
34
- _ALLOWED_ORIGIN_PREFIXES = (
35
- "http://127.0.0.1",
36
- "https://127.0.0.1",
37
- "http://localhost",
38
- "https://localhost",
39
- "http://[::1]",
40
- "https://[::1]",
41
- )
42
-
43
-
44
34
  def _origin_is_loopback(origin: str) -> bool:
45
35
  """Return True iff ``origin`` is absent or a loopback URL."""
46
- if not origin:
47
- return True
48
- return any(origin.startswith(p) for p in _ALLOWED_ORIGIN_PREFIXES)
36
+ from superlocalmemory.server.origin import origin_is_loopback
37
+
38
+ return origin_is_loopback(origin)
49
39
 
50
40
 
51
41
  @router.get("/internal/token")
@@ -52,6 +52,42 @@ async def _apply_runtime_config(request: Request, config, *, mode_change: bool)
52
52
  authorization.complete()
53
53
 
54
54
 
55
+ def _require_manage(request: Request) -> None:
56
+ """Use one explicit authorization boundary for dashboard configuration."""
57
+ from superlocalmemory.server.rbac_enforce import require_manage
58
+
59
+ require_manage(request)
60
+
61
+
62
+ def _resolve_profile(request: Request, requested: str = "") -> str:
63
+ """Resolve a profile override and enforce READ on its actual target."""
64
+ from superlocalmemory.access.rbac import Permission
65
+ from superlocalmemory.server.rbac_enforce import require_permission
66
+ from superlocalmemory.server.routes.helpers import get_active_profile
67
+
68
+ if requested and not isinstance(requested, str):
69
+ raise HTTPException(status_code=422, detail="profile must be a string")
70
+ profile = requested.strip() if requested else get_active_profile()
71
+ require_permission(request, Permission.READ, profile=profile)
72
+ return profile
73
+
74
+
75
+ def _resolve_mutation_profile(requested: object) -> str:
76
+ """Resolve a body-supplied profile without silently coercing bad input."""
77
+ from superlocalmemory.server.routes.helpers import get_active_profile
78
+
79
+ if requested is not None and not isinstance(requested, str):
80
+ raise HTTPException(status_code=422, detail="profile must be a string")
81
+ return requested.strip() if requested and requested.strip() else get_active_profile()
82
+
83
+
84
+ def _require_manage_for_profile(request: Request, profile: str) -> None:
85
+ """Enforce MANAGE on the resolved mutation target before any side effect."""
86
+ from superlocalmemory.server.rbac_enforce import require_manage
87
+
88
+ require_manage(request, profile=profile)
89
+
90
+
55
91
  # ── Dashboard ────────────────────────────────────────────────
56
92
 
57
93
  @router.get("/dashboard")
@@ -448,9 +484,9 @@ async def set_scope_config(request: Request):
448
484
  @router.post("/embedding/test")
449
485
  async def test_embedding_endpoint(request: Request):
450
486
  """Test connectivity to a custom embedding endpoint."""
487
+ _require_manage(request)
451
488
  try:
452
489
  import httpx
453
- from urllib.parse import urlparse
454
490
  body = await request.json()
455
491
  endpoint = body.get("api_endpoint", "").rstrip("/")
456
492
  model = body.get("model_name", "test")
@@ -459,12 +495,10 @@ async def test_embedding_endpoint(request: Request):
459
495
  if not endpoint:
460
496
  return JSONResponse({"error": "No endpoint provided"}, status_code=400)
461
497
 
462
- parsed = urlparse(endpoint)
463
- if parsed.scheme not in ("http", "https"):
464
- return JSONResponse({"error": "Only http/https endpoints supported"}, status_code=400)
465
- host = parsed.hostname or ""
466
- if host in ("169.254.169.254", "metadata.google.internal"):
467
- return JSONResponse({"error": "Cloud metadata endpoints not allowed"}, status_code=400)
498
+ client_host = request.client.host if request.client else ""
499
+ error = _validate_provider_url(endpoint, client_host)
500
+ if error:
501
+ return JSONResponse({"error": error}, status_code=400)
468
502
 
469
503
  if not endpoint.endswith("/embeddings"):
470
504
  endpoint = f"{endpoint}/embeddings"
@@ -980,6 +1014,7 @@ async def get_auto_capture_config():
980
1014
  @router.put("/auto-capture/config")
981
1015
  async def set_auto_capture_config(request: Request):
982
1016
  """Update auto-capture config. Body: {"enabled": true, "capture_decisions": true, ...}"""
1017
+ _require_manage(request)
983
1018
  try:
984
1019
  body = await request.json()
985
1020
  from superlocalmemory.hooks.rules_engine import RulesEngine
@@ -1010,6 +1045,7 @@ async def get_auto_recall_config():
1010
1045
  @router.put("/auto-recall/config")
1011
1046
  async def set_auto_recall_config(request: Request):
1012
1047
  """Update auto-recall config."""
1048
+ _require_manage(request)
1013
1049
  try:
1014
1050
  body = await request.json()
1015
1051
  from superlocalmemory.hooks.rules_engine import RulesEngine
@@ -1096,6 +1132,7 @@ async def run_consolidation(request: Request):
1096
1132
 
1097
1133
  config = SLMConfig.load()
1098
1134
  learning_db = DB_PATH.parent / "learning.db"
1135
+ _require_manage_for_profile(request, config.active_profile)
1099
1136
  authorization = authorize_route_mutation(
1100
1137
  request,
1101
1138
  operation="update",
@@ -1213,6 +1250,7 @@ async def set_auto_invoke_config(request: Request):
1213
1250
 
1214
1251
  Body: {"enabled": true, "min_score": 0.15, "weights": {...}}
1215
1252
  """
1253
+ _require_manage(request)
1216
1254
  try:
1217
1255
  body = await request.json()
1218
1256
 
@@ -1254,10 +1292,10 @@ async def get_associations(
1254
1292
  profile: str = "",
1255
1293
  ):
1256
1294
  """Get association edges for a profile with content previews."""
1295
+ pid = _resolve_profile(request, profile)
1257
1296
  try:
1258
1297
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
1259
1298
  import sqlite3
1260
- pid = profile or get_active_profile()
1261
1299
 
1262
1300
  if not DB_PATH.exists():
1263
1301
  return {"edges": [], "total": 0}
@@ -1321,10 +1359,10 @@ async def get_associations(
1321
1359
  @router.get("/associations/stats")
1322
1360
  async def get_association_stats(request: Request, profile: str = ""):
1323
1361
  """Get association graph statistics."""
1362
+ pid = _resolve_profile(request, profile)
1324
1363
  try:
1325
1364
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
1326
1365
  import sqlite3
1327
- pid = profile or get_active_profile()
1328
1366
 
1329
1367
  if not DB_PATH.exists():
1330
1368
  return {
@@ -1422,12 +1460,12 @@ async def get_association_stats(request: Request, profile: str = ""):
1422
1460
  @router.get("/consolidation/status")
1423
1461
  async def get_consolidation_status(request: Request, profile: str = ""):
1424
1462
  """Get consolidation status and last run results."""
1463
+ pid = _resolve_profile(request, profile)
1425
1464
  try:
1426
1465
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
1427
1466
  from superlocalmemory.core.config import SLMConfig
1428
1467
  import sqlite3
1429
1468
 
1430
- pid = profile or get_active_profile()
1431
1469
  config = SLMConfig.load()
1432
1470
  cons_cfg = config.consolidation
1433
1471
 
@@ -1520,7 +1558,8 @@ async def trigger_consolidation(request: Request):
1520
1558
  profile = body.get("profile", "")
1521
1559
 
1522
1560
  from superlocalmemory.server.routes.helpers import get_active_profile
1523
- pid = profile or get_active_profile()
1561
+ pid = _resolve_mutation_profile(profile)
1562
+ _require_manage_for_profile(request, pid)
1524
1563
  authorization = authorize_route_mutation(
1525
1564
  request,
1526
1565
  operation="update",
@@ -1590,10 +1629,10 @@ async def trigger_consolidation(request: Request):
1590
1629
  @router.get("/core-memory")
1591
1630
  async def get_core_memory(request: Request, profile: str = ""):
1592
1631
  """Get all Core Memory blocks for a profile."""
1632
+ pid = _resolve_profile(request, profile)
1593
1633
  try:
1594
1634
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
1595
1635
  import sqlite3
1596
- pid = profile or get_active_profile()
1597
1636
 
1598
1637
  if not DB_PATH.exists():
1599
1638
  return {"blocks": [], "total_chars": 0, "char_limit": 2000}
@@ -1719,6 +1758,7 @@ async def update_core_memory_block(block_id: str, request: Request):
1719
1758
  @router.get("/vector-store/status")
1720
1759
  async def get_vector_store_status(request: Request, profile: str = ""):
1721
1760
  """Get VectorStore health and statistics."""
1761
+ pid = _resolve_profile(request, profile)
1722
1762
  try:
1723
1763
  from superlocalmemory.core.config import SLMConfig
1724
1764
  from superlocalmemory.server.routes.helpers import DB_PATH
@@ -1749,7 +1789,8 @@ async def get_vector_store_status(request: Request, profile: str = ""):
1749
1789
  try:
1750
1790
  conn = sqlite3.connect(str(DB_PATH))
1751
1791
  count = conn.execute(
1752
- "SELECT COUNT(*) FROM embedding_metadata"
1792
+ "SELECT COUNT(*) FROM embedding_metadata WHERE profile_id = ?",
1793
+ (pid,),
1753
1794
  ).fetchone()[0]
1754
1795
  result["total_vectors"] = count
1755
1796
  conn.close()
@@ -1779,10 +1820,10 @@ async def get_vector_store_status(request: Request, profile: str = ""):
1779
1820
  @router.get("/forgetting/stats")
1780
1821
  async def forgetting_stats(request: Request, profile: str = ""):
1781
1822
  """Get memory retention zone distribution."""
1823
+ pid = _resolve_profile(request, profile)
1782
1824
  try:
1783
1825
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
1784
1826
  import sqlite3 as _sqlite3
1785
- pid = profile or get_active_profile()
1786
1827
 
1787
1828
  zones = {"active": 0, "warm": 0, "cold": 0, "archive": 0, "forgotten": 0}
1788
1829
  total = 0
@@ -1830,7 +1871,8 @@ async def run_forgetting(request: Request):
1830
1871
 
1831
1872
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
1832
1873
  import sqlite3 as _sqlite3
1833
- pid = profile or get_active_profile()
1874
+ pid = _resolve_mutation_profile(profile)
1875
+ _require_manage_for_profile(request, pid)
1834
1876
 
1835
1877
  if not DB_PATH.exists():
1836
1878
  return {"success": False, "error": "Database not found"}
@@ -1907,10 +1949,10 @@ async def run_forgetting(request: Request):
1907
1949
  @router.get("/quantization/stats")
1908
1950
  async def quantization_stats(request: Request, profile: str = ""):
1909
1951
  """Get embedding quantization tier distribution."""
1952
+ pid = _resolve_profile(request, profile)
1910
1953
  try:
1911
1954
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
1912
1955
  import sqlite3 as _sqlite3
1913
- pid = profile or get_active_profile()
1914
1956
 
1915
1957
  tiers = {"float32": 0, "int8": 0, "polar4": 0, "polar2": 0}
1916
1958
  total = 0
@@ -1970,10 +2012,10 @@ async def quantization_stats(request: Request, profile: str = ""):
1970
2012
  @router.get("/ccq/blocks")
1971
2013
  async def ccq_blocks(request: Request, profile: str = "", limit: int = 50):
1972
2014
  """Get CCQ consolidated blocks."""
2015
+ pid = _resolve_profile(request, profile)
1973
2016
  try:
1974
2017
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
1975
2018
  import sqlite3 as _sqlite3
1976
- pid = profile or get_active_profile()
1977
2019
 
1978
2020
  if not DB_PATH.exists():
1979
2021
  return {"blocks": [], "total": 0}
@@ -2030,10 +2072,10 @@ async def ccq_blocks(request: Request, profile: str = "", limit: int = 50):
2030
2072
  @router.get("/soft-prompts")
2031
2073
  async def get_soft_prompts(request: Request, profile: str = ""):
2032
2074
  """Get active soft prompt templates."""
2075
+ pid = _resolve_profile(request, profile)
2033
2076
  try:
2034
2077
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
2035
2078
  import sqlite3 as _sqlite3
2036
- pid = profile or get_active_profile()
2037
2079
 
2038
2080
  if not DB_PATH.exists():
2039
2081
  return {"prompts": [], "total": 0, "total_tokens": 0}
@@ -2083,6 +2125,7 @@ async def get_soft_prompts(request: Request, profile: str = ""):
2083
2125
  @router.get("/health/processes")
2084
2126
  async def process_health(request: Request):
2085
2127
  """Get SLM process health status."""
2128
+ _resolve_profile(request)
2086
2129
  try:
2087
2130
  import os as _os
2088
2131
 
@@ -2137,10 +2180,10 @@ async def get_graph_communities(request: Request, profile: str = ""):
2137
2180
  at consolidation time). Falls back to inline word frequency if labels
2138
2181
  not yet computed.
2139
2182
  """
2183
+ pid = _resolve_profile(request, profile)
2140
2184
  try:
2141
2185
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
2142
2186
  import sqlite3
2143
- pid = profile or get_active_profile()
2144
2187
 
2145
2188
  if not DB_PATH.exists():
2146
2189
  return {"communities": [], "total": 0}
@@ -2267,6 +2310,7 @@ async def run_community_detection(request: Request):
2267
2310
  from superlocalmemory.core.graph_analyzer import GraphAnalyzer
2268
2311
 
2269
2312
  pid = get_active_profile()
2313
+ _require_manage_for_profile(request, pid)
2270
2314
  authorization = authorize_route_mutation(
2271
2315
  request,
2272
2316
  operation="update",
@@ -2289,10 +2333,10 @@ async def run_community_detection(request: Request):
2289
2333
  @router.get("/v33/overview")
2290
2334
  async def v33_overview(request: Request, profile: str = ""):
2291
2335
  """Get SLM 3.3 feature overview -- all new capabilities at a glance."""
2336
+ pid = _resolve_profile(request, profile)
2292
2337
  try:
2293
2338
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
2294
2339
  import sqlite3 as _sqlite3
2295
- pid = profile or get_active_profile()
2296
2340
 
2297
2341
  overview: dict = {
2298
2342
  "version": "3.3",