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.
- package/CHANGELOG.md +60 -0
- package/README.md +32 -120
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +1 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +2 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +134 -7
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +2 -7
- package/src/superlocalmemory/cli/main.py +10 -4
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
- package/src/superlocalmemory/core/embedding_worker.py +4 -5
- package/src/superlocalmemory/core/embeddings.py +119 -45
- package/src/superlocalmemory/core/engine.py +24 -21
- package/src/superlocalmemory/core/engine_ingestion.py +332 -45
- package/src/superlocalmemory/core/ingestion_command.py +134 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
- package/src/superlocalmemory/core/recall_pipeline.py +5 -3
- package/src/superlocalmemory/core/store_pipeline.py +153 -20
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/portable_kit.py +34 -2
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/ledger.py +25 -5
- package/src/superlocalmemory/mcp/server.py +11 -30
- package/src/superlocalmemory/mcp/tools_core.py +4 -3
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/retrieval/engine.py +53 -21
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/routes/backup.py +293 -70
- package/src/superlocalmemory/server/routes/behavioral.py +336 -59
- package/src/superlocalmemory/server/routes/brain.py +57 -16
- package/src/superlocalmemory/server/routes/config_api.py +84 -82
- package/src/superlocalmemory/server/routes/entity.py +100 -23
- package/src/superlocalmemory/server/routes/evolution.py +103 -100
- package/src/superlocalmemory/server/routes/learning.py +276 -105
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/mesh.py +121 -32
- package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
- package/src/superlocalmemory/server/routes/stats.py +93 -155
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +64 -20
- package/src/superlocalmemory/server/unified_daemon.py +467 -40
- package/src/superlocalmemory/storage/migration_runner.py +79 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/schema.py +49 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/ui/index.html +3 -6
- package/src/superlocalmemory/ui/js/core.js +52 -9
- package/src/superlocalmemory/ui/js/dashboard.js +169 -82
- package/src/superlocalmemory/ui/js/od-backup.js +156 -65
- package/src/superlocalmemory/ui/js/od-brain.js +88 -51
- package/src/superlocalmemory/ui/js/od-entities.js +22 -22
- package/src/superlocalmemory/ui/js/od-graph.js +11 -4
- package/src/superlocalmemory/ui/js/od-memories.js +47 -5
- package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
- package/src/superlocalmemory/ui/js/od-settings.js +113 -59
- package/src/superlocalmemory/ui/js/od-shell.js +249 -33
- package/src/superlocalmemory/ui/js/od-skills.js +44 -17
- package/src/superlocalmemory/ui/js/settings.js +15 -1
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-governance-advisor.md +0 -80
- package/plugin-src/agents/slm-loop-runner.md +0 -71
- package/plugin-src/agents/slm-memory-advisor.md +0 -49
- package/plugin-src/agents/slm-optimize-advisor.md +0 -44
- package/plugin-src/commands/slm-loop.md +0 -31
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -102
- package/plugin-src/manifest.json +0 -30
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -62
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -25
- package/plugin-src/skills/slm-governance/SKILL.md +0 -248
- package/plugin-src/skills/slm-loop/SKILL.md +0 -99
- package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
- package/plugin-src/skills/slm-profile/SKILL.md +0 -148
- 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
|
|
7
|
+
Routes: /api/stats, /api/timeline
|
|
8
8
|
"""
|
|
9
|
-
import json
|
|
10
9
|
import logging
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
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
|
|
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",
|