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
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -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 json
21from pathlib import Path
22from typing import Any
24from little_loops.config.core import resolve_config_path
25from little_loops.hooks.types import LLHookEvent, LLHookResult
27_NO_CONFIG_MSG = (
28 "[little-loops] No config found. Run /ll:init to set up little-loops for this project."
29)
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"
35_MIN_PROMPT_LENGTH = 10
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
49def handle(event: LLHookEvent) -> LLHookResult:
50 """Apply prompt optimization if enabled; return rendered template on stdout.
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 = ""
60 if not user_prompt.strip():
61 return LLHookResult(exit_code=0)
63 cwd = Path.cwd()
64 config = _load_config(cwd)
66 if config is None:
67 return LLHookResult(exit_code=0, stdout=_NO_CONFIG_MSG + "\n")
69 raw_opt = config.get("prompt_optimization", {})
70 prompt_opt: dict[str, Any] = raw_opt if isinstance(raw_opt, dict) else {}
72 if not prompt_opt.get("enabled", False):
73 return LLHookResult(exit_code=0)
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", "*"))
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)
91 if not _PROMPT_FILE.is_file():
92 return LLHookResult(exit_code=0)
94 try:
95 template = _PROMPT_FILE.read_text(encoding="utf-8")
96 except OSError:
97 return LLHookResult(exit_code=0)
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)