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

53 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -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 json 

21from pathlib import Path 

22from typing import Any 

23 

24from little_loops.config.core import resolve_config_path 

25from little_loops.hooks.types import LLHookEvent, LLHookResult 

26 

27_NO_CONFIG_MSG = ( 

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

29) 

30 

31# Navigate from this file up to the plugin root, then into hooks/prompts/. 

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

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

34 

35_MIN_PROMPT_LENGTH = 10 

36 

37 

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

39 config_path = resolve_config_path(cwd) 

40 if config_path is None: 

41 return None 

42 try: 

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

44 except (OSError, json.JSONDecodeError): 

45 return None 

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

47 

48 

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

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

51 

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

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

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

55 """ 

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

57 if not isinstance(user_prompt, str): 

58 user_prompt = "" 

59 

60 if not user_prompt.strip(): 

61 return LLHookResult(exit_code=0) 

62 

63 cwd = Path.cwd() 

64 config = _load_config(cwd) 

65 

66 if config is None: 

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

68 

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

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

71 

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

73 return LLHookResult(exit_code=0) 

74 

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

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

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

78 

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

80 if bypass_prefix and user_prompt.startswith(bypass_prefix): 

81 return LLHookResult(exit_code=0) 

82 if user_prompt.startswith("/"): 

83 return LLHookResult(exit_code=0) 

84 if user_prompt.startswith("#"): 

85 return LLHookResult(exit_code=0) 

86 if user_prompt.startswith("?"): 

87 return LLHookResult(exit_code=0) 

88 if len(user_prompt) < _MIN_PROMPT_LENGTH: 

89 return LLHookResult(exit_code=0) 

90 

91 if not _PROMPT_FILE.is_file(): 

92 return LLHookResult(exit_code=0) 

93 

94 try: 

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

96 except OSError: 

97 return LLHookResult(exit_code=0) 

98 

99 rendered = ( 

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

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

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

103 ) 

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