superlocalmemory 3.8.0 → 3.8.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.
Files changed (134) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +2 -2
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +3 -5
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +2 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +3 -5
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +2 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +494 -9
  35. package/src/superlocalmemory/cli/daemon.py +7 -0
  36. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  37. package/src/superlocalmemory/cli/main.py +72 -7
  38. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  39. package/src/superlocalmemory/cli/version_banner.py +17 -3
  40. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  41. package/src/superlocalmemory/core/component_healer.py +144 -0
  42. package/src/superlocalmemory/core/component_registry.py +487 -0
  43. package/src/superlocalmemory/core/config.py +21 -0
  44. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  45. package/src/superlocalmemory/core/embeddings.py +132 -45
  46. package/src/superlocalmemory/core/engine.py +29 -22
  47. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  48. package/src/superlocalmemory/core/ingestion_command.py +154 -25
  49. package/src/superlocalmemory/core/injection.py +12 -7
  50. package/src/superlocalmemory/core/maintenance.py +43 -0
  51. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
  52. package/src/superlocalmemory/core/recall_pipeline.py +42 -4
  53. package/src/superlocalmemory/core/store_pipeline.py +195 -20
  54. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  55. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  56. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  57. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  58. package/src/superlocalmemory/learning/reward.py +50 -0
  59. package/src/superlocalmemory/learning/source_quality.py +523 -1
  60. package/src/superlocalmemory/loops/ledger.py +25 -5
  61. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  62. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  63. package/src/superlocalmemory/mcp/server.py +11 -30
  64. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  65. package/src/superlocalmemory/mcp/tools_core.py +21 -5
  66. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  67. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  68. package/src/superlocalmemory/retrieval/engine.py +53 -21
  69. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  70. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  71. package/src/superlocalmemory/server/config_file.py +90 -0
  72. package/src/superlocalmemory/server/origin.py +50 -0
  73. package/src/superlocalmemory/server/routes/backup.py +293 -70
  74. package/src/superlocalmemory/server/routes/behavioral.py +342 -61
  75. package/src/superlocalmemory/server/routes/brain.py +57 -16
  76. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  77. package/src/superlocalmemory/server/routes/entity.py +100 -23
  78. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  79. package/src/superlocalmemory/server/routes/learning.py +286 -105
  80. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  81. package/src/superlocalmemory/server/routes/memories.py +8 -3
  82. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  83. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  84. package/src/superlocalmemory/server/routes/stats.py +93 -155
  85. package/src/superlocalmemory/server/routes/token.py +3 -13
  86. package/src/superlocalmemory/server/routes/v3_api.py +184 -20
  87. package/src/superlocalmemory/server/unified_daemon.py +732 -41
  88. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  89. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  90. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  92. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  93. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  94. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  95. package/src/superlocalmemory/storage/schema.py +49 -1
  96. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  97. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  98. package/src/superlocalmemory/ui/index.html +6 -8
  99. package/src/superlocalmemory/ui/js/core.js +52 -9
  100. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  101. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  102. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  103. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  104. package/src/superlocalmemory/ui/js/od-entities.js +65 -22
  105. package/src/superlocalmemory/ui/js/od-graph.js +46 -4
  106. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  107. package/src/superlocalmemory/ui/js/od-memories.js +84 -5
  108. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  109. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  110. package/src/superlocalmemory/ui/js/od-settings.js +186 -63
  111. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  112. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  113. package/src/superlocalmemory/ui/js/settings.js +15 -1
  114. package/plugin-src/.mcp.json +0 -12
  115. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  116. package/plugin-src/agents/slm-loop-runner.md +0 -71
  117. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  118. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  119. package/plugin-src/commands/slm-loop.md +0 -31
  120. package/plugin-src/hooks/.gitkeep +0 -0
  121. package/plugin-src/hooks/hooks.json +0 -102
  122. package/plugin-src/manifest.json +0 -30
  123. package/plugin-src/requirements.txt +0 -1
  124. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  125. package/plugin-src/scripts/ensure-venv.bat +0 -122
  126. package/plugin-src/scripts/ensure-venv.sh +0 -105
  127. package/plugin-src/scripts/slm-launch +0 -62
  128. package/plugin-src/scripts/slm-launch.bat +0 -23
  129. package/plugin-src/settings.json +0 -25
  130. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  131. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  132. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  133. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  134. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -11,8 +11,10 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import logging
