superlocalmemory 3.4.18 → 3.4.21

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 (172) hide show
  1. package/CHANGELOG.md +35 -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 +3 -2
  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 +219 -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/embeddings.py +8 -2
  31. package/src/superlocalmemory/core/engine.py +38 -2
  32. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  33. package/src/superlocalmemory/core/ram_lock.py +111 -0
  34. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  35. package/src/superlocalmemory/core/recall_worker.py +8 -3
  36. package/src/superlocalmemory/core/security_primitives.py +635 -0
  37. package/src/superlocalmemory/core/shadow_router.py +319 -0
  38. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  39. package/src/superlocalmemory/core/slmignore.py +125 -0
  40. package/src/superlocalmemory/core/topic_signature.py +143 -0
  41. package/src/superlocalmemory/core/worker_pool.py +14 -3
  42. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  43. package/src/superlocalmemory/evolution/budget.py +321 -0
  44. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  45. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  46. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  47. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  48. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  49. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  50. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  51. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  52. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  53. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  54. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  55. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  56. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  57. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  58. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  59. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  60. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  61. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  62. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  63. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  64. package/src/superlocalmemory/infra/backup.py +3 -3
  65. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  66. package/src/superlocalmemory/infra/event_bus.py +2 -2
  67. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  68. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  69. package/src/superlocalmemory/learning/bandit.py +526 -0
  70. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  71. package/src/superlocalmemory/learning/behavioral.py +53 -1
  72. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  73. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  74. package/src/superlocalmemory/learning/database.py +256 -0
  75. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  76. package/src/superlocalmemory/learning/ensemble.py +300 -0
  77. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  78. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  79. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  80. package/src/superlocalmemory/learning/labeler.py +87 -0
  81. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  82. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  83. package/src/superlocalmemory/learning/model_cache.py +269 -0
  84. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  85. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  86. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  87. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  88. package/src/superlocalmemory/learning/ranker.py +225 -81
  89. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  91. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  92. package/src/superlocalmemory/learning/reward.py +777 -0
  93. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  94. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  96. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  97. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  98. package/src/superlocalmemory/learning/signals.py +314 -0
  99. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  100. package/src/superlocalmemory/mcp/server.py +5 -5
  101. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  102. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  103. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  104. package/src/superlocalmemory/retrieval/engine.py +52 -0
  105. package/src/superlocalmemory/retrieval/reranker.py +4 -2
  106. package/src/superlocalmemory/server/api.py +2 -2
  107. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  108. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  109. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  110. package/src/superlocalmemory/server/routes/backup.py +36 -13
  111. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  112. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  113. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  114. package/src/superlocalmemory/server/routes/events.py +2 -2
  115. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  116. package/src/superlocalmemory/server/routes/learning.py +192 -7
  117. package/src/superlocalmemory/server/routes/memories.py +189 -1
  118. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  119. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  120. package/src/superlocalmemory/server/routes/token.py +88 -0
  121. package/src/superlocalmemory/server/routes/ws.py +5 -5
  122. package/src/superlocalmemory/server/security_middleware.py +13 -7
  123. package/src/superlocalmemory/server/ui.py +2 -2
  124. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  125. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  126. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  127. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  128. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  129. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  130. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  131. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  132. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  133. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  134. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  135. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  136. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  137. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  138. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  139. package/src/superlocalmemory/storage/models.py +4 -0
  140. package/src/superlocalmemory/ui/css/brain.css +409 -0
  141. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  142. package/src/superlocalmemory/ui/index.html +459 -1345
  143. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  144. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  145. package/src/superlocalmemory/ui/js/init.js +48 -39
  146. package/src/superlocalmemory/ui/js/memories.js +88 -2
  147. package/src/superlocalmemory/ui/js/modal.js +71 -1
  148. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  149. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  153. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  154. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  155. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  157. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  159. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  160. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  161. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  162. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  163. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  164. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  165. package/src/superlocalmemory/ui/js/learning.js +0 -435
  166. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  167. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  168. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  169. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  170. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  171. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  172. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,223 @@
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.21 — Track A.2 (LLD-09 / LLD-00)
4
+
5
+ """PostToolUse hook — detect fact usage + write engagement signal.
6
+
7
+ Flow (hot path, <10 ms typical, <20 ms hard):
8
+ 1. Read Claude Code JSON from stdin.
9
+ 2. Resolve session_id via ``safe_resolve_identifier`` (LLD-00 §4).
10
+ 3. Cap tool_response to 100 KB (bounded scan, LLD-09 §7 failure-mode #4).
11
+ 4. Extract HMAC markers (``slm:fact:<id>:<hmac8>``) — validate each
12
+ (LLD-00 §3). Bare substring scans are **banned** by the Stage-5b
13
+ CI gate.
14
+ 5. For each validated fact_id, find a pending_outcomes row where
15
+ ``session_id`` matches AND ``fact_ids_json`` includes the fact_id
16
+ AND ``status='pending'`` — call ``register_signal(outcome_id,
17
+ signal_name, True)``. ``signal_name`` is ``'edit'`` for
18
+ mutating tools (Edit/Write/NotebookEdit), else ``'dwell_ms'`` with
19
+ a nominal 3000 ms value.
20
+ 6. Always emit ``{}`` on stdout and return 0. NEVER raise.
21
+
22
+ Crash-safety (LLD-09 §6):
23
+ - Outer try/except around every code path. stderr breadcrumb (no
24
+ stack trace, no payload echo). Always exit 0.
25
+ - SQLite ``busy_timeout=50`` → fast-fail on DB contention.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ import sys
32
+ import time
33
+ from pathlib import Path
34
+
35
+ from superlocalmemory.hooks._outcome_common import (
36
+ emit_empty_json,
37
+ log_perf,
38
+ memory_db_path as _memory_db_path_fn,
39
+ now_ms,
40
+ open_memory_db,
41
+ read_stdin_json,
42
+ session_state_file,
43
+ summarize_response,
44
+ )
45
+
46
+
47
+ _HOOK_NAME = "post_tool_outcome"
48
+
49
+ # Monkey-patchable indirection for tests.
50
+ def _memory_db_path() -> Path:
51
+ return _memory_db_path_fn()
52
+
53
+
54
+ # Tools that imply an "edit" signal (the agent acted on the fact).
55
+ _EDIT_TOOLS = frozenset({"Edit", "Write", "NotebookEdit"})
56
+
57
+ # Nominal dwell value for non-edit tool uses that hit a marker.
58
+ # The label formula clamps 2s..10s → 0.05..0.15 reward bonus.
59
+ _DEFAULT_DWELL_MS = 3000
60
+
61
+ # Marker regex — mirrors recall_pipeline._emit_marker but scoped locally
62
+ # so this module has no hot-path import of the full recall pipeline.
63
+ #
64
+ # S-L04 — ``fact_id`` is constrained to a conservative alphabet
65
+ # (alphanumerics, ``-`` and ``_``). The previous ``[^:\s]+`` allowed
66
+ # colons and let a malicious tool response emit markers like
67
+ # ``slm:fact:evil:deadbeef:abcdef01`` that the regex grouped wrong and
68
+ # still handed off to the validator. Defence-in-depth: the HMAC
69
+ # validator already rejects these, but disallowing colons keeps garbage
70
+ # from reaching it in the first place. The HMAC suffix stays lowercase
71
+ # hex (matches ``recall_pipeline._emit_marker``).
72
+ _MARKER_RE = re.compile(r"slm:fact:([A-Za-z0-9_\-]+):([0-9a-f]{8})")
73
+
74
+
75
+ def _validate(marker: str) -> str | None:
76
+ """Delegate to the canonical validator (LLD-00 §3)."""
77
+ try:
78
+ from superlocalmemory.core.recall_pipeline import _validate_marker
79
+ except Exception:
80
+ return None
81
+ try:
82
+ return _validate_marker(marker)
83
+ except Exception:
84
+ return None
85
+
86
+
87
+ def _inner_main() -> str:
88
+ """Return an ``outcome`` string (for perf log); never raises."""
89
+ payload = read_stdin_json()
90
+ if payload is None:
91
+ return "invalid_payload"
92
+
93
+ session_id = payload.get("session_id")
94
+ tool_name = payload.get("tool_name") or ""
95
+ if not isinstance(session_id, str) or not session_id:
96
+ return "no_session"
97
+
98
+ # S9-DASH-10: keep registry fresh on every PostToolUse so the MCP
99
+ # server can pick up the current session even mid-turn.
100
+ try:
101
+ from superlocalmemory.hooks.session_registry import mark_active
102
+ mark_active(session_id, agent_type="claude")
103
+ except Exception:
104
+ pass
105
+
106
+ # Path-escape defence (SEC-C-02) — any unsafe session_id means we
107
+ # must not touch the filesystem for this invocation. We still want
108
+ # to safely query the DB (it uses parameterised SQL), so we only
109
+ # gate the filesystem branch.
110
+ _ = session_state_file(session_id) # None → caller skips FS writes
111
+ # Note: for post_tool_outcome we do NOT need to write session state.
112
+ # Rehash / stop hooks are the writers/readers.
113
+
114
+ # Response scan — capped BEFORE regex (bound O(cap)).
115
+ response_text = summarize_response(payload.get("tool_response"))
116
+ if not response_text:
117
+ return "no_response"
118
+
119
+ # Fast pre-check: if the HMAC prefix is absent, no marker can exist.
120
+ if "slm:fact:" not in response_text:
121
+ return "no_marker"
122
+
123
+ # S9-W2 M-SEC-03: cap marker iteration to prevent adversarial
124
+ # response_text floods. 5,000 crafted markers × ~5 μs HMAC = 25 ms
125
+ # of CPU inside a 20 ms hook budget — enough to cascade budget
126
+ # misses. LLD-09 says ≤10 facts per recall; 100 is ample headroom.
127
+ _MAX_MARKERS = 100
128
+ hits: list[str] = []
129
+ for m in _MARKER_RE.finditer(response_text):
130
+ if len(hits) >= _MAX_MARKERS:
131
+ break
132
+ marker = m.group(0)
133
+ fact_id = _validate(marker)
134
+ if fact_id:
135
+ hits.append(fact_id)
136
+ if not hits:
137
+ return "no_validated_marker"
138
+
139
+ # Persist signals via the canonical reward model — the DB write is
140
+ # behind ``register_signal`` which enforces the schema contract and
141
+ # the pending→settled state machine.
142
+ try:
143
+ from superlocalmemory.learning.reward import EngagementRewardModel
144
+ except Exception:
145
+ return "import_fail"
146
+
147
+ signal_name = "edit" if tool_name in _EDIT_TOOLS else "dwell_ms"
148
+ signal_value: object = True if signal_name == "edit" else _DEFAULT_DWELL_MS
149
+
150
+ # S9-W3 C6: single connection for BOTH the pending-row match AND
151
+ # the signal writes. Previously the hook opened ``open_memory_db()``
152
+ # for the SELECT, closed it, then constructed EngagementRewardModel
153
+ # which cached its own writer — two connects per invocation × 1-4 ms
154
+ # each × FileVault contention = blown 20 ms hook budget.
155
+ #
156
+ # H-SKEP-03 / H-ARC-H4: pending-row window raised back to 50
157
+ # (SEC-M2 had tightened it to 5 which silently dropped signals on
158
+ # heavy Claude Code sessions). Outer cap on returned outcome_ids
159
+ # caps UPDATE amplification at PENDING_WRITE_CAP × 10 by default.
160
+ try:
161
+ model = EngagementRewardModel(_memory_db_path())
162
+ except Exception:
163
+ return "model_init_fail"
164
+
165
+ try:
166
+ target_outcome_ids = model.match_pending_for_fact_ids(
167
+ session_id=session_id, fact_ids=hits,
168
+ )
169
+ if not target_outcome_ids:
170
+ # Distinguish "no pending rows exist" from "rows exist but
171
+ # none matched" for perf-log observability.
172
+ with model._lock:
173
+ conn = model._get_conn()
174
+ has_pending = conn.execute(
175
+ "SELECT 1 FROM pending_outcomes "
176
+ "WHERE session_id = ? AND status = 'pending' "
177
+ "LIMIT 1",
178
+ (session_id,),
179
+ ).fetchone()
180
+ return "no_match" if has_pending else "no_pending"
181
+
182
+ wrote = 0
183
+ for oid in target_outcome_ids:
184
+ ok = model.register_signal(
185
+ outcome_id=oid,
186
+ signal_name=signal_name,
187
+ signal_value=signal_value,
188
+ )
189
+ if ok:
190
+ wrote += 1
191
+ return f"signal_{signal_name}_x{wrote}"
192
+ finally:
193
+ try:
194
+ model.close()
195
+ except Exception:
196
+ pass
197
+
198
+
199
+ def main() -> int:
200
+ """Hook entry point — stdin JSON → signals_json update. Always exits 0."""
201
+ t0 = time.perf_counter()
202
+ outcome = "exception"
203
+ try:
204
+ outcome = _inner_main()
205
+ except Exception as exc: # pragma: no cover — defensive
206
+ try:
207
+ sys.stderr.write(
208
+ f"slm-hook {_HOOK_NAME}: {type(exc).__name__}\n"
209
+ )
210
+ except Exception:
211
+ pass
212
+ finally:
213
+ duration_ms = (time.perf_counter() - t0) * 1000.0
214
+ emit_empty_json()
215
+ try:
216
+ log_perf(_HOOK_NAME, duration_ms, outcome)
217
+ except Exception:
218
+ pass
219
+ return 0
220
+
221
+
222
+ if __name__ == "__main__": # pragma: no cover — CLI entry only
223
+ sys.exit(main())
@@ -0,0 +1,170 @@
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.21 — LLD-01 §4.5
4
+
5
+ """Authentication primitives for the /internal/prewarm daemon route.
6
+
7
+ LLD reference: `.backup/active-brain/lld/LLD-01-context-cache-and-hot-path-hooks.md`
8
+ Section 4.5.
9
+
10
+ Four gates, applied in order, BEFORE any engine work:
11
+ 1. Loopback-only — client address must be 127.0.0.1 / ::1.
12
+ 2. Origin-header CSRF guard — browsers always send Origin on CORS
13
+ requests; hooks using stdlib urllib do not. Present Origin ⇒ reject.
14
+ 3. Install-token match — X-SLM-Hook-Token constant-time compared to
15
+ the bytes stored at ``~/.superlocalmemory/.install_token``.
16
+ 4. Body-size cap — requests > ``MAX_BODY_BYTES`` rejected upfront.
17
+
18
+ Framework-agnostic. The FastAPI route composes these primitives; tests
19
+ exercise them without starting an HTTP server.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Mapping
26
+
27
+ from superlocalmemory.core.security_primitives import verify_install_token
28
+
29
+ # Headers we consider equivalent for the install-token lookup. Covers the
30
+ # common normalizations (exact, lowercase, title-case).
31
+ _TOKEN_HEADER_VARIANTS: tuple[str, ...] = (
32
+ "X-SLM-Hook-Token",
33
+ "x-slm-hook-token",
34
+ "X-Slm-Hook-Token",
35
+ )
36
+
37
+ _ORIGIN_HEADER_VARIANTS: tuple[str, ...] = ("Origin", "origin")
38
+
39
+ # Loopback addresses accepted by LLD-01. ``localhost`` is NOT included per
40
+ # SEC-01-02 — we want literal IPs only to avoid DNS-based bypass tricks.
41
+ _LOOPBACK_ADDRS: frozenset[str] = frozenset({"127.0.0.1", "::1"})
42
+
43
+ # Body-size cap: LLD-01 §4.5 step 4 → 8 KB.
44
+ MAX_BODY_BYTES: int = 8 * 1024
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class AuthDecision:
49
+ """Outcome of ``authorize``.
50
+
51
+ - ``allowed`` — True when the request passes every gate.
52
+ - ``status`` — suggested HTTP status when ``allowed`` is False
53
+ (``200`` otherwise).
54
+ - ``reason`` — short machine-readable tag. Never echoes secrets.
55
+ """
56
+
57
+ allowed: bool
58
+ status: int
59
+ reason: str = ""
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Gate 1: Loopback-only
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def is_loopback(client_host: str) -> bool:
68
+ """Return True iff ``client_host`` is an accepted loopback literal."""
69
+ if not isinstance(client_host, str) or not client_host:
70
+ return False
71
+ return client_host in _LOOPBACK_ADDRS
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Gate 2: Origin CSRF guard
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def is_browser_originated(headers: Mapping[str, str]) -> bool:
80
+ """True if the request carries a non-empty ``Origin`` header.
81
+
82
+ Defensive against accidental case variants. We treat an explicit empty
83
+ Origin as non-browser per LLD-01 §4.5 — real browsers always send a
84
+ non-empty origin on cross-origin requests.
85
+ """
86
+ if not headers:
87
+ return False
88
+ for name in _ORIGIN_HEADER_VARIANTS:
89
+ val = headers.get(name)
90
+ if val:
91
+ return True
92
+ return False
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Gate 3: Install-token
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def _extract_token(headers: Mapping[str, str]) -> str:
101
+ """Return the presented X-SLM-Hook-Token across casing variants."""
102
+ if not headers:
103
+ return ""
104
+ for name in _TOKEN_HEADER_VARIANTS:
105
+ val = headers.get(name)
106
+ if val:
107
+ return val
108
+ return ""
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Gate 4: Body size
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ def check_body_size(body: bytes) -> tuple[bool, str]:
117
+ """Verify request body is within ``MAX_BODY_BYTES``.
118
+
119
+ Returns ``(True, "")`` on pass and ``(False, reason)`` on fail.
120
+ """
121
+ if not isinstance(body, (bytes, bytearray)):
122
+ return False, "body must be bytes"
123
+ if len(body) > MAX_BODY_BYTES:
124
+ return False, f"body size {len(body)} exceeds {MAX_BODY_BYTES}"
125
+ return True, ""
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Composite authorize()
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def authorize(
134
+ *,
135
+ client_host: str,
136
+ headers: Mapping[str, str],
137
+ ) -> AuthDecision:
138
+ """Run gates 1 → 2 → 3 in order and return the first failure.
139
+
140
+ Order rationale:
141
+ - Loopback check runs first so we reject off-host traffic with 403
142
+ before touching any user-supplied header material.
143
+ - Origin check runs second to neutralize browser-driven CSRF even
144
+ when the attacker somehow obtained the install token.
145
+ - Token check runs last; constant-time compared via
146
+ ``verify_install_token``.
147
+ """
148
+ if not is_loopback(client_host):
149
+ return AuthDecision(False, 403, "loopback only")
150
+
151
+ if is_browser_originated(headers):
152
+ return AuthDecision(False, 403, "origin header not allowed")
153
+
154
+ token = _extract_token(headers)
155
+ if not token:
156
+ return AuthDecision(False, 401, "unauthorized: missing token")
157
+ if not verify_install_token(token):
158
+ return AuthDecision(False, 401, "unauthorized: token mismatch")
159
+
160
+ return AuthDecision(True, 200, "")
161
+
162
+
163
+ __all__ = (
164
+ "AuthDecision",
165
+ "MAX_BODY_BYTES",
166
+ "authorize",
167
+ "check_body_size",
168
+ "is_browser_originated",
169
+ "is_loopback",
170
+ )
@@ -0,0 +1,186 @@
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.21 — S9-DASH-10
4
+
5
+ """Lightweight session registry for cross-process session_id handoff.
6
+
7
+ **Problem.** Claude Code (and Cursor/Antigravity) invoke two separate
8
+ SLM surfaces per user turn:
9
+
10
+ 1. ``user_prompt_hook`` — receives ``session_id`` via stdin JSON
11
+ (Claude Code's hook payload). This is the real session id.
12
+ 2. MCP ``recall`` tool — invoked by the AI mid-turn. The MCP protocol
13
+ does NOT thread ``CLAUDE_SESSION_ID`` into tool arguments by
14
+ default, so the MCP tool cannot see what session it is serving.
15
+
16
+ Result: ``record_recall`` writes ``pending_outcomes`` with
17
+ ``session_id='mcp:mcp_client'`` while the Stop hook queries by the
18
+ real session id — they never match, so cite/edit/dwell signals are
19
+ lost (reaper finalizes everything at neutral 0.5).
20
+
21
+ **Fix (this module).** A simple file-based registry:
22
+
23
+ * ``mark_active(session_id, agent_type)`` — called by hooks on every
24
+ prompt/tool event. Writes ``(session_id, agent_type, ts_ns, pid)``
25
+ to ``~/.superlocalmemory/.active_sessions.json``.
26
+ * ``most_recent_active(agent_type, within_seconds=60)`` — queries the
27
+ registry for the most recently seen session of the named agent.
28
+ MCP uses this as the default when the tool caller omits
29
+ ``session_id``.
30
+
31
+ Concurrency: one reader/writer lock (``fcntl.flock``) serialises
32
+ updates. Rollover: entries older than 1 hour are pruned on every
33
+ write. Fail-soft: every error path returns empty or the passed
34
+ default — the learning loop must never crash the hot path.
35
+
36
+ This is not a perfect correlation channel; two Claude sessions
37
+ typing in the same second can race. For single-user workstations
38
+ (the overwhelming SLM case) it is 99%+ accurate.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import logging
45
+ import os
46
+ import time
47
+ from pathlib import Path
48
+ from typing import Optional
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ _REGISTRY_FILE = Path.home() / ".superlocalmemory" / ".active_sessions.json"
54
+ _PRUNE_AFTER_SEC = 3600 # 1h — anything older is dead
55
+
56
+
57
+ def _now_ns() -> int:
58
+ return time.time_ns()
59
+
60
+
61
+ def _load() -> dict:
62
+ try:
63
+ if not _REGISTRY_FILE.exists():
64
+ return {}
65
+ return json.loads(_REGISTRY_FILE.read_text(encoding="utf-8"))
66
+ except Exception:
67
+ return {}
68
+
69
+
70
+ def _save(data: dict) -> None:
71
+ try:
72
+ _REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
73
+ tmp = _REGISTRY_FILE.with_suffix(
74
+ f".{os.getpid()}.{time.time_ns()}.tmp",
75
+ )
76
+ tmp.write_text(json.dumps(data), encoding="utf-8")
77
+ os.replace(tmp, _REGISTRY_FILE)
78
+ try:
79
+ os.chmod(_REGISTRY_FILE, 0o600)
80
+ except OSError:
81
+ pass
82
+ except Exception as exc: # pragma: no cover — defensive
83
+ logger.debug("session_registry save failed: %s", exc)
84
+
85
+
86
+ def _prune(data: dict) -> dict:
87
+ cutoff_ns = _now_ns() - (_PRUNE_AFTER_SEC * 1_000_000_000)
88
+ return {
89
+ sid: row for sid, row in data.items()
90
+ if isinstance(row, dict) and int(row.get("ts_ns", 0)) >= cutoff_ns
91
+ }
92
+
93
+
94
+ def mark_active(
95
+ session_id: str,
96
+ agent_type: str = "claude",
97
+ ) -> None:
98
+ """Record ``session_id`` keyed by the CALLING process PID.
99
+
100
+ Called from UserPromptSubmit + PostToolUse hooks — those hooks run
101
+ INSIDE the Claude Code / IDE process. So ``os.getpid()`` is the
102
+ IDE's PID. The MCP server spawned BY that same IDE process has
103
+ ``os.getppid() == IDE_PID``. Keying by PID means two parallel
104
+ Claude Code windows never collide — each MCP server reads only
105
+ its own parent's entry.
106
+
107
+ Hot-path safe — returns within <2 ms on a warm cache. Never raises.
108
+ """
109
+ if not session_id or not isinstance(session_id, str):
110
+ return
111
+ try:
112
+ data = _load()
113
+ key = str(os.getpid()) # the IDE / hook process PID
114
+ data[key] = {
115
+ "session_id": session_id,
116
+ "agent_type": agent_type or "unknown",
117
+ "ts_ns": _now_ns(),
118
+ }
119
+ data = _prune(data)
120
+ _save(data)
121
+ except Exception as exc: # pragma: no cover — defensive
122
+ logger.debug("mark_active failed: %s", exc)
123
+
124
+
125
+ def lookup_by_parent(within_seconds: int = 60) -> Optional[str]:
126
+ """Return the session_id whose registry key == ``os.getppid()``.
127
+
128
+ Called from the MCP server process. ``os.getppid()`` is the PID of
129
+ the IDE that spawned the MCP server — exactly the same PID that
130
+ the hook used as its key in ``mark_active``. Collision-free across
131
+ multiple parallel Claude Code / IDE sessions.
132
+ """
133
+ try:
134
+ parent_key = str(os.getppid())
135
+ data = _load()
136
+ row = data.get(parent_key)
137
+ if not isinstance(row, dict):
138
+ return None
139
+ ts = int(row.get("ts_ns", 0))
140
+ if _now_ns() - ts > within_seconds * 1_000_000_000:
141
+ return None # stale — IDE likely restarted
142
+ return row.get("session_id") or None
143
+ except Exception:
144
+ return None
145
+
146
+
147
+ def most_recent_active(
148
+ agent_type: Optional[str] = None,
149
+ within_seconds: int = 60,
150
+ ) -> Optional[str]:
151
+ """Fallback: most-recently-written entry of the given agent_type.
152
+
153
+ Used by surfaces that DON'T have a stable parent-PID linkage (e.g.
154
+ CLI tools invoked ad-hoc). Prefer ``lookup_by_parent`` for MCP.
155
+ """
156
+ try:
157
+ data = _load()
158
+ if not data:
159
+ return None
160
+ cutoff_ns = _now_ns() - (within_seconds * 1_000_000_000)
161
+ candidates = []
162
+ for _key, row in data.items():
163
+ if not isinstance(row, dict):
164
+ continue
165
+ ts = int(row.get("ts_ns", 0))
166
+ if ts < cutoff_ns:
167
+ continue
168
+ if agent_type and row.get("agent_type") != agent_type:
169
+ continue
170
+ sid = row.get("session_id")
171
+ if sid:
172
+ candidates.append((ts, sid))
173
+ if not candidates:
174
+ return None
175
+ candidates.sort(reverse=True)
176
+ return candidates[0][1]
177
+ except Exception:
178
+ return None
179
+
180
+
181
+ def _reset_for_testing() -> None:
182
+ """TEST-ONLY: wipe registry."""
183
+ try:
184
+ _REGISTRY_FILE.unlink(missing_ok=True)
185
+ except Exception:
186
+ pass