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.
- package/CHANGELOG.md +24 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +4 -3
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +254 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +423 -0
- package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +348 -0
- package/src/superlocalmemory/skills/slm-recall/SKILL.md +343 -0
- package/src/superlocalmemory/skills/slm-remember/SKILL.md +194 -0
- package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +224 -0
- package/src/superlocalmemory/skills/slm-status/SKILL.md +363 -0
- package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- 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.22 — 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.22 (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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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.22): 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.22) — 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.22 — 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())
|