14
+ import hashlib
15
15
  import json
16
+ import logging
17
+ import uuid
16
18
  from typing import TYPE_CHECKING, Any
17
19
 
18
20
  if TYPE_CHECKING:
@@ -21,7 +23,9 @@ if TYPE_CHECKING:
21
23
  from superlocalmemory.storage.database import DatabaseManager
22
24
 
23
25
  from superlocalmemory.storage.models import (
24
- AtomicFact, FactType, MemoryRecord,
26
+ AtomicFact,
27
+ FactType,
28
+ MemoryRecord,
25
29
  )
26
30
 
27
31
  logger = logging.getLogger(__name__)
@@ -30,6 +34,105 @@ logger = logging.getLogger(__name__)
30
34
  _INIT_LANGEVIN_RADIUS = 0.05
31
35
 
32
36
 
37
+ def _ingestion_effect_id(operation_id: str, *parts: object) -> str:
38
+ """Return a stable ID for a relational effect owned by one ingestion."""
39
+ if not operation_id:
40
+ return uuid.uuid4().hex
41
+ payload = "\0".join((operation_id, *(str(part) for part in parts)))
42
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
43
+
44
+
45
+ def _record_fact_entity_association(
46
+ db: DatabaseManager,
47
+ *,
48
+ operation_id: str,
49
+ profile_id: str,
50
+ fact_id: str,
51
+ entity_id: str,
52
+ ) -> None:
53
+ """Apply one fact/entity count effect in O(1), exactly once."""
54
+ if not operation_id:
55
+ db.increment_entity_fact_count(entity_id, profile_id)
56
+ return
57
+ with db.transaction():
58
+ claimed = db.execute(
59
+ "INSERT INTO fact_entity_associations "
60
+ "(profile_id,fact_id,entity_id,first_operation_id,count_applied) "
61
+ "SELECT ?,?,?,?,"
62
+ "CASE WHEN fact.rowid > repair.target_fact_rowid THEN 1 ELSE 0 END "
63
+ "FROM canonical_entities AS entity "
64
+ "JOIN atomic_facts AS fact "
65
+ "ON fact.fact_id=? AND fact.profile_id=? "
66
+ "JOIN fact_entity_association_repair_state AS repair "
67
+ "ON repair.repair_key='historical-backfill' "
68
+ "WHERE entity.entity_id=? AND entity.profile_id=? "
69
+ "ON CONFLICT(profile_id,fact_id,entity_id) DO UPDATE SET "
70
+ "count_applied=excluded.count_applied,"
71
+ "first_operation_id=excluded.first_operation_id "
72
+ "WHERE fact_entity_associations.count_applied=0 "
73
+ "AND excluded.count_applied=1 "
74
+ "RETURNING count_applied",
75
+ (
76
+ profile_id,
77
+ fact_id,
78
+ entity_id,
79
+ operation_id,
80
+ fact_id,
81
+ profile_id,
82
+ entity_id,
83
+ profile_id,
84
+ ),
85
+ )
86
+ if claimed and int(claimed[0]["count_applied"]) == 1:
87
+ db.execute(
88
+ "UPDATE canonical_entities SET fact_count=fact_count+1 "
89
+ "WHERE entity_id=? AND profile_id=?",
90
+ (entity_id, profile_id),
91
+ )
92
+ elif not claimed:
93
+ # An empty result has TWO causes: (a) the 'historical-backfill'
94
+ # repair-state row is missing — M028 DDL ran but its INSERT rolled
95
+ # back (partial migration) — so the JOIN returned zero rows and the
96
+ # main INSERT produced no output; or (b) the association already
97
+ # exists and the ON CONFLICT update was correctly skipped (an
98
+ # idempotent re-run). Only (a) needs the fallback insert; (b) is a
99
+ # no-op. Disambiguate by probing the repair-state table directly —
100
+ # a DIFFERENT table, so this stays a constant-query, idempotent
101
+ # effect and never re-fires the insert on an already-applied row.
102
+ repair_ready = db.execute(
103
+ "SELECT 1 FROM fact_entity_association_repair_state "
104
+ "WHERE repair_key='historical-backfill' LIMIT 1",
105
+ )
106
+ if repair_ready:
107
+ # (b) idempotent skip — the association was already applied.
108
+ return
109
+ # (a) partial migration: insert with count_applied=1 (no historical-
110
+ # rowid check needed — all associations in this state are
111
+ # post-migration). ON CONFLICT DO NOTHING makes this retry-safe.
112
+ fallback = db.execute(
113
+ "INSERT INTO fact_entity_associations "
114
+ "(profile_id,fact_id,entity_id,first_operation_id,count_applied) "
115
+ "SELECT ?,?,?,?,1 "
116
+ "FROM canonical_entities AS entity "
117
+ "JOIN atomic_facts AS fact "
118
+ "ON fact.fact_id=? AND fact.profile_id=? "
119
+ "WHERE entity.entity_id=? AND entity.profile_id=? "
120
+ "ON CONFLICT(profile_id,fact_id,entity_id) DO NOTHING "
121
+ "RETURNING count_applied",
122
+ (
123
+ profile_id, fact_id, entity_id, operation_id,
124
+ fact_id, profile_id,
125
+ entity_id, profile_id,
126
+ ),
127
+ )
128
+ if fallback:
129
+ db.execute(
130
+ "UPDATE canonical_entities SET fact_count=fact_count+1 "
131
+ "WHERE entity_id=? AND profile_id=?",
132
+ (entity_id, profile_id),
133
+ )
134
+
135
+
33
136
  def _init_langevin_position(dim: int = 8) -> list[float]:
