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.
@@ -0,0 +1,3 @@
1
+ """Claude Code plugin package for mneme."""
2
+
3
+ __version__ = "2.0.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())