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.
Files changed (57) hide show
  1. capt_hook-0.2.0.dist-info/METADATA +113 -0
  2. capt_hook-0.2.0.dist-info/RECORD +57 -0
  3. capt_hook-0.2.0.dist-info/WHEEL +4 -0
  4. capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
  5. capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
  6. captain_hook/__init__.py +246 -0
  7. captain_hook/__main__.py +6 -0
  8. captain_hook/app.py +278 -0
  9. captain_hook/classifiers/__init__.py +30 -0
  10. captain_hook/classifiers/conductor.py +35 -0
  11. captain_hook/classifiers/droid.py +20 -0
  12. captain_hook/classifiers/native.py +19 -0
  13. captain_hook/cli.py +341 -0
  14. captain_hook/command.py +356 -0
  15. captain_hook/conditions.py +136 -0
  16. captain_hook/context.py +161 -0
  17. captain_hook/dispatch.py +107 -0
  18. captain_hook/events.py +318 -0
  19. captain_hook/file.py +120 -0
  20. captain_hook/llm/__init__.py +9 -0
  21. captain_hook/llm/backends.py +152 -0
  22. captain_hook/loader.py +62 -0
  23. captain_hook/log.py +60 -0
  24. captain_hook/primitives/__init__.py +51 -0
  25. captain_hook/primitives/audit.py +71 -0
  26. captain_hook/primitives/commands.py +61 -0
  27. captain_hook/primitives/lint.py +216 -0
  28. captain_hook/primitives/llm.py +376 -0
  29. captain_hook/primitives/nudge.py +95 -0
  30. captain_hook/prompt.py +103 -0
  31. captain_hook/py.typed +1 -0
  32. captain_hook/session.py +158 -0
  33. captain_hook/settings.py +120 -0
  34. captain_hook/signals/__init__.py +86 -0
  35. captain_hook/signals/nlp.py +105 -0
  36. captain_hook/state.py +221 -0
  37. captain_hook/styleguide/__init__.py +183 -0
  38. captain_hook/styleguide/query.py +238 -0
  39. captain_hook/styleguide/scope.py +46 -0
  40. captain_hook/styleguide/types.py +70 -0
  41. captain_hook/tasks.py +112 -0
  42. captain_hook/templates/example_hook.py.tmpl +85 -0
  43. captain_hook/testing/__init__.py +10 -0
  44. captain_hook/testing/helpers.py +392 -0
  45. captain_hook/testing/session_cache.py +50 -0
  46. captain_hook/testing/types.py +88 -0
  47. captain_hook/tests/__init__.py +27 -0
  48. captain_hook/tests/helpers.py +361 -0
  49. captain_hook/tools.py +59 -0
  50. captain_hook/transcript/__init__.py +572 -0
  51. captain_hook/transcript/inputs.py +226 -0
  52. captain_hook/transcript/models.py +186 -0
  53. captain_hook/types.py +381 -0
  54. captain_hook/util/__init__.py +0 -0
  55. captain_hook/util/model_cache.py +87 -0
  56. captain_hook/utils.py +27 -0
  57. 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
+