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