Coverage for little_loops / hooks / user_prompt_submit.py: 0%
77 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""UserPromptSubmit hook handler: auto-prompt optimization.
3Python port of ``hooks/scripts/user-prompt-check.sh`` (FEAT-1482). The
4``handle`` function is invoked by the dispatcher in
5``little_loops.hooks.__init__::main_hooks`` after the Codex adapter
6(``hooks/adapters/codex/prompt-submit.sh``) reads the host's stdin payload.
8Applies bypass guards (slash commands, *, #, ?, short prompts) then
9renders ``hooks/prompts/optimize-prompt-hook.md`` with
10``prompt_optimization.*`` config values substituted. Returns the rendered
11template via ``LLHookResult.stdout`` so the host injects it as
12``additionalContext`` alongside the user's original prompt.
14Config path is resolved via :func:`little_loops.config.core.resolve_config_path`,
15which probes ``.codex/ll-config.json`` first when ``LL_HOOK_HOST=codex``.
16"""
18from __future__ import annotations
20import contextlib
21import json
22import re
23from pathlib import Path
24from typing import Any
26from little_loops.config.core import resolve_config_path
27from little_loops.config.features import AnalyticsCaptureConfig, feature_enabled
28from little_loops.hooks.types import LLHookEvent, LLHookResult
29from little_loops.session_store import is_correction, record_correction, record_skill_event
31_NO_CONFIG_MSG = (
32 "[little-loops] No config found. Run ll-init to set up little-loops for this project."
33)
35_MIN_PROMPT_LENGTH = 10
38def _find_prompt_file() -> Path:
39 """Resolve the optimize-prompt-hook template, checking CLAUDE_PLUGIN_ROOT first."""
40 import os
42 plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
43 if plugin_root:
44 custom = Path(plugin_root) / "hooks" / "prompts" / "optimize-prompt-hook.md"
45 if custom.is_file():
46 return custom
47 return Path(__file__).parent / "prompts" / "optimize-prompt-hook.md"
50def _load_config(cwd: Path) -> dict[str, Any] | None:
51 config_path = resolve_config_path(cwd)
52 if config_path is None:
53 return None
54 try:
55 data = json.loads(config_path.read_text(encoding="utf-8"))
56 except (OSError, json.JSONDecodeError):
57 return None
58 return data if isinstance(data, dict) else None
61def handle(event: LLHookEvent) -> LLHookResult:
62 """Apply prompt optimization if enabled; return rendered template on stdout.
64 Mirrors the bypass-guard logic of ``user-prompt-check.sh`` and uses
65 ``resolve_config_path()`` for host-aware config lookup (probes
66 ``.codex/ll-config.json`` first when ``LL_HOOK_HOST=codex``).
67 """
68 user_prompt = event.payload.get("prompt", "")
69 if not isinstance(user_prompt, str):
70 user_prompt = ""
72 if not user_prompt.strip():
73 return LLHookResult(exit_code=0)
75 cwd = Path.cwd()
76 config = _load_config(cwd)
78 if config is not None and feature_enabled(config, "analytics.enabled"):
79 capture = AnalyticsCaptureConfig.from_dict(config.get("analytics", {}).get("capture", {}))
80 if is_correction(user_prompt, extra_patterns=capture.correction_patterns):
81 if capture.corrections:
82 session_id = event.payload.get("session_id") or event.session_id
83 with contextlib.suppress(Exception):
84 record_correction(
85 cwd / ".ll" / "history.db", session_id, user_prompt, "user_prompt_submit"
86 )
87 # TODO(ENH-1835): wire analytics.capture.cli_commands gate when ENH-1834 lands
88 m = re.match(r"^/ll:([a-z][a-z0-9-]*)(.*)", user_prompt.strip(), re.DOTALL)
89 if m:
90 session_id = event.payload.get("session_id") or event.session_id
91 with contextlib.suppress(Exception):
92 record_skill_event(
93 cwd / ".ll" / "history.db", session_id, m.group(1), m.group(2).strip()[:200]
94 )
96 if config is None:
97 return LLHookResult(exit_code=0, stdout=_NO_CONFIG_MSG + "\n")
99 raw_opt = config.get("prompt_optimization", {})
100 prompt_opt: dict[str, Any] = raw_opt if isinstance(raw_opt, dict) else {}
102 if not prompt_opt.get("enabled", False):
103 return LLHookResult(exit_code=0)
105 mode = str(prompt_opt.get("mode", "quick"))
106 confirm = str(prompt_opt.get("confirm", "true"))
107 bypass_prefix = str(prompt_opt.get("bypass_prefix", "*"))
109 # Bypass guards (mirrors user-prompt-check.sh order)
110 if bypass_prefix and user_prompt.startswith(bypass_prefix):
111 return LLHookResult(exit_code=0)
112 if user_prompt.startswith("/"):
113 return LLHookResult(exit_code=0)
114 if user_prompt.startswith("#"):
115 return LLHookResult(exit_code=0)
116 if user_prompt.startswith("?"):
117 return LLHookResult(exit_code=0)
118 if len(user_prompt) < _MIN_PROMPT_LENGTH:
119 return LLHookResult(exit_code=0)
121 prompt_file = _find_prompt_file()
122 if not prompt_file.is_file():
123 return LLHookResult(exit_code=0)
125 try:
126 template = prompt_file.read_text(encoding="utf-8")
127 except OSError:
128 return LLHookResult(exit_code=0)
130 rendered = (
131 template.replace("{{USER_PROMPT}}", user_prompt)
132 .replace("{{MODE}}", mode)
133 .replace("{{CONFIRM}}", confirm)
134 )
135 return LLHookResult(exit_code=0, stdout=rendered)