superlocalmemory 3.4.19 → 3.4.22
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 +24 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +4 -3
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +254 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +423 -0
- package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +348 -0
- package/src/superlocalmemory/skills/slm-recall/SKILL.md +343 -0
- package/src/superlocalmemory/skills/slm-remember/SKILL.md +194 -0
- package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +224 -0
- package/src/superlocalmemory/skills/slm-status/SKILL.md +363 -0
- package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- package/src/superlocalmemory.egg-info/top_level.txt +0 -1
|
@@ -11,7 +11,7 @@ import gzip
|
|
|
11
11
|
import json
|
|
12
12
|
import logging
|
|
13
13
|
from typing import Optional
|
|
14
|
-
from datetime import datetime
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
15
|
|
|
16
16
|
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File
|
|
17
17
|
from fastapi.responses import StreamingResponse
|
|
@@ -79,14 +79,14 @@ async def export_memories(
|
|
|
79
79
|
else:
|
|
80
80
|
content = json.dumps({
|
|
81
81
|
"version": "3.0.0",
|
|
82
|
-
"exported_at": datetime.now().isoformat(),
|
|
82
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
83
83
|
"total_memories": len(memories),
|
|
84
84
|
"filters": {"category": category, "project_name": project_name},
|
|
85
85
|
"memories": memories,
|
|
86
86
|
}, indent=2)
|
|
87
87
|
media_type = "application/json"
|
|
88
88
|
|
|
89
|
-
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
89
|
+
ts = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
|
90
90
|
if len(content) > 10000:
|
|
91
91
|
compressed = gzip.compress(content.encode())
|
|
92
92
|
return StreamingResponse(
|
|
@@ -167,7 +167,7 @@ async def import_memories(request: Request, file: UploadFile = File(...)):
|
|
|
167
167
|
if ws_manager:
|
|
168
168
|
await ws_manager.broadcast({
|
|
169
169
|
"type": "memory_added", "memory_id": imported,
|
|
170
|
-
"timestamp": datetime.now().isoformat(),
|
|
170
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
171
171
|
})
|
|
172
172
|
|
|
173
173
|
except Exception as e:
|
|
@@ -12,7 +12,7 @@ import threading
|
|
|
12
12
|
import queue as _queue
|
|
13
13
|
import logging
|
|
14
14
|
from typing import Optional, Set
|
|
15
|
-
from datetime import datetime
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
16
|
|
|
17
17
|
from fastapi import APIRouter, HTTPException, Query
|
|
18
18
|
from fastapi.responses import StreamingResponse
|
|
@@ -136,7 +136,7 @@ async def event_stream(
|
|
|
136
136
|
|
|
137
137
|
# 3. Keepalive + sleep
|
|
138
138
|
if not drained:
|
|
139
|
-
yield f": keepalive {datetime.now().isoformat()}\n\n"
|
|
139
|
+
yield f": keepalive {datetime.now(timezone.utc).isoformat()}\n\n"
|
|
140
140
|
await asyncio.sleep(1)
|
|
141
141
|
finally:
|
|
142
142
|
with _sse_queues_lock:
|
|
@@ -257,7 +257,7 @@ def ensure_profile_in_json(name: str, description: str = "") -> None:
|
|
|
257
257
|
profiles[name] = {
|
|
258
258
|
'name': name,
|
|
259
259
|
'description': description or f'Memory profile: {name}',
|
|
260
|
-
'created_at': datetime.now().isoformat(),
|
|
260
|
+
'created_at': datetime.now(timezone.utc).isoformat(),
|
|
261
261
|
'last_used': None,
|
|
262
262
|
}
|
|
263
263
|
config['profiles'] = profiles
|
|
@@ -11,7 +11,7 @@ Uses V3 learning modules: FeedbackCollector, EngagementTracker, AdaptiveLearner.
|
|
|
11
11
|
"""
|
|
12
12
|
import shutil
|
|
13
13
|
import logging
|
|
14
|
-
from datetime import datetime
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
17
|
from fastapi import APIRouter
|
|
@@ -23,6 +23,84 @@ router = APIRouter()
|
|
|
23
23
|
|
|
24
24
|
LEARNING_DB = MEMORY_DIR / "learning.db"
|
|
25
25
|
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# LLD-02 §4.10 — Dashboard phase truth
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _compute_ranker_phase(
|
|
33
|
+
profile_id: str,
|
|
34
|
+
*,
|
|
35
|
+
learning_db_path: Path | None = None,
|
|
36
|
+
) -> dict:
|
|
37
|
+
"""Return {phase, label, model_active, signals} — LLD-02 §4.10.
|
|
38
|
+
|
|
39
|
+
Phase 3 requires BOTH an active (is_active=1) row AND a successful
|
|
40
|
+
SHA-256 verification on the model_cache load. Tampered bytes fall
|
|
41
|
+
back to phase 2.
|
|
42
|
+
"""
|
|
43
|
+
from superlocalmemory.learning.database import LearningDatabase
|
|
44
|
+
from superlocalmemory.learning.model_cache import load_active, invalidate
|
|
45
|
+
|
|
46
|
+
db_path = Path(learning_db_path) if learning_db_path else LEARNING_DB
|
|
47
|
+
if not db_path.exists():
|
|
48
|
+
return {
|
|
49
|
+
"phase": 1,
|
|
50
|
+
"label": "Cold start (cross-encoder only)",
|
|
51
|
+
"model_active": False,
|
|
52
|
+
"signals": 0,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
db = LearningDatabase(db_path)
|
|
56
|
+
try:
|
|
57
|
+
signals = db.count_signals(profile_id)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
logger.warning("count_signals failed: %s", exc)
|
|
60
|
+
signals = 0
|
|
61
|
+
|
|
62
|
+
# Force a cache-bypass load — the dashboard read is rare and we want
|
|
63
|
+
# tamper detection to surface immediately.
|
|
64
|
+
invalidate(profile_id)
|
|
65
|
+
try:
|
|
66
|
+
model = load_active(db, profile_id, use_cache=False)
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
logger.warning("load_active failed: %s", exc)
|
|
69
|
+
model = None
|
|
70
|
+
|
|
71
|
+
active = model is not None
|
|
72
|
+
|
|
73
|
+
if active and signals >= 200:
|
|
74
|
+
return {
|
|
75
|
+
"phase": 3,
|
|
76
|
+
"label": "LightGBM ranker active",
|
|
77
|
+
"model_active": True,
|
|
78
|
+
"signals": signals,
|
|
79
|
+
}
|
|
80
|
+
if signals >= 50:
|
|
81
|
+
return {
|
|
82
|
+
"phase": 2,
|
|
83
|
+
"label": "Contextual bandit",
|
|
84
|
+
"model_active": False,
|
|
85
|
+
"signals": signals,
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
"phase": 1,
|
|
89
|
+
"label": "Cold start (cross-encoder only)",
|
|
90
|
+
"model_active": False,
|
|
91
|
+
"signals": signals,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/api/learning/ranker_phase")
|
|
96
|
+
async def ranker_phase():
|
|
97
|
+
"""Dashboard endpoint — LLD-02 §4.10 phase truth."""
|
|
98
|
+
try:
|
|
99
|
+
profile = get_active_profile()
|
|
100
|
+
except Exception:
|
|
101
|
+
profile = "default"
|
|
102
|
+
return _compute_ranker_phase(profile)
|
|
103
|
+
|
|
26
104
|
# Feature detection
|
|
27
105
|
LEARNING_AVAILABLE = False
|
|
28
106
|
BEHAVIORAL_AVAILABLE = False
|
|
@@ -341,6 +419,42 @@ async def feedback_stats():
|
|
|
341
419
|
# PATTERNS ENDPOINT (v3.4.1 — CRITICAL FIX: frontend calls /api/patterns)
|
|
342
420
|
# ============================================================================
|
|
343
421
|
|
|
422
|
+
|
|
423
|
+
@router.delete("/api/patterns/delete")
|
|
424
|
+
async def delete_pattern(data: dict) -> dict:
|
|
425
|
+
"""S9-DASH-04: delete a single auto-detected pattern by key.
|
|
426
|
+
|
|
427
|
+
Body: ``{pattern_type: str, pattern_key: str}``
|
|
428
|
+
|
|
429
|
+
Returns ``{success: bool, deleted: int}``. The pattern is scoped
|
|
430
|
+
to the active profile so cross-profile deletion is impossible.
|
|
431
|
+
"""
|
|
432
|
+
if not BEHAVIORAL_AVAILABLE:
|
|
433
|
+
return {"success": False, "error": "Behavioral engine not available"}
|
|
434
|
+
ptype = (data or {}).get("pattern_type", "")
|
|
435
|
+
pkey = (data or {}).get("pattern_key", "")
|
|
436
|
+
if not ptype or not pkey:
|
|
437
|
+
return {
|
|
438
|
+
"success": False,
|
|
439
|
+
"error": "pattern_type and pattern_key are required",
|
|
440
|
+
}
|
|
441
|
+
try:
|
|
442
|
+
profile = get_active_profile()
|
|
443
|
+
store = BehavioralPatternStore(str(LEARNING_DB))
|
|
444
|
+
deleted = store.delete_pattern_by_key(
|
|
445
|
+
profile_id=profile,
|
|
446
|
+
pattern_type=ptype,
|
|
447
|
+
pattern_key=pkey,
|
|
448
|
+
)
|
|
449
|
+
return {
|
|
450
|
+
"success": True, "deleted": int(deleted),
|
|
451
|
+
"active_profile": profile,
|
|
452
|
+
}
|
|
453
|
+
except Exception as exc: # noqa: BLE001
|
|
454
|
+
logger.error("delete_pattern failed: %s", exc)
|
|
455
|
+
return {"success": False, "error": str(exc)}
|
|
456
|
+
|
|
457
|
+
|
|
344
458
|
@router.get("/api/patterns")
|
|
345
459
|
async def get_patterns():
|
|
346
460
|
"""Get learned behavioral patterns for the Patterns dashboard tab.
|
|
@@ -434,7 +548,7 @@ async def learning_backup():
|
|
|
434
548
|
if not LEARNING_DB.exists():
|
|
435
549
|
return {"success": False, "error": "No learning.db found"}
|
|
436
550
|
|
|
437
|
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
551
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
438
552
|
backup_name = f"learning.db.backup_{timestamp}"
|
|
439
553
|
backup_path = MEMORY_DIR / backup_name
|
|
440
554
|
shutil.copy2(str(LEARNING_DB), str(backup_path))
|
|
@@ -451,15 +565,86 @@ async def learning_backup():
|
|
|
451
565
|
|
|
452
566
|
@router.post("/api/learning/reset")
|
|
453
567
|
async def learning_reset():
|
|
454
|
-
"""Reset all learning data. Memories preserved."""
|
|
568
|
+
"""Reset all learning data for the active profile. Memories preserved."""
|
|
455
569
|
if not LEARNING_AVAILABLE:
|
|
456
570
|
return {"success": False, "error": "Learning system not available"}
|
|
457
|
-
|
|
571
|
+
try:
|
|
572
|
+
from superlocalmemory.learning.database import LearningDatabase
|
|
573
|
+
db = LearningDatabase(LEARNING_DB)
|
|
574
|
+
profile_id = get_active_profile() or "default"
|
|
575
|
+
db.reset(profile_id=profile_id)
|
|
576
|
+
return {
|
|
577
|
+
"success": True,
|
|
578
|
+
"message": "Learning data reset. Memories preserved.",
|
|
579
|
+
"profile_id": profile_id,
|
|
580
|
+
}
|
|
581
|
+
except Exception as exc: # noqa: BLE001
|
|
582
|
+
logger.error("learning_reset failed: %s", exc)
|
|
583
|
+
return {"success": False, "error": str(exc)}
|
|
458
584
|
|
|
459
585
|
|
|
460
586
|
@router.post("/api/learning/retrain")
|
|
461
|
-
async def learning_retrain():
|
|
462
|
-
"""Force retrain the
|
|
587
|
+
async def learning_retrain(data: dict | None = None):
|
|
588
|
+
"""Force a retrain of the LightGBM ranker.
|
|
589
|
+
|
|
590
|
+
Body (optional, JSON):
|
|
591
|
+
``{"include_synthetic": bool}`` — when True, migrated legacy rows
|
|
592
|
+
(``is_synthetic=1``) participate in training. Default False.
|
|
593
|
+
"""
|
|
463
594
|
if not LEARNING_AVAILABLE:
|
|
464
595
|
return {"success": False, "error": "Learning system not available"}
|
|
465
|
-
|
|
596
|
+
include_synthetic = bool(
|
|
597
|
+
data and data.get("include_synthetic")
|
|
598
|
+
) if isinstance(data, dict) else False
|
|
599
|
+
try:
|
|
600
|
+
from superlocalmemory.learning.consolidation_worker import (
|
|
601
|
+
_retrain_ranker_impl,
|
|
602
|
+
)
|
|
603
|
+
profile_id = get_active_profile() or "default"
|
|
604
|
+
trained = _retrain_ranker_impl(
|
|
605
|
+
LEARNING_DB,
|
|
606
|
+
profile_id,
|
|
607
|
+
include_synthetic=include_synthetic,
|
|
608
|
+
)
|
|
609
|
+
if trained:
|
|
610
|
+
return {
|
|
611
|
+
"success": True,
|
|
612
|
+
"trained": True,
|
|
613
|
+
"profile_id": profile_id,
|
|
614
|
+
"include_synthetic": include_synthetic,
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
"success": True,
|
|
618
|
+
"trained": False,
|
|
619
|
+
"profile_id": profile_id,
|
|
620
|
+
"include_synthetic": include_synthetic,
|
|
621
|
+
"message": (
|
|
622
|
+
"Not enough training rows yet. Keep using SLM, or run "
|
|
623
|
+
"legacy migration + retry with include_synthetic=true."
|
|
624
|
+
),
|
|
625
|
+
}
|
|
626
|
+
except Exception as exc: # noqa: BLE001
|
|
627
|
+
logger.error("learning_retrain failed: %s", exc)
|
|
628
|
+
return {"success": False, "error": str(exc)}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@router.post("/api/learning/migrate-legacy")
|
|
632
|
+
async def learning_migrate_legacy():
|
|
633
|
+
"""Copy ``learning_feedback`` rows into LLD-02 tables for training.
|
|
634
|
+
|
|
635
|
+
Idempotent: subsequent calls detect the migration_log sentinel and
|
|
636
|
+
return ``already_done=True`` without re-copying. The rows are written
|
|
637
|
+
with ``is_synthetic=1`` to preserve provenance; the trainer must be
|
|
638
|
+
invoked with ``include_synthetic=True`` to use them.
|
|
639
|
+
"""
|
|
640
|
+
if not LEARNING_AVAILABLE:
|
|
641
|
+
return {"success": False, "error": "Learning system not available"}
|
|
642
|
+
try:
|
|
643
|
+
from superlocalmemory.learning.legacy_migration import (
|
|
644
|
+
migrate_legacy_feedback,
|
|
645
|
+
)
|
|
646
|
+
stats = migrate_legacy_feedback(LEARNING_DB)
|
|
647
|
+
return {"success": True, **stats}
|
|
648
|
+
except Exception as exc: # noqa: BLE001
|
|
649
|
+
logger.error("learning_migrate_legacy failed: %s", exc)
|
|
650
|
+
return {"success": False, "error": str(exc)}
|
|
@@ -211,8 +211,22 @@ async def get_memories(
|
|
|
211
211
|
tags: Optional[str] = None,
|
|
212
212
|
limit: int = Query(50, ge=1, le=200),
|
|
213
213
|
offset: int = Query(0, ge=0),
|
|
214
|
+
filter: Optional[str] = Query(
|
|
215
|
+
None,
|
|
216
|
+
description="Named filter: 'high_reward' | 'being_forgotten'",
|
|
217
|
+
),
|
|
214
218
|
):
|
|
215
|
-
"""List memories with optional filtering and pagination.
|
|
219
|
+
"""List memories with optional filtering and pagination.
|
|
220
|
+
|
|
221
|
+
S9-DASH-07: ``filter`` enables dashboard "learning-visible" views:
|
|
222
|
+
|
|
223
|
+
* ``high_reward``: facts cited by ``action_outcomes`` with
|
|
224
|
+
``reward >= 0.7`` in the last 30 days. Surfaces what the ranker
|
|
225
|
+
is actually learning from.
|
|
226
|
+
* ``being_forgotten``: facts in ``archive_status='archived'`` OR
|
|
227
|
+
with ``lifecycle='cold'`` AND no positive reward in 60 days.
|
|
228
|
+
Makes "memory decay" tangible to the operator.
|
|
229
|
+
"""
|
|
216
230
|
try:
|
|
217
231
|
conn = get_db_connection()
|
|
218
232
|
conn.row_factory = dict_factory
|
|
@@ -277,6 +291,56 @@ async def get_memories(
|
|
|
277
291
|
query += " AND tags LIKE ?"
|
|
278
292
|
params.append(f'%{tag}%')
|
|
279
293
|
|
|
294
|
+
# S9-DASH-07: named filters — "high_reward" and "being_forgotten".
|
|
295
|
+
# Only supported on the v3 (atomic_facts) path — v2 fallback
|
|
296
|
+
# ignores the flag silently.
|
|
297
|
+
if filter and use_v3:
|
|
298
|
+
if filter == "high_reward":
|
|
299
|
+
query += (
|
|
300
|
+
" AND fact_id IN ("
|
|
301
|
+
" SELECT DISTINCT json_each.value"
|
|
302
|
+
" FROM action_outcomes, json_each(action_outcomes.fact_ids_json)"
|
|
303
|
+
" WHERE action_outcomes.reward >= 0.7"
|
|
304
|
+
" AND datetime(action_outcomes.settled_at) >= "
|
|
305
|
+
" datetime('now', '-30 day')"
|
|
306
|
+
")"
|
|
307
|
+
)
|
|
308
|
+
count_base += (
|
|
309
|
+
" AND fact_id IN ("
|
|
310
|
+
" SELECT DISTINCT json_each.value"
|
|
311
|
+
" FROM action_outcomes, json_each(action_outcomes.fact_ids_json)"
|
|
312
|
+
" WHERE action_outcomes.reward >= 0.7"
|
|
313
|
+
" AND datetime(action_outcomes.settled_at) >= "
|
|
314
|
+
" datetime('now', '-30 day')"
|
|
315
|
+
")"
|
|
316
|
+
)
|
|
317
|
+
elif filter == "being_forgotten":
|
|
318
|
+
# Cold / archived + no recent positive reward.
|
|
319
|
+
query += (
|
|
320
|
+
" AND ("
|
|
321
|
+
" archive_status = 'archived' OR "
|
|
322
|
+
" (lifecycle = 'cold' AND fact_id NOT IN ("
|
|
323
|
+
" SELECT DISTINCT json_each.value"
|
|
324
|
+
" FROM action_outcomes, json_each(action_outcomes.fact_ids_json)"
|
|
325
|
+
" WHERE action_outcomes.reward >= 0.5"
|
|
326
|
+
" AND datetime(action_outcomes.settled_at) >= "
|
|
327
|
+
" datetime('now', '-60 day')"
|
|
328
|
+
" ))"
|
|
329
|
+
")"
|
|
330
|
+
)
|
|
331
|
+
count_base += (
|
|
332
|
+
" AND ("
|
|
333
|
+
" archive_status = 'archived' OR "
|
|
334
|
+
" (lifecycle = 'cold' AND fact_id NOT IN ("
|
|
335
|
+
" SELECT DISTINCT json_each.value"
|
|
336
|
+
" FROM action_outcomes, json_each(action_outcomes.fact_ids_json)"
|
|
337
|
+
" WHERE action_outcomes.reward >= 0.5"
|
|
338
|
+
" AND datetime(action_outcomes.settled_at) >= "
|
|
339
|
+
" datetime('now', '-60 day')"
|
|
340
|
+
" ))"
|
|
341
|
+
")"
|
|
342
|
+
)
|
|
343
|
+
|
|
280
344
|
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
281
345
|
params.extend([limit, offset])
|
|
282
346
|
|
|
@@ -569,6 +633,130 @@ async def delete_memory(request: Request, fact_id: str):
|
|
|
569
633
|
raise HTTPException(status_code=500, detail=f"Delete error: {str(e)}")
|
|
570
634
|
|
|
571
635
|
|
|
636
|
+
@router.post("/api/memories/{fact_id}/forget")
|
|
637
|
+
async def forget_memory(request: Request, fact_id: str):
|
|
638
|
+
"""S9-DASH-08: soft-forget a fact — flip archive_status='archived'.
|
|
639
|
+
|
|
640
|
+
Non-destructive: the row stays in ``atomic_facts`` for audit and
|
|
641
|
+
can be un-archived later. Default recall paths filter it out.
|
|
642
|
+
The fact's payload is ALSO copied into ``memory_archive`` so a
|
|
643
|
+
future ``slm restore`` can bring it back.
|
|
644
|
+
"""
|
|
645
|
+
import json as _json
|
|
646
|
+
try:
|
|
647
|
+
conn = get_db_connection()
|
|
648
|
+
conn.row_factory = dict_factory
|
|
649
|
+
cursor = conn.cursor()
|
|
650
|
+
active_profile = get_active_profile()
|
|
651
|
+
cursor.execute(
|
|
652
|
+
"SELECT fact_id, content, importance, confidence, "
|
|
653
|
+
" canonical_entities_json, embedding, created_at "
|
|
654
|
+
"FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
|
|
655
|
+
(fact_id, active_profile),
|
|
656
|
+
)
|
|
657
|
+
row = cursor.fetchone()
|
|
658
|
+
if not row:
|
|
659
|
+
conn.close()
|
|
660
|
+
raise HTTPException(status_code=404, detail="Memory not found")
|
|
661
|
+
# Archive copy — payload_json small enough for the canonical row.
|
|
662
|
+
payload = {
|
|
663
|
+
"fact_id": row["fact_id"],
|
|
664
|
+
"content": row["content"],
|
|
665
|
+
"canonical_entities_json": row.get("canonical_entities_json"),
|
|
666
|
+
"importance": row.get("importance"),
|
|
667
|
+
"confidence": row.get("confidence"),
|
|
668
|
+
"created_at": row.get("created_at"),
|
|
669
|
+
}
|
|
670
|
+
from datetime import datetime, timezone
|
|
671
|
+
archived_at = datetime.now(timezone.utc).isoformat()
|
|
672
|
+
import uuid as _uuid
|
|
673
|
+
cursor.execute(
|
|
674
|
+
"INSERT INTO memory_archive "
|
|
675
|
+
"(archive_id, fact_id, profile_id, payload_json, archived_at, reason) "
|
|
676
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
677
|
+
(str(_uuid.uuid4()), fact_id, active_profile,
|
|
678
|
+
_json.dumps(payload), archived_at, "user_forget_dashboard"),
|
|
679
|
+
)
|
|
680
|
+
cursor.execute(
|
|
681
|
+
"UPDATE atomic_facts SET archive_status = 'archived' "
|
|
682
|
+
"WHERE fact_id = ?",
|
|
683
|
+
(fact_id,),
|
|
684
|
+
)
|
|
685
|
+
conn.commit()
|
|
686
|
+
conn.close()
|
|
687
|
+
return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
|
|
688
|
+
except HTTPException:
|
|
689
|
+
raise
|
|
690
|
+
except Exception as e:
|
|
691
|
+
raise HTTPException(status_code=500, detail=f"Forget error: {str(e)}")
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@router.post("/api/memories/{fact_id}/merge")
|
|
695
|
+
async def merge_memory(request: Request, fact_id: str):
|
|
696
|
+
"""S9-DASH-08: merge this fact into another (keep the other).
|
|
697
|
+
|
|
698
|
+
Body: ``{into: <kept_fact_id>}``.
|
|
699
|
+
|
|
700
|
+
Writes a ``memory_merge_log`` row (M011) for provenance and marks
|
|
701
|
+
the loser's ``merged_into`` column. The loser is archived so it
|
|
702
|
+
no longer appears in default recall. The winner is untouched.
|
|
703
|
+
"""
|
|
704
|
+
try:
|
|
705
|
+
body = await request.json()
|
|
706
|
+
kept = str((body or {}).get("into", "")).strip()
|
|
707
|
+
if not kept:
|
|
708
|
+
raise HTTPException(400, "Body field 'into' is required")
|
|
709
|
+
# S9-AUDIT: cap length defensively — fact_ids are UUID-v4 36 chars.
|
|
710
|
+
if len(kept) > 200:
|
|
711
|
+
raise HTTPException(400, "'into' exceeds 200-char limit")
|
|
712
|
+
if kept == fact_id:
|
|
713
|
+
raise HTTPException(400, "Cannot merge a fact into itself")
|
|
714
|
+
conn = get_db_connection()
|
|
715
|
+
conn.row_factory = dict_factory
|
|
716
|
+
cursor = conn.cursor()
|
|
717
|
+
active_profile = get_active_profile()
|
|
718
|
+
# Both must belong to the active profile.
|
|
719
|
+
cursor.execute(
|
|
720
|
+
"SELECT fact_id FROM atomic_facts "
|
|
721
|
+
"WHERE fact_id IN (?, ?) AND profile_id = ?",
|
|
722
|
+
(fact_id, kept, active_profile),
|
|
723
|
+
)
|
|
724
|
+
found = {r["fact_id"] for r in cursor.fetchall()}
|
|
725
|
+
if fact_id not in found or kept not in found:
|
|
726
|
+
conn.close()
|
|
727
|
+
raise HTTPException(
|
|
728
|
+
404,
|
|
729
|
+
"Both fact_ids must exist in the active profile",
|
|
730
|
+
)
|
|
731
|
+
from datetime import datetime, timezone
|
|
732
|
+
merged_at = datetime.now(timezone.utc).isoformat()
|
|
733
|
+
cursor.execute(
|
|
734
|
+
"INSERT INTO memory_merge_log "
|
|
735
|
+
"(kept_fact_id, merged_fact_id, profile_id, reason, merged_at) "
|
|
736
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
737
|
+
(kept, fact_id, active_profile,
|
|
738
|
+
"user_merge_dashboard", merged_at),
|
|
739
|
+
)
|
|
740
|
+
cursor.execute(
|
|
741
|
+
"UPDATE atomic_facts "
|
|
742
|
+
"SET merged_into = ?, archive_status = 'archived' "
|
|
743
|
+
"WHERE fact_id = ?",
|
|
744
|
+
(kept, fact_id),
|
|
745
|
+
)
|
|
746
|
+
conn.commit()
|
|
747
|
+
conn.close()
|
|
748
|
+
return {
|
|
749
|
+
"success": True,
|
|
750
|
+
"merged": fact_id,
|
|
751
|
+
"into": kept,
|
|
752
|
+
"merged_at": merged_at,
|
|
753
|
+
}
|
|
754
|
+
except HTTPException:
|
|
755
|
+
raise
|
|
756
|
+
except Exception as e:
|
|
757
|
+
raise HTTPException(status_code=500, detail=f"Merge error: {str(e)}")
|
|
758
|
+
|
|
759
|
+
|
|
572
760
|
@router.patch("/api/memories/{fact_id}")
|
|
573
761
|
async def edit_memory(request: Request, fact_id: str):
|
|
574
762
|
"""Edit the content of a specific memory (atomic fact)."""
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory v3.4.22 — LLD-01 §4.4 / §4.5
|
|
4
|
+
|
|
5
|
+
"""POST /internal/prewarm — populates the context cache for a session.
|
|
6
|
+
|
|
7
|
+
S8-SK-02 fix: Wave 2A shipped ``hooks/prewarm_auth.authorize`` (gates
|
|
8
|
+
loopback → origin → install-token → body-size) and unit-tested it, but
|
|
9
|
+
no FastAPI route mounted it. The hot-path ``post_tool_async_hook`` POSTs
|
|
10
|
+
to ``/internal/prewarm`` after every tool call to refresh the
|
|
11
|
+
``active_brain_cache`` row for the current session/topic. Without a
|
|
12
|
+
route registered here, those POSTs 404'd silently, the cache never
|
|
13
|
+
populated, and every ``UserPromptSubmit`` ended up a structural miss.
|
|
14
|
+
|
|
15
|
+
Design notes
|
|
16
|
+
------------
|
|
17
|
+
* All 4 gates from LLD-01 §4.4 run before any engine work: loopback
|
|
18
|
+
peer, absence of browser ``Origin`` header, valid install-token, body
|
|
19
|
+
<= ``MAX_BODY_BYTES``. On any gate failure we return the decision's
|
|
20
|
+
status code with ``application/json`` error envelope and do not touch
|
|
21
|
+
the engine. This is LLD-07 SEC-HR-03 applied at the edge.
|
|
22
|
+
* The route is async; the actual cache write (``ContextCache.upsert``)
|
|
23
|
+
is synchronous SQLite and runs on the default executor via
|
|
24
|
+
``asyncio.to_thread`` so we never block the event loop.
|
|
25
|
+
* Body schema is intentionally narrow: ``{"session_id": str,
|
|
26
|
+
"prompt": str, "content": str, "fact_ids": list[str]}``. Missing or
|
|
27
|
+
wrong-type fields produce 400.
|
|
28
|
+
* Never raises past this function. Any unexpected exception is caught,
|
|
29
|
+
logged at ``debug`` to avoid log flooding under a hostile peer, and
|
|
30
|
+
returned as 500 JSON. The hot path ``post_tool_async_hook`` treats
|
|
31
|
+
any non-2xx as "fire-and-forget, try again later", so degradation is
|
|
32
|
+
graceful.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import logging
|
|
39
|
+
import time
|
|
40
|
+
|
|
41
|
+
from fastapi import APIRouter, Request
|
|
42
|
+
from fastapi.responses import JSONResponse
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
router = APIRouter(tags=["internal"])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_ALLOWED_BODY_KEYS = frozenset({"session_id", "prompt", "content", "fact_ids"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/internal/prewarm")
|
|
53
|
+
async def prewarm(request: Request) -> JSONResponse:
|
|
54
|
+
"""Write (or refresh) a context-cache entry for the caller's session.
|
|
55
|
+
|
|
56
|
+
Gates (LLD-01 §4.4):
|
|
57
|
+
1. Loopback-only client (127.0.0.1 / ::1 / localhost).
|
|
58
|
+
2. Reject browser-originated calls (any ``Origin`` header).
|
|
59
|
+
3. Install-token present and constant-time-verified.
|
|
60
|
+
4. Body <= ``MAX_BODY_BYTES``.
|
|
61
|
+
|
|
62
|
+
On success, returns ``{"ok": true}``. On any failure, returns the
|
|
63
|
+
AuthDecision's status with a terse JSON body. Never exposes engine
|
|
64
|
+
error detail to the caller.
|
|
65
|
+
"""
|
|
66
|
+
# Gates 1-3 first (cheap; reject hostile peers before reading body).
|
|
67
|
+
try:
|
|
68
|
+
from superlocalmemory.hooks.prewarm_auth import (
|
|
69
|
+
MAX_BODY_BYTES,
|
|
70
|
+
authorize,
|
|
71
|
+
check_body_size,
|
|
72
|
+
)
|
|
73
|
+
except Exception as exc: # pragma: no cover — primitives always present
|
|
74
|
+
logger.debug("prewarm: auth primitives unimportable: %s", exc)
|
|
75
|
+
return JSONResponse({"error": "server_error"}, status_code=500)
|
|
76
|
+
|
|
77
|
+
client_host = request.client.host if request.client else ""
|
|
78
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
79
|
+
|
|
80
|
+
decision = authorize(client_host=client_host, headers=headers)
|
|
81
|
+
if not decision.allowed:
|
|
82
|
+
return JSONResponse(
|
|
83
|
+
{"error": decision.reason}, status_code=decision.status,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Gate 4 — read body with a hard size cap. FastAPI/Starlette has no
|
|
87
|
+
# cheap way to check the Content-Length up front in all servers, so
|
|
88
|
+
# we read at most MAX_BODY_BYTES+1 and reject if we got more.
|
|
89
|
+
try:
|
|
90
|
+
body_bytes = await request.body()
|
|
91
|
+
except Exception as exc: # pragma: no cover
|
|
92
|
+
logger.debug("prewarm: body read failed: %s", exc)
|
|
93
|
+
return JSONResponse({"error": "bad_body"}, status_code=400)
|
|
94
|
+
ok, reason = check_body_size(body_bytes)
|
|
95
|
+
if not ok:
|
|
96
|
+
return JSONResponse({"error": reason}, status_code=413)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
import json as _json
|
|
100
|
+
payload = _json.loads(body_bytes or b"{}")
|
|
101
|
+
except Exception:
|
|
102
|
+
return JSONResponse({"error": "invalid_json"}, status_code=400)
|
|
103
|
+
|
|
104
|
+
if not isinstance(payload, dict):
|
|
105
|
+
return JSONResponse({"error": "invalid_json"}, status_code=400)
|
|
106
|
+
|
|
107
|
+
# Narrow contract: reject unknown keys to keep the surface small.
|
|
108
|
+
# S10-SEC-N-02: fixed error tag, never echo attacker-supplied keys.
|
|
109
|
+
unknown = set(payload.keys()) - _ALLOWED_BODY_KEYS
|
|
110
|
+
if unknown:
|
|
111
|
+
return JSONResponse(
|
|
112
|
+
{"error": "unknown_keys"}, status_code=400,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
session_id = payload.get("session_id")
|
|
116
|
+
prompt = payload.get("prompt")
|
|
117
|
+
content = payload.get("content")
|
|
118
|
+
fact_ids = payload.get("fact_ids") or []
|
|
119
|
+
if not isinstance(session_id, str) or not session_id:
|
|
120
|
+
return JSONResponse({"error": "session_id_required"}, status_code=400)
|
|
121
|
+
if not isinstance(prompt, str) or not prompt:
|
|
122
|
+
return JSONResponse({"error": "prompt_required"}, status_code=400)
|
|
123
|
+
if not isinstance(content, str) or not content:
|
|
124
|
+
return JSONResponse({"error": "content_required"}, status_code=400)
|
|
125
|
+
if not isinstance(fact_ids, list) or not all(
|
|
126
|
+
isinstance(f, str) for f in fact_ids
|
|
127
|
+
):
|
|
128
|
+
return JSONResponse({"error": "fact_ids_list"}, status_code=400)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
topic_sig = await asyncio.to_thread(_compute_topic_sig, prompt)
|
|
132
|
+
await asyncio.to_thread(
|
|
133
|
+
_upsert_cache,
|
|
134
|
+
session_id=session_id,
|
|
135
|
+
topic_sig=topic_sig,
|
|
136
|
+
content=content,
|
|
137
|
+
fact_ids=fact_ids,
|
|
138
|
+
)
|
|
139
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
140
|
+
logger.debug("prewarm: upsert failed: %s", exc)
|
|
141
|
+
return JSONResponse({"error": "upsert_failed"}, status_code=500)
|
|
142
|
+
|
|
143
|
+
return JSONResponse({"ok": True})
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _compute_topic_sig(prompt: str) -> str:
|
|
147
|
+
"""Lazy import so module import is free of hot-path SLM modules."""
|
|
148
|
+
from superlocalmemory.core.topic_signature import compute_topic_signature
|
|
149
|
+
return compute_topic_signature(prompt)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _upsert_cache(
|
|
153
|
+
*, session_id: str, topic_sig: str,
|
|
154
|
+
content: str, fact_ids: list[str],
|
|
155
|
+
) -> None:
|
|
156
|
+
from superlocalmemory.core.context_cache import CacheEntry, ContextCache
|
|
157
|
+
cache = ContextCache()
|
|
158
|
+
try:
|
|
159
|
+
cache.upsert(CacheEntry(
|
|
160
|
+
session_id=session_id,
|
|
161
|
+
topic_sig=topic_sig,
|
|
162
|
+
content=content,
|
|
163
|
+
fact_ids=tuple(fact_ids),
|
|
164
|
+
provenance="prewarm_post_tool",
|
|
165
|
+
computed_at=int(time.time()),
|
|
166
|
+
))
|
|
167
|
+
finally:
|
|
168
|
+
cache.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = ("router",)
|