superlocalmemory 3.2.1 → 3.2.2
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 +23 -1
- package/README.md +61 -1
- package/package.json +1 -1
- package/pyproject.toml +26 -1
- package/src/superlocalmemory/attribution/signer.py +6 -1
- package/src/superlocalmemory/core/config.py +114 -1
- package/src/superlocalmemory/core/consolidation_engine.py +595 -0
- package/src/superlocalmemory/core/embeddings.py +0 -1
- package/src/superlocalmemory/core/engine.py +164 -674
- package/src/superlocalmemory/core/engine_wiring.py +474 -0
- package/src/superlocalmemory/core/graph_analyzer.py +199 -0
- package/src/superlocalmemory/core/recall_pipeline.py +247 -0
- package/src/superlocalmemory/core/store_pipeline.py +483 -0
- package/src/superlocalmemory/core/worker_pool.py +35 -12
- package/src/superlocalmemory/encoding/auto_linker.py +308 -0
- package/src/superlocalmemory/encoding/context_generator.py +175 -0
- package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
- package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
- package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
- package/src/superlocalmemory/retrieval/engine.py +12 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
- package/src/superlocalmemory/retrieval/strategy.py +6 -6
- package/src/superlocalmemory/retrieval/vector_store.py +386 -0
- package/src/superlocalmemory/server/routes/v3_api.py +576 -0
- package/src/superlocalmemory/storage/access_log.py +169 -0
- package/src/superlocalmemory/storage/database.py +288 -0
- package/src/superlocalmemory/storage/schema.py +10 -0
- package/src/superlocalmemory/storage/schema_v32.py +252 -0
- package/src/superlocalmemory/storage/v2_migrator.py +24 -2
|
@@ -614,3 +614,579 @@ async def hooks_status():
|
|
|
614
614
|
return {"success": True, **check_status()}
|
|
615
615
|
except Exception as exc:
|
|
616
616
|
return {"success": False, "error": str(exc)}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ── Phase 6: V3.2 API Endpoints ──────────────────────────────
|
|
620
|
+
# 9 new endpoints for the V3.2 dashboard tabs:
|
|
621
|
+
# Auto-Invoke (2), Associations (2), Consolidation (2),
|
|
622
|
+
# Core Memory (2), VectorStore (1)
|
|
623
|
+
#
|
|
624
|
+
# Rules enforced:
|
|
625
|
+
# 01 - Profile scoping on ALL endpoints
|
|
626
|
+
# 06 - No engine import from routes (direct sqlite3)
|
|
627
|
+
# 11 - Parameterized SQL everywhere
|
|
628
|
+
# 18 - WorkerPool for POST consolidation/trigger
|
|
629
|
+
# 19 - Silent errors with JSONResponse
|
|
630
|
+
# ──────────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _load_auto_invoke_json() -> dict:
|
|
634
|
+
"""Load auto-invoke config from config.json's auto_invoke section."""
|
|
635
|
+
from superlocalmemory.server.routes.helpers import MEMORY_DIR
|
|
636
|
+
config_path = MEMORY_DIR / "config.json"
|
|
637
|
+
if config_path.exists():
|
|
638
|
+
try:
|
|
639
|
+
data = json.loads(config_path.read_text())
|
|
640
|
+
return data.get("auto_invoke", {})
|
|
641
|
+
except (json.JSONDecodeError, IOError):
|
|
642
|
+
pass
|
|
643
|
+
return {}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _save_auto_invoke_json(auto_invoke_data: dict) -> None:
|
|
647
|
+
"""Persist auto-invoke config into config.json's auto_invoke section."""
|
|
648
|
+
from superlocalmemory.server.routes.helpers import MEMORY_DIR
|
|
649
|
+
config_path = MEMORY_DIR / "config.json"
|
|
650
|
+
cfg: dict = {}
|
|
651
|
+
if config_path.exists():
|
|
652
|
+
try:
|
|
653
|
+
cfg = json.loads(config_path.read_text())
|
|
654
|
+
except (json.JSONDecodeError, IOError):
|
|
655
|
+
pass
|
|
656
|
+
cfg["auto_invoke"] = auto_invoke_data
|
|
657
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
658
|
+
config_path.write_text(json.dumps(cfg, indent=2))
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ── 1. GET /api/v3/auto-invoke/config ─────────────────────────
|
|
662
|
+
|
|
663
|
+
@router.get("/auto-invoke/config")
|
|
664
|
+
async def get_auto_invoke_config(request: Request):
|
|
665
|
+
"""Get current auto-invoke configuration."""
|
|
666
|
+
try:
|
|
667
|
+
from superlocalmemory.core.config import AutoInvokeConfig
|
|
668
|
+
defaults = AutoInvokeConfig()
|
|
669
|
+
|
|
670
|
+
persisted = _load_auto_invoke_json()
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
"enabled": persisted.get("enabled", defaults.enabled),
|
|
674
|
+
"min_score": persisted.get("min_score", defaults.fok_threshold),
|
|
675
|
+
"weights": persisted.get("weights", dict(defaults.weights)),
|
|
676
|
+
"act_r_mode": persisted.get("act_r_mode", defaults.use_act_r),
|
|
677
|
+
"invocation_count": persisted.get("invocation_count", 0),
|
|
678
|
+
"last_invocation": persisted.get("last_invocation", None),
|
|
679
|
+
}
|
|
680
|
+
except Exception as e:
|
|
681
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ── 2. PUT /api/v3/auto-invoke/config ─────────────────────────
|
|
685
|
+
|
|
686
|
+
@router.put("/auto-invoke/config")
|
|
687
|
+
async def set_auto_invoke_config(request: Request):
|
|
688
|
+
"""Update auto-invoke configuration.
|
|
689
|
+
|
|
690
|
+
Body: {"enabled": true, "min_score": 0.15, "weights": {...}}
|
|
691
|
+
"""
|
|
692
|
+
try:
|
|
693
|
+
body = await request.json()
|
|
694
|
+
|
|
695
|
+
# Validate min_score range
|
|
696
|
+
min_score = body.get("min_score")
|
|
697
|
+
if min_score is not None and (min_score < 0 or min_score > 1):
|
|
698
|
+
return JSONResponse(
|
|
699
|
+
{"error": "min_score must be between 0 and 1"},
|
|
700
|
+
status_code=400,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Load existing, merge updates
|
|
704
|
+
from superlocalmemory.core.config import AutoInvokeConfig
|
|
705
|
+
defaults = AutoInvokeConfig()
|
|
706
|
+
persisted = _load_auto_invoke_json()
|
|
707
|
+
|
|
708
|
+
updated = {
|
|
709
|
+
"enabled": body.get("enabled", persisted.get("enabled", defaults.enabled)),
|
|
710
|
+
"min_score": body.get("min_score", persisted.get("min_score", defaults.fok_threshold)),
|
|
711
|
+
"weights": body.get("weights", persisted.get("weights", dict(defaults.weights))),
|
|
712
|
+
"act_r_mode": body.get("act_r_mode", persisted.get("act_r_mode", defaults.use_act_r)),
|
|
713
|
+
"invocation_count": persisted.get("invocation_count", 0),
|
|
714
|
+
"last_invocation": persisted.get("last_invocation", None),
|
|
715
|
+
}
|
|
716
|
+
_save_auto_invoke_json(updated)
|
|
717
|
+
|
|
718
|
+
return updated
|
|
719
|
+
except Exception as e:
|
|
720
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# ── 3. GET /api/v3/associations ───────────────────────────────
|
|
724
|
+
|
|
725
|
+
@router.get("/associations")
|
|
726
|
+
async def get_associations(
|
|
727
|
+
request: Request,
|
|
728
|
+
limit: int = 50,
|
|
729
|
+
type: str = "",
|
|
730
|
+
profile: str = "",
|
|
731
|
+
):
|
|
732
|
+
"""Get association edges for a profile with content previews."""
|
|
733
|
+
try:
|
|
734
|
+
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
735
|
+
import sqlite3
|
|
736
|
+
pid = profile or get_active_profile()
|
|
737
|
+
|
|
738
|
+
if not DB_PATH.exists():
|
|
739
|
+
return {"edges": [], "total": 0}
|
|
740
|
+
|
|
741
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
742
|
+
conn.row_factory = sqlite3.Row
|
|
743
|
+
|
|
744
|
+
# Build query with optional type filter (parameterized)
|
|
745
|
+
params: list = [pid]
|
|
746
|
+
sql = (
|
|
747
|
+
"SELECT ae.edge_id, ae.source_fact_id, ae.target_fact_id, "
|
|
748
|
+
"ae.association_type, ae.weight, ae.co_access_count, ae.created_at, "
|
|
749
|
+
"sf.content AS source_content, tf.content AS target_content "
|
|
750
|
+
"FROM association_edges ae "
|
|
751
|
+
"LEFT JOIN atomic_facts sf ON sf.fact_id = ae.source_fact_id "
|
|
752
|
+
"LEFT JOIN atomic_facts tf ON tf.fact_id = ae.target_fact_id "
|
|
753
|
+
"WHERE ae.profile_id = ? "
|
|
754
|
+
)
|
|
755
|
+
if type:
|
|
756
|
+
sql += "AND ae.association_type = ? "
|
|
757
|
+
params.append(type)
|
|
758
|
+
sql += "ORDER BY ae.created_at DESC LIMIT ?"
|
|
759
|
+
params.append(limit)
|
|
760
|
+
|
|
761
|
+
rows = conn.execute(sql, params).fetchall()
|
|
762
|
+
|
|
763
|
+
# Total count (separate query for pagination info)
|
|
764
|
+
count_sql = "SELECT COUNT(*) FROM association_edges WHERE profile_id = ?"
|
|
765
|
+
count_params: list = [pid]
|
|
766
|
+
if type:
|
|
767
|
+
count_sql += " AND association_type = ?"
|
|
768
|
+
count_params.append(type)
|
|
769
|
+
total = conn.execute(count_sql, count_params).fetchone()[0]
|
|
770
|
+
|
|
771
|
+
conn.close()
|
|
772
|
+
|
|
773
|
+
edges = []
|
|
774
|
+
for r in rows:
|
|
775
|
+
row = dict(r)
|
|
776
|
+
source_content = row.get("source_content") or ""
|
|
777
|
+
target_content = row.get("target_content") or ""
|
|
778
|
+
edges.append({
|
|
779
|
+
"edge_id": row["edge_id"],
|
|
780
|
+
"source_fact_id": row["source_fact_id"],
|
|
781
|
+
"target_fact_id": row["target_fact_id"],
|
|
782
|
+
"association_type": row["association_type"],
|
|
783
|
+
"weight": round(float(row["weight"]), 3),
|
|
784
|
+
"co_access_count": row["co_access_count"],
|
|
785
|
+
"created_at": row["created_at"],
|
|
786
|
+
"source_preview": source_content[:100],
|
|
787
|
+
"target_preview": target_content[:100],
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
return {"edges": edges, "total": total}
|
|
791
|
+
except Exception as e:
|
|
792
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# ── 4. GET /api/v3/associations/stats ─────────────────────────
|
|
796
|
+
|
|
797
|
+
@router.get("/associations/stats")
|
|
798
|
+
async def get_association_stats(request: Request, profile: str = ""):
|
|
799
|
+
"""Get association graph statistics."""
|
|
800
|
+
try:
|
|
801
|
+
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
802
|
+
import sqlite3
|
|
803
|
+
pid = profile or get_active_profile()
|
|
804
|
+
|
|
805
|
+
if not DB_PATH.exists():
|
|
806
|
+
return {
|
|
807
|
+
"total_edges": 0,
|
|
808
|
+
"by_type": {},
|
|
809
|
+
"community_count": 0,
|
|
810
|
+
"avg_weight": 0.0,
|
|
811
|
+
"top_connected_facts": [],
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
815
|
+
conn.row_factory = sqlite3.Row
|
|
816
|
+
|
|
817
|
+
# Total edges
|
|
818
|
+
total = conn.execute(
|
|
819
|
+
"SELECT COUNT(*) FROM association_edges WHERE profile_id = ?",
|
|
820
|
+
(pid,),
|
|
821
|
+
).fetchone()[0]
|
|
822
|
+
|
|
823
|
+
# Edges by type
|
|
824
|
+
by_type_rows = conn.execute(
|
|
825
|
+
"SELECT association_type, COUNT(*) AS cnt "
|
|
826
|
+
"FROM association_edges WHERE profile_id = ? "
|
|
827
|
+
"GROUP BY association_type",
|
|
828
|
+
(pid,),
|
|
829
|
+
).fetchall()
|
|
830
|
+
by_type = {row["association_type"]: row["cnt"] for row in by_type_rows}
|
|
831
|
+
|
|
832
|
+
# Average weight
|
|
833
|
+
avg_row = conn.execute(
|
|
834
|
+
"SELECT AVG(weight) AS avg_w FROM association_edges "
|
|
835
|
+
"WHERE profile_id = ?",
|
|
836
|
+
(pid,),
|
|
837
|
+
).fetchone()
|
|
838
|
+
avg_weight = round(float(avg_row["avg_w"] or 0), 3)
|
|
839
|
+
|
|
840
|
+
# Community count from fact_importance table
|
|
841
|
+
community_count = 0
|
|
842
|
+
try:
|
|
843
|
+
cc_row = conn.execute(
|
|
844
|
+
"SELECT COUNT(DISTINCT community_id) AS cnt "
|
|
845
|
+
"FROM fact_importance "
|
|
846
|
+
"WHERE profile_id = ? AND community_id IS NOT NULL",
|
|
847
|
+
(pid,),
|
|
848
|
+
).fetchone()
|
|
849
|
+
community_count = cc_row["cnt"] if cc_row else 0
|
|
850
|
+
except Exception:
|
|
851
|
+
pass
|
|
852
|
+
|
|
853
|
+
# Top connected facts (by degree = count of edges as source or target)
|
|
854
|
+
top_facts = []
|
|
855
|
+
try:
|
|
856
|
+
degree_rows = conn.execute(
|
|
857
|
+
"SELECT fact_id, degree FROM ("
|
|
858
|
+
" SELECT source_fact_id AS fact_id, COUNT(*) AS degree "
|
|
859
|
+
" FROM association_edges WHERE profile_id = ? "
|
|
860
|
+
" GROUP BY source_fact_id "
|
|
861
|
+
" UNION ALL "
|
|
862
|
+
" SELECT target_fact_id AS fact_id, COUNT(*) AS degree "
|
|
863
|
+
" FROM association_edges WHERE profile_id = ? "
|
|
864
|
+
" GROUP BY target_fact_id "
|
|
865
|
+
") GROUP BY fact_id ORDER BY SUM(degree) DESC LIMIT 5",
|
|
866
|
+
(pid, pid),
|
|
867
|
+
).fetchall()
|
|
868
|
+
for dr in degree_rows:
|
|
869
|
+
fact_id = dr["fact_id"]
|
|
870
|
+
preview_row = conn.execute(
|
|
871
|
+
"SELECT content FROM atomic_facts WHERE fact_id = ?",
|
|
872
|
+
(fact_id,),
|
|
873
|
+
).fetchone()
|
|
874
|
+
preview = (dict(preview_row).get("content", "")[:80]) if preview_row else ""
|
|
875
|
+
top_facts.append({
|
|
876
|
+
"fact_id": fact_id,
|
|
877
|
+
"degree": dr["degree"],
|
|
878
|
+
"preview": preview,
|
|
879
|
+
})
|
|
880
|
+
except Exception:
|
|
881
|
+
pass
|
|
882
|
+
|
|
883
|
+
conn.close()
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
"total_edges": total,
|
|
887
|
+
"by_type": by_type,
|
|
888
|
+
"community_count": community_count,
|
|
889
|
+
"avg_weight": avg_weight,
|
|
890
|
+
"top_connected_facts": top_facts,
|
|
891
|
+
}
|
|
892
|
+
except Exception as e:
|
|
893
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
# ── 5. GET /api/v3/consolidation/status ───────────────────────
|
|
897
|
+
|
|
898
|
+
@router.get("/consolidation/status")
|
|
899
|
+
async def get_consolidation_status(request: Request, profile: str = ""):
|
|
900
|
+
"""Get consolidation status and last run results."""
|
|
901
|
+
try:
|
|
902
|
+
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
903
|
+
from superlocalmemory.core.config import SLMConfig
|
|
904
|
+
import sqlite3
|
|
905
|
+
|
|
906
|
+
pid = profile or get_active_profile()
|
|
907
|
+
config = SLMConfig.load()
|
|
908
|
+
cons_cfg = config.consolidation
|
|
909
|
+
|
|
910
|
+
result: dict = {
|
|
911
|
+
"enabled": cons_cfg.enabled,
|
|
912
|
+
"last_run": None,
|
|
913
|
+
"last_result": None,
|
|
914
|
+
"triggers": {
|
|
915
|
+
"session_end": cons_cfg.session_trigger,
|
|
916
|
+
"idle_timeout": cons_cfg.idle_timeout_seconds,
|
|
917
|
+
"step_count": cons_cfg.step_count_trigger,
|
|
918
|
+
"scheduled_sessions": cons_cfg.scheduled_sessions,
|
|
919
|
+
},
|
|
920
|
+
"store_count_since_last": 0,
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if not DB_PATH.exists():
|
|
924
|
+
return result
|
|
925
|
+
|
|
926
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
927
|
+
conn.row_factory = sqlite3.Row
|
|
928
|
+
|
|
929
|
+
# Last consolidation log entry
|
|
930
|
+
try:
|
|
931
|
+
last_row = conn.execute(
|
|
932
|
+
"SELECT timestamp, action_type, reason "
|
|
933
|
+
"FROM consolidation_log "
|
|
934
|
+
"WHERE profile_id = ? ORDER BY timestamp DESC LIMIT 1",
|
|
935
|
+
(pid,),
|
|
936
|
+
).fetchone()
|
|
937
|
+
if last_row:
|
|
938
|
+
result["last_run"] = dict(last_row).get("timestamp")
|
|
939
|
+
except Exception:
|
|
940
|
+
pass
|
|
941
|
+
|
|
942
|
+
# Count blocks compiled (proxy for last consolidation result)
|
|
943
|
+
try:
|
|
944
|
+
block_count = conn.execute(
|
|
945
|
+
"SELECT COUNT(*) FROM core_memory_blocks WHERE profile_id = ?",
|
|
946
|
+
(pid,),
|
|
947
|
+
).fetchone()[0]
|
|
948
|
+
edge_count = conn.execute(
|
|
949
|
+
"SELECT COUNT(*) FROM association_edges WHERE profile_id = ?",
|
|
950
|
+
(pid,),
|
|
951
|
+
).fetchone()[0]
|
|
952
|
+
result["last_result"] = {
|
|
953
|
+
"blocks_compiled": block_count,
|
|
954
|
+
"total_edges": edge_count,
|
|
955
|
+
}
|
|
956
|
+
except Exception:
|
|
957
|
+
pass
|
|
958
|
+
|
|
959
|
+
# Store count since last consolidation
|
|
960
|
+
try:
|
|
961
|
+
if result["last_run"]:
|
|
962
|
+
sc = conn.execute(
|
|
963
|
+
"SELECT COUNT(*) FROM atomic_facts "
|
|
964
|
+
"WHERE profile_id = ? AND created_at > ?",
|
|
965
|
+
(pid, result["last_run"]),
|
|
966
|
+
).fetchone()[0]
|
|
967
|
+
result["store_count_since_last"] = sc
|
|
968
|
+
else:
|
|
969
|
+
sc = conn.execute(
|
|
970
|
+
"SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
|
|
971
|
+
(pid,),
|
|
972
|
+
).fetchone()[0]
|
|
973
|
+
result["store_count_since_last"] = sc
|
|
974
|
+
except Exception:
|
|
975
|
+
pass
|
|
976
|
+
|
|
977
|
+
conn.close()
|
|
978
|
+
return result
|
|
979
|
+
except Exception as e:
|
|
980
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
# ── 6. POST /api/v3/consolidation/trigger ─────────────────────
|
|
984
|
+
|
|
985
|
+
@router.post("/consolidation/trigger")
|
|
986
|
+
async def trigger_consolidation(request: Request):
|
|
987
|
+
"""Trigger consolidation manually.
|
|
988
|
+
|
|
989
|
+
Body: {"lightweight": false, "profile": ""}
|
|
990
|
+
Uses WorkerPool for thread safety (Rule 18).
|
|
991
|
+
"""
|
|
992
|
+
try:
|
|
993
|
+
body = await request.json()
|
|
994
|
+
lightweight = body.get("lightweight", False)
|
|
995
|
+
profile = body.get("profile", "")
|
|
996
|
+
|
|
997
|
+
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
998
|
+
pid = profile or get_active_profile()
|
|
999
|
+
|
|
1000
|
+
# Use WorkerPool to run consolidation in the worker subprocess (Rule 18)
|
|
1001
|
+
try:
|
|
1002
|
+
from superlocalmemory.core.worker_pool import WorkerPool
|
|
1003
|
+
pool = WorkerPool.shared()
|
|
1004
|
+
result = pool.send_command({
|
|
1005
|
+
"action": "consolidate",
|
|
1006
|
+
"profile_id": pid,
|
|
1007
|
+
"lightweight": lightweight,
|
|
1008
|
+
})
|
|
1009
|
+
if result and result.get("ok"):
|
|
1010
|
+
return {"success": True, **result}
|
|
1011
|
+
except Exception:
|
|
1012
|
+
pass
|
|
1013
|
+
|
|
1014
|
+
# Fallback: direct consolidation if WorkerPool unavailable
|
|
1015
|
+
from superlocalmemory.core.config import SLMConfig
|
|
1016
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
1017
|
+
from superlocalmemory.storage import schema as _schema
|
|
1018
|
+
from superlocalmemory.core.consolidation_engine import ConsolidationEngine
|
|
1019
|
+
|
|
1020
|
+
config = SLMConfig.load()
|
|
1021
|
+
db = DatabaseManager(config.db_path)
|
|
1022
|
+
db.initialize(_schema)
|
|
1023
|
+
|
|
1024
|
+
engine = ConsolidationEngine(db=db, config=config.consolidation, slm_config=config)
|
|
1025
|
+
result = engine.consolidate(profile_id=pid, lightweight=lightweight)
|
|
1026
|
+
|
|
1027
|
+
return {"success": True, **result}
|
|
1028
|
+
except Exception as e:
|
|
1029
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
# ── 7. GET /api/v3/core-memory ────────────────────────────────
|
|
1033
|
+
|
|
1034
|
+
@router.get("/core-memory")
|
|
1035
|
+
async def get_core_memory(request: Request, profile: str = ""):
|
|
1036
|
+
"""Get all Core Memory blocks for a profile."""
|
|
1037
|
+
try:
|
|
1038
|
+
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
1039
|
+
import sqlite3
|
|
1040
|
+
pid = profile or get_active_profile()
|
|
1041
|
+
|
|
1042
|
+
if not DB_PATH.exists():
|
|
1043
|
+
return {"blocks": [], "total_chars": 0, "char_limit": 2000}
|
|
1044
|
+
|
|
1045
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
1046
|
+
conn.row_factory = sqlite3.Row
|
|
1047
|
+
|
|
1048
|
+
rows = conn.execute(
|
|
1049
|
+
"SELECT block_id, block_type, content, char_count, version, "
|
|
1050
|
+
"compiled_by, updated_at FROM core_memory_blocks "
|
|
1051
|
+
"WHERE profile_id = ? ORDER BY block_type",
|
|
1052
|
+
(pid,),
|
|
1053
|
+
).fetchall()
|
|
1054
|
+
|
|
1055
|
+
conn.close()
|
|
1056
|
+
|
|
1057
|
+
blocks = []
|
|
1058
|
+
total_chars = 0
|
|
1059
|
+
for r in rows:
|
|
1060
|
+
row = dict(r)
|
|
1061
|
+
char_count = row.get("char_count", 0) or len(row.get("content", ""))
|
|
1062
|
+
total_chars += char_count
|
|
1063
|
+
blocks.append({
|
|
1064
|
+
"block_id": row["block_id"],
|
|
1065
|
+
"block_type": row["block_type"],
|
|
1066
|
+
"content": row["content"],
|
|
1067
|
+
"char_count": char_count,
|
|
1068
|
+
"version": row["version"],
|
|
1069
|
+
"compiled_by": row["compiled_by"],
|
|
1070
|
+
"updated_at": row["updated_at"],
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
return {"blocks": blocks, "total_chars": total_chars, "char_limit": 2000}
|
|
1074
|
+
except Exception as e:
|
|
1075
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
# ── 8. PUT /api/v3/core-memory/{block_id} ─────────────────────
|
|
1079
|
+
|
|
1080
|
+
@router.put("/core-memory/{block_id}")
|
|
1081
|
+
async def update_core_memory_block(block_id: str, request: Request):
|
|
1082
|
+
"""Update a Core Memory block's content manually.
|
|
1083
|
+
|
|
1084
|
+
Body: {"content": "Updated content..."}
|
|
1085
|
+
"""
|
|
1086
|
+
try:
|
|
1087
|
+
body = await request.json()
|
|
1088
|
+
content = body.get("content")
|
|
1089
|
+
if content is None:
|
|
1090
|
+
return JSONResponse(
|
|
1091
|
+
{"error": "content field is required"},
|
|
1092
|
+
status_code=400,
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
from superlocalmemory.server.routes.helpers import DB_PATH
|
|
1096
|
+
import sqlite3
|
|
1097
|
+
from datetime import datetime, timezone
|
|
1098
|
+
|
|
1099
|
+
if not DB_PATH.exists():
|
|
1100
|
+
return JSONResponse(
|
|
1101
|
+
{"error": "Database not found"},
|
|
1102
|
+
status_code=404,
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
1106
|
+
conn.row_factory = sqlite3.Row
|
|
1107
|
+
|
|
1108
|
+
# Verify block exists
|
|
1109
|
+
existing = conn.execute(
|
|
1110
|
+
"SELECT block_id, profile_id, block_type, version "
|
|
1111
|
+
"FROM core_memory_blocks WHERE block_id = ?",
|
|
1112
|
+
(block_id,),
|
|
1113
|
+
).fetchone()
|
|
1114
|
+
|
|
1115
|
+
if not existing:
|
|
1116
|
+
conn.close()
|
|
1117
|
+
return JSONResponse(
|
|
1118
|
+
{"error": f"Block {block_id} not found"},
|
|
1119
|
+
status_code=404,
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
existing_dict = dict(existing)
|
|
1123
|
+
new_version = existing_dict["version"] + 1
|
|
1124
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
1125
|
+
|
|
1126
|
+
conn.execute(
|
|
1127
|
+
"UPDATE core_memory_blocks SET content = ?, char_count = ?, "
|
|
1128
|
+
"version = ?, compiled_by = 'manual', updated_at = ? "
|
|
1129
|
+
"WHERE block_id = ?",
|
|
1130
|
+
(content, len(content), new_version, now, block_id),
|
|
1131
|
+
)
|
|
1132
|
+
conn.commit()
|
|
1133
|
+
|
|
1134
|
+
# Read back updated block
|
|
1135
|
+
updated = conn.execute(
|
|
1136
|
+
"SELECT block_id, block_type, content, char_count, version, "
|
|
1137
|
+
"compiled_by, updated_at FROM core_memory_blocks "
|
|
1138
|
+
"WHERE block_id = ?",
|
|
1139
|
+
(block_id,),
|
|
1140
|
+
).fetchone()
|
|
1141
|
+
conn.close()
|
|
1142
|
+
|
|
1143
|
+
return dict(updated) if updated else {"block_id": block_id, "updated": True}
|
|
1144
|
+
except Exception as e:
|
|
1145
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
# ── 9. GET /api/v3/vector-store/status ────────────────────────
|
|
1149
|
+
|
|
1150
|
+
@router.get("/vector-store/status")
|
|
1151
|
+
async def get_vector_store_status(request: Request, profile: str = ""):
|
|
1152
|
+
"""Get VectorStore health and statistics."""
|
|
1153
|
+
try:
|
|
1154
|
+
from superlocalmemory.core.config import SLMConfig
|
|
1155
|
+
from superlocalmemory.server.routes.helpers import DB_PATH
|
|
1156
|
+
import sqlite3
|
|
1157
|
+
|
|
1158
|
+
config = SLMConfig.load()
|
|
1159
|
+
|
|
1160
|
+
result: dict = {
|
|
1161
|
+
"available": False,
|
|
1162
|
+
"provider": "sqlite-vec",
|
|
1163
|
+
"dimension": config.embedding.dimension,
|
|
1164
|
+
"embedding_model": config.embedding.model_name,
|
|
1165
|
+
"total_vectors": 0,
|
|
1166
|
+
"binary_quantization": False,
|
|
1167
|
+
"binary_quantization_threshold": 100000,
|
|
1168
|
+
"fallback_to_ann": False,
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
# Check if sqlite-vec extension is available
|
|
1172
|
+
try:
|
|
1173
|
+
import sqlite_vec # noqa: F401
|
|
1174
|
+
result["available"] = True
|
|
1175
|
+
except ImportError:
|
|
1176
|
+
result["fallback_to_ann"] = True
|
|
1177
|
+
|
|
1178
|
+
# Count vectors in embedding_metadata
|
|
1179
|
+
if DB_PATH.exists():
|
|
1180
|
+
try:
|
|
1181
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
1182
|
+
count = conn.execute(
|
|
1183
|
+
"SELECT COUNT(*) FROM embedding_metadata"
|
|
1184
|
+
).fetchone()[0]
|
|
1185
|
+
result["total_vectors"] = count
|
|
1186
|
+
conn.close()
|
|
1187
|
+
except Exception:
|
|
1188
|
+
pass
|
|
1189
|
+
|
|
1190
|
+
return result
|
|
1191
|
+
except Exception as e:
|
|
1192
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|