1"""UserPromptSubmit hook handler: auto-prompt optimization.

2

3 Python 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.

7

8 Applies bypass guards (slash commands, *, #, ?, short prompts) then

9 renders ``hooks/prompts/optimize-prompt-hook.md`` with

10``prompt_optimization.*`` config values substituted. Returns the rendered

11 template via ``LLHookResult.stdout`` so the host injects it as

12``additionalContext`` alongside the user's original prompt.

13

14 Config path is resolved via :func:`little_loops.config.core.resolve_config_path`,

15 which probes ``.codex/ll-config.json`` first when ``LL_HOOK_HOST=codex``.

16"""

17

18 from __future__ import annotations

19

20 import contextlib

21 import json

22 import re

23 from pathlib import Path

24 from typing import Any

25

26 from little_loops.config.core import resolve_config_path

27 from little_loops.config.features import AnalyticsCaptureConfig, feature_enabled

28 from little_loops.hooks.types import LLHookEvent, LLHookResult

29 from little_loops.session_store import is_correction, record_correction, record_skill_event

30

31_NO_CONFIG_MSG = (

32 "[little-loops] No config found. Run ll-init to set up little-loops for this project."

33)

34

35_MIN_PROMPT_LENGTH = 10

36

37

38 def _find_prompt_file() -> Path:

39 """Resolve the optimize-prompt-hook template, checking CLAUDE_PLUGIN_ROOT first."""

40 import os

41

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"

48

49

50 def _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

59

60

61 def handle(event: LLHookEvent) -> LLHookResult:

62 """Apply prompt optimization if enabled; return rendered template on stdout.

63

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 = ""

71

72 if not user_prompt.strip():

73 return LLHookResult(exit_code=0)

74

75 cwd = Path.cwd()

76 config = _load_config(cwd)

77

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 )

95

96 if config is None:

97 return LLHookResult(exit_code=0, stdout=_NO_CONFIG_MSG + "\n")

98

99 raw_opt = config.get("prompt_optimization", {})

100 prompt_opt: dict[str, Any] = raw_opt if isinstance(raw_opt, dict) else {}

101

102 if not prompt_opt.get("enabled", True):

103 return LLHookResult(exit_code=0)

104

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", "*"))

108

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)

120

121 prompt_file = _find_prompt_file()

122 if not prompt_file.is_file():

123 return LLHookResult(exit_code=0)

124

125 try:

126 template = prompt_file.read_text(encoding="utf-8")

127 except OSError:

128 return LLHookResult(exit_code=0)

129

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)