mneme-cc-plugin 2.0.0__py3-none-any.whl
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.
- mneme_cc_plugin/__init__.py +3 -0
- mneme_cc_plugin/cli.py +12 -0
- mneme_cc_plugin/hooks/__init__.py +11 -0
- mneme_cc_plugin/hooks/lib.py +152 -0
- mneme_cc_plugin/hooks/lock.py +14 -0
- mneme_cc_plugin/hooks/post_tool_use.py +99 -0
- mneme_cc_plugin/hooks/pre_compact.py +59 -0
- mneme_cc_plugin/hooks/session_end.py +144 -0
- mneme_cc_plugin/hooks/session_start.py +189 -0
- mneme_cc_plugin/hooks/stop.py +185 -0
- mneme_cc_plugin/install/__init__.py +1 -0
- mneme_cc_plugin/install/cli.py +1212 -0
- mneme_cc_plugin/install/settings.py +286 -0
- mneme_cc_plugin/py.typed +1 -0
- mneme_cc_plugin-2.0.0.data/data/share/mneme-cc-plugin/commands/migrate.md +41 -0
- mneme_cc_plugin-2.0.0.data/data/share/mneme-cc-plugin/commands/prime.md +36 -0
- mneme_cc_plugin-2.0.0.data/data/share/mneme-cc-plugin/commands/recall.md +31 -0
- mneme_cc_plugin-2.0.0.data/data/share/mneme-cc-plugin/hooks/hooks.json +40 -0
- mneme_cc_plugin-2.0.0.data/data/share/mneme-cc-plugin/plugin.json +56 -0
- mneme_cc_plugin-2.0.0.data/data/share/mneme-cc-plugin/skills/mneme-prime/SKILL.md +35 -0
- mneme_cc_plugin-2.0.0.data/data/share/mneme-cc-plugin/skills/mneme-search/SKILL.md +42 -0
- mneme_cc_plugin-2.0.0.dist-info/METADATA +70 -0
- mneme_cc_plugin-2.0.0.dist-info/RECORD +25 -0
- mneme_cc_plugin-2.0.0.dist-info/WHEEL +4 -0
- mneme_cc_plugin-2.0.0.dist-info/entry_points.txt +2 -0
mneme_cc_plugin/cli.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Top-level entry point for the ``mneme`` console script.
|
|
2
|
+
|
|
3
|
+
This module exists so ``project.scripts.mneme`` in ``pyproject.toml``
|
|
4
|
+
can stay short. The actual CLI logic lives in
|
|
5
|
+
``mneme_cc_plugin.install.cli``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mneme_cc_plugin.install.cli import main
|
|
11
|
+
|
|
12
|
+
__all__ = ["main"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Claude Code hook implementations for the mneme plugin.
|
|
2
|
+
|
|
3
|
+
Each hook module exposes a ``main()`` entry point that reads a JSON
|
|
4
|
+
event from stdin, performs its task, and writes a JSON response to
|
|
5
|
+
stdout. Every hook fails closed: any exception inside the hook is
|
|
6
|
+
caught and converted to a benign success response so Claude Code is
|
|
7
|
+
never blocked by a mneme defect.
|
|
8
|
+
|
|
9
|
+
The ``MNEME_DISABLED`` environment variable acts as a global kill
|
|
10
|
+
switch. When truthy, every hook short-circuits before doing any work.
|
|
11
|
+
"""
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Shared plumbing for Claude Code hook implementations.
|
|
2
|
+
|
|
3
|
+
The functions here implement the lifecycle every hook follows:
|
|
4
|
+
|
|
5
|
+
1. ``is_disabled`` Check the kill switch first.
|
|
6
|
+
2. ``read_event`` Parse JSON from stdin, tolerating empty input.
|
|
7
|
+
3. ``resolve_vault`` Locate the VaultConfig, returning None on miss.
|
|
8
|
+
4. (do hook work)
|
|
9
|
+
5. ``emit`` Write the success/error JSON envelope.
|
|
10
|
+
|
|
11
|
+
Hook handlers wrap their work in ``run_hook`` which centralizes the
|
|
12
|
+
failure-soft contract: any exception inside ``handler`` is caught and
|
|
13
|
+
converted to a benign success response. Claude Code never sees a hook
|
|
14
|
+
crash from mneme.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import traceback
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from mneme_core.vault.config import VaultConfig, VaultNotFoundError
|
|
27
|
+
|
|
28
|
+
KILL_SWITCH_ENV = "MNEME_DISABLED"
|
|
29
|
+
SKIP_HOOKS_ENV = "MNEME_SKIP_HOOKS"
|
|
30
|
+
|
|
31
|
+
_SKIP_ALL_VALUES = {"all", "1", "true"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_disabled() -> bool:
|
|
35
|
+
"""Return True if the kill switch env var is set to a truthy value."""
|
|
36
|
+
raw = os.environ.get(KILL_SWITCH_ENV, "")
|
|
37
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_skipped(hook_event_name: str) -> bool:
|
|
41
|
+
"""Return True if *hook_event_name* should be skipped via MNEME_SKIP_HOOKS.
|
|
42
|
+
|
|
43
|
+
``MNEME_SKIP_HOOKS`` accepts:
|
|
44
|
+
|
|
45
|
+
* A comma-separated, case-insensitive list of hook event names, e.g.
|
|
46
|
+
``Stop,SessionStart`` — only those events are skipped.
|
|
47
|
+
* The literal values ``all``, ``1``, or ``true`` — every event is skipped.
|
|
48
|
+
* Unset or empty string — nothing is skipped.
|
|
49
|
+
"""
|
|
50
|
+
raw = os.environ.get(SKIP_HOOKS_ENV, "").strip()
|
|
51
|
+
if not raw:
|
|
52
|
+
return False
|
|
53
|
+
normalized = raw.lower()
|
|
54
|
+
if normalized in _SKIP_ALL_VALUES:
|
|
55
|
+
return True
|
|
56
|
+
skipped = {part.strip().lower() for part in raw.split(",")}
|
|
57
|
+
return hook_event_name.lower() in skipped
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_event() -> dict[str, Any]:
|
|
61
|
+
"""Read the Claude Code hook event from stdin.
|
|
62
|
+
|
|
63
|
+
The contract: Claude Code writes a JSON object to the hook stdin
|
|
64
|
+
that contains at least ``hook_event_name``. Older or sparser
|
|
65
|
+
versions may pass an empty stdin; tolerate that by returning an
|
|
66
|
+
empty dict.
|
|
67
|
+
"""
|
|
68
|
+
if sys.stdin.isatty():
|
|
69
|
+
return {}
|
|
70
|
+
raw = sys.stdin.read()
|
|
71
|
+
if not raw or not raw.strip():
|
|
72
|
+
return {}
|
|
73
|
+
try:
|
|
74
|
+
parsed = json.loads(raw)
|
|
75
|
+
except json.JSONDecodeError:
|
|
76
|
+
return {}
|
|
77
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def emit(
|
|
81
|
+
*,
|
|
82
|
+
continue_: bool = True,
|
|
83
|
+
suppress_output: bool = True,
|
|
84
|
+
additional_context: str | None = None,
|
|
85
|
+
system_message: str | None = None,
|
|
86
|
+
hook_event_name: str | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Write the hook response JSON envelope to stdout.
|
|
89
|
+
|
|
90
|
+
``additional_context`` is wrapped inside ``hookSpecificOutput`` as
|
|
91
|
+
Claude Code expects when surfacing extra context.
|
|
92
|
+
"""
|
|
93
|
+
payload: dict[str, Any] = {
|
|
94
|
+
"continue": continue_,
|
|
95
|
+
"suppressOutput": suppress_output,
|
|
96
|
+
}
|
|
97
|
+
if system_message is not None:
|
|
98
|
+
payload["systemMessage"] = system_message
|
|
99
|
+
if additional_context is not None:
|
|
100
|
+
payload["hookSpecificOutput"] = {
|
|
101
|
+
"hookEventName": hook_event_name or "Unknown",
|
|
102
|
+
"additionalContext": additional_context,
|
|
103
|
+
}
|
|
104
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
105
|
+
sys.stdout.flush()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_vault() -> VaultConfig | None:
|
|
109
|
+
"""Resolve VaultConfig, returning None if no vault could be found.
|
|
110
|
+
|
|
111
|
+
Hooks are fail-soft: a missing vault is not a crash, it is a
|
|
112
|
+
no-op. The user runs ``mneme install`` to provision one.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
return VaultConfig.resolve()
|
|
116
|
+
except VaultNotFoundError:
|
|
117
|
+
return None
|
|
118
|
+
except Exception:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def run_hook(
|
|
123
|
+
handler: Callable[[dict[str, Any], VaultConfig | None], None],
|
|
124
|
+
*,
|
|
125
|
+
hook_event_name: str,
|
|
126
|
+
) -> int:
|
|
127
|
+
"""Execute a hook handler with kill-switch + fail-soft semantics.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
handler: function ``(event, vault) -> None``. It owns calling
|
|
131
|
+
``emit`` itself (so it can attach hook-specific output).
|
|
132
|
+
hook_event_name: name reported in error envelopes for triage.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Always 0. Non-zero exit codes from hooks risk being treated
|
|
136
|
+
as a block by some Claude Code versions.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
if is_disabled() or is_skipped(hook_event_name):
|
|
140
|
+
emit(hook_event_name=hook_event_name)
|
|
141
|
+
return 0
|
|
142
|
+
event = read_event()
|
|
143
|
+
vault = resolve_vault()
|
|
144
|
+
handler(event, vault)
|
|
145
|
+
except SystemExit:
|
|
146
|
+
raise
|
|
147
|
+
except BaseException:
|
|
148
|
+
# Surface failure to stderr for operator debugging without
|
|
149
|
+
# blocking Claude Code. stdout still gets a benign success.
|
|
150
|
+
sys.stderr.write(f"[mneme:{hook_event_name}] {traceback.format_exc()}")
|
|
151
|
+
emit(hook_event_name=hook_event_name)
|
|
152
|
+
return 0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Cross-platform exclusive file lock for hook concurrency control.
|
|
2
|
+
|
|
3
|
+
The canonical implementation moved to
|
|
4
|
+
``mneme_core.vault.file_lock`` so it can be reused by the
|
|
5
|
+
compression cost ledger (Codex Pass 2 reservation pattern). This
|
|
6
|
+
module re-exports the same symbol so existing hook imports do not
|
|
7
|
+
break.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from mneme_core.vault.file_lock import file_lock
|
|
13
|
+
|
|
14
|
+
__all__ = ["file_lock"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""PostToolUse hook: stage tool events for the FTS5 indexer.
|
|
2
|
+
|
|
3
|
+
For each captured tool invocation, write a JSON staging record into
|
|
4
|
+
the vault's staging directory. The indexer picks up staged records on
|
|
5
|
+
its next pass and folds them into the FTS5 index. The hook itself
|
|
6
|
+
performs no LLM call and writes no markdown into the vault - that is
|
|
7
|
+
the Stop hook's job.
|
|
8
|
+
|
|
9
|
+
The full ``capture_event`` implementation lives in
|
|
10
|
+
``mneme_core.compression.staging``. This module is a thin Claude Code
|
|
11
|
+
adapter that constructs a StagingConfig pointing at the resolved vault
|
|
12
|
+
and forwards the event payload.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from mneme_core.compression.staging import StagingConfig, capture_event
|
|
21
|
+
from mneme_core.distill.shell_compress import (
|
|
22
|
+
ShellCompressOpts,
|
|
23
|
+
compress_shell_output,
|
|
24
|
+
)
|
|
25
|
+
from mneme_core.kg import kg_config_from_vault, stage_event
|
|
26
|
+
from mneme_core.vault.config import VaultConfig
|
|
27
|
+
|
|
28
|
+
from .lib import emit, run_hook
|
|
29
|
+
|
|
30
|
+
# Bash tool responses can land verbose stdout under any of these keys
|
|
31
|
+
# depending on Claude Code version. Walk known keys and compress any
|
|
32
|
+
# string longer than the threshold before staging. Keys not present in
|
|
33
|
+
# the event are silently skipped.
|
|
34
|
+
_SHELL_OUTPUT_KEYS = ("stdout", "stderr", "output", "result", "text", "content")
|
|
35
|
+
_COMPRESS_MIN_BYTES = 256
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _compress_bash_payload(event: dict[str, Any]) -> None:
|
|
39
|
+
"""Apply distill.shell_compress in-place to Bash tool response strings.
|
|
40
|
+
|
|
41
|
+
Codex Pass 2 review fix: docs/HOOKS advertised PostToolUse-time
|
|
42
|
+
shell output compression but the hook forwarded raw events. This
|
|
43
|
+
function compresses long stdout/stderr fields before they reach
|
|
44
|
+
the staging JSONL, matching the documented behavior.
|
|
45
|
+
"""
|
|
46
|
+
if event.get("tool_name") != "Bash":
|
|
47
|
+
return
|
|
48
|
+
resp = event.get("tool_response")
|
|
49
|
+
if not isinstance(resp, dict):
|
|
50
|
+
return
|
|
51
|
+
opts = ShellCompressOpts()
|
|
52
|
+
for key in _SHELL_OUTPUT_KEYS:
|
|
53
|
+
value = resp.get(key)
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
continue
|
|
56
|
+
if len(value.encode("utf-8")) < _COMPRESS_MIN_BYTES:
|
|
57
|
+
continue
|
|
58
|
+
stats = compress_shell_output(value, opts)
|
|
59
|
+
if stats.compressed_bytes < stats.original_bytes:
|
|
60
|
+
resp[key] = stats.compressed_text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def handle(event: dict[str, Any], vault: VaultConfig | None) -> None:
|
|
64
|
+
if vault is None:
|
|
65
|
+
emit(hook_event_name="PostToolUse")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Compress before staging so both the FTS index and any later
|
|
69
|
+
# compression call see the reduced payload.
|
|
70
|
+
try:
|
|
71
|
+
_compress_bash_payload(event)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
sys.stderr.write(f"[mneme:PostToolUse] shell compress skipped: {exc}\n")
|
|
74
|
+
|
|
75
|
+
config = StagingConfig(
|
|
76
|
+
staging_dir=vault.staging_dir,
|
|
77
|
+
audit_dir=vault.audit_log_dir,
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
capture_event(event, config)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
sys.stderr.write(f"[mneme:PostToolUse] capture skipped: {exc}\n")
|
|
83
|
+
|
|
84
|
+
# KG staging is gated by kg_active_flag inside stage_event itself.
|
|
85
|
+
# Cheap no-op for lite/standard profiles where the flag is absent.
|
|
86
|
+
try:
|
|
87
|
+
stage_event(event, kg_config_from_vault(vault))
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
sys.stderr.write(f"[mneme:PostToolUse] kg stage skipped: {exc}\n")
|
|
90
|
+
|
|
91
|
+
emit(hook_event_name="PostToolUse")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main() -> int:
|
|
95
|
+
return run_hook(handle, hook_event_name="PostToolUse")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
sys.exit(main())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""PreCompact hook: snapshot the vault state file.
|
|
2
|
+
|
|
3
|
+
Claude Code calls PreCompact right before context compaction. The
|
|
4
|
+
mneme hook responds by stamping a `last_precompact_at` field into
|
|
5
|
+
``vault/.mneme/state.json`` so the next SessionStart can decide
|
|
6
|
+
whether to refresh its preamble. The hook does no other work; the
|
|
7
|
+
500ms budget would not survive a heavier touch.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from mneme_core.vault.atomic_write import atomic_write_text
|
|
18
|
+
from mneme_core.vault.config import VaultConfig
|
|
19
|
+
|
|
20
|
+
from .lib import emit, run_hook
|
|
21
|
+
|
|
22
|
+
STATE_FILENAME = "state.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def handle(event: dict[str, Any], vault: VaultConfig | None) -> None:
|
|
26
|
+
if vault is None:
|
|
27
|
+
emit(hook_event_name="PreCompact")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
state_path = vault.state_dir / STATE_FILENAME
|
|
31
|
+
try:
|
|
32
|
+
if state_path.exists():
|
|
33
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
34
|
+
else:
|
|
35
|
+
state = {}
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
state = {}
|
|
38
|
+
|
|
39
|
+
state["last_precompact_at"] = datetime.now(UTC).isoformat()
|
|
40
|
+
state.setdefault("schema_version", 1)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
atomic_write_text(
|
|
45
|
+
state_path,
|
|
46
|
+
json.dumps(state, indent=2, ensure_ascii=False) + "\n",
|
|
47
|
+
)
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
sys.stderr.write(f"[mneme:PreCompact] write failed: {exc}\n")
|
|
50
|
+
|
|
51
|
+
emit(hook_event_name="PreCompact")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> int:
|
|
55
|
+
return run_hook(handle, hook_event_name="PreCompact")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
sys.exit(main())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""SessionEnd hook: flush staging buffers and (opt-in) compression.
|
|
2
|
+
|
|
3
|
+
In the lite profile this is a near no-op: it stamps
|
|
4
|
+
``last_session_end_at`` into the vault state file so SessionStart
|
|
5
|
+
can compute the elapsed-since-last-session window. Full profile
|
|
6
|
+
will later trigger an asynchronous Graphiti episode flush. That
|
|
7
|
+
flush lives behind the install profile gate and is not invoked
|
|
8
|
+
from here at v1.0.
|
|
9
|
+
|
|
10
|
+
Background compression, when opted into by the user, is launched
|
|
11
|
+
as a fire-and-forget subprocess so the hook still returns inside
|
|
12
|
+
its latency budget. v1.0 ships compression OFF by default so this
|
|
13
|
+
hook does not invoke any LLM unprompted. The gating is layered:
|
|
14
|
+
|
|
15
|
+
1. The compression flag must be true in the vault config.
|
|
16
|
+
2. The pause flag must not be present.
|
|
17
|
+
3. The Anthropic key (or any compatible LLM auth) must be in env.
|
|
18
|
+
|
|
19
|
+
The subprocess inherits the parent env so the user does not need
|
|
20
|
+
to re-export the key, and the spawn is detached so the hook
|
|
21
|
+
returns immediately. Codex Pass 2 review fix: prior implementation
|
|
22
|
+
documented this behavior but never spawned the subprocess.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from mneme_core.vault.atomic_write import atomic_write_text
|
|
35
|
+
from mneme_core.vault.config import VaultConfig
|
|
36
|
+
|
|
37
|
+
from .lib import emit, run_hook
|
|
38
|
+
|
|
39
|
+
STATE_FILENAME = "state.json"
|
|
40
|
+
|
|
41
|
+
# Subprocess launch flags that disconnect the child from the hook so
|
|
42
|
+
# the hook returns inside its latency budget while the LLM call runs.
|
|
43
|
+
if sys.platform == "win32":
|
|
44
|
+
_DETACHED_FLAGS = 0x00000008 | 0x00000200 # DETACHED | NEW_PROCESS_GROUP
|
|
45
|
+
else:
|
|
46
|
+
_DETACHED_FLAGS = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _compression_enabled(vault: VaultConfig) -> bool:
|
|
50
|
+
"""Read the vault compression config flag without importing LLM deps.
|
|
51
|
+
|
|
52
|
+
Returns False if the file is missing or malformed so opt-out
|
|
53
|
+
behavior survives any config-side drift.
|
|
54
|
+
"""
|
|
55
|
+
cfg_path = vault.compression_config_path
|
|
56
|
+
if not cfg_path.is_file():
|
|
57
|
+
return False
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, json.JSONDecodeError):
|
|
61
|
+
return False
|
|
62
|
+
return bool(data.get("enabled", False))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _has_provider_auth() -> bool:
|
|
66
|
+
"""Cheap env check so we never spawn when the LLM call would fail."""
|
|
67
|
+
for var in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "MNEME_LLM_API_KEY"):
|
|
68
|
+
if os.environ.get(var):
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _maybe_launch_compression(vault: VaultConfig) -> None:
|
|
74
|
+
"""Fire-and-forget background compression run when opt-in gates pass."""
|
|
75
|
+
if not _compression_enabled(vault):
|
|
76
|
+
return
|
|
77
|
+
if vault.compression_pause_flag.is_file():
|
|
78
|
+
return
|
|
79
|
+
if not _has_provider_auth():
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
cmd = [
|
|
83
|
+
sys.executable,
|
|
84
|
+
"-m",
|
|
85
|
+
"mneme_core.cli",
|
|
86
|
+
"compress",
|
|
87
|
+
"run",
|
|
88
|
+
]
|
|
89
|
+
popen_kwargs: dict[str, Any] = {
|
|
90
|
+
"cwd": str(vault.root),
|
|
91
|
+
"stdout": subprocess.DEVNULL,
|
|
92
|
+
"stderr": subprocess.DEVNULL,
|
|
93
|
+
"stdin": subprocess.DEVNULL,
|
|
94
|
+
"close_fds": True,
|
|
95
|
+
}
|
|
96
|
+
if sys.platform == "win32":
|
|
97
|
+
popen_kwargs["creationflags"] = _DETACHED_FLAGS
|
|
98
|
+
else:
|
|
99
|
+
popen_kwargs["start_new_session"] = True
|
|
100
|
+
subprocess.Popen(cmd, **popen_kwargs)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
sys.stderr.write(
|
|
103
|
+
f"[mneme:SessionEnd] compression launch skipped: {exc}\n"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def handle(event: dict[str, Any], vault: VaultConfig | None) -> None:
|
|
108
|
+
if vault is None:
|
|
109
|
+
emit(hook_event_name="SessionEnd")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
state_path = vault.state_dir / STATE_FILENAME
|
|
113
|
+
try:
|
|
114
|
+
if state_path.exists():
|
|
115
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
116
|
+
else:
|
|
117
|
+
state = {}
|
|
118
|
+
except (OSError, json.JSONDecodeError):
|
|
119
|
+
state = {}
|
|
120
|
+
|
|
121
|
+
state["last_session_end_at"] = datetime.now(UTC).isoformat()
|
|
122
|
+
state.setdefault("schema_version", 1)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
atomic_write_text(
|
|
127
|
+
state_path,
|
|
128
|
+
json.dumps(state, indent=2, ensure_ascii=False) + "\n",
|
|
129
|
+
)
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
sys.stderr.write(f"[mneme:SessionEnd] write failed: {exc}\n")
|
|
132
|
+
|
|
133
|
+
# Three-layer gate enforced inside _maybe_launch_compression.
|
|
134
|
+
_maybe_launch_compression(vault)
|
|
135
|
+
|
|
136
|
+
emit(hook_event_name="SessionEnd")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main() -> int:
|
|
140
|
+
return run_hook(handle, hook_event_name="SessionEnd")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
sys.exit(main())
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""SessionStart hook: inject preflight vault context.
|
|
2
|
+
|
|
3
|
+
Returns up to ``MAX_CHARS`` characters of markdown that Claude Code
|
|
4
|
+
surfaces as conversation preamble. The bundle contains:
|
|
5
|
+
|
|
6
|
+
- Today's date.
|
|
7
|
+
- The last few headings from any session document modified today.
|
|
8
|
+
- A short git status summary if the vault is a git repository.
|
|
9
|
+
- The five most recently modified session-typed documents.
|
|
10
|
+
|
|
11
|
+
Each block is optional. If any source is missing the block is
|
|
12
|
+
silently omitted rather than reported as an error. The hook's job is
|
|
13
|
+
to make the new session smarter, not to surface diagnostic noise.
|
|
14
|
+
|
|
15
|
+
Heavy retrieval (LEANN dense + Graphiti KG) is intentionally not
|
|
16
|
+
called from this hook. SessionStart has a 500ms p95 budget. We hold
|
|
17
|
+
the p95 with fast SQLite reads; the optional git summary runs two
|
|
18
|
+
short ``git`` execs (status + log) under a shared
|
|
19
|
+
``GIT_BUDGET_SECONDS`` deadline, so a slow or hung repo degrades the
|
|
20
|
+
git block rather than blowing the budget by seconds.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import sqlite3
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
from datetime import date
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from mneme_core.injection import wrap_untrusted
|
|
34
|
+
from mneme_core.vault.config import VaultConfig
|
|
35
|
+
|
|
36
|
+
from .lib import emit, run_hook
|
|
37
|
+
|
|
38
|
+
MAX_CHARS = 8_000
|
|
39
|
+
RECENT_SESSION_LIMIT = 5
|
|
40
|
+
GIT_STATUS_LINE_LIMIT = 10
|
|
41
|
+
# Hard ceiling shared by the two git execs. The healthy-repo p95 is far
|
|
42
|
+
# below this; the budget only bites when git is slow or hung, where we
|
|
43
|
+
# would rather drop the git block than make every session wait.
|
|
44
|
+
GIT_BUDGET_SECONDS = 1.5
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _today_iso() -> str:
|
|
48
|
+
return date.today().isoformat()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _block_date() -> str:
|
|
52
|
+
return f"### Date\n{_today_iso()}\n"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _block_today_headings(vault: VaultConfig) -> str:
|
|
56
|
+
# The Stop hook writes the daily session log under sessions/ (its
|
|
57
|
+
# LOG_DIR_NAME), so read from there rather than the vault root.
|
|
58
|
+
today_md = vault.root / "sessions" / f"{_today_iso()}.md"
|
|
59
|
+
if not today_md.exists():
|
|
60
|
+
return ""
|
|
61
|
+
try:
|
|
62
|
+
lines = today_md.read_text(encoding="utf-8").splitlines()
|
|
63
|
+
except OSError:
|
|
64
|
+
return ""
|
|
65
|
+
headings = [ln for ln in lines if ln.startswith("##")][-5:]
|
|
66
|
+
if not headings:
|
|
67
|
+
return ""
|
|
68
|
+
out = ["### Today's recent section headings", ""]
|
|
69
|
+
out.extend(f"- {h.lstrip('# ').strip()}" for h in headings)
|
|
70
|
+
return "\n".join(out) + "\n"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _block_git_summary(vault_root: Path) -> str:
|
|
74
|
+
if not (vault_root / ".git").exists():
|
|
75
|
+
return ""
|
|
76
|
+
deadline = time.monotonic() + GIT_BUDGET_SECONDS
|
|
77
|
+
try:
|
|
78
|
+
status = subprocess.run(
|
|
79
|
+
["git", "status", "--short"],
|
|
80
|
+
cwd=str(vault_root),
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
timeout=GIT_BUDGET_SECONDS,
|
|
85
|
+
)
|
|
86
|
+
remaining = deadline - time.monotonic()
|
|
87
|
+
if remaining <= 0:
|
|
88
|
+
# status alone consumed the budget on a slow repo; drop the
|
|
89
|
+
# whole block rather than starting a second exec over budget.
|
|
90
|
+
return ""
|
|
91
|
+
log = subprocess.run(
|
|
92
|
+
["git", "log", "--oneline", "-5"],
|
|
93
|
+
cwd=str(vault_root),
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
encoding="utf-8",
|
|
97
|
+
timeout=remaining,
|
|
98
|
+
)
|
|
99
|
+
except (subprocess.TimeoutExpired, OSError, FileNotFoundError):
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
status_lines = [ln for ln in status.stdout.splitlines() if ln.strip()]
|
|
103
|
+
out = ["### Vault git status"]
|
|
104
|
+
if status_lines:
|
|
105
|
+
head = status_lines[:GIT_STATUS_LINE_LIMIT]
|
|
106
|
+
out.append(f"{len(status_lines)} changed (showing first {len(head)}):")
|
|
107
|
+
out.extend(f" {ln}" for ln in head)
|
|
108
|
+
else:
|
|
109
|
+
out.append("Clean")
|
|
110
|
+
out.append("")
|
|
111
|
+
out.append("### Last 5 commits")
|
|
112
|
+
out.append(log.stdout.strip() if log.stdout.strip() else "(no git log)")
|
|
113
|
+
return "\n".join(out) + "\n"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _block_recent_sessions(vault: VaultConfig) -> str:
|
|
117
|
+
if not vault.fts5_db.exists():
|
|
118
|
+
return ""
|
|
119
|
+
try:
|
|
120
|
+
conn = sqlite3.connect(
|
|
121
|
+
f"file:{vault.fts5_db}?mode=ro",
|
|
122
|
+
uri=True,
|
|
123
|
+
)
|
|
124
|
+
except sqlite3.OperationalError:
|
|
125
|
+
# mode=ro raises OperationalError when the file is missing or
|
|
126
|
+
# cannot be opened for reading; degrade gracefully.
|
|
127
|
+
return ""
|
|
128
|
+
except sqlite3.Error:
|
|
129
|
+
return ""
|
|
130
|
+
try:
|
|
131
|
+
rows = conn.execute(
|
|
132
|
+
"SELECT path, COALESCE(title, '') AS title "
|
|
133
|
+
"FROM documents "
|
|
134
|
+
"WHERE frontmatter_type = 'session' "
|
|
135
|
+
"ORDER BY mtime DESC LIMIT ?",
|
|
136
|
+
(RECENT_SESSION_LIMIT,),
|
|
137
|
+
).fetchall()
|
|
138
|
+
except sqlite3.Error:
|
|
139
|
+
return ""
|
|
140
|
+
finally:
|
|
141
|
+
conn.close()
|
|
142
|
+
|
|
143
|
+
if not rows:
|
|
144
|
+
return ""
|
|
145
|
+
out = ["### Most recent session documents", ""]
|
|
146
|
+
for path, title in rows:
|
|
147
|
+
label = title or path
|
|
148
|
+
out.append(f"- `{path}` -- {label}")
|
|
149
|
+
return "\n".join(out) + "\n"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_context(vault: VaultConfig) -> str:
|
|
153
|
+
# Vault-derived blocks are untrusted: a crafted note title, section
|
|
154
|
+
# heading, or commit message could carry prompt-injection text that
|
|
155
|
+
# would otherwise be surfaced as authoritative preamble. Fence them
|
|
156
|
+
# with the spotlighting guard (gap G-3) so the model treats them as
|
|
157
|
+
# data. The date block is system-generated and stays outside.
|
|
158
|
+
untrusted = "\n".join(
|
|
159
|
+
b
|
|
160
|
+
for b in (
|
|
161
|
+
_block_today_headings(vault),
|
|
162
|
+
_block_git_summary(vault.root),
|
|
163
|
+
_block_recent_sessions(vault),
|
|
164
|
+
)
|
|
165
|
+
if b
|
|
166
|
+
)
|
|
167
|
+
parts: list[str] = ["## Vault Context (mneme SessionStart)", "", _block_date()]
|
|
168
|
+
if untrusted:
|
|
169
|
+
parts.append(wrap_untrusted(untrusted, source="vault-session-start"))
|
|
170
|
+
full = "\n".join(p for p in parts if p)
|
|
171
|
+
if len(full) > MAX_CHARS:
|
|
172
|
+
full = full[:MAX_CHARS] + "\n\n[CONTEXT TRUNCATED]"
|
|
173
|
+
return full
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def handle(event: dict[str, Any], vault: VaultConfig | None) -> None:
|
|
177
|
+
if vault is None:
|
|
178
|
+
emit(hook_event_name="SessionStart")
|
|
179
|
+
return
|
|
180
|
+
context = _build_context(vault)
|
|
181
|
+
emit(hook_event_name="SessionStart", additional_context=context)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main() -> int:
|
|
185
|
+
return run_hook(handle, hook_event_name="SessionStart")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
sys.exit(main())
|