Coverage for little_loops / hooks / user_prompt_submit.py: 0%

69 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -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# Navigate from this file up to the plugin root, then into hooks/prompts/. 

36# Path: scripts/little_loops/hooks/user_prompt_submit.py → parents[3] = repo root. 

37_PROMPT_FILE = Path(__file__).resolve().parents[3] / "hooks" / "prompts" / "optimize-prompt-hook.md" 

38 

39_MIN_PROMPT_LENGTH = 10 

40 

41 

42def _load_config(cwd: Path) -> dict[str, Any] | None: 

43 config_path = resolve_config_path(cwd) 

44 if config_path is None: 

45 return None 

46 try: 

47 data = json.loads(config_path.read_text(encoding="utf-8")) 

48 except (OSError, json.JSONDecodeError): 

49 return None 

50 return data if isinstance(data, dict) else None 

51 

52 

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

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

55 

56 Mirrors the bypass-guard logic of ``user-prompt-check.sh`` and uses 

57 ``resolve_config_path()`` for host-aware config lookup (probes 

58 ``.codex/ll-config.json`` first when ``LL_HOOK_HOST=codex``). 

59 """ 

60 user_prompt = event.payload.get("prompt", "") 

61 if not isinstance(user_prompt, str): 

62 user_prompt = "" 

63 

64 if not user_prompt.strip(): 

65 return LLHookResult(exit_code=0) 

66 

67 cwd = Path.cwd() 

68 config = _load_config(cwd) 

69 

70 if config is not None and feature_enabled(config, "analytics.enabled"): 

71 capture = AnalyticsCaptureConfig.from_dict(config.get("analytics", {}).get("capture", {})) 

72 if is_correction(user_prompt, extra_patterns=capture.correction_patterns): 

73 if capture.corrections: 

74 session_id = event.payload.get("session_id") or event.session_id 

75 with contextlib.suppress(Exception): 

76 record_correction( 

77 cwd / ".ll" / "history.db", session_id, user_prompt, "user_prompt_submit" 

78 ) 

79 # TODO(ENH-1835): wire analytics.capture.cli_commands gate when ENH-1834 lands 

80 m = re.match(r"^/ll:([a-z][a-z0-9-]*)(.*)", user_prompt.strip(), re.DOTALL) 

81 if m: 

82 session_id = event.payload.get("session_id") or event.session_id 

83 with contextlib.suppress(Exception): 

84 record_skill_event( 

85 cwd / ".ll" / "history.db", session_id, m.group(1), m.group(2).strip()[:200] 

86 ) 

87 

88 if config is None: 

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

90 

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

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

93 

94 if not prompt_opt.get("enabled", False): 

95 return LLHookResult(exit_code=0) 

96 

97 mode = str(prompt_opt.get("mode", "quick")) 

98 confirm = str(prompt_opt.get("confirm", "true")) 

99 bypass_prefix = str(prompt_opt.get("bypass_prefix", "*")) 

100 

101 # Bypass guards (mirrors user-prompt-check.sh order) 

102 if bypass_prefix and user_prompt.startswith(bypass_prefix): 

103 return LLHookResult(exit_code=0) 

104 if user_prompt.startswith("/"): 

105 return LLHookResult(exit_code=0) 

106 if user_prompt.startswith("#"): 

107 return LLHookResult(exit_code=0) 

108 if user_prompt.startswith("?"): 

109 return LLHookResult(exit_code=0) 

110 if len(user_prompt) < _MIN_PROMPT_LENGTH: 

111 return LLHookResult(exit_code=0) 

112 

113 if not _PROMPT_FILE.is_file(): 

114 return LLHookResult(exit_code=0) 

115 

116 try: 

117 template = _PROMPT_FILE.read_text(encoding="utf-8") 

118 except OSError: 

119 return LLHookResult(exit_code=0) 

120 

121 rendered = ( 

122 template.replace("{{USER_PROMPT}}", user_prompt) 

123 .replace("{{MODE}}", mode) 

124 .replace("{{CONFIRM}}", confirm) 

125 ) 

126 return LLHookResult(exit_code=0, stdout=rendered)