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.
- daimon_briefing/__init__.py +25 -0
- daimon_briefing/anchor.py +103 -0
- daimon_briefing/briefing.py +274 -0
- daimon_briefing/carry.py +97 -0
- daimon_briefing/cli.py +1119 -0
- daimon_briefing/config.py +406 -0
- daimon_briefing/configure.py +81 -0
- daimon_briefing/harvest.py +189 -0
- daimon_briefing/hooks.py +78 -0
- daimon_briefing/llm.py +239 -0
- daimon_briefing/recall.py +588 -0
- daimon_briefing/render.py +389 -0
- daimon_briefing/scoring.py +79 -0
- daimon_briefing/serializer.py +550 -0
- daimon_briefing/store.py +506 -0
- daimon_briefing/teamsync.py +484 -0
- daimon_briefing/transcript.py +258 -0
- daimon_briefing-0.3.0.dist-info/METADATA +161 -0
- daimon_briefing-0.3.0.dist-info/RECORD +21 -0
- daimon_briefing-0.3.0.dist-info/WHEEL +4 -0
- daimon_briefing-0.3.0.dist-info/entry_points.txt +6 -0
daimon_briefing/hooks.py
ADDED
|
@@ -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)
|