capt-hook 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capt_hook-0.2.0.dist-info/METADATA +113 -0
- capt_hook-0.2.0.dist-info/RECORD +57 -0
- capt_hook-0.2.0.dist-info/WHEEL +4 -0
- capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
- capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
- captain_hook/__init__.py +246 -0
- captain_hook/__main__.py +6 -0
- captain_hook/app.py +278 -0
- captain_hook/classifiers/__init__.py +30 -0
- captain_hook/classifiers/conductor.py +35 -0
- captain_hook/classifiers/droid.py +20 -0
- captain_hook/classifiers/native.py +19 -0
- captain_hook/cli.py +341 -0
- captain_hook/command.py +356 -0
- captain_hook/conditions.py +136 -0
- captain_hook/context.py +161 -0
- captain_hook/dispatch.py +107 -0
- captain_hook/events.py +318 -0
- captain_hook/file.py +120 -0
- captain_hook/llm/__init__.py +9 -0
- captain_hook/llm/backends.py +152 -0
- captain_hook/loader.py +62 -0
- captain_hook/log.py +60 -0
- captain_hook/primitives/__init__.py +51 -0
- captain_hook/primitives/audit.py +71 -0
- captain_hook/primitives/commands.py +61 -0
- captain_hook/primitives/lint.py +216 -0
- captain_hook/primitives/llm.py +376 -0
- captain_hook/primitives/nudge.py +95 -0
- captain_hook/prompt.py +103 -0
- captain_hook/py.typed +1 -0
- captain_hook/session.py +158 -0
- captain_hook/settings.py +120 -0
- captain_hook/signals/__init__.py +86 -0
- captain_hook/signals/nlp.py +105 -0
- captain_hook/state.py +221 -0
- captain_hook/styleguide/__init__.py +183 -0
- captain_hook/styleguide/query.py +238 -0
- captain_hook/styleguide/scope.py +46 -0
- captain_hook/styleguide/types.py +70 -0
- captain_hook/tasks.py +112 -0
- captain_hook/templates/example_hook.py.tmpl +85 -0
- captain_hook/testing/__init__.py +10 -0
- captain_hook/testing/helpers.py +392 -0
- captain_hook/testing/session_cache.py +50 -0
- captain_hook/testing/types.py +88 -0
- captain_hook/tests/__init__.py +27 -0
- captain_hook/tests/helpers.py +361 -0
- captain_hook/tools.py +59 -0
- captain_hook/transcript/__init__.py +572 -0
- captain_hook/transcript/inputs.py +226 -0
- captain_hook/transcript/models.py +186 -0
- captain_hook/types.py +381 -0
- captain_hook/util/__init__.py +0 -0
- captain_hook/util/model_cache.py +87 -0
- captain_hook/utils.py +27 -0
- captain_hook/workflow.py +119 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from captain_hook import state
|
|
13
|
+
from captain_hook.app import on
|
|
14
|
+
from captain_hook.primitives.audit import session_id_for
|
|
15
|
+
from captain_hook.prompt import Prompt, PromptMessage
|
|
16
|
+
from captain_hook.state import PrimitiveState, fired_this_turn, hook_name, record_fire
|
|
17
|
+
from captain_hook.types import (
|
|
18
|
+
Action,
|
|
19
|
+
Event,
|
|
20
|
+
HookResult,
|
|
21
|
+
InlineTests,
|
|
22
|
+
Signal,
|
|
23
|
+
Signals,
|
|
24
|
+
TCondition,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
FAILURE_ROOT = state.CACHE_ROOT / "failures"
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from captain_hook.events import BaseHookEvent
|
|
31
|
+
from captain_hook.llm import TModel, TSpecialty
|
|
32
|
+
from captain_hook.signals.nlp import NlpSignal
|
|
33
|
+
|
|
34
|
+
from captain_hook.signals import extract_signal_context, resolve_signals, transcript_texts
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GateVerdict(BaseModel):
|
|
38
|
+
"""LLM response model for ``llm_gate``. The LLM sets ``block=True`` to deny."""
|
|
39
|
+
|
|
40
|
+
block: bool
|
|
41
|
+
reasoning: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NudgeVerdict(BaseModel):
|
|
45
|
+
"""LLM response model for ``llm_nudge``. The LLM sets ``fire=True`` to trigger the nudge."""
|
|
46
|
+
|
|
47
|
+
fire: bool
|
|
48
|
+
reasoning: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PromptCheckVerdict(BaseModel):
|
|
52
|
+
"""LLM response model for ``prompt_check``. Action is ``"ok"``, ``"warning"``, or ``"block"``."""
|
|
53
|
+
|
|
54
|
+
action: Literal["ok", "warning", "block"]
|
|
55
|
+
reason: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def llm_evaluate[M: BaseModel](
|
|
59
|
+
evt: BaseHookEvent,
|
|
60
|
+
prompt: str,
|
|
61
|
+
response_model: type[M],
|
|
62
|
+
*,
|
|
63
|
+
signals: Sequence[Signal | NlpSignal] | Signals | None = None,
|
|
64
|
+
when: Callable[[BaseHookEvent], bool] | None = None,
|
|
65
|
+
max_context: int = 2000,
|
|
66
|
+
specialty: TSpecialty = "review",
|
|
67
|
+
model: TModel = "small",
|
|
68
|
+
agent: bool = False,
|
|
69
|
+
transcript: bool = False,
|
|
70
|
+
) -> M | None:
|
|
71
|
+
if fired_this_turn(evt):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
if sig := resolve_signals(signals):
|
|
75
|
+
ps = evt.ctx.s[PrimitiveState].get(PrimitiveState())
|
|
76
|
+
texts = transcript_texts(evt, sig.window)
|
|
77
|
+
old_consumed = ps.consumed.copy()
|
|
78
|
+
if not (contributing_texts := ps.match_signals(sig, texts)):
|
|
79
|
+
evt.ctx.s[PrimitiveState].set(ps)
|
|
80
|
+
return None
|
|
81
|
+
ps.consumed = old_consumed
|
|
82
|
+
evt.ctx.s[PrimitiveState].set(ps)
|
|
83
|
+
elif when is not None and not when(evt):
|
|
84
|
+
return None
|
|
85
|
+
else:
|
|
86
|
+
contributing_texts = transcript_texts(evt, 5)
|
|
87
|
+
|
|
88
|
+
context = "\n".join(
|
|
89
|
+
[line for text in contributing_texts for line in extract_signal_context(sig.patterns, text)]
|
|
90
|
+
if sig
|
|
91
|
+
else contributing_texts
|
|
92
|
+
)[:max_context]
|
|
93
|
+
|
|
94
|
+
built = Prompt().system(prompt).context("context", context or None)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
return evt.ctx.call_llm(
|
|
98
|
+
built,
|
|
99
|
+
specialty=specialty,
|
|
100
|
+
model=model,
|
|
101
|
+
agent=agent,
|
|
102
|
+
transcript=transcript,
|
|
103
|
+
response_model=response_model,
|
|
104
|
+
)
|
|
105
|
+
except Exception:
|
|
106
|
+
logger.bind(prompt=prompt).opt(exception=True).warning("llm evaluate failed")
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def consume_signals(evt: BaseHookEvent, sig: Signals | None) -> None:
|
|
111
|
+
if not sig:
|
|
112
|
+
return
|
|
113
|
+
ps = evt.ctx.s[PrimitiveState].get(PrimitiveState())
|
|
114
|
+
texts = transcript_texts(evt, sig.window)
|
|
115
|
+
ps.match_signals(sig, texts)
|
|
116
|
+
evt.ctx.s[PrimitiveState].set(ps)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def llm_primitive[M: BaseModel](
|
|
120
|
+
prompt: str,
|
|
121
|
+
*,
|
|
122
|
+
action: Action,
|
|
123
|
+
label: str,
|
|
124
|
+
message: str | Callable[[M], str],
|
|
125
|
+
response_model: type[M],
|
|
126
|
+
verdict: Callable[[M], bool],
|
|
127
|
+
default_events: Event,
|
|
128
|
+
default_max_fires: int,
|
|
129
|
+
signals: Sequence[Signal | NlpSignal] | Signals | None = None,
|
|
130
|
+
when: Callable[[BaseHookEvent], bool] | None = None,
|
|
131
|
+
only_if: Sequence[TCondition] = (),
|
|
132
|
+
skip_if: Sequence[TCondition] = (),
|
|
133
|
+
events: Event | None = None,
|
|
134
|
+
max_fires: int | None = None,
|
|
135
|
+
tests: InlineTests | None = None,
|
|
136
|
+
async_: bool = False,
|
|
137
|
+
max_context: int = 2000,
|
|
138
|
+
specialty: TSpecialty = "review",
|
|
139
|
+
model: TModel = "small",
|
|
140
|
+
agent: bool = False,
|
|
141
|
+
transcript: bool = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
sig = resolve_signals(signals)
|
|
144
|
+
|
|
145
|
+
def handler(evt: BaseHookEvent) -> HookResult | None:
|
|
146
|
+
if not (
|
|
147
|
+
result := llm_evaluate(
|
|
148
|
+
evt,
|
|
149
|
+
prompt,
|
|
150
|
+
response_model,
|
|
151
|
+
signals=signals,
|
|
152
|
+
when=when,
|
|
153
|
+
max_context=max_context,
|
|
154
|
+
specialty=specialty,
|
|
155
|
+
model=model,
|
|
156
|
+
agent=agent,
|
|
157
|
+
transcript=transcript,
|
|
158
|
+
)
|
|
159
|
+
):
|
|
160
|
+
return None
|
|
161
|
+
if not verdict(result):
|
|
162
|
+
return None
|
|
163
|
+
consume_signals(evt, sig)
|
|
164
|
+
record_fire(evt)
|
|
165
|
+
return HookResult(
|
|
166
|
+
action=action,
|
|
167
|
+
message=message(result) if callable(message) else message,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
handler.__name__ = handler.__qualname__ = hook_name(label, None, prompt)
|
|
171
|
+
|
|
172
|
+
on(
|
|
173
|
+
events or default_events,
|
|
174
|
+
only_if=only_if,
|
|
175
|
+
skip_if=skip_if,
|
|
176
|
+
max_fires=max_fires if max_fires is not None else default_max_fires,
|
|
177
|
+
tests=tests,
|
|
178
|
+
async_=async_,
|
|
179
|
+
)(handler)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def llm_gate(
|
|
183
|
+
prompt: str,
|
|
184
|
+
*,
|
|
185
|
+
message: str | Callable[[GateVerdict], str],
|
|
186
|
+
response_model: type[GateVerdict] = GateVerdict,
|
|
187
|
+
verdict: Callable[[GateVerdict], bool] = lambda r: r.block,
|
|
188
|
+
signals: Sequence[Signal | NlpSignal] | Signals | None = None,
|
|
189
|
+
when: Callable[[BaseHookEvent], bool] | None = None,
|
|
190
|
+
only_if: Sequence[TCondition] = (),
|
|
191
|
+
skip_if: Sequence[TCondition] = (),
|
|
192
|
+
events: Event | None = None,
|
|
193
|
+
max_fires: int | None = None,
|
|
194
|
+
tests: InlineTests | None = None,
|
|
195
|
+
max_context: int = 2000,
|
|
196
|
+
specialty: TSpecialty = "review",
|
|
197
|
+
model: TModel = "small",
|
|
198
|
+
agent: bool = True,
|
|
199
|
+
transcript: bool = True,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Register an LLM-powered blocking gate.
|
|
202
|
+
|
|
203
|
+
Defaults are tuned for the common case: ``agent=True`` and ``transcript=True``
|
|
204
|
+
so the gate has tool access and full transcript context. Pass
|
|
205
|
+
``agent=False, transcript=False`` for cheap, stateless yes/no checks.
|
|
206
|
+
|
|
207
|
+
Example:
|
|
208
|
+
>>> llm_gate("Is the agent making excuses?",
|
|
209
|
+
... message=lambda r: f"Excuse detected: {r.reasoning}",
|
|
210
|
+
... signals=Signals([Signal(r"external.*service", weight=2)], threshold=2))
|
|
211
|
+
"""
|
|
212
|
+
llm_primitive(
|
|
213
|
+
prompt,
|
|
214
|
+
action=Action.block,
|
|
215
|
+
label="llm_gate",
|
|
216
|
+
message=message,
|
|
217
|
+
response_model=response_model,
|
|
218
|
+
verdict=verdict,
|
|
219
|
+
default_events=Event.Stop | Event.SubagentStop,
|
|
220
|
+
default_max_fires=1,
|
|
221
|
+
signals=signals,
|
|
222
|
+
when=when,
|
|
223
|
+
only_if=only_if,
|
|
224
|
+
skip_if=skip_if,
|
|
225
|
+
events=events,
|
|
226
|
+
max_fires=max_fires,
|
|
227
|
+
tests=tests,
|
|
228
|
+
max_context=max_context,
|
|
229
|
+
specialty=specialty,
|
|
230
|
+
model=model,
|
|
231
|
+
agent=agent,
|
|
232
|
+
transcript=transcript,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def llm_nudge(
|
|
237
|
+
prompt: str,
|
|
238
|
+
*,
|
|
239
|
+
message: str | Callable[[NudgeVerdict], str],
|
|
240
|
+
response_model: type[NudgeVerdict] = NudgeVerdict,
|
|
241
|
+
verdict: Callable[[NudgeVerdict], bool] = lambda r: r.fire,
|
|
242
|
+
signals: Sequence[Signal | NlpSignal] | Signals | None = None,
|
|
243
|
+
when: Callable[[BaseHookEvent], bool] | None = None,
|
|
244
|
+
only_if: Sequence[TCondition] = (),
|
|
245
|
+
skip_if: Sequence[TCondition] = (),
|
|
246
|
+
events: Event | None = None,
|
|
247
|
+
max_fires: int | None = None,
|
|
248
|
+
tests: InlineTests | None = None,
|
|
249
|
+
async_: bool = False,
|
|
250
|
+
max_context: int = 2000,
|
|
251
|
+
specialty: TSpecialty = "review",
|
|
252
|
+
model: TModel = "small",
|
|
253
|
+
agent: bool = True,
|
|
254
|
+
transcript: bool = True,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Register an LLM-powered advisory nudge.
|
|
257
|
+
|
|
258
|
+
Defaults are tuned for the common case: ``agent=True`` and ``transcript=True``
|
|
259
|
+
so the nudge has tool access and full transcript context. Pass
|
|
260
|
+
``agent=False, transcript=False`` for cheap, stateless yes/no checks.
|
|
261
|
+
|
|
262
|
+
Example:
|
|
263
|
+
>>> llm_nudge("Is the agent speculating instead of observing?",
|
|
264
|
+
... message="Observe, don't infer -- check traces first",
|
|
265
|
+
... signals=Signals([Signal(r"should contain", weight=2)], threshold=3))
|
|
266
|
+
"""
|
|
267
|
+
llm_primitive(
|
|
268
|
+
prompt,
|
|
269
|
+
action=Action.warn,
|
|
270
|
+
label="llm_nudge",
|
|
271
|
+
message=message,
|
|
272
|
+
response_model=response_model,
|
|
273
|
+
verdict=verdict,
|
|
274
|
+
default_events=Event.PostToolUse,
|
|
275
|
+
default_max_fires=3,
|
|
276
|
+
signals=signals,
|
|
277
|
+
when=when,
|
|
278
|
+
only_if=only_if,
|
|
279
|
+
skip_if=skip_if,
|
|
280
|
+
events=events,
|
|
281
|
+
max_fires=max_fires,
|
|
282
|
+
tests=tests,
|
|
283
|
+
async_=async_,
|
|
284
|
+
max_context=max_context,
|
|
285
|
+
specialty=specialty,
|
|
286
|
+
model=model,
|
|
287
|
+
agent=agent,
|
|
288
|
+
transcript=transcript,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def record_prompt_check_failure(
|
|
293
|
+
evt: BaseHookEvent,
|
|
294
|
+
prefix: str,
|
|
295
|
+
prompt: str,
|
|
296
|
+
exc: BaseException,
|
|
297
|
+
) -> None:
|
|
298
|
+
timestamp = datetime.now(UTC).isoformat().replace(":", "-")
|
|
299
|
+
match exc:
|
|
300
|
+
case subprocess.CalledProcessError(cmd=cmd, returncode=rc, output=out, stderr=err):
|
|
301
|
+
argv = list(cmd) if isinstance(cmd, list | tuple) else str(cmd)
|
|
302
|
+
exit_code, stdout, stderr = rc, out or "", err or ""
|
|
303
|
+
case _:
|
|
304
|
+
argv, exit_code, stdout, stderr = None, None, "", ""
|
|
305
|
+
|
|
306
|
+
failure_path = FAILURE_ROOT / (session_id_for(evt) or "unknown") / f"{timestamp}.json"
|
|
307
|
+
failure_path.parent.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
failure_path.write_text(
|
|
309
|
+
json.dumps(
|
|
310
|
+
{
|
|
311
|
+
"timestamp": timestamp,
|
|
312
|
+
"prefix": prefix,
|
|
313
|
+
"argv": argv,
|
|
314
|
+
"exit_code": exit_code,
|
|
315
|
+
"stdout": stdout,
|
|
316
|
+
"stderr": stderr,
|
|
317
|
+
"prompt": prompt,
|
|
318
|
+
"exception_type": type(exc).__name__,
|
|
319
|
+
"exception_str": str(exc),
|
|
320
|
+
},
|
|
321
|
+
indent=2,
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
logger.opt(exception=exc).warning(
|
|
326
|
+
f"prompt_check failed for {prefix}\n"
|
|
327
|
+
f" argv: {argv}\n"
|
|
328
|
+
f" exit_code: {exit_code}\n"
|
|
329
|
+
f" stderr: {stderr}\n"
|
|
330
|
+
f" stdout (tail 4KB): {stdout[-4096:]}\n"
|
|
331
|
+
f" prompt (tail 1KB): {prompt[-1024:]}\n"
|
|
332
|
+
f" failure_record: {failure_path}",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def prompt_check(
|
|
337
|
+
evt: BaseHookEvent,
|
|
338
|
+
template: str | PromptMessage,
|
|
339
|
+
fmt: dict[str, Any] | None = None,
|
|
340
|
+
*,
|
|
341
|
+
prefix: str,
|
|
342
|
+
suffix: str = "",
|
|
343
|
+
timeout: int = 45,
|
|
344
|
+
include_reasoning: bool = True,
|
|
345
|
+
response_model: type[PromptCheckVerdict] = PromptCheckVerdict,
|
|
346
|
+
) -> HookResult | None:
|
|
347
|
+
"""Run an LLM check with a formatted prompt and return block/warn/None."""
|
|
348
|
+
reasoning = ""
|
|
349
|
+
if include_reasoning:
|
|
350
|
+
reasoning = evt.ctx.t.recent(50).assistant_text() if hasattr(evt.ctx.t, "recent") else ""
|
|
351
|
+
|
|
352
|
+
base = template if isinstance(template, PromptMessage) else Prompt().system(template.format(**(fmt or {})))
|
|
353
|
+
built = base.context("agent_reasoning", reasoning or None)
|
|
354
|
+
prompt_str = str(built)
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
verdict = evt.ctx.call_llm(
|
|
358
|
+
built,
|
|
359
|
+
timeout=timeout,
|
|
360
|
+
response_model=response_model,
|
|
361
|
+
)
|
|
362
|
+
except Exception as exc:
|
|
363
|
+
record_prompt_check_failure(evt, prefix, prompt_str, exc)
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
if not verdict:
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
assert isinstance(verdict, response_model)
|
|
370
|
+
match verdict.action:
|
|
371
|
+
case "block":
|
|
372
|
+
return HookResult(action=Action.block, message=f"{prefix}: {verdict.reason}{suffix}")
|
|
373
|
+
case "warning":
|
|
374
|
+
return HookResult(action=Action.warn, message=f"{prefix}: {verdict.reason}{suffix}")
|
|
375
|
+
case _:
|
|
376
|
+
return None
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from captain_hook.app import on
|
|
7
|
+
from captain_hook.signals import cite_message, resolve_signals, transcript_texts
|
|
8
|
+
from captain_hook.state import EchoTracker, PrimitiveState, fired_this_turn, hook_name, record_fire
|
|
9
|
+
from captain_hook.types import (
|
|
10
|
+
Action,
|
|
11
|
+
Event,
|
|
12
|
+
HookResult,
|
|
13
|
+
InlineTests,
|
|
14
|
+
Signal,
|
|
15
|
+
Signals,
|
|
16
|
+
TCondition,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from captain_hook.events import BaseHookEvent
|
|
21
|
+
from captain_hook.signals.nlp import NlpSignal
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def nudge(
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
when: Callable[[BaseHookEvent], bool] | None = None,
|
|
28
|
+
signals: Sequence[Signal | NlpSignal] | Signals | None = None,
|
|
29
|
+
only_if: Sequence[TCondition] = (),
|
|
30
|
+
skip_if: Sequence[TCondition] = (),
|
|
31
|
+
block: bool = False,
|
|
32
|
+
events: Event | None = None,
|
|
33
|
+
max_fires: int | None = None,
|
|
34
|
+
tests: InlineTests | None = None,
|
|
35
|
+
async_: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Register a nudge that warns (or blocks) when conditions or signals match.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> nudge("Remember to run tests", only_if=[TouchedFile("**/*.py")])
|
|
41
|
+
|
|
42
|
+
With signal scoring:
|
|
43
|
+
>>> nudge("Stop retrying",
|
|
44
|
+
... signals=Signals([Signal(r"retry", weight=2)], threshold=2, window=5))
|
|
45
|
+
"""
|
|
46
|
+
sig = resolve_signals(signals)
|
|
47
|
+
|
|
48
|
+
def handler(evt: BaseHookEvent) -> HookResult | None:
|
|
49
|
+
if block and fired_this_turn(evt):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if sig:
|
|
53
|
+
ps = evt.ctx.s[PrimitiveState].get(PrimitiveState())
|
|
54
|
+
tracker = EchoTracker()
|
|
55
|
+
candidates = [t for t in transcript_texts(evt, sig.window) if not tracker.saw(t, evt=evt)]
|
|
56
|
+
if not (triggering := ps.match_signals(sig, candidates)):
|
|
57
|
+
evt.ctx.s[PrimitiveState].set(ps)
|
|
58
|
+
return None
|
|
59
|
+
ps.last_fired_at = len(evt.ctx.t)
|
|
60
|
+
evt.ctx.s[PrimitiveState].set(ps)
|
|
61
|
+
tracker.record(message, triggering=triggering, evt=evt)
|
|
62
|
+
cited = cite_message(sig, triggering, message)
|
|
63
|
+
elif when is not None and not when(evt):
|
|
64
|
+
return None
|
|
65
|
+
else:
|
|
66
|
+
cited = message
|
|
67
|
+
|
|
68
|
+
if block:
|
|
69
|
+
record_fire(evt)
|
|
70
|
+
return HookResult.of(Action.block, cited)
|
|
71
|
+
return HookResult.of(Action.warn, cited)
|
|
72
|
+
|
|
73
|
+
handler.__name__ = handler.__qualname__ = hook_name(
|
|
74
|
+
"gate" if block else "nudge",
|
|
75
|
+
None,
|
|
76
|
+
message,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
on(
|
|
80
|
+
events or ((Event.Stop | Event.SubagentStop) if block else Event.PostToolUse if sig else Event.PreToolUse),
|
|
81
|
+
only_if=only_if,
|
|
82
|
+
skip_if=skip_if,
|
|
83
|
+
max_fires=max_fires if max_fires is not None else (3 if sig else 1),
|
|
84
|
+
tests=tests,
|
|
85
|
+
async_=async_,
|
|
86
|
+
)(handler)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def gate(message: str, **kwargs: Any) -> None:
|
|
90
|
+
"""Register a blocking gate — shorthand for ``nudge(message, block=True, ...)``.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
>>> gate("Run tests before committing", when=lambda evt: not has_tests(evt))
|
|
94
|
+
"""
|
|
95
|
+
nudge(message, block=True, **kwargs)
|
captain_hook/prompt.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import textwrap
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def dedent_text(text: str) -> str:
|
|
10
|
+
return textwrap.dedent(text).strip()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_FRAMEWORK_DIR = Path(__file__).resolve().parent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _caller_dir() -> Path:
|
|
17
|
+
frame = inspect.currentframe()
|
|
18
|
+
while frame and Path(frame.f_code.co_filename).resolve().is_relative_to(_FRAMEWORK_DIR):
|
|
19
|
+
frame = frame.f_back
|
|
20
|
+
return Path(frame.f_code.co_filename).resolve().parent if frame else Path.cwd()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, kw_only=True)
|
|
24
|
+
class PromptMessage:
|
|
25
|
+
"""Fluent builder for structured LLM prompts with system text, XML context sections, and a question.
|
|
26
|
+
|
|
27
|
+
Chain ``.system()``, ``.context(tag, content)``, and ``.ask()`` to build prompts.
|
|
28
|
+
``str()`` renders the full prompt with XML-wrapped context blocks.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
system_text: str = ""
|
|
32
|
+
contexts: tuple[tuple[str, str], ...] = ()
|
|
33
|
+
ask_text: str = ""
|
|
34
|
+
|
|
35
|
+
def system(self, text: str) -> PromptMessage:
|
|
36
|
+
return PromptMessage(
|
|
37
|
+
system_text=dedent_text(text),
|
|
38
|
+
contexts=self.contexts,
|
|
39
|
+
ask_text=self.ask_text,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def context(self, tag: str, content: str | None) -> PromptMessage:
|
|
43
|
+
if content is None or not (normalized := dedent_text(content)):
|
|
44
|
+
return self
|
|
45
|
+
return PromptMessage(
|
|
46
|
+
system_text=self.system_text,
|
|
47
|
+
contexts=(*self.contexts, (tag, normalized)),
|
|
48
|
+
ask_text=self.ask_text,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def ask(self, text: str) -> PromptMessage:
|
|
52
|
+
return PromptMessage(
|
|
53
|
+
system_text=self.system_text,
|
|
54
|
+
contexts=self.contexts,
|
|
55
|
+
ask_text=dedent_text(text),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_template(cls, text: str, **vars: object) -> PromptMessage:
|
|
60
|
+
try:
|
|
61
|
+
return cls(system_text=textwrap.dedent(text).strip().format_map(vars))
|
|
62
|
+
except KeyError as exc:
|
|
63
|
+
raise KeyError(f"template variable {exc.args[0]!r} not supplied") from exc
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def load(cls, name: str, *, base: str | Path | None = None, **vars: object) -> PromptMessage:
|
|
67
|
+
"""Load a prompt from a ``.md`` file and render it via :meth:`from_template`.
|
|
68
|
+
|
|
69
|
+
Resolution searches directories in order, returning the first existing file:
|
|
70
|
+
the ``base`` directory if given (otherwise a ``prompts/`` directory beside the
|
|
71
|
+
calling module), then the framework's bundled ``captain_hook/prompts/``. The
|
|
72
|
+
file path is ``<dir>/<name>.md``; ``name`` may contain ``/`` to nest.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
name: Prompt name without the ``.md`` suffix; may include ``/`` for nesting.
|
|
76
|
+
base: Optional directory to search instead of the caller-relative ``prompts/``.
|
|
77
|
+
**vars: Template variables substituted into the file via ``str.format_map``.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A :class:`PromptMessage` whose system text is the rendered file contents.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
FileNotFoundError: If no matching file exists in any searched directory.
|
|
84
|
+
KeyError: If the file references a placeholder not supplied in ``**vars``.
|
|
85
|
+
"""
|
|
86
|
+
dirs = [Path(base) if base else _caller_dir() / "prompts", _FRAMEWORK_DIR / "prompts"]
|
|
87
|
+
for path in (d / f"{name}.md" for d in dirs):
|
|
88
|
+
if path.is_file():
|
|
89
|
+
return cls.from_template(path.read_text(), **vars)
|
|
90
|
+
raise FileNotFoundError(f"prompt {name!r} not found; searched: {', '.join(str(d) for d in dirs)}")
|
|
91
|
+
|
|
92
|
+
def __str__(self) -> str:
|
|
93
|
+
parts: list[str] = []
|
|
94
|
+
if self.system_text:
|
|
95
|
+
parts.append(self.system_text)
|
|
96
|
+
for tag, content in self.contexts:
|
|
97
|
+
parts.append(f"<{tag}>\n{content}\n</{tag}>")
|
|
98
|
+
if self.ask_text:
|
|
99
|
+
parts.append(self.ask_text)
|
|
100
|
+
return "\n\n".join(parts)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
Prompt = PromptMessage
|
captain_hook/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|