daimon-briefing 0.3.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.
@@ -0,0 +1,78 @@
1
+ """Hermes hook callbacks. Both hooks are defensive: a broken hook must NEVER break
2
+ the user's session, so everything is wrapped — failures log and give up silently.
3
+
4
+ # VERIFIED website/docs/guides/build-a-hermes-plugin.md (hook callback signatures):
5
+ # on_session_end(session_id, completed, interrupted, model, platform, **kwargs)
6
+ # pre_llm_call(session_id, user_message, conversation_history, is_first_turn,
7
+ # model, platform, **kwargs) -> {"context": str} | str | None
8
+ """
9
+
10
+ import logging
11
+ import time
12
+
13
+ from . import briefing, config, harvest, llm, serializer, store, transcript
14
+
15
+ log = logging.getLogger("daimon_briefing")
16
+
17
+ # Module-level seam so tests can inject a fake LLM client.
18
+ _chat = llm.chat
19
+
20
+
21
+ def on_session_end(session_id, completed=None, interrupted=None, model=None, platform=None, **kwargs):
22
+ """End-of-session: read transcript -> serialize -> validate -> store. Never raises.
23
+
24
+ DAIMON_TIMEOUT is the TOTAL budget for the serialize LLM work: the deadline is
25
+ computed here at hook start and forwarded so retries cannot stack past it.
26
+ Everything — including config access — lives inside the try; nothing may escape.
27
+ """
28
+ try:
29
+ if config.is_disabled():
30
+ return
31
+ start = time.monotonic()
32
+ deadline = start + config.timeout_seconds()
33
+ messages = transcript.from_session(session_id)
34
+ if not messages or len(messages) < config.min_messages():
35
+ return
36
+ checkpoint = serializer.serialize(session_id, messages, chat=_chat, deadline=deadline)
37
+ if checkpoint is None:
38
+ log.info("daimon: no checkpoint produced for session %s (skip)", session_id)
39
+ return
40
+ root = config.resolve_project_root(config.project_dir())
41
+ store.write_checkpoint(session_id, checkpoint, project_dir=root)
42
+ log.info(
43
+ "daimon: wrote checkpoint for session %s (took %ds)",
44
+ session_id,
45
+ int(time.monotonic() - start),
46
+ )
47
+ if config.scar_harvest_enabled():
48
+ try:
49
+ harvest.run(messages, project_root=root, session_id=session_id)
50
+ except Exception:
51
+ log.exception("daimon: scar harvest failed (checkpoint unaffected)")
52
+ except Exception: # a broken hook must not break the session
53
+ log.exception("daimon: on_session_end failed for session %s (giving up)", session_id)
54
+
55
+
56
+ def pre_llm_call(session_id=None, user_message=None, conversation_history=None,
57
+ is_first_turn=False, model=None, platform=None, **kwargs):
58
+ """First turn of a new session: inject the 'while you were away' briefing.
59
+
60
+ Returns {"context": briefing} to append to the user message, or None. Never raises.
61
+ """
62
+ try:
63
+ if config.is_disabled():
64
+ return None
65
+ if not is_first_turn:
66
+ return None
67
+ checkpoint = store.read_latest(
68
+ project_dir=config.resolve_project_root(config.project_dir())
69
+ )
70
+ if checkpoint is None:
71
+ return None
72
+ text = briefing.render(checkpoint)
73
+ if not text:
74
+ return None
75
+ return {"context": text}
76
+ except Exception:
77
+ log.exception("daimon: pre_llm_call failed (no briefing injected)")
78
+ return None
daimon_briefing/llm.py ADDED
@@ -0,0 +1,239 @@
1
+ """Minimal OpenAI-compatible chat client. Stdlib only (urllib). Config via env
2
+ (see config.py): DAIMON_LLM_* falling back to LITELLM_*. Reuses the Track-A
3
+ pattern from research/experiments/lib/llm.py — clean copy inside the package."""
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ from . import config
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def _run_command(argv, stdin_text, timeout, env, cwd):
22
+ """Run a CLI, piping stdin_text to its stdin. Returns (rc, stdout, stderr).
23
+ The ONLY subprocess boundary — tests monkeypatch this. Raises
24
+ FileNotFoundError (missing binary) / subprocess.TimeoutExpired."""
25
+ proc = subprocess.run(
26
+ argv, input=stdin_text, capture_output=True, text=True,
27
+ timeout=timeout, env=env, cwd=cwd,
28
+ )
29
+ return proc.returncode, proc.stdout, proc.stderr
30
+
31
+
32
+ class ChatError(RuntimeError):
33
+ """A chat call failed after retries. Callers catch this to give up gracefully."""
34
+
35
+
36
+ def _chat_litellm(messages, model=None, temperature=None, timeout=None, retries=3, deadline=None):
37
+ """POST /v1/chat/completions. Returns the assistant message content (str).
38
+
39
+ Retries transient failures (timeout, connection, 5xx) with backoff; 4xx fails
40
+ fast. Raises ChatError on giving up. Signature is callable-compatible with the
41
+ fake injected in tests: _chat_litellm(messages, **kwargs) -> str.
42
+
43
+ `temperature=None` (the default) resolves config.llm_temperature()
44
+ (DAIMON_LLM_TEMPERATURE, default 0.0). An explicit argument always wins.
45
+
46
+ `deadline` (time.monotonic() seconds) is a TOTAL budget across all attempts:
47
+ each attempt's socket timeout is capped to the remaining time, and retrying
48
+ stops once the deadline would be exceeded.
49
+
50
+ Error messages NEVER include the HTTP response body — error payloads can echo
51
+ request contents/secrets, and hooks log these messages.
52
+ """
53
+ base = config.llm_base_url()
54
+ key = config.llm_api_key()
55
+ if not key:
56
+ raise ChatError("No LLM API key (set DAIMON_LLM_API_KEY or LITELLM_API_KEY).")
57
+ mdl = model or config.llm_model()
58
+ if not mdl:
59
+ raise ChatError("No LLM model (set DAIMON_LLM_MODEL or LITELLM_MODEL).")
60
+ if timeout is None:
61
+ timeout = config.timeout_seconds()
62
+ if temperature is None:
63
+ temperature = config.llm_temperature()
64
+
65
+ # temperature is always sent explicitly — some upstreams reject requests
66
+ # that omit it or send a value other than the one they pin.
67
+ payload = {"model": mdl, "messages": messages, "temperature": temperature}
68
+ if config.llm_no_cache():
69
+ # LiteLLM per-request cache bypass. Opt-in only: strict upstreams may
70
+ # reject unknown fields, so the default body must stay unchanged.
71
+ payload["cache"] = {"no-cache": True}
72
+ body = json.dumps(payload).encode()
73
+ last = None
74
+ for attempt in range(retries):
75
+ attempt_timeout = timeout
76
+ if deadline is not None:
77
+ remaining = deadline - time.monotonic()
78
+ if remaining <= 0:
79
+ raise ChatError(f"LLM deadline exhausted after {attempt} tries: {last}")
80
+ attempt_timeout = min(timeout, remaining)
81
+ req = urllib.request.Request(
82
+ base + "/v1/chat/completions",
83
+ data=body,
84
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
85
+ )
86
+ try:
87
+ with urllib.request.urlopen(req, timeout=attempt_timeout) as r:
88
+ data = json.loads(r.read())
89
+ # Surface token cost — the serializer discards the rest of the
90
+ # response, so this log line is the only record of per-call spend.
91
+ usage = data.get("usage") or {}
92
+ if usage:
93
+ log.info("LLM usage model=%s total_tokens=%s prompt=%s completion=%s",
94
+ mdl, usage.get("total_tokens"),
95
+ usage.get("prompt_tokens"), usage.get("completion_tokens"))
96
+ return data["choices"][0]["message"]["content"]
97
+ except urllib.error.HTTPError as e:
98
+ if 500 <= e.code < 600 and attempt < retries - 1:
99
+ last = f"HTTP {e.code}"
100
+ else:
101
+ raise ChatError(f"LLM HTTP {e.code} (response body suppressed)")
102
+ except (TimeoutError, urllib.error.URLError) as e:
103
+ last = getattr(e, "reason", e)
104
+ if attempt == retries - 1:
105
+ raise ChatError(f"LLM unreachable/timeout after {retries} tries at {base}: {last}")
106
+ backoff = 3 * (attempt + 1)
107
+ if deadline is not None and time.monotonic() + backoff >= deadline:
108
+ raise ChatError(f"LLM deadline exhausted after {attempt + 1} tries: {last}")
109
+ # `last` is "HTTP <code>" or the transport reason — never the response
110
+ # body (it can echo request contents/secrets; see docstring).
111
+ log.warning("LLM %s (attempt %d/%d), backing off %ds",
112
+ last, attempt + 1, retries, backoff)
113
+ time.sleep(backoff)
114
+ raise ChatError(f"LLM failed after {retries} tries: {last}")
115
+
116
+
117
+ def chat(messages, model=None, temperature=None, timeout=None, retries=3, deadline=None):
118
+ """Dispatch to the configured backend. litellm (default) falls back to a
119
+ command backend on ChatError when fallback is enabled and one resolves."""
120
+ backend = config.llm_backend()
121
+ if backend == "auto":
122
+ if config.llm_api_key():
123
+ backend = "litellm"
124
+ elif _resolve_command() is not None:
125
+ backend = "command"
126
+ else:
127
+ backend = "litellm" # let _chat_litellm raise the helpful no-key error
128
+ if backend in ("command", "claude-cli"):
129
+ return _chat_command(messages, deadline)
130
+ try:
131
+ return _chat_litellm(messages, model=model, temperature=temperature,
132
+ timeout=timeout, retries=retries, deadline=deadline)
133
+ except ChatError:
134
+ if config.llm_fallback() and _resolve_command() is not None:
135
+ log.warning("llm.fallback backend=command (litellm failed)")
136
+ return _chat_command(messages, deadline)
137
+ raise
138
+
139
+
140
+ def extract_json(text):
141
+ """Pull a JSON object/array out of a model response, tolerating ```json fences.
142
+
143
+ Raises json.JSONDecodeError when nothing parseable is found.
144
+ """
145
+ t = text.strip()
146
+ if t.startswith("```"):
147
+ t = t.split("```", 2)[1]
148
+ if t.startswith("json"):
149
+ t = t[4:]
150
+ t = t.strip()
151
+ try:
152
+ return json.loads(t)
153
+ except json.JSONDecodeError:
154
+ pass
155
+ candidates = []
156
+ for op, cl in (("[", "]"), ("{", "}")):
157
+ i, j = t.find(op), t.rfind(cl)
158
+ if i != -1 and j != -1 and j > i:
159
+ candidates.append((i, t[i:j + 1]))
160
+ for _, span in sorted(candidates):
161
+ try:
162
+ return json.loads(span)
163
+ except json.JSONDecodeError:
164
+ continue
165
+ raise json.JSONDecodeError("no JSON object/array found in response", t, 0)
166
+
167
+
168
+ _CLAUDE_PRESET = ("claude -p --model haiku --output-format json", "json:result")
169
+
170
+
171
+ def _flatten_messages(messages):
172
+ """Format messages for CLI input: role in caps, one newline between role and content,
173
+ two newlines between messages."""
174
+ return "\n\n".join(f"{m['role'].upper()}:\n{m['content']}" for m in messages)
175
+
176
+
177
+ def _resolve_command():
178
+ """Resolve the command backend (command_str, output_spec) or None.
179
+
180
+ Order: explicit DAIMON_LLM_COMMAND, else claude-cli preset if claude is
181
+ on PATH, else None (no fallback possible)."""
182
+ cmd = config.llm_command()
183
+ if cmd:
184
+ return cmd, (config.llm_command_output() or "text")
185
+ if shutil.which("claude"):
186
+ return _CLAUDE_PRESET
187
+ return None
188
+
189
+
190
+ def _extract_output(stdout, output_spec):
191
+ """Extract the LLM response from command output.
192
+
193
+ output_spec format:
194
+ - "text": return stripped stdout
195
+ - "json:<key>": parse stdout as JSON and extract [key]
196
+ """
197
+ if output_spec.startswith("json:"):
198
+ key = output_spec[len("json:"):]
199
+ obj = json.loads(stdout)
200
+ return obj[key]
201
+ return stdout.strip()
202
+
203
+
204
+ def _chat_command(messages, deadline):
205
+ """Serialize via a headless LLM CLI. Prompt via stdin; runs isolated
206
+ (DAIMON_DISABLE=1, temp cwd). Raises ChatError on any failure — never
207
+ echoes prompt/stdout/stderr (they can carry secrets)."""
208
+ resolved = _resolve_command()
209
+ if not resolved:
210
+ raise ChatError("No command backend (set DAIMON_LLM_COMMAND or install claude).")
211
+ command, output_spec = resolved
212
+ argv = shlex.split(command)
213
+ stdin_text = _flatten_messages(messages)
214
+ timeout = config.timeout_seconds()
215
+ if deadline is not None:
216
+ timeout = min(timeout, max(0.0, deadline - time.monotonic()))
217
+ if timeout <= 0:
218
+ raise ChatError("LLM deadline exhausted before command backend")
219
+ env = {**os.environ, "DAIMON_DISABLE": "1"}
220
+ cwd = tempfile.mkdtemp(prefix="daimon-cli-")
221
+ try:
222
+ try:
223
+ rc, out, err = _run_command(argv, stdin_text, timeout, env, cwd)
224
+ except FileNotFoundError:
225
+ raise ChatError(f"command backend binary not found: {argv[0]}")
226
+ except subprocess.TimeoutExpired:
227
+ raise ChatError("command backend timed out")
228
+ if rc != 0:
229
+ raise ChatError(f"command backend exited {rc} (stderr suppressed)")
230
+ try:
231
+ text = _extract_output(out, output_spec)
232
+ except (json.JSONDecodeError, KeyError, TypeError):
233
+ raise ChatError("command backend output unparseable (body suppressed)")
234
+ if not text or not text.strip():
235
+ raise ChatError("command backend returned empty output")
236
+ log.info("LLM command backend ok argv0=%s", argv[0])
237
+ return text
238
+ finally:
239
+ shutil.rmtree(cwd, ignore_errors=True)