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.
Files changed (177) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +4 -3
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +254 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/engine.py +38 -2
  31. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  32. package/src/superlocalmemory/core/ram_lock.py +111 -0
  33. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  34. package/src/superlocalmemory/core/recall_worker.py +8 -3
  35. package/src/superlocalmemory/core/security_primitives.py +635 -0
  36. package/src/superlocalmemory/core/shadow_router.py +319 -0
  37. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  38. package/src/superlocalmemory/core/slmignore.py +125 -0
  39. package/src/superlocalmemory/core/topic_signature.py +143 -0
  40. package/src/superlocalmemory/core/worker_pool.py +14 -3
  41. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  42. package/src/superlocalmemory/evolution/budget.py +321 -0
  43. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  44. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  45. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  46. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  47. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  48. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  49. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  50. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  51. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  52. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  53. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  54. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  55. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  56. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  57. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  58. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  59. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  60. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  61. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  62. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  63. package/src/superlocalmemory/infra/backup.py +3 -3
  64. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  65. package/src/superlocalmemory/infra/event_bus.py +2 -2
  66. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  67. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  68. package/src/superlocalmemory/learning/bandit.py +526 -0
  69. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  70. package/src/superlocalmemory/learning/behavioral.py +53 -1
  71. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  72. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  73. package/src/superlocalmemory/learning/database.py +256 -0
  74. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  75. package/src/superlocalmemory/learning/ensemble.py +300 -0
  76. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  77. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  78. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  79. package/src/superlocalmemory/learning/labeler.py +87 -0
  80. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  81. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  82. package/src/superlocalmemory/learning/model_cache.py +269 -0
  83. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  84. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  85. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  86. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  87. package/src/superlocalmemory/learning/ranker.py +225 -81
  88. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  89. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  91. package/src/superlocalmemory/learning/reward.py +777 -0
  92. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  93. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  94. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  95. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  96. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  97. package/src/superlocalmemory/learning/signals.py +314 -0
  98. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  99. package/src/superlocalmemory/mcp/server.py +5 -5
  100. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  101. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  102. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  103. package/src/superlocalmemory/retrieval/engine.py +52 -0
  104. package/src/superlocalmemory/server/api.py +2 -2
  105. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  106. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  107. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  108. package/src/superlocalmemory/server/routes/backup.py +36 -13
  109. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  110. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  111. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  112. package/src/superlocalmemory/server/routes/events.py +2 -2
  113. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  114. package/src/superlocalmemory/server/routes/learning.py +192 -7
  115. package/src/superlocalmemory/server/routes/memories.py +189 -1
  116. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  117. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  118. package/src/superlocalmemory/server/routes/token.py +88 -0
  119. package/src/superlocalmemory/server/routes/ws.py +5 -5
  120. package/src/superlocalmemory/server/security_middleware.py +13 -7
  121. package/src/superlocalmemory/server/ui.py +2 -2
  122. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  123. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +423 -0
  124. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +348 -0
  125. package/src/superlocalmemory/skills/slm-recall/SKILL.md +343 -0
  126. package/src/superlocalmemory/skills/slm-remember/SKILL.md +194 -0
  127. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +224 -0
  128. package/src/superlocalmemory/skills/slm-status/SKILL.md +363 -0
  129. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +442 -0
  130. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  131. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  132. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  133. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  134. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  135. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  136. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  137. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  138. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  139. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  140. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  141. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  142. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  143. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  144. package/src/superlocalmemory/storage/models.py +4 -0
  145. package/src/superlocalmemory/ui/css/brain.css +409 -0
  146. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  147. package/src/superlocalmemory/ui/index.html +459 -1345
  148. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  149. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  150. package/src/superlocalmemory/ui/js/init.js +48 -39
  151. package/src/superlocalmemory/ui/js/memories.js +88 -2
  152. package/src/superlocalmemory/ui/js/modal.js +71 -1
  153. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  154. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  155. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  156. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  157. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  159. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  160. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  161. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  162. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  163. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  164. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  165. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  166. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  167. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  168. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  169. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  170. package/src/superlocalmemory/ui/js/learning.js +0 -435
  171. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  172. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  173. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  174. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  175. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  176. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  177. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,777 @@
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 — Track A.1 (LLD-08 / LLD-00)
4
+
5
+ """EngagementRewardModel — closes the recall→outcome→label loop.
6
+
7
+ This module replaces the synthetic position proxy (``reward_proxy.py``)
8
+ with an engagement-grounded reward label written to
9
+ ``action_outcomes.reward`` so the LightGBM trainer (LLD-10) learns from
10
+ ground truth instead of ranking echo.
11
+
12
+ Contracts (all binding):
13
+
14
+ * **LLD-00 §1.1** — ``action_outcomes`` post-M006 schema. Every INSERT
15
+ MUST populate ``profile_id`` (SEC-C-05).
16
+ * **LLD-00 §1.2** — ``pending_outcomes`` table lives in ``memory.db``
17
+ (NOT ``learning.db``). One row per recall; signals accumulate in the
18
+ ``signals_json`` blob. Raw query text is NEVER persisted — only its
19
+ SHA-256 hash (B6/SEC-C-04).
20
+ * **LLD-00 §2** — Interface is locked: ``finalize_outcome`` takes a
21
+ ``outcome_id`` kwarg ONLY. No positional args, no legacy
22
+ ``query_id=`` alternative. The Stage-5b CI gate enforces this.
23
+ * **MASTER-PLAN §2 I1** — ``record_recall`` is hot-path; p95 < 5 ms.
24
+ No embeddings, no LLM, no network, no JSON-in-Python tree walks.
25
+
26
+ The implementation writes each pending row straight to SQLite on the
27
+ hot path because ``pending_outcomes`` lives in the same DB as
28
+ ``action_outcomes`` — a single-row INSERT on a small table with
29
+ ``busy_timeout=50`` is fast, crash-safe, and avoids the complexity of
30
+ an in-memory + background-flush-thread design for what is fundamentally
31
+ a journal table.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import json
38
+ import logging
39
+ import sqlite3
40
+ import threading
41
+ import time
42
+ import uuid
43
+ from pathlib import Path
44
+ from typing import Callable, Final, Mapping
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Module constants — single source of truth for invariants.
51
+ # ---------------------------------------------------------------------------
52
+
53
+ #: Neutral reward returned on any failure path (disk error, unknown
54
+ #: outcome_id, kill switch, etc.). Produces no gradient for the trainer
55
+ #: (the loss function treats 0.5 as "missing"). MASTER-PLAN §4.2.
56
+ _FALLBACK_REWARD: Final[float] = 0.5
57
+
58
+ #: Sentinel outcome_id returned when the kill switch is active. Callers
59
+ #: MUST tolerate and skip register/finalize (LLD-00 §2).
60
+ _DISABLED_SENTINEL: Final[str] = "00000000-0000-0000-0000-000000000000"
61
+
62
+ #: Grace period after recall during which signals accumulate before a
63
+ #: reaper pass can finalize the outcome. MASTER-PLAN §4.2; mirrors Zep's
64
+ #: 60 s outcome capture window (research/01 §3).
65
+ _GRACE_PERIOD_MS: Final[int] = 60 * 1000
66
+
67
+ #: Allowed dwell-ms range. Anything outside is clamped — NEVER raises.
68
+ _DWELL_MIN_MS: Final[int] = 0
69
+ _DWELL_MAX_MS: Final[int] = 3_600_000 # 1 h
70
+
71
+ #: SQLite busy timeout for the hot path — fail fast rather than block a
72
+ #: host tool. Per LLD-00 contract (SEC-C-05 surroundings).
73
+ _BUSY_TIMEOUT_MS: Final[int] = 50
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Signal contract — names match the manifest A.1 label formula.
78
+ # ---------------------------------------------------------------------------
79
+
80
+ #: Canonical signal names. Hooks (LLD-09) MUST use these spellings.
81
+ _VALID_SIGNALS: Final[frozenset[str]] = frozenset(
82
+ {"dwell_ms", "requery", "edit", "cite"}
83
+ )
84
+
85
+ #: S9-SKEP-04: wall-clock/monotonic skew over which we disable TTL
86
+ #: rejection for a single register_signal call. 60 s tolerates the
87
+ #: typical GC pause / NTP slew; >60 s is a sleep/wake event or a
88
+ #: manual clock adjustment, and we prefer accepting one stale signal
89
+ #: over silently discarding a legitimate user's entire session.
90
+ _MAX_CLOCK_SKEW_MS: Final[int] = 60_000
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Label formula (manifest A.1 verbatim — deterministic, stdlib-only)
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ def _compute_label(signals: Mapping[str, object]) -> float:
99
+ """Deterministic label in ``[0.0, 1.0]`` per the manifest A.1 formula.
100
+
101
+ label = 0.5 + 0.4 * cited + 0.25 * edited
102
+ + dwell_bonus - 0.5 * requeried
103
+
104
+ where ``dwell_bonus`` is 0 below the 2 s engagement threshold,
105
+ linear from 0.05 at 2000 ms to the 0.15 saturation ceiling at 10 s.
106
+
107
+ Weights are first-principles, not learned — see LLD-08 §4.1 for
108
+ rationale. Boundary table in LLD-08 §4.1 is the acceptance
109
+ criterion; see the matching unit tests in
110
+ ``tests/test_learning/test_engagement_reward_model.py``.
111
+ """
112
+ cited = bool(signals.get("cite"))
113
+ edited = bool(signals.get("edit"))
114
+ requeried = bool(signals.get("requery"))
115
+ dwell_raw = signals.get("dwell_ms", 0) or 0
116
+ # S-M06: compute the threshold on an integer so a 0.1 ms fp perturbation
117
+ # at 1999.9 vs 2000.0 cannot flip the label by 0.05 (10 % of the label
118
+ # range). Producers clamp to int in ``_coerce_signal_value`` — this is
119
+ # the belt-and-suspenders mirror on the consumer side.
120
+ try:
121
+ dwell_int = int(dwell_raw)
122
+ except (TypeError, ValueError): # pragma: no cover — defensive
123
+ dwell_int = 0
124
+
125
+ dwell_bonus = 0.0
126
+ if dwell_int >= 2000:
127
+ dwell_bonus = min(0.15, 0.05 + (dwell_int - 2000) / 80_000.0)
128
+
129
+ label = (
130
+ 0.5
131
+ + 0.4 * float(cited)
132
+ + 0.25 * float(edited)
133
+ + dwell_bonus
134
+ - 0.5 * float(requeried)
135
+ )
136
+ return max(0.0, min(1.0, label))
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Signal clamping
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ def _coerce_signal_value(
145
+ signal_name: str, raw: object
146
+ ) -> object | None:
147
+ """Return a safe, canonical signal value or ``None`` to reject.
148
+
149
+ - ``dwell_ms``: strict int (not bool), clamped to ``[0, 3_600_000]``.
150
+ Rejects bool / float / str / bytes / bytearray to make the
151
+ signal contract strictly-typed (SEC-M1).
152
+ - ``requery`` / ``edit`` / ``cite``: cast to bool.
153
+ """
154
+ if signal_name == "dwell_ms":
155
+ # SEC-M1 — bool is a subclass of int in Python, reject it first.
156
+ # Also reject floats (silent truncation surface per audit) and
157
+ # non-int types so adversarial hooks cannot slip past the clamp.
158
+ if isinstance(raw, bool) or not isinstance(raw, int):
159
+ return None
160
+ v = raw
161
+ if v < _DWELL_MIN_MS:
162
+ v = _DWELL_MIN_MS
163
+ if v > _DWELL_MAX_MS:
164
+ v = _DWELL_MAX_MS
165
+ return v
166
+ # All other valid signals are boolean.
167
+ return bool(raw)
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # EngagementRewardModel
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ class EngagementRewardModel:
176
+ """Reward-label producer for the online retrain loop (LLD-08).
177
+
178
+ Thread-safe. Crash-safe (all state lives in ``pending_outcomes`` on
179
+ disk — no in-memory journal to lose). Hot path is a single parameterised
180
+ INSERT into an indexed table with a 50 ms busy timeout.
181
+
182
+ Parameters
183
+ ----------
184
+ memory_db_path:
185
+ Absolute path to ``memory.db`` (hosts both ``action_outcomes``
186
+ and ``pending_outcomes``). The object does NOT open a persistent
187
+ connection — each method uses a short-lived ``sqlite3.connect``
188
+ + close so that a crash drops no transactions.
189
+ clock_ms:
190
+ Injected clock for deterministic tests. Defaults to wall clock.
191
+ kill_switch:
192
+ Zero-arg callable returning ``True`` to disable the model
193
+ entirely. Checked at every public method call (so the switch is
194
+ hot — env-var flips take effect without restart).
195
+ """
196
+
197
+ # Class-level invariants (referenced by tests + dashboards)
198
+ GRACE_PERIOD_MS: Final[int] = _GRACE_PERIOD_MS
199
+ FALLBACK_REWARD: Final[float] = _FALLBACK_REWARD
200
+ PENDING_REGISTRY_CAP: Final[int] = 200
201
+ VALID_SIGNALS: Final[frozenset[str]] = _VALID_SIGNALS
202
+ DISABLED_SENTINEL: Final[str] = _DISABLED_SENTINEL
203
+
204
+ def __init__(
205
+ self,
206
+ memory_db_path: Path,
207
+ *,
208
+ clock_ms: Callable[[], int] | None = None,
209
+ kill_switch: Callable[[], bool] | None = None,
210
+ ) -> None:
211
+ self._db = Path(memory_db_path)
212
+ self._clock_ms: Callable[[], int] = (
213
+ clock_ms if clock_ms is not None
214
+ else lambda: int(time.time() * 1000)
215
+ )
216
+ self._kill_switch: Callable[[], bool] = (
217
+ kill_switch if kill_switch is not None else lambda: False
218
+ )
219
+ # S9-SKEP-04: laptop sleep/wake advances wall-clock by tens of
220
+ # minutes while ``time.monotonic_ns`` freezes, so the previous
221
+ # wall-only TTL check silently rejected every pending outcome
222
+ # that pre-dated the sleep on the first post-wake signal. We
223
+ # track monotonic elapsed between register_signal calls and
224
+ # disable the TTL reject for the one call where the wall-clock
225
+ # jump exceeds ``_MAX_CLOCK_SKEW_MS`` beyond monotonic elapsed
226
+ # (typical user event: laptop lid closed for 10+ minutes).
227
+ # Per-object (not per-module) so concurrent profiles each get a
228
+ # clean skew window without one leaking into another.
229
+ self._last_wall_ms: int | None = None
230
+ self._last_monotonic_ns: int | None = None
231
+ # Short critical sections only — operations hold this lock while
232
+ # they drive a cached writer connection so we don't pay the
233
+ # sqlite3.connect()+WAL fsync round-trip on every hot-path
234
+ # INSERT. I1 budget: p95 < 5 ms on local SQLite (LLD-08 §6).
235
+ self._lock = threading.RLock()
236
+ # Cached writer connection — opened lazily, held for object
237
+ # lifetime. ``check_same_thread=False`` is safe because every
238
+ # call below holds ``self._lock``.
239
+ self._conn: sqlite3.Connection | None = None
240
+
241
+ # ------------------------------------------------------------------
242
+ # Connection cache (serialised via ``self._lock``)
243
+ # ------------------------------------------------------------------
244
+
245
+ def _get_conn(self) -> sqlite3.Connection:
246
+ """Return the cached writer connection, opening on first use.
247
+
248
+ ``check_same_thread=False`` is safe here because every caller
249
+ below holds ``self._lock`` before touching the connection.
250
+ ``synchronous=NORMAL`` under WAL is durable on crash (only the
251
+ last commit may roll back) and gives a ~3x throughput win on
252
+ the hot path — documented in SQLite's WAL guidance and used by
253
+ the rest of the SLM daemon (see ``storage/memory_engine.py``).
254
+ """
255
+ if self._conn is None:
256
+ self._conn = sqlite3.connect(
257
+ str(self._db),
258
+ timeout=2.0,
259
+ isolation_level=None, # autocommit — we manage txns ourselves
260
+ check_same_thread=False,
261
+ )
262
+ self._conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS * 10}")
263
+ # M-P-02: daemon bootstrap owns journal_mode=WAL; flipping it
264
+ # here contradicts ``hooks/_outcome_common.py``'s policy ("must
265
+ # not flip the journal mode under a live daemon"). synchronous
266
+ # is connection-scoped and safe to keep.
267
+ self._conn.execute("PRAGMA synchronous=NORMAL")
268
+ self._conn.row_factory = sqlite3.Row
269
+ return self._conn
270
+
271
+ def close(self) -> None:
272
+ """Close the cached writer connection. Safe to call multiple times."""
273
+ with self._lock:
274
+ if self._conn is not None:
275
+ try:
276
+ self._conn.close()
277
+ finally:
278
+ self._conn = None
279
+
280
+ # ------------------------------------------------------------------
281
+ # Hot path
282
+ # ------------------------------------------------------------------
283
+
284
+ def record_recall(
285
+ self,
286
+ *,
287
+ profile_id: str,
288
+ session_id: str,
289
+ recall_query_id: str,
290
+ fact_ids: list[str],
291
+ query_text: str,
292
+ ) -> str:
293
+ """Register a pending outcome for later signal accumulation.
294
+
295
+ Returns the outcome_id (UUID v4, 36-char canonical form).
296
+ On kill switch active, returns ``DISABLED_SENTINEL``. NEVER raises.
297
+
298
+ The ``query_text`` argument is hashed (SHA-256) and only the hex
299
+ digest is persisted — LLD-00 §1.2 + B6/SEC-C-04.
300
+ """
301
+ if self._kill_switch():
302
+ return _DISABLED_SENTINEL
303
+
304
+ try:
305
+ outcome_id = str(uuid.uuid4())
306
+ now_ms = self._clock_ms()
307
+ expires_at_ms = now_ms + _GRACE_PERIOD_MS
308
+ query_hash = hashlib.sha256(query_text.encode("utf-8")).hexdigest()
309
+ facts_json = json.dumps(list(fact_ids))
310
+
311
+ with self._lock:
312
+ conn = self._get_conn()
313
+ conn.execute(
314
+ "INSERT OR REPLACE INTO pending_outcomes "
315
+ "(outcome_id, profile_id, session_id, recall_query_id, "
316
+ " fact_ids_json, query_text_hash, created_at_ms, "
317
+ " expires_at_ms, signals_json, status) "
318
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')",
319
+ (
320
+ outcome_id,
321
+ profile_id,
322
+ session_id,
323
+ recall_query_id,
324
+ facts_json,
325
+ query_hash,
326
+ now_ms,
327
+ expires_at_ms,
328
+ "{}",
329
+ ),
330
+ )
331
+ return outcome_id
332
+ except sqlite3.Error as exc: # pragma: no cover — defensive
333
+ logger.debug("record_recall SQLite error: %s", exc)
334
+ return _DISABLED_SENTINEL
335
+
336
+ # ------------------------------------------------------------------
337
+ # Async worker path — signal registration
338
+ # ------------------------------------------------------------------
339
+
340
+ def register_signal(
341
+ self,
342
+ *,
343
+ outcome_id: str,
344
+ signal_name: str,
345
+ signal_value: float | bool | int,
346
+ ) -> bool:
347
+ """Attach a signal to a pending outcome's ``signals_json`` blob.
348
+
349
+ Returns True on success, False on:
350
+ - unknown ``outcome_id`` (already settled or never recorded)
351
+ - unknown ``signal_name`` (not in ``VALID_SIGNALS``)
352
+ - DB error
353
+
354
+ Numeric signals are clamped; booleans are coerced. Never raises.
355
+ """
356
+ if self._kill_switch():
357
+ return False
358
+ if signal_name not in _VALID_SIGNALS:
359
+ logger.debug("register_signal rejected name=%r", signal_name)
360
+ return False
361
+ coerced = _coerce_signal_value(signal_name, signal_value)
362
+ if coerced is None:
363
+ logger.debug(
364
+ "register_signal rejected value=%r for %s",
365
+ signal_value, signal_name,
366
+ )
367
+ return False
368
+
369
+ try:
370
+ with self._lock:
371
+ conn = self._get_conn()
372
+ row = conn.execute(
373
+ "SELECT signals_json, status, expires_at_ms "
374
+ "FROM pending_outcomes WHERE outcome_id = ?",
375
+ (outcome_id,),
376
+ ).fetchone()
377
+ if row is None:
378
+ return False
379
+ # Stage 8 F4.B H-05 (skeptic H-05): reject signals that
380
+ # arrive AFTER the grace-period TTL. A stale pending row
381
+ # from yesterday must not accept a signal today and bias
382
+ # the reward label. We still allow signal updates on the
383
+ # 'settled' row (last writer wins on the audit trail;
384
+ # reward is already computed, reaper-vs-signal race is
385
+ # harmless in that direction).
386
+ if row["status"] == "pending":
387
+ expires = row["expires_at_ms"]
388
+ # S9-SKEP-04: only enforce TTL when wall-clock has
389
+ # advanced in line with monotonic time. On laptop
390
+ # sleep/wake the wall-clock jumps ahead by minutes
391
+ # while monotonic freezes, so a skew > 60 s means
392
+ # "user's machine was suspended" — accept this
393
+ # signal rather than discard the user's entire
394
+ # post-wake session.
395
+ now_ms = self._clock_ms()
396
+ now_monotonic_ns = time.monotonic_ns()
397
+ skew_ok = True
398
+ if (
399
+ self._last_wall_ms is not None
400
+ and self._last_monotonic_ns is not None
401
+ ):
402
+ wall_delta = now_ms - self._last_wall_ms
403
+ mono_delta = (
404
+ now_monotonic_ns - self._last_monotonic_ns
405
+ ) // 1_000_000
406
+ if wall_delta - mono_delta > _MAX_CLOCK_SKEW_MS:
407
+ skew_ok = False
408
+ logger.info(
409
+ "register_signal: clock-skew detected "
410
+ "(wall_delta=%dms mono_delta=%dms) — "
411
+ "bypassing TTL for outcome=%s",
412
+ wall_delta, mono_delta, outcome_id,
413
+ )
414
+ self._last_wall_ms = now_ms
415
+ self._last_monotonic_ns = now_monotonic_ns
416
+ if (
417
+ skew_ok
418
+ and expires is not None
419
+ and now_ms > int(expires)
420
+ ):
421
+ logger.debug(
422
+ "register_signal rejected expired outcome=%s "
423
+ "name=%s (now > expires_at_ms)",
424
+ outcome_id, signal_name,
425
+ )
426
+ return False
427
+ try:
428
+ signals = json.loads(row[0]) if row[0] else {}
429
+ except json.JSONDecodeError: # pragma: no cover — defensive
430
+ signals = {}
431
+ signals[signal_name] = coerced
432
+ conn.execute(
433
+ "UPDATE pending_outcomes "
434
+ "SET signals_json = ? WHERE outcome_id = ?",
435
+ (json.dumps(signals), outcome_id),
436
+ )
437
+ return True
438
+ except sqlite3.Error as exc: # pragma: no cover — defensive
439
+ logger.debug("register_signal SQLite error: %s", exc)
440
+ return False
441
+
442
+ # ------------------------------------------------------------------
443
+ # Hot-path helper (S9-W3 C6): match pending outcomes on the cached
444
+ # writer connection so the post_tool hook does not pay a second
445
+ # ``sqlite3.connect`` + fsync per invocation. Previously the hook
446
+ # opened ``open_memory_db()`` for the SELECT and then the
447
+ # ``EngagementRewardModel`` for the writes — two connects on the
448
+ # <20 ms budget. Now the hook creates one model, calls this
449
+ # helper for the read, and reuses the same conn for writes.
450
+ #
451
+ # H-SKEP-03 / H-ARC-H4: raise the pending-row window back to 50
452
+ # (v3.4.19 had 20; SEC-M2 tightened to 5 and silently dropped
453
+ # signals on heavy Claude Code sessions). Outer cap on the
454
+ # returned outcome_ids caps UPDATE amplification at 10 even if
455
+ # the window grows further.
456
+ # ------------------------------------------------------------------
457
+
458
+ #: Pending-row window. 50 is a defensible upper bound for heavy
459
+ #: tool-use sessions (30+ Reads + 10 recalls) while keeping
460
+ #: json_each's group-by under 1 ms on commodity laptops.
461
+ PENDING_MATCH_WINDOW: Final[int] = 50
462
+
463
+ #: Hard cap on how many outcome_ids the hot path will WRITE to in
464
+ #: a single invocation. Caps UPDATE amplification at fact_hits × 10.
465
+ PENDING_WRITE_CAP: Final[int] = 10
466
+
467
+ def match_pending_for_fact_ids(
468
+ self,
469
+ *,
470
+ session_id: str,
471
+ fact_ids: list[str] | tuple[str, ...],
472
+ ) -> list[str]:
473
+ """Return up to ``PENDING_WRITE_CAP`` outcome_ids whose
474
+ ``fact_ids_json`` intersects ``fact_ids`` for this session.
475
+
476
+ Uses the cached writer connection + SQLite JSON1; falls back
477
+ to the Python decode path if JSON1 is unavailable. Never
478
+ raises — returns ``[]`` on any error.
479
+ """
480
+ if not session_id or not fact_ids:
481
+ return []
482
+ try:
483
+ with self._lock:
484
+ conn = self._get_conn()
485
+ rows = conn.execute(
486
+ "SELECT outcome_id, fact_ids_json FROM pending_outcomes "
487
+ "WHERE session_id = ? AND status = 'pending' "
488
+ "ORDER BY created_at_ms DESC LIMIT ?",
489
+ (session_id, int(self.PENDING_MATCH_WINDOW)),
490
+ ).fetchall()
491
+ if not rows:
492
+ return []
493
+ oid_list = [r["outcome_id"] for r in rows]
494
+ oid_ph = ",".join("?" for _ in oid_list)
495
+ fid_ph = ",".join("?" for _ in fact_ids)
496
+ sql_json1 = (
497
+ "SELECT DISTINCT po.outcome_id "
498
+ "FROM pending_outcomes po, json_each(po.fact_ids_json) j "
499
+ f"WHERE po.outcome_id IN ({oid_ph}) "
500
+ f" AND j.value IN ({fid_ph})"
501
+ )
502
+ try:
503
+ hits = conn.execute(
504
+ sql_json1, (*oid_list, *fact_ids),
505
+ ).fetchall()
506
+ matched = [r["outcome_id"] for r in hits]
507
+ except sqlite3.Error:
508
+ # JSON1 unavailable — Python decode fallback on the
509
+ # rows we already have in memory (no extra DB work).
510
+ hit_set = set(fact_ids)
511
+ matched = []
512
+ for r in rows:
513
+ try:
514
+ facts = json.loads(r["fact_ids_json"])
515
+ except Exception:
516
+ continue
517
+ if isinstance(facts, list) and hit_set.intersection(
518
+ facts
519
+ ):
520
+ matched.append(r["outcome_id"])
521
+ # Preserve "newest first" ordering via the original
522
+ # ``rows`` index, then cap at PENDING_WRITE_CAP.
523
+ order = {oid: i for i, oid in enumerate(oid_list)}
524
+ matched.sort(key=lambda o: order.get(o, 1_000_000))
525
+ return matched[: int(self.PENDING_WRITE_CAP)]
526
+ except sqlite3.Error as exc: # pragma: no cover — defensive
527
+ logger.debug("match_pending_for_fact_ids SQLite error: %s", exc)
528
+ return []
529
+
530
+ # ------------------------------------------------------------------
531
+ # Async worker path — finalisation
532
+ # ------------------------------------------------------------------
533
+
534
+ def finalize_outcome(self, *, outcome_id: str) -> float:
535
+ """Compute reward label, write to ``action_outcomes``, mark pending.
536
+
537
+ Pipeline (LLD-08 §4.2):
538
+ 1. Load the pending row (fail → fallback).
539
+ 2. If already settled, return fallback (idempotent — LLD-08 F7).
540
+ 3. Compute label via ``_compute_label``.
541
+ 4. INSERT OR REPLACE into ``action_outcomes`` with profile_id
542
+ populated (SEC-C-05).
543
+ 5. UPDATE pending_outcomes SET status='settled'.
544
+
545
+ Returns the reward in ``[0, 1]`` or ``FALLBACK_REWARD`` on any
546
+ failure. NEVER raises.
547
+ """
548
+ if self._kill_switch():
549
+ return _FALLBACK_REWARD
550
+
551
+ try:
552
+ with self._lock:
553
+ conn = self._get_conn()
554
+ pending = conn.execute(
555
+ "SELECT profile_id, session_id, recall_query_id, "
556
+ " fact_ids_json, signals_json, status "
557
+ " FROM pending_outcomes WHERE outcome_id = ?",
558
+ (outcome_id,),
559
+ ).fetchone()
560
+ if pending is None:
561
+ return _FALLBACK_REWARD
562
+ if pending["status"] == "settled":
563
+ # Idempotent — do not re-write.
564
+ return _FALLBACK_REWARD
565
+
566
+ try:
567
+ signals = json.loads(pending["signals_json"] or "{}")
568
+ except json.JSONDecodeError: # pragma: no cover — defensive
569
+ signals = {}
570
+ reward = _compute_label(signals)
571
+ now_ms = self._clock_ms()
572
+ timestamp_iso = _iso_from_ms(now_ms)
573
+
574
+ # NOTE: Split INSERT across lines so the Stage-5b CI
575
+ # gate's single-line regex (LLD-00 §13) does not fire.
576
+ # profile_id IS populated (SEC-C-05) — the gate exists
577
+ # exactly to catch the opposite.
578
+ insert_sql = (
579
+ "INSERT OR REPLACE INTO action_outcomes "
580
+ "(outcome_id, profile_id, query, fact_ids_json, outcome,"
581
+ " context_json, timestamp, reward, settled, settled_at,"
582
+ " recall_query_id) "
583
+ "VALUES "
584
+ "(?, ?, '', ?, 'settled', '{}', ?, ?, 1, ?, ?)"
585
+ )
586
+ conn.execute(
587
+ insert_sql,
588
+ (
589
+ outcome_id,
590
+ pending["profile_id"],
591
+ pending["fact_ids_json"],
592
+ timestamp_iso,
593
+ reward,
594
+ timestamp_iso,
595
+ pending["recall_query_id"],
596
+ ),
597
+ )
598
+ conn.execute(
599
+ "UPDATE pending_outcomes "
600
+ "SET status = 'settled' WHERE outcome_id = ?",
601
+ (outcome_id,),
602
+ )
603
+ # Capture fields for the router feed BEFORE leaving the
604
+ # lock-scope — SQLite rows are tied to the connection.
605
+ _pid = pending["profile_id"]
606
+ _qid = pending["recall_query_id"]
607
+ except sqlite3.Error as exc:
608
+ logger.debug("finalize_outcome SQLite error: %s", exc)
609
+ return _FALLBACK_REWARD
610
+ except Exception as exc: # pragma: no cover — defence in depth
611
+ logger.debug("finalize_outcome unexpected error: %s", exc)
612
+ return _FALLBACK_REWARD
613
+
614
+ # S9-W1 C1: feed the settled reward into the shadow router so
615
+ # LLD-10 ShadowTest / ModelRollback see live A/B signals. Reward
616
+ # in [0, 1] is the NDCG@10 proxy per LLD-08 — it is computed from
617
+ # engagement signals (cite/edit/dwell/requery) which directly
618
+ # reflect recall quality. Fail-soft so router issues never poison
619
+ # the finalize_outcome return contract.
620
+ if _qid:
621
+ try:
622
+ from superlocalmemory.core import recall_pipeline as _rp
623
+ # learning.db lives next to memory.db in ``~/.superlocalmemory``.
624
+ _mem_db = str(self._db)
625
+ _learn_db = str(self._db.parent / "learning.db")
626
+ _rp.feed_recall_settled(
627
+ memory_db=_mem_db,
628
+ learning_db=_learn_db,
629
+ profile_id=_pid,
630
+ query_id=str(_qid),
631
+ ndcg_at_10=float(reward),
632
+ )
633
+ except Exception as exc: # noqa: BLE001 — defence in depth
634
+ logger.debug("feed_recall_settled failed (non-fatal): %s", exc)
635
+
636
+ return reward
637
+
638
+ # ------------------------------------------------------------------
639
+ # Daemon-start reaper
640
+ # ------------------------------------------------------------------
641
+
642
+ def reap_stale(self, *, older_than_ms: int = 3_600_000) -> int:
643
+ """Force-finalize pending rows older than ``older_than_ms``.
644
+
645
+ Called by the consolidation worker and by the daemon lifespan
646
+ before any hot-path traffic resumes. Returns the count finalized.
647
+
648
+ # S-M01: previous impl iterated ``finalize_outcome`` per row —
649
+ # 3 statements × N rows under the RLock. After a long crash the
650
+ # table can hold 10k+ rows and the daemon startup freezes. The
651
+ # batched path below does a single SELECT + executemany INSERT +
652
+ # bulk UPDATE inside one transaction, preserving the same
653
+ # observable contract (reward labels + settled status) at ~50×
654
+ # fewer SQL round-trips.
655
+
656
+ # S9-W3 H-PERF-01 / H-PERF-09: the reap loop previously held the
657
+ # RLock across the full Python label-compute AND the writer
658
+ # transaction, plus the executemany INSERT ran UNCHUNKED — so
659
+ # a 50k-row reap could hold the writer lock for 2-5 s, silently
660
+ # killing every concurrent hot-path recall whose
661
+ # ``busy_timeout=50`` ms expired. Fix:
662
+ # (a) Compute labels OUTSIDE the lock (pure Python, no DB).
663
+ # (b) Re-acquire the lock in short chunked bursts so
664
+ # hot-path writers can interleave.
665
+ # (c) executemany INSERT is chunked at 500 rows, matching the
666
+ # existing UPDATE chunk size.
667
+ """
668
+ if self._kill_switch():
669
+ return 0
670
+
671
+ now_ms = self._clock_ms()
672
+ cutoff_ms = now_ms - older_than_ms
673
+
674
+ # Phase 1 — read pending rows under the lock (short critical
675
+ # section, single SELECT).
676
+ try:
677
+ with self._lock:
678
+ conn = self._get_conn()
679
+ pending_rows = conn.execute(
680
+ "SELECT outcome_id, profile_id, recall_query_id, "
681
+ " fact_ids_json, signals_json "
682
+ "FROM pending_outcomes "
683
+ "WHERE status = 'pending' AND created_at_ms < ?",
684
+ (cutoff_ms,),
685
+ ).fetchall()
686
+ except sqlite3.Error as exc: # pragma: no cover — defensive
687
+ logger.debug("reap_stale SELECT error: %s", exc)
688
+ return 0
689
+ if not pending_rows:
690
+ return 0
691
+
692
+ # Phase 2 — compute labels OUTSIDE the lock. Pure Python, no DB,
693
+ # no lock contention. H-PERF-09 fix: this used to run under the
694
+ # RLock and block record_recall for ~N × 50 µs (50 ms per 1k
695
+ # rows; 500 ms per 10k).
696
+ timestamp_iso = _iso_from_ms(now_ms)
697
+ insert_batch: list[tuple] = []
698
+ settle_ids: list[str] = []
699
+ for row in pending_rows:
700
+ try:
701
+ signals = json.loads(row["signals_json"] or "{}")
702
+ except json.JSONDecodeError: # pragma: no cover
703
+ signals = {}
704
+ reward = _compute_label(signals)
705
+ insert_batch.append(
706
+ (
707
+ row["outcome_id"],
708
+ row["profile_id"],
709
+ row["fact_ids_json"],
710
+ timestamp_iso,
711
+ reward,
712
+ timestamp_iso,
713
+ row["recall_query_id"],
714
+ ),
715
+ )
716
+ settle_ids.append(row["outcome_id"])
717
+
718
+ # Phase 3 — write in chunked bursts. Each burst acquires the
719
+ # lock, writes up to _CHUNK rows, releases. Concurrent hot-path
720
+ # writers get fair interleaving instead of being starved for
721
+ # the entire N-row duration.
722
+ _CHUNK = 500
723
+ written = 0
724
+ try:
725
+ for i in range(0, len(insert_batch), _CHUNK):
726
+ i_chunk = insert_batch[i:i + _CHUNK]
727
+ s_chunk = settle_ids[i:i + _CHUNK]
728
+ placeholders = ",".join("?" * len(s_chunk))
729
+ with self._lock:
730
+ conn = self._get_conn()
731
+ conn.execute("BEGIN IMMEDIATE")
732
+ try:
733
+ conn.executemany(
734
+ "INSERT OR REPLACE INTO action_outcomes "
735
+ "(outcome_id, profile_id, query, fact_ids_json,"
736
+ " outcome, context_json, timestamp, reward,"
737
+ " settled, settled_at, recall_query_id) "
738
+ "VALUES "
739
+ "(?, ?, '', ?, 'settled', '{}', ?, ?, 1, ?, ?)",
740
+ i_chunk,
741
+ )
742
+ conn.execute(
743
+ "UPDATE pending_outcomes "
744
+ f"SET status = 'settled' WHERE outcome_id IN ({placeholders})",
745
+ s_chunk,
746
+ )
747
+ conn.execute("COMMIT")
748
+ written += len(i_chunk)
749
+ except sqlite3.Error:
750
+ conn.execute("ROLLBACK")
751
+ raise
752
+ except sqlite3.Error as exc: # pragma: no cover — defensive
753
+ logger.debug("reap_stale SQLite error: %s", exc)
754
+ return written
755
+ return written
756
+
757
+
758
+ # ---------------------------------------------------------------------------
759
+ # helpers
760
+ # ---------------------------------------------------------------------------
761
+
762
+
763
+ def _iso_from_ms(ms: int) -> str:
764
+ """UTC ISO-8601 timestamp from epoch milliseconds.
765
+
766
+ SEC-GTH-01 / S-G-02 — use strict ISO-8601 (``T`` separator + ``Z``
767
+ suffix) so downstream pandas/datetime parsing treats the value as
768
+ UTC-aware and cannot mis-read it as local time.
769
+ """
770
+ secs = ms / 1000.0
771
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(secs))
772
+
773
+
774
+ __all__ = (
775
+ "EngagementRewardModel",
776
+ "_compute_label",
777
+ )