34
137
  """Initialize Langevin position near origin for a new fact.
35
138
 
@@ -60,7 +163,7 @@ def enrich_fact(
60
163
  temporal_parser: Any,
61
164
  ) -> AtomicFact:
62
165
  """Enrich fact with embeddings, entities, temporal, emotional data."""
63
- from superlocalmemory.encoding.emotional import tag_emotion, emotional_importance_boost
166
+ from superlocalmemory.encoding.emotional import emotional_importance_boost, tag_emotion
64
167
  from superlocalmemory.encoding.signal_inference import infer_signal
65
168
 
66
169
  embedding = embedder.embed(fact.content) if embedder else None
@@ -189,6 +292,9 @@ def run_store(
189
292
  ingestion_source_type: str = "store",
190
293
  ingestion_operation_id: str = "",
191
294
  derivation_report: dict[str, bool] | None = None,
295
+ precompleted_derivation_stages: frozenset[str] = frozenset(),
296
+ materialization_progress: dict[str, Any] | None = None,
297
+ materialization_checkpoint: Any = None,
192
298
  ) -> list[str]:
193
299
  """Store content and extract structured facts. Returns fact_ids.
194
300
 
@@ -286,10 +392,19 @@ def run_store(
286
392
  )
287
393
  db.store_memory(record)
288
394
 
289
- extraction_complete = False
290
- consolidation_complete = consolidator is not None
291
- canonicalization_complete = entity_resolver is not None
292
- graph_complete = graph_builder is not None
395
+ extraction_complete = "extraction" in precompleted_derivation_stages
396
+ consolidation_complete = (
397
+ "consolidation" in precompleted_derivation_stages
398
+ or consolidator is not None
399
+ )
400
+ canonicalization_complete = (
401
+ "canonicalization" in precompleted_derivation_stages
402
+ or entity_resolver is not None
403
+ )
404
+ graph_complete = (
405
+ "graph" in precompleted_derivation_stages
406
+ or graph_builder is not None
407
+ )
293
408
  temporal_complete = True
