Coverage for little_loops / hooks / user_prompt_submit.py: 0%
69 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -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 contextlib
21import json
22import re
23from pathlib import Path
24from typing import Any
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
31_NO_CONFIG_MSG = (
32 "[little-loops] No config found. Run ll-init to set up little-loops for this project."
33)
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"
39_MIN_PROMPT_LENGTH = 10
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
53def handle(event: LLHookEvent) -> LLHookResult:
54 """Apply prompt optimization if enabled; return rendered template on stdout.
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 = ""
64 if not user_prompt.strip():
65 return LLHookResult(exit_code=0)
67 cwd = Path.cwd()
68 config = _load_config(cwd)
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 )
88 if config is None:
89 return LLHookResult(exit_code=0, stdout=_NO_CONFIG_MSG + "\n")
91 raw_opt = config.get("prompt_optimization", {})
92 prompt_opt: dict[str, Any] = raw_opt if isinstance(raw_opt, dict) else {}
94 if not prompt_opt.get("enabled", False):
95 return LLHookResult(exit_code=0)
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", "*"))
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)
113 if not _PROMPT_FILE.is_file():
114 return LLHookResult(exit_code=0)
116 try:
117 template = _PROMPT_FILE.read_text(encoding="utf-8")
118 except OSError:
119 return LLHookResult(exit_code=0)
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)