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