superlocalmemory 3.2.1 → 3.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +61 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +26 -1
  5. package/src/superlocalmemory/attribution/signer.py +6 -1
  6. package/src/superlocalmemory/core/config.py +113 -1
  7. package/src/superlocalmemory/core/consolidation_engine.py +595 -0
  8. package/src/superlocalmemory/core/embeddings.py +0 -1
  9. package/src/superlocalmemory/core/engine.py +164 -674
  10. package/src/superlocalmemory/core/engine_wiring.py +474 -0
  11. package/src/superlocalmemory/core/graph_analyzer.py +199 -0
  12. package/src/superlocalmemory/core/recall_pipeline.py +247 -0
  13. package/src/superlocalmemory/core/store_pipeline.py +483 -0
  14. package/src/superlocalmemory/core/worker_pool.py +35 -12
  15. package/src/superlocalmemory/encoding/auto_linker.py +308 -0
  16. package/src/superlocalmemory/encoding/context_generator.py +175 -0
  17. package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
  18. package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
  19. package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
  20. package/src/superlocalmemory/retrieval/engine.py +12 -0
  21. package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
  22. package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
  23. package/src/superlocalmemory/retrieval/strategy.py +6 -6
  24. package/src/superlocalmemory/retrieval/vector_store.py +386 -0
  25. package/src/superlocalmemory/server/routes/v3_api.py +576 -0
  26. package/src/superlocalmemory/storage/access_log.py +169 -0
  27. package/src/superlocalmemory/storage/database.py +288 -0
  28. package/src/superlocalmemory/storage/schema.py +10 -0
  29. package/src/superlocalmemory/storage/schema_v32.py +252 -0
  30. 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)