294
409
  provenance_complete = provenance is not None
295
410
 
@@ -330,7 +445,6 @@ def run_store(
330
445
  and content.strip()
331
446
  and len(content.strip()) >= 40
332
447
  and len(content.strip().split()) >= _MIN_VERBATIM_WORDS):
333
- import uuid
334
448
  import re as _re
335
449
  _verbatim_text = content.strip()
336
450
  # Extract entities using the same regex as fact_extractor
@@ -374,7 +488,6 @@ def run_store(
374
488
  # their data should NEVER be silently dropped. The min-length and min-word filters
375
489
  # are designed for automatic conversation extraction, not explicit user storage.
376
490
  if not facts and content.strip():
377
- import uuid
378
491
  facts = [AtomicFact(
379
492
  fact_id=uuid.uuid4().hex[:16],
380
493
  content=content.strip(),
@@ -403,6 +516,22 @@ def run_store(
403
516
  temporal_parser=temporal_parser,
404
517
  )
405
518
 
519
+ if (
520
+ materialization_progress is not None
521
+ and not materialization_progress.get("relational_started", False)
522
+ ):
523
+ if materialization_checkpoint is not None:
524
+ materialization_checkpoint(
525
+ "relational_started",
526
+ (),
527
+ {
528
+ "pipeline_started": True,
529
+ "relational_started": True,
530
+ "pipeline": False,
531
+ },
532
+ )
533
+ materialization_progress["relational_started"] = True
534
+
406
535
  is_queryable_promotion = fact.fact_id in queryable_ids
407
536
  if is_queryable_promotion:
408
537
  db.update_fact(fact.fact_id, {
@@ -515,6 +644,8 @@ def run_store(
515
644
 
516
645
  if fact.fact_id not in stored_ids:
517
646
  stored_ids.append(fact.fact_id)
647
+ if materialization_progress is not None:
648
+ materialization_progress["fact_ids"] = tuple(stored_ids)
518
649
 
519
650
  # Dual-write embedding to ANN index + vector store (embed on-demand if
520
651
  # a consolidated ADD fact arrived without one). See _upsert_fact_vectors.
@@ -611,12 +742,17 @@ def run_store(
611
742
  for eid in fact.canonical_entities:
612
743
  observation_builder.update_profile(eid, fact, profile_id)
613
744
 
614
- # Increment fact_count for each linked canonical entity (scoped to profile).
745
+ # The normalized association key makes this O(1) and exactly-once
746
+ # across retries, crashes, and separate operations that consolidate to
747
+ # the same fact.
615
748
  for eid in fact.canonical_entities:
616
- try:
617
- db.increment_entity_fact_count(eid, profile_id)
618
- except Exception:
619
- pass # Non-critical — entity may have been deleted
749
+ _record_fact_entity_association(
750
+ db,
751
+ operation_id=ingestion_operation_id,
752
+ profile_id=profile_id,
753
+ fact_id=fact.fact_id,
754
+ entity_id=eid,
755
+ )
620
756
  if scene_builder:
621
757
  scene_builder.assign_to_scene(fact, profile_id)
622
758
 
@@ -627,6 +763,13 @@ def run_store(
627
763
  from superlocalmemory.storage.models import TemporalEvent
628
764
  for eid in fact.canonical_entities:
629
765
  event = TemporalEvent(
766
+ event_id=_ingestion_effect_id(
767
+ ingestion_operation_id,
768
+ "temporal",
769
+ fact.fact_id,
770
+ eid,
771
+ "observed",
772
+ ),
630
773
  profile_id=profile_id, entity_id=eid,
631
774
  fact_id=fact.fact_id,
632
775
  scope=fact.scope,
@@ -644,8 +787,16 @@ def run_store(
644
787
  from superlocalmemory.encoding.foresight import extract_foresight_signals
645
788
  from superlocalmemory.storage.models import TemporalEvent as _TE
646
789
  foresight_signals = extract_foresight_signals(fact)
647
- for sig in foresight_signals:
790
+ for signal_index, sig in enumerate(foresight_signals):
648
791
  f_event = _TE(
792
+ event_id=_ingestion_effect_id(
793
+ ingestion_operation_id,
794
+ "temporal",
795
+ fact.fact_id,
796
+ sig.get("entity_id", ""),
797
+ "foresight",
798
+ signal_index,
799
+ ),
649
800
  profile_id=profile_id,
650
801
  entity_id=sig.get("entity_id", ""),
651
802
  fact_id=fact.fact_id,
@@ -679,11 +830,6 @@ def run_store(
679
830
 
680
831
  logger.info("Stored %d facts (session=%s)", len(stored_ids), session_id)
681
832
 
682
- # Post-operation hooks (audit, trust signal, event bus)
683
- hook_ctx["fact_ids"] = stored_ids
684
- hook_ctx["fact_count"] = len(stored_ids)
685
- hooks.run_post("store", hook_ctx)
686
-
687
833
  if derivation_report is not None:
688
834
  derivation_report.update({
689
835
  "extraction": extraction_complete,
@@ -694,6 +840,35 @@ def run_store(
694
840
  "provenance": provenance_complete,
695
841
  })
696
842
 
843
+ # Stage observations and emitted fact IDs are recorded before optional
844
+ # post-hooks. A hook failure must not erase the durable retry boundary and
845
+ # cause extraction/consolidation to run again.
846
+ if materialization_progress is not None:
847
+ materialization_progress["fact_ids"] = tuple(stored_ids)
848
+ materialization_progress["relational_complete"] = True
849
+ materialization_progress["post_hooks"] = False
850
+ if materialization_checkpoint is not None and stored_ids:
851
+ materialization_checkpoint(
852
+ "relational_complete",
853
+ tuple(stored_ids),
854
+ {
855
+ "pipeline_started": True,
856
+ "relational_started": True,
857
+ "pipeline": True,
858
+ "post_hooks": False,
859
+ "relational": True,
860
+ **dict(derivation_report or {}),
861
+ },
862
+ )
863
+
864
+ # Post-operation hooks (audit, trust signal, event bus)
865
+ hook_ctx["ingestion_operation_id"] = ingestion_operation_id
866
+ hook_ctx["fact_ids"] = stored_ids
867
+ hook_ctx["fact_count"] = len(stored_ids)
868
+ hooks.run_post("store", hook_ctx)
869
+ if materialization_progress is not None:
870
+ materialization_progress["post_hooks"] = True
871
+
697
872
  # Phase 5: Step-count trigger for lightweight consolidation (L7)
698
873
  if consolidation_engine is not None:
699
874
  try:
@@ -26,6 +26,8 @@ import urllib.error
26
26
  import urllib.request
27
27
  from pathlib import Path
28
28
 
29
+ from superlocalmemory import __version__
30
+
29
31
  # ---------------------------------------------------------------------------
30
32
  # Cross-platform temp paths
31
33
  # ---------------------------------------------------------------------------
@@ -221,7 +223,10 @@ def _codex_mcp_session_init(project_dir: str, payload: dict) -> dict:
221
223
  proc.stdin.write(json.dumps({
222
224
  "jsonrpc": "2.0", "id": 1, "method": "initialize",
223
225
  "params": {"protocolVersion": "2024-11-05", "capabilities": {},
224
- "clientInfo": {"name": "superlocalmemory-codex-hook", "version": "3.7"}},
226
+ "clientInfo": {
227
+ "name": "superlocalmemory-codex-hook",
228
+ "version": __version__,
229
+ }},
225
230
  }) + "\n")
226
231
  proc.stdin.flush()
227
232
  proc.stdout.readline()
@@ -240,11 +240,12 @@ def connect_ide(
240
240
  here: bool = False,
241
241
  profile: str | None = None,
242
242
  agents_md_source: Callable[[], str] | None = None,
243
+ dry_run: bool = False,
243
244
  ) -> dict[str, Any]:
244
245
  """Wire SLM into the target IDE config via merge-not-clobber.
245
246
 
246
247
  Returns a result dict:
247
- {ide, mcp_config: wrote|merged|unchanged|skipped|error,
248
+ {ide, mcp_config: wrote|merged|unchanged|would_write|skipped|error,
248
249
  mcp_path, agents_md: wrote|skipped(...)|unchanged|error,
249
250
  servers_preserved: int, error: str|None}
250
251
  """
@@ -265,6 +266,13 @@ def connect_ide(
265
266
  )
266
267
  return result
267
268
 
269
+ if ide_id == "vscode-copilot" and not here:
270
+ result["error"] = (
271
+ "VS Code / Copilot integration is project-scoped. "
272
+ "Run `slm connect vscode-copilot --here` from the project root."
273
+ )
274
+ return result
275
+
268
276
  # Step 1a — claude-code short-circuit (AC6)
269
277
  if desc.fmt == "":
270
278
  print(CLAUDE_CODE_PLUGIN_POINTER)
@@ -325,7 +333,28 @@ def connect_ide(
325
333
  result["servers_preserved"] = max(0, pre_count - (0 if pre_slm is None else 1))
326
334
  result["mcp_config"] = mcp_status
327
335
 
328
- # Step 6 — atomic write
336
+ # Step 6 — atomic write. A dry run deliberately exercises the merge and
337
+ # packaged-asset lookup path without touching a user-owned IDE config.
338
+ if dry_run:
339
+ result["mcp_config"] = "would_write"
340
+ if desc.agents_md_path is None:
341
+ result["agents_md"] = "skipped(unsupported)"
342
+ elif agents_md_source is None:
343
+ result["agents_md"] = "skipped(no-source)"
344
+ else:
345
+ try:
346
+ source_content = agents_md_source()
347
+ except Exception as exc:
348
+ result["agents_md"] = "error(source-unavailable)"
349
+ result["error"] = f"AGENTS.md asset lookup failed: {exc}"
350
+ return result
351
+ if not isinstance(source_content, str) or not source_content.strip():
352
+ result["agents_md"] = "error(empty-source)"
353
+ result["error"] = "AGENTS.md asset is empty"
354
+ return result
355
+ result["agents_md"] = "would_write"
356
+ return result
357
+
329
358
  try:
330
359
  _atomic_write(config_path, data, desc.fmt)
331
360
  except Exception as exc:
@@ -528,6 +557,9 @@ def _handle_agents_md(
528
557
  except Exception as exc:
529
558
  logger.warning("agents_md_source() failed: %s — skipping AGENTS.md write", exc)
530
559
  return "skipped(source-error)"
560
+ if not isinstance(source_content, str) or not source_content.strip():
561
+ logger.warning("agents_md_source() returned no content — skipping AGENTS.md write")
562
+ return "skipped(no-source-content)"
531
563
 
532
564
  # Read existing content
533
565
  existing = ""
@@ -34,6 +34,8 @@ import sqlite3
34
34
  from datetime import datetime, timedelta, timezone
35
35
  from typing import Final
36
36
 
37
+ from superlocalmemory.learning.model_cache import invalidate as invalidate_model_cache
38
+
37
39
  logger = logging.getLogger(__name__)
38
40
 
39
41
 
@@ -235,6 +237,7 @@ class ModelRollback:
235
237
  )
236
238
  return False
237
239
 
240
+ invalidate_model_cache(self._profile_id)
238
241
  logger.warning(
239
242
  "AUTO-ROLLBACK profile=%s reason=%s observations=%d "
240
243
  "baseline_ndcg=%.4f current_ndcg=%.4f",
@@ -33,6 +33,7 @@ from datetime import datetime, timezone
33
33
  from pathlib import Path
34
34
  from typing import Final
35
35
 
36
+ from superlocalmemory.learning.model_cache import invalidate as invalidate_model_cache
36
37
  from superlocalmemory.learning.ranker_common import (
37
38
  _build_training_matrix,
38
39
  _compute_eval_metrics,
@@ -389,6 +390,7 @@ def _promote_candidate(
389
390
  (json.dumps(meta), candidate_id),
390
391
  )
391
392
  conn.commit()
393
+ invalidate_model_cache(profile_id)
392
394
  return True
393
395
  except sqlite3.Error as exc:
394
396
  conn.rollback()
@@ -73,6 +73,24 @@ _DWELL_MAX_MS: Final[int] = 3_600_000 # 1 h
73
73
  _BUSY_TIMEOUT_MS: Final[int] = 50
74
74
 
75
75
 
76
+ def _feed_source_quality(
77
+ memory_db_path: Path,
78
+ rewards: list[tuple[str, str, list[str], float]],
79
+ ) -> None:
80
+ """Fail-soft provenance bridge kept outside the reward transaction."""
81
+ try:
82
+ from superlocalmemory.learning.source_quality import (
83
+ update_source_quality_for_reward_batch,
84
+ )
85
+ update_source_quality_for_reward_batch(
86
+ memory_db_path=memory_db_path,
87
+ learning_db_path=memory_db_path.parent / "learning.db",
88
+ rewards=rewards,
89
+ )
90
+ except Exception as exc: # noqa: BLE001 - reward finalization must survive
91
+ logger.debug("source-quality reward feed failed (non-fatal): %s", exc)
92
+
93
+
76
94
  # ---------------------------------------------------------------------------
77
95
  # Signal contract — names match the manifest A.1 label formula.
78
96
  # ---------------------------------------------------------------------------
@@ -604,6 +622,7 @@ class EngagementRewardModel:
604
622
  # lock-scope — SQLite rows are tied to the connection.
605
623
  _pid = pending["profile_id"]
606
624
  _qid = pending["recall_query_id"]
625
+ _facts_json = str(pending["fact_ids_json"] or "[]")
607
626
  except sqlite3.Error as exc:
608
627
  logger.debug("finalize_outcome SQLite error: %s", exc)
609
628
  return _FALLBACK_REWARD
@@ -633,6 +652,21 @@ class EngagementRewardModel:
633
652
  except Exception as exc: # noqa: BLE001 — defence in depth
634
653
  logger.debug("feed_recall_settled failed (non-fatal): %s", exc)
635
654
 
655
+ try:
656
+ _fact_ids = json.loads(_facts_json)
657
+ if not isinstance(_fact_ids, list):
658
+ _fact_ids = []
659
+ except (TypeError, ValueError, json.JSONDecodeError):
660
+ _fact_ids = []
661
+ _feed_source_quality(
662
+ self._db,
663
+ [(
664
+ str(_pid),
665
+ str(outcome_id),
666
+ [str(fid) for fid in _fact_ids if fid],
667
+ float(reward),
668
+ )],
669
+ )
636
670
  return reward
637
671
 
638
672
  # ------------------------------------------------------------------
@@ -749,6 +783,22 @@ class EngagementRewardModel:
749
783
  except sqlite3.Error:
750
784
  conn.execute("ROLLBACK")
751
785
  raise
786
+ reward_sources = []
787
+ for item in i_chunk:
788
+ try:
789
+ fact_ids = json.loads(str(item[2] or "[]"))
790
+ except (TypeError, ValueError, json.JSONDecodeError):
791
+ fact_ids = []
792
+ reward_sources.append((
793
+ str(item[1]),
794
+ str(item[0]),
795
+ (
796
+ [str(fid) for fid in fact_ids if fid]
797
+ if isinstance(fact_ids, list) else []
798
+ ),
799
+ float(item[4]),
800
+ ))
801
+ _feed_source_quality(self._db, reward_sources)
752
802
  except sqlite3.Error as exc: # pragma: no cover — defensive
753
803
  logger.debug("reap_stale SQLite error: %s", exc)
754
804
  return written