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

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

2 

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. 

7 

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. 

13 

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

17 

18from __future__ import annotations 

19 

20import contextlib 

21import json 

22import re 

23from pathlib import Path 

24from typing import Any 

25 

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 

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 

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

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 

59 

60 

61def 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", False): 

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)