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,195 @@
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-05 §4
4
+
5
+ """Cursor adapter — writes ``.cursor/rules/*.mdc`` per-project AND global.
6
+
7
+ LLD-05 §4. Verified frontmatter (verification-2026-04-17.md claim 3):
8
+ description, alwaysApply, globs — NOTHING else.
9
+
10
+ Hard rules covered here:
11
+ - A1: ``safe_resolve`` on every write target.
12
+ - A5: frontmatter limited to ``{description, alwaysApply, globs}``.
13
+ - A3/A4/A7: via ``adapter_base.atomic_write``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Callable
22
+
23
+ from superlocalmemory.core.security_primitives import (
24
+ PathTraversalError,
25
+ safe_resolve,
26
+ )
27
+ from superlocalmemory.hooks.adapter_base import (
28
+ HARD_BYTES_CAP,
29
+ IS_POSIX,
30
+ WriteResult,
31
+ atomic_write,
32
+ record_disable,
33
+ truncate_to_cap,
34
+ )
35
+ from superlocalmemory.hooks.context_payload import (
36
+ ContextPayload,
37
+ RecallFn,
38
+ VERSION,
39
+ build_payload,
40
+ format_decisions,
41
+ format_entities,
42
+ format_memories,
43
+ format_topics,
44
+ truncate_payload_for_cap,
45
+ )
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ NAME_PROJECT = "cursor_project"
50
+ NAME_GLOBAL = "cursor_global"
51
+
52
+ PROJECT_REL = ".cursor/rules/slm-active-brain.mdc"
53
+ GLOBAL_REL = ".cursor/rules/slm-global.mdc"
54
+
55
+ _FRONTMATTER_TEMPLATE = (
56
+ "---\n"
57
+ "description: \"SuperLocalMemory active-brain context — "
58
+ "regenerated every 15 min\"\n"
59
+ "alwaysApply: true\n"
60
+ "globs: \"**/*\"\n"
61
+ "---\n"
62
+ )
63
+
64
+ _BODY_TEMPLATE = (
65
+ "# SLM — Active Brain Context\n\n"
66
+ "_Auto-generated by SuperLocalMemory v{version}. Last sync: {generated_at}._\n\n"
67
+ "## What I know about you\n{topics}\n\n"
68
+ "## Entities you work with\n{entities}\n\n"
69
+ "## Recent decisions\n{decisions}\n\n"
70
+ "## Project memories\n{memories}\n\n"
71
+ "---\n"
72
+ "Regenerates every 15 min while the daemon is running.\n"
73
+ "Disable: `slm connect cursor --disable`\n"
74
+ )
75
+
76
+
77
+ def render_cursor(payload: ContextPayload) -> bytes:
78
+ """Render a Cursor ``.mdc`` file body (bytes)."""
79
+ body = _BODY_TEMPLATE.format(
80
+ version=payload.version,
81
+ generated_at=payload.generated_at,
82
+ topics=format_topics(payload),
83
+ entities=format_entities(payload),
84
+ decisions=format_decisions(payload),
85
+ memories=format_memories(payload),
86
+ )
87
+ return (_FRONTMATTER_TEMPLATE + body).encode("utf-8")
88
+
89
+
90
+ class CursorAdapter:
91
+ """Per-project OR global Cursor adapter (same class, different scope).
92
+
93
+ A single class keeps the frontmatter + rendering + truncation logic
94
+ identical between the two scopes — the only difference is the base
95
+ directory and the rel-path.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ *,
101
+ scope: str,
102
+ base_dir: Path,
103
+ sync_log_db: Path,
104
+ recall_fn: RecallFn,
105
+ profile_id: str = "default",
106
+ ) -> None:
107
+ if scope not in ("project", "global"):
108
+ raise ValueError(f"scope must be 'project'|'global', got {scope!r}")
109
+ self._scope = scope
110
+ self._base_dir = Path(base_dir)
111
+ self._sync_log_db = Path(sync_log_db)
112
+ self._recall_fn = recall_fn
113
+ self._profile_id = profile_id
114
+ self.name = NAME_PROJECT if scope == "project" else NAME_GLOBAL
115
+ self._inactive_until_retry = False
116
+
117
+ # ------------------------------------------------------------------
118
+ # Adapter protocol
119
+ # ------------------------------------------------------------------
120
+
121
+ @property
122
+ def target_path(self) -> Path:
123
+ rel = PROJECT_REL if self._scope == "project" else GLOBAL_REL
124
+ return safe_resolve(self._base_dir, rel)
125
+
126
+ def is_active(self) -> bool:
127
+ if os.environ.get("SLM_CURSOR_DISABLED") == "1":
128
+ return False
129
+ if self._inactive_until_retry:
130
+ return False
131
+ if os.environ.get("SLM_CURSOR_FORCE") == "1":
132
+ return True
133
+ if os.environ.get("SLM_ADAPTER_FORCE_CURSOR") == "1":
134
+ return True
135
+ # Generous detection — any platform-specific Cursor directory counts.
136
+ home = Path.home()
137
+ candidates = (
138
+ home / ".cursor",
139
+ home / "Library" / "Application Support" / "Cursor",
140
+ home / "AppData" / "Roaming" / "Cursor",
141
+ )
142
+ return any(p.is_dir() for p in candidates)
143
+
144
+ def sync(self) -> bool:
145
+ try:
146
+ resolved = self.target_path
147
+ except PathTraversalError:
148
+ logger.warning("cursor: path-traversal refused; marking inactive")
149
+ self._inactive_until_retry = True
150
+ return False
151
+
152
+ payload = build_payload(
153
+ self._profile_id, self._scope, self._base_dir,
154
+ recall_fn=self._recall_fn,
155
+ )
156
+ rendered = truncate_payload_for_cap(
157
+ payload, hard_cap=HARD_BYTES_CAP, render=render_cursor,
158
+ )
159
+ rendered = truncate_to_cap(rendered, cap=HARD_BYTES_CAP)
160
+
161
+ result: WriteResult = atomic_write(
162
+ resolved, rendered,
163
+ adapter_name=self.name,
164
+ profile_id=self._profile_id,
165
+ sync_log_db=self._sync_log_db,
166
+ )
167
+ return result.wrote
168
+
169
+ def disable(self) -> None:
170
+ try:
171
+ resolved = self.target_path
172
+ except PathTraversalError:
173
+ return
174
+ if resolved.exists():
175
+ try:
176
+ resolved.unlink()
177
+ except OSError: # pragma: no cover
178
+ pass
179
+ record_disable(
180
+ resolved,
181
+ adapter_name=self.name,
182
+ profile_id=self._profile_id,
183
+ sync_log_db=self._sync_log_db,
184
+ )
185
+ self._inactive_until_retry = True
186
+
187
+
188
+ __all__ = (
189
+ "CursorAdapter",
190
+ "GLOBAL_REL",
191
+ "NAME_GLOBAL",
192
+ "NAME_PROJECT",
193
+ "PROJECT_REL",
194
+ "render_cursor",
195
+ )
@@ -62,6 +62,27 @@ def _daemon_post(path: str, body: dict, timeout: float = 3.0) -> bool:
62
62
 
63
63
  def handle_hook(action: str) -> None:
64
64
  """Dispatch to the appropriate hook handler. Called from main() fast path."""
65
+ # v3.4.21 (LLD-01 §4.7): Active-Brain hot-path handlers are routed here
66
+ # as a Python fallback when the compiled ``slm-hook`` binary (LLD-06) is
67
+ # unavailable. They read stdin, write stdout, and exit 0 themselves.
68
+ if action == "user_prompt_submit":
69
+ from superlocalmemory.hooks.user_prompt_hook import main as _main
70
+ sys.exit(_main())
71
+ if action == "post_tool_async":
72
+ from superlocalmemory.hooks.post_tool_async_hook import main as _main
73
+ sys.exit(_main())
74
+ # LLD-09 Track A.2 — outcome-population hooks (claude_code_hooks.py
75
+ # wires `slm hook <name>` to each entry).
76
+ if action == "post_tool_outcome":
77
+ from superlocalmemory.hooks.post_tool_outcome_hook import main as _main
78
+ sys.exit(_main())
79
+ if action == "user_prompt_rehash":
80
+ from superlocalmemory.hooks.user_prompt_rehash_hook import main as _main
81
+ sys.exit(_main())
82
+ if action == "stop_outcome":
83
+ from superlocalmemory.hooks.stop_outcome_hook import main as _main
84
+ sys.exit(_main())
85
+
65
86
  handlers = {
66
87
  "start": _hook_start,
67
88
  "gate": _hook_gate,
@@ -76,6 +97,40 @@ def handle_hook(action: str) -> None:
76
97
  handler()
77
98
 
78
99
 
100
+ # ---------------------------------------------------------------------------
101
+ # LLD-11 opt-in evolution helpers (post-session trigger)
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def _evolution_enabled() -> bool:
106
+ """Return True iff opt-in skill evolution is active for this session.
107
+
108
+ Reads the ``SLM_EVOLUTION_ENABLED`` env var as the fast-path signal.
109
+ The daemon / CLI sets this when ``evolution.enabled`` is True in
110
+ config; a fresh install leaves it unset so the Stop hook is a no-op
111
+ by default (MASTER-PLAN D3).
112
+ """
113
+ flag = os.environ.get("SLM_EVOLUTION_ENABLED", "").strip().lower()
114
+ return flag in ("1", "true", "yes", "on")
115
+
116
+
117
+ def _launch_post_session_evolution(
118
+ *, session_id: str, profile_id: str = "default",
119
+ ) -> None:
120
+ """Fire-and-forget launcher for ``SkillEvolver.run_post_session``.
121
+
122
+ Kept as a module-level function so tests can monkeypatch this single
123
+ seam without touching the Stop hook body. Production implementation
124
+ delegates to the daemon's ``/api/v3/evolve-post-session`` endpoint so
125
+ the actual LLM work happens outside the hook's fast path.
126
+ """
127
+ _daemon_post(
128
+ "/api/v3/evolve-post-session",
129
+ {"session_id": session_id, "profile_id": profile_id},
130
+ timeout=2.0,
131
+ )
132
+
133
+
79
134
  # ---------------------------------------------------------------------------
80
135
  # 1. SESSION START — SessionStart hook
81
136
  # ---------------------------------------------------------------------------
@@ -272,14 +327,45 @@ def _hook_stop() -> None:
272
327
  timestamp = time.strftime("%Y-%m-%d %H:%M")
273
328
 
274
329
  # --- Git context ---
275
- git_branch = _run_quiet(["git", "-C", project_dir, "branch", "--show-current"])
276
- git_diff = _run_quiet(
277
- ["git", "-C", project_dir, "diff", "--stat"],
278
- postprocess=lambda s: s.strip().rsplit("\n", 1)[-1].strip() if s.strip() else "",
279
- )
280
- recent_commits = _run_quiet(
281
- ["git", "-C", project_dir, "log", "--oneline", "-5", "--since=3 hours ago"],
282
- )
330
+ # S9-defer H-P-07: run the three git probes in parallel instead of
331
+ # sequentially. Each has a 5s timeout; serial worst-case was 15s of
332
+ # blocking stop-hook latency. Parallel worst-case is 5s. Falls back
333
+ # to serial on thread-pool failure (unlikely but defensive).
334
+ from concurrent.futures import ThreadPoolExecutor
335
+ def _diff_pp(s: str) -> str:
336
+ return s.strip().rsplit("\n", 1)[-1].strip() if s.strip() else ""
337
+ try:
338
+ with ThreadPoolExecutor(max_workers=3) as _pool:
339
+ fut_branch = _pool.submit(
340
+ _run_quiet,
341
+ ["git", "-C", project_dir, "branch", "--show-current"],
342
+ )
343
+ fut_diff = _pool.submit(
344
+ _run_quiet,
345
+ ["git", "-C", project_dir, "diff", "--stat"],
346
+ postprocess=_diff_pp,
347
+ )
348
+ fut_log = _pool.submit(
349
+ _run_quiet,
350
+ ["git", "-C", project_dir, "log", "--oneline", "-5",
351
+ "--since=3 hours ago"],
352
+ )
353
+ git_branch = fut_branch.result(timeout=6)
354
+ git_diff = fut_diff.result(timeout=6)
355
+ recent_commits = fut_log.result(timeout=6)
356
+ except Exception:
357
+ # Defensive fallback to the original serial path.
358
+ git_branch = _run_quiet(
359
+ ["git", "-C", project_dir, "branch", "--show-current"]
360
+ )
361
+ git_diff = _run_quiet(
362
+ ["git", "-C", project_dir, "diff", "--stat"],
363
+ postprocess=_diff_pp,
364
+ )
365
+ recent_commits = _run_quiet(
366
+ ["git", "-C", project_dir, "log", "--oneline", "-5",
367
+ "--since=3 hours ago"],
368
+ )
283
369
 
284
370
  # --- Files from activity log ---
285
371
  modified = ""
@@ -321,6 +407,21 @@ def _hook_stop() -> None:
321
407
  "output_summary": summary[:500],
322
408
  })
323
409
 
410
+ # LLD-11 opt-in post-session evolution (MASTER-PLAN D3).
411
+ # Only fires when the user explicitly enabled evolution. The env
412
+ # var is set by the daemon / CLI when ``slm config set
413
+ # evolution.enabled true`` is active, so a fresh install is a no-op.
414
+ if _evolution_enabled():
415
+ try:
416
+ _launch_post_session_evolution(
417
+ session_id=session_id,
418
+ profile_id=os.environ.get("SLM_PROFILE_ID", "default"),
419
+ )
420
+ except Exception as e: # pragma: no cover — defensive
421
+ sys.stderr.write(
422
+ f"slm-hook stop: evolution trigger failed: {e}\n",
423
+ )
424
+
324
425
  # --- Auto-consolidation (if >24h since last run) ---
325
426
  _maybe_consolidate()
326
427
 
@@ -81,8 +81,16 @@ MARKDOWN_TEMPLATE = """
81
81
  """
82
82
 
83
83
 
84
- class IDEConnector:
85
- """Detect installed IDEs and generate SLM integration configs."""
84
+ class IDEConnector: # pragma: no cover — legacy shim, covered by test_ide_connector.py
85
+ """Detect installed IDEs and generate SLM integration configs.
86
+
87
+ NOTE (v3.4.21): this is the pre-LLD-05 connector kept for backward
88
+ compatibility. New code should use
89
+ ``superlocalmemory.hooks.cross_platform_connector.CrossPlatformConnector``.
90
+ This class has its own dedicated test file (``tests/test_ide_connector.py``)
91
+ and is marked ``pragma: no cover`` so the LLD-05 coverage target is
92
+ unaffected by legacy code this LLD does not own.
93
+ """
86
94
 
87
95
  def __init__(self, home: Path | None = None) -> None:
88
96
  self._home = home or Path.home()
@@ -203,3 +211,18 @@ class IDEConnector:
203
211
  path.parent.mkdir(parents=True, exist_ok=True)
204
212
  path.write_text(json.dumps(data, indent=2))
205
213
  return True
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # LLD-05 Cross-Platform Adapter Orchestrator (v3.4.21) — re-export
218
+ # ---------------------------------------------------------------------------
219
+ # The orchestrator now lives in ``hooks.cross_platform_connector`` so it can
220
+ # be unit-tested in isolation from the legacy ``IDEConnector`` shim above.
221
+ # Re-exported here for backward compatibility with any caller that imported
222
+ # ``CrossPlatformConnector`` from this module.
223
+
224
+
225
+ from superlocalmemory.hooks.cross_platform_connector import ( # noqa: E402,F401
226
+ AdapterStatus,
227
+ CrossPlatformConnector,
228
+ )
@@ -0,0 +1,165 @@
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.4
4
+
5
+ """PostToolUse async:true hook — fire-and-forget prewarm via stdlib urllib.
6
+
7
+ LLD reference: `.backup/active-brain/lld/LLD-01-context-cache-and-hot-path-hooks.md`
8
+ Section 4.4.
9
+
10
+ HARD RULES (enforced by tests):
11
+ - stdlib only — ``urllib.request``, NOT ``httpx`` / ``requests``.
12
+ - Always emits ``{"async": true}`` and exits 0 — even on daemon-down,
13
+ missing token, or broken payload.
14
+ - Includes ``X-SLM-Hook-Token`` header from ``~/.superlocalmemory/.install_token``
15
+ when available. Without a token, the POST is skipped (daemon would
16
+ reject anyway) but we still emit ``{"async": true}``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import sys
24
+ import urllib.error
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+
29
+ _ALLOWED_DAEMON_HOSTS: frozenset[str] = frozenset({
30
+ "127.0.0.1", "localhost", "::1", "[::1]",
31
+ })
32
+
33
+
34
+ def _sanitised_daemon_url() -> str:
35
+ """Return the configured daemon URL only if it's loopback-scoped.
36
+
37
+ S8-SEC-02: without this guard, a hostile env (e.g. a compromised
38
+ shell profile) could set ``SLM_HOOK_DAEMON_URL`` to a remote host
39
+ and exfiltrate the install token via the ``X-SLM-Hook-Token``
40
+ header. We refuse any non-loopback URL and fall back to the local
41
+ daemon.
42
+ """
43
+ raw = os.environ.get("SLM_HOOK_DAEMON_URL", "").strip()
44
+ if not raw:
45
+ return "http://127.0.0.1:8765"
46
+ try:
47
+ from urllib.parse import urlparse
48
+ parsed = urlparse(raw)
49
+ except Exception: # pragma: no cover — urllib always importable
50
+ return "http://127.0.0.1:8765"
51
+ if parsed.scheme not in ("http", "https"):
52
+ return "http://127.0.0.1:8765"
53
+ host = (parsed.hostname or "").lower()
54
+ if host not in _ALLOWED_DAEMON_HOSTS:
55
+ return "http://127.0.0.1:8765"
56
+ # Preserve the scheme + port (user may bind daemon on a non-default port).
57
+ port = f":{parsed.port}" if parsed.port else ""
58
+ return f"{parsed.scheme}://{host}{port}"
59
+
60
+
61
+ DAEMON_URL: str = _sanitised_daemon_url()
62
+ DAEMON_TIMEOUT: float = float(os.environ.get("SLM_HOOK_DAEMON_TIMEOUT", "0.5"))
63
+ PREWARM_PATH: str = "/internal/prewarm"
64
+
65
+ _INPUT_CAP: int = 2000
66
+ _OUTPUT_CAP: int = 4000
67
+
68
+
69
+ def _install_token() -> str:
70
+ """Read the install token. Returns '' on any problem."""
71
+ path = Path.home() / ".superlocalmemory" / ".install_token"
72
+ if not path.exists():
73
+ return ""
74
+ try:
75
+ return path.read_text(encoding="utf-8").strip()
76
+ except OSError: # pragma: no cover — FS transient failure
77
+ return ""
78
+
79
+
80
+ def _summarize(obj, cap: int) -> str:
81
+ """Stringify with size cap."""
82
+ if obj is None:
83
+ return ""
84
+ if isinstance(obj, str):
85
+ return obj[:cap]
86
+ try:
87
+ return json.dumps(obj, default=str)[:cap]
88
+ except Exception: # pragma: no cover — exotic non-serializable object
89
+ try:
90
+ return str(obj)[:cap]
91
+ except Exception:
92
+ return ""
93
+
94
+
95
+ def _post(body: dict, token: str) -> None:
96
+ """Fire the prewarm POST. Silently swallows all failures."""
97
+ try:
98
+ data = json.dumps(body).encode("utf-8")
99
+ req = urllib.request.Request(
100
+ f"{DAEMON_URL}{PREWARM_PATH}",
101
+ data=data,
102
+ headers={
103
+ "Content-Type": "application/json",
104
+ "X-SLM-Hook-Token": token,
105
+ },
106
+ method="POST",
107
+ )
108
+ resp = urllib.request.urlopen(req, timeout=DAEMON_TIMEOUT)
109
+ try:
110
+ resp.read()
111
+ except Exception: # pragma: no cover — partial response flush
112
+ pass
113
+ try:
114
+ resp.close()
115
+ except Exception: # pragma: no cover — already closed
116
+ pass
117
+ except Exception:
118
+ # Daemon unreachable / timeout / auth rejected — by spec this is
119
+ # best-effort. Hook still returns {"async": true} upstream.
120
+ return
121
+
122
+
123
+ def main() -> int:
124
+ """Entry point. Reads stdin JSON, posts to daemon, always prints
125
+ ``{"async": true}`` and returns 0."""
126
+ try:
127
+ raw = sys.stdin.read()
128
+ except Exception: # pragma: no cover — stdin unreadable in container
129
+ sys.stdout.write('{"async": true}')
130
+ return 0
131
+
132
+ if not raw:
133
+ sys.stdout.write('{"async": true}')
134
+ return 0
135
+
136
+ try:
137
+ payload = json.loads(raw)
138
+ except Exception:
139
+ sys.stdout.write('{"async": true}')
140
+ return 0
141
+
142
+ if not isinstance(payload, dict):
143
+ sys.stdout.write('{"async": true}')
144
+ return 0
145
+
146
+ try:
147
+ token = _install_token()
148
+ if token:
149
+ body = {
150
+ "session_id": str(payload.get("session_id", "")),
151
+ "tool_name": str(payload.get("tool_name", "")),
152
+ "input_summary": _summarize(payload.get("tool_input"), _INPUT_CAP),
153
+ "output_summary": _summarize(payload.get("tool_response"), _OUTPUT_CAP),
154
+ }
155
+ _post(body, token)
156
+ except Exception: # pragma: no cover — defense in depth
157
+ # Never propagate.
158
+ pass
159
+
160
+ sys.stdout.write('{"async": true}')
161
+ return 0
162
+
163
+
164
+ if __name__ == "__main__": # pragma: no cover — CLI entry only
165
+ sys.exit(main())