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,506 @@
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.2 (LLD-09)
4
+
5
+ """Shared helpers for the three outcome-population hooks (LLD-09).
6
+
7
+ All helpers are stdlib-only, never raise, and bound their work by budget.
8
+ Used by:
9
+ - ``post_tool_outcome_hook`` (hot path, <10 ms typical, <20 ms hard)
10
+ - ``user_prompt_rehash_hook`` (hot path, <10 ms typical, <20 ms hard)
11
+ - ``stop_outcome_hook`` (session-end, <500 ms typical, <1 s hard)
12
+
13
+ Contract refs:
14
+ - LLD-00 §1.2 — pending_outcomes lives in memory.db, NOT cache.db.
15
+ - LLD-00 §3 — HMAC marker validator for fact_id matching.
16
+ - LLD-00 §4 — safe_resolve_identifier for any path built from session_id.
17
+ - MASTER-PLAN §2 I1 — hot-path p95 budget.
18
+
19
+ This module is the single source of truth for:
20
+ 1. Locating memory.db (respecting SLM_HOME override used in tests).
21
+ 2. Opening a short-lived sqlite3 connection with busy_timeout=50.
22
+ 3. Reading/writing session_state/<session_id>.json with path-escape
23
+ defence.
24
+ 4. Appending one NDJSON line to logs/hook-perf.log.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import atexit
30
+ import json
31
+ import os
32
+ import sqlite3
33
+ import sys
34
+ import threading
35
+ import time
36
+ from pathlib import Path
37
+ from typing import IO, Optional
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Budget constants
42
+ # ---------------------------------------------------------------------------
43
+
44
+ #: Hot-path SQLite busy timeout (ms). Fail fast rather than block a host tool.
45
+ BUSY_TIMEOUT_MS: int = 50
46
+
47
+ #: Cap on tool_response bytes scanned — bounds substring work to O(100 KB).
48
+ SCAN_BYTES_CAP: int = 100_000
49
+
50
+ #: Re-query detection window (ms). Outside → no signal.
51
+ REQUERY_WINDOW_MS: int = 60_000
52
+
53
+ # SEC-M4 — perf log rotation. Cap at 10 MB; keep one rotated copy
54
+ # (``hook-perf.log.1``). Bounds disk growth + limits info-disclosure
55
+ # window on multi-year retention.
56
+ PERF_LOG_MAX_BYTES: int = 10 * 1024 * 1024
57
+ PERF_LOG_CHECK_EVERY: int = 256 # check size every N writes, not every write
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Paths
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def slm_home() -> Path:
66
+ """Return ``~/.superlocalmemory`` honouring ``SLM_HOME`` override.
67
+
68
+ ``SLM_HOME`` exists solely so unit tests can isolate filesystem state.
69
+ Production code sets nothing and falls back to the home-directory path.
70
+
71
+ SEC-M6 — first-creation chmod's the dir to 0700 so the audit marker
72
+ in ``ram_lock.sem`` (``{pid}:{name}``) and session-state files are
73
+ not world-readable on shared hosts.
74
+ """
75
+ override = os.environ.get("SLM_HOME", "").strip()
76
+ base = Path(override) if override else (Path.home() / ".superlocalmemory")
77
+ try:
78
+ if not base.exists():
79
+ base.mkdir(parents=True, exist_ok=True)
80
+ if os.name == "posix":
81
+ os.chmod(base, 0o700) # SEC-M6
82
+ except Exception: # pragma: no cover — read-only fs / perms
83
+ pass
84
+ return base
85
+
86
+
87
+ def memory_db_path() -> Path:
88
+ """Canonical memory.db path (hosts pending_outcomes + action_outcomes)."""
89
+ return slm_home() / "memory.db"
90
+
91
+
92
+ def session_state_dir() -> Path:
93
+ """Per-session JSON state directory (created on demand).
94
+
95
+ SEC-M3 — chmod 0700 so session_state/*.json (topic_sig, outcome_id)
96
+ side-channels are not readable by other UIDs.
97
+ """
98
+ d = slm_home() / "session_state"
99
+ try:
100
+ d.mkdir(parents=True, exist_ok=True)
101
+ if os.name == "posix":
102
+ os.chmod(d, 0o700) # SEC-M3
103
+ except Exception: # pragma: no cover — disk full / ro fs
104
+ pass
105
+ return d
106
+
107
+
108
+ def perf_log_path() -> Path:
109
+ d = slm_home() / "logs"
110
+ try:
111
+ d.mkdir(parents=True, exist_ok=True)
112
+ if os.name == "posix":
113
+ os.chmod(d, 0o700) # SEC-M4 — logs dir private
114
+ except Exception: # pragma: no cover
115
+ pass
116
+ return d / "hook-perf.log"
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # SQLite — short-lived connection with busy_timeout
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def open_memory_db() -> sqlite3.Connection:
125
+ """Open memory.db with the hot-path busy timeout + autocommit.
126
+
127
+ Caller is responsible for ``close()``. We intentionally do NOT enable
128
+ WAL here — the daemon already set it on first boot; hooks are writers
129
+ to a WAL DB and must not flip the journal mode under a live daemon.
130
+ """
131
+ conn = sqlite3.connect(
132
+ str(memory_db_path()),
133
+ timeout=2.0,
134
+ isolation_level=None, # autocommit — each statement is its own txn
135
+ )
136
+ conn.execute(f"PRAGMA busy_timeout={BUSY_TIMEOUT_MS}")
137
+ conn.row_factory = sqlite3.Row
138
+ return conn
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Session state — path-escape-hardened read/write
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ def session_state_file(session_id: str) -> Path | None:
147
+ """Resolve ``<session_state_dir>/<session_id>.json`` via the LLD-00
148
+ §4 identifier validator. Returns ``None`` if ``session_id`` is unsafe.
149
+ """
150
+ try:
151
+ from superlocalmemory.core.security_primitives import (
152
+ safe_resolve_identifier,
153
+ )
154
+ except Exception: # pragma: no cover — SLM import broken
155
+ return None
156
+ base = session_state_dir()
157
+ try:
158
+ path = safe_resolve_identifier(base, session_id)
159
+ except ValueError:
160
+ return None
161
+ return path.with_suffix(".json") if path.suffix != ".json" else path
162
+
163
+
164
+ def load_session_state(session_id: str) -> dict:
165
+ """Read session state JSON; ``{}`` on any failure."""
166
+ p = session_state_file(session_id)
167
+ if p is None or not p.exists():
168
+ return {}
169
+ try:
170
+ raw = p.read_text()
171
+ obj = json.loads(raw)
172
+ if isinstance(obj, dict):
173
+ return obj
174
+ except Exception:
175
+ return {}
176
+ return {}
177
+
178
+
179
+ def save_session_state(session_id: str, state: dict) -> None:
180
+ """Persist session state JSON (best-effort; never raises).
181
+
182
+ # H-12/M-P-06: atomic temp-file + os.replace so a hook killed
183
+ # mid-write cannot leave a truncated JSON on disk. A truncated file
184
+ # would make ``load_session_state`` return ``{}`` and silently
185
+ # forfeit the rehash signal on the next turn.
186
+
187
+ # S9-W2 H-SEC-07: tmp file now opens with mode 0600 via os.open so a
188
+ # shared-host observer watching the dir with inotify cannot read the
189
+ # session state (outcome_id, last_prompt_ts) between write_text and
190
+ # os.replace. Previously ``Path.write_text`` opened at 0666 & ~umask
191
+ # (typically 0644) leaving the data world-readable for ~microseconds.
192
+ # Also makes the tmp filename per-pid + nanosecond unique so two
193
+ # concurrent hooks don't overwrite each other's tmp (M-SKEP-03
194
+ # data-tearing).
195
+ """
196
+ p = session_state_file(session_id)
197
+ if p is None:
198
+ return
199
+ try:
200
+ data = json.dumps(state)
201
+ tmp = p.with_suffix(
202
+ f"{p.suffix}.{os.getpid()}.{time.time_ns()}.tmp"
203
+ )
204
+ if os.name == "posix":
205
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
206
+ if hasattr(os, "O_NOFOLLOW"):
207
+ flags |= os.O_NOFOLLOW
208
+ fd = os.open(str(tmp), flags, 0o600)
209
+ try:
210
+ os.write(fd, data.encode("utf-8"))
211
+ finally:
212
+ os.close(fd)
213
+ else: # pragma: no cover — Windows path
214
+ tmp.write_text(data)
215
+ os.replace(tmp, p)
216
+ except Exception:
217
+ # Best-effort cleanup of orphaned tmp — M-PERF-05.
218
+ try:
219
+ if tmp.exists(): # type: ignore[name-defined]
220
+ tmp.unlink()
221
+ except Exception: # pragma: no cover
222
+ pass
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Tool-response size guard
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ def summarize_response(raw: object, cap: int = SCAN_BYTES_CAP) -> str:
231
+ """Coerce ``raw`` to a string capped at ``cap`` bytes (UTF-8 safe).
232
+
233
+ Claude Code passes tool_response as a string OR a structured blob; we
234
+ str()-ify as a defensive fallback. The cap is applied before any
235
+ regex / substring scan so the hot-path cost is O(cap) regardless of
236
+ input size (failure mode #4 in LLD-09 §7).
237
+ """
238
+ if raw is None:
239
+ return ""
240
+ if not isinstance(raw, str):
241
+ try:
242
+ raw = json.dumps(raw, default=str)
243
+ except Exception:
244
+ try:
245
+ raw = str(raw)
246
+ except Exception:
247
+ return ""
248
+ if len(raw) <= cap:
249
+ return raw
250
+ return raw[:cap]
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # Perf log (NDJSON append, best-effort)
255
+ # ---------------------------------------------------------------------------
256
+
257
+
258
+ # M-P-01: module-level append-only fd + atexit flush/close. Previously
259
+ # ``log_perf`` opened and closed the perf log on every invocation. At
260
+ # 20 tool-events/min × 8 h that was ~9.6k gratuitous APFS metadata
261
+ # round-trips per day. The shared fd is guarded by a lock because long-
262
+ # lived daemons may call ``log_perf`` from multiple threads; POSIX
263
+ # ``write()`` is atomic for payloads ≤ PIPE_BUF but our lock keeps us
264
+ # safe across platforms and captures a post-rotation reopen cleanly.
265
+ _PERF_LOG_FD: Optional[IO[str]] = None
266
+ _PERF_LOG_PATH: Optional[Path] = None
267
+ # S9-W3 M-PERF-02: RLock (not Lock) so a reentrant acquire during
268
+ # atexit shutdown — e.g. a handler that calls ``log_perf`` while
269
+ # ``_perf_log_flush`` already holds the lock — does not deadlock
270
+ # the interpreter for the 30s graceful-shutdown timeout.
271
+ _PERF_LOG_LOCK = threading.RLock()
272
+ _PERF_LOG_WRITE_COUNT: int = 0 # SEC-M4 — rotation cadence counter
273
+ _PERF_LOG_OWNER_PID: int | None = None # S9-W2 H-SEC-05 — fork safety
274
+
275
+
276
+ def _reset_perf_log_for_child() -> None:
277
+ """S9-W2 H-SEC-05: wipe the inherited fd in the fork child.
278
+
279
+ Buffered file objects inherited across fork() interleave their
280
+ userland buffers when both processes flush to the same fd offset.
281
+ We orphan the child's handle so the next ``log_perf`` reopens a
282
+ fresh one; the parent keeps its fd intact.
283
+ """
284
+ global _PERF_LOG_FD, _PERF_LOG_PATH, _PERF_LOG_WRITE_COUNT, _PERF_LOG_OWNER_PID
285
+ _PERF_LOG_FD = None
286
+ _PERF_LOG_PATH = None
287
+ _PERF_LOG_WRITE_COUNT = 0
288
+ _PERF_LOG_OWNER_PID = os.getpid()
289
+
290
+
291
+ if hasattr(os, "register_at_fork"):
292
+ os.register_at_fork(after_in_child=_reset_perf_log_for_child)
293
+
294
+
295
+ def _open_perf_log_fd(path: Path) -> Optional[IO[str]]:
296
+ """Open the append-only perf-log fd at mode 0600 on POSIX.
297
+
298
+ SEC-M4 — log is private (info-disclosure surface). ``os.open`` is
299
+ used to set the mode on creation; we wrap the fd with fdopen so the
300
+ rest of the module sees a normal text file object.
301
+ """
302
+ try:
303
+ if os.name == "posix":
304
+ flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
305
+ fd_int = os.open(str(path), flags, 0o600)
306
+ # Harden existing files that may predate this change.
307
+ try:
308
+ os.chmod(path, 0o600)
309
+ except OSError: # pragma: no cover — perms
310
+ pass
311
+ return os.fdopen(fd_int, "a", encoding="utf-8", buffering=1)
312
+ return open(path, "a", encoding="utf-8", buffering=1)
313
+ except Exception: # pragma: no cover — disk full / perms
314
+ return None
315
+
316
+
317
+ def _maybe_rotate_perf_log(path: Path) -> None:
318
+ """Rotate ``hook-perf.log`` → ``hook-perf.log.1`` when over 10 MB.
319
+
320
+ SEC-M4 — called from ``log_perf`` under ``_PERF_LOG_LOCK`` every
321
+ ``PERF_LOG_CHECK_EVERY`` writes so the stat() cost is negligible
322
+ on the hot path. Single rotation slot (overwrite .1 if present).
323
+
324
+ S9-W2 H-SEC-06: rotation now uses ``os.replace`` instead of the
325
+ ``unlink() + rename()`` two-step. Two-step left a window between
326
+ the unlink and the rename during which a concurrent writer could
327
+ open a fresh fd at ``path`` and have its line land in the rotated
328
+ archive. ``os.replace`` is atomic on POSIX and Windows.
329
+ """
330
+ try:
331
+ size = path.stat().st_size
332
+ except OSError:
333
+ return
334
+ if size < PERF_LOG_MAX_BYTES:
335
+ return
336
+ rotated = path.with_suffix(path.suffix + ".1")
337
+ try:
338
+ os.replace(str(path), str(rotated))
339
+ except OSError: # pragma: no cover — fs race
340
+ pass
341
+
342
+
343
+ def _perf_log_flush() -> None:
344
+ """Flush the cached perf log fd (atexit hook). Never raises."""
345
+ global _PERF_LOG_FD
346
+ with _PERF_LOG_LOCK:
347
+ fd = _PERF_LOG_FD
348
+ _PERF_LOG_FD = None
349
+ if fd is None:
350
+ return
351
+ try:
352
+ fd.flush()
353
+ except Exception: # pragma: no cover
354
+ pass
355
+ try:
356
+ fd.close()
357
+ except Exception: # pragma: no cover
358
+ pass
359
+
360
+
361
+ atexit.register(_perf_log_flush)
362
+
363
+
364
+ #: S9-W3 C8: rotation flag set on hot path, drained on exit / next
365
+ #: call at no latency cost. Every 256th write flips the flag; the NEXT
366
+ #: invocation notices the flag and runs the rotation BEFORE acquiring
367
+ #: the write lock for its own record. Net effect: the hot-path caller
368
+ #: that trips the counter pays only a bool flip (not a rename + reopen);
369
+ #: the next caller pays the rotation cost but only once per 10 MB of
370
+ #: log traffic — amortised to essentially free on 20 tool-events/min.
371
+ _PERF_LOG_ROTATION_PENDING = False
372
+
373
+
374
+ def _drain_rotation_pending(path: Path) -> None:
375
+ """C8: execute a pending rotation outside the hot-path lock.
376
+
377
+ Runs the unlink/rename + fd reopen. Callers invoke it with the
378
+ lock released so concurrent hot-path writers are not blocked for
379
+ the 5-20 ms the rotation can take on a contended FS.
380
+ """
381
+ global _PERF_LOG_FD, _PERF_LOG_PATH, _PERF_LOG_ROTATION_PENDING
382
+ # Race-harmless double-check under the lock: if someone else
383
+ # already drained the flag, just return.
384
+ with _PERF_LOG_LOCK:
385
+ if not _PERF_LOG_ROTATION_PENDING:
386
+ return
387
+ _PERF_LOG_ROTATION_PENDING = False
388
+ fd_to_close = _PERF_LOG_FD
389
+ _PERF_LOG_FD = None
390
+ # Close + rotate + reopen without holding the lock.
391
+ if fd_to_close is not None:
392
+ try:
393
+ fd_to_close.close()
394
+ except Exception: # pragma: no cover
395
+ pass
396
+ _maybe_rotate_perf_log(path)
397
+ new_fd = _open_perf_log_fd(path)
398
+ with _PERF_LOG_LOCK:
399
+ # Another thread may have reopened already — don't clobber.
400
+ if _PERF_LOG_FD is None:
401
+ _PERF_LOG_FD = new_fd
402
+ _PERF_LOG_PATH = path
403
+
404
+
405
+ def log_perf(hook_name: str, duration_ms: float, outcome: str) -> None:
406
+ """Append one NDJSON line to ``logs/hook-perf.log``.
407
+
408
+ Best-effort: disk full / unwritable dir → silently skip. Uses a
409
+ module-level append-only fd opened on first use and flushed on
410
+ process exit via :func:`_perf_log_flush`.
411
+
412
+ S9-W3 C8: the rotation/rename/reopen workflow has moved OFF the
413
+ hot-path lock. Previously every 256th call held the lock across
414
+ ``stat + unlink + rename + os.open + os.chmod + fdopen`` — 5-20 ms
415
+ while every concurrent hook blocked. Now the hot-path branch only
416
+ flips a bool; the drain function runs the slow path after the
417
+ lock is released.
418
+ """
419
+ global _PERF_LOG_FD, _PERF_LOG_PATH, _PERF_LOG_WRITE_COUNT
420
+ global _PERF_LOG_ROTATION_PENDING
421
+ try:
422
+ rec = {
423
+ "ts": int(time.time() * 1000),
424
+ "hook": hook_name,
425
+ "duration_ms": round(duration_ms, 3),
426
+ "outcome": outcome,
427
+ }
428
+ line = json.dumps(rec, separators=(",", ":")) + "\n"
429
+ path = perf_log_path()
430
+
431
+ # Drain any rotation pending from a prior call. This runs
432
+ # BEFORE we take the hot-path lock so the current caller's
433
+ # write goes to the post-rotation file without waiting.
434
+ if _PERF_LOG_ROTATION_PENDING:
435
+ _drain_rotation_pending(path)
436
+
437
+ with _PERF_LOG_LOCK:
438
+ _PERF_LOG_WRITE_COUNT += 1
439
+ # SEC-M4 — amortised rotation check (now: flag-only under
440
+ # the lock; the slow path runs out-of-lock on the NEXT call).
441
+ if _PERF_LOG_WRITE_COUNT % PERF_LOG_CHECK_EVERY == 0:
442
+ _PERF_LOG_ROTATION_PENDING = True
443
+ # Reopen if first use OR if the target path has changed (tests
444
+ # flip ``SLM_HOME`` between cases — honour the new location).
445
+ if _PERF_LOG_FD is None or _PERF_LOG_PATH != path:
446
+ if _PERF_LOG_FD is not None:
447
+ try:
448
+ _PERF_LOG_FD.close()
449
+ except Exception: # pragma: no cover
450
+ pass
451
+ _PERF_LOG_FD = _open_perf_log_fd(path)
452
+ _PERF_LOG_PATH = path
453
+ fd = _PERF_LOG_FD
454
+ if fd is None:
455
+ return
456
+ fd.write(line)
457
+ except Exception: # pragma: no cover — disk full / perms
458
+ pass
459
+
460
+
461
+ # ---------------------------------------------------------------------------
462
+ # Entry-point helpers — shared exit-0 crash guard
463
+ # ---------------------------------------------------------------------------
464
+
465
+
466
+ def emit_empty_json() -> None:
467
+ """Write ``{}`` to stdout. Hooks are passive observers (LLD-09 §3.4)."""
468
+ try:
469
+ sys.stdout.write("{}")
470
+ except Exception: # pragma: no cover — stdout closed
471
+ pass
472
+
473
+
474
+ #: Upper bound on stdin bytes read per hook invocation. Claude Code
475
+ #: pipes the full tool_response through stdin; a large blob (e.g. a
476
+ #: multi-MB git log) would otherwise block the hook while the pipe
477
+ #: drains. ``summarize_response`` caps the SCANNED payload at 100 KB
478
+ #: downstream, so reading 200 KB here keeps header/envelope fields
479
+ #: intact without exceeding the hot-path budget.
480
+ STDIN_READ_CAP_BYTES: int = 200_000
481
+
482
+
483
+ def read_stdin_json() -> dict | None:
484
+ """Read a JSON dict from stdin. Returns None on any failure.
485
+
486
+ # H-12/M-P-05: bounded read — previously ``sys.stdin.read()`` was
487
+ # unbounded and a multi-MB tool_response could block the hook for
488
+ # hundreds of ms just to drain the pipe.
489
+ """
490
+ try:
491
+ raw = sys.stdin.read(STDIN_READ_CAP_BYTES)
492
+ except Exception:
493
+ return None
494
+ if not raw:
495
+ return None
496
+ try:
497
+ obj = json.loads(raw)
498
+ except Exception:
499
+ return None
500
+ if not isinstance(obj, dict):
501
+ return None
502
+ return obj
503
+
504
+
505
+ def now_ms() -> int:
506
+ return int(time.time() * 1000)