@litfamily/lithermes 1.0.23 → 1.0.25

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.
@@ -1,483 +0,0 @@
1
- """Optional Jev skill hint for the per-turn ``pre_llm_call`` route.
2
-
3
- Off by default. It runs only when ``LITHERMES_JEV=1`` and ``TYPESAFE_API_KEY``
4
- are both present in the process environment, on a top-level turn that no
5
- deterministic LitHermes route already claimed. One POST per eligible turn asks
6
- Jev which bundled LitHermes skill fits the redacted prompt. The answer is
7
- untrusted data: only an exact catalog id with enough confidence becomes one
8
- fixed advisory sentence. Every failure falls back to the unchanged turn, with
9
- one visible note per session delivered through ``transform_llm_output``. When
10
- both switches are on, the first reply of each session also opens with one plain
11
- ``✦ Jev skill hint ON`` line so the user notices the feature is enabled.
12
-
13
- Status and doctor show the last accepted hint (skill id and latency only) of
14
- one session. They run as a separate ``hermes`` CLI process, so the hinting
15
- process also writes that pair, with a timestamp, to Hermes home
16
- ``lithermes/jev-last-<session hash>.json``, one file per session, removed when
17
- the session ends. Status reads the file of the session named by
18
- ``HERMES_SESSION_ID``, which Hermes sets for commands run inside a session;
19
- outside a session it shows no hint, so concurrent sessions never see each
20
- other's.
21
-
22
- The key is read from the environment and used only in the request header. It
23
- is never logged, traced, stored, or placed in an error or note.
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import contextlib
29
- import hashlib
30
- import json
31
- import math
32
- import os
33
- import re
34
- import threading
35
- import time
36
- import urllib.error
37
- import urllib.request
38
- from pathlib import Path
39
- from typing import Any, Callable, Iterable
40
-
41
- try:
42
- from .core_runtime import assert_within_isolation, get_hermes_home, redact_obj, utc_now
43
- except (ImportError, ModuleNotFoundError):
44
- from core_runtime import assert_within_isolation, get_hermes_home, redact_obj, utc_now # type: ignore
45
-
46
-
47
- FLAG = "LITHERMES_JEV"
48
- KEY_ENV = "TYPESAFE_API_KEY"
49
- SESSION_ENV = "HERMES_SESSION_ID"
50
- ENDPOINT = "https://api.typesafe.ai/v1/systemone"
51
- DEFAULT_MODEL = "jev-1.13.0"
52
- DEFAULT_TIMEOUT_MS = 1500
53
- MAX_TIMEOUT_MS = 3000
54
- DEFAULT_MAX_CALLS = 200
55
- DEFAULT_MIN_CONFIDENCE = 0.35
56
- MAX_PROMPT_CHARS = 2000
57
- REDACTION_WINDOW_CHARS = 8000
58
- MAX_DESCRIPTION_CHARS = 300
59
- MAX_REQUEST_BYTES = 64 * 1024
60
- MAX_RESPONSE_BYTES = 64 * 1024
61
- MAX_TRACKED_SESSIONS = 256
62
- LAST_HINT_PREFIX = "jev-last-"
63
- MAX_LAST_HINT_BYTES = 4096
64
- NONE_ID = "none"
65
- _NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
66
- # Reply transforms land in Markdown and transcripts, where the product keeps
67
- # every mark plain; the banner therefore carries no ANSI colour.
68
- BANNER = "✦ Jev skill hint ON"
69
- QUESTION = (
70
- "Which one skill, if any, is the best fit for the user's request? "
71
- "Choose none when no catalog skill fits."
72
- )
73
- NONE_CRITERION = "No specialized skill in this catalog fits; answer the user directly."
74
-
75
- # A transport takes (url, headers, body, timeout seconds) and returns
76
- # (HTTP status, response bytes). Tests replace it; production uses urllib.
77
- Transport = Callable[[str, dict[str, str], bytes, float], tuple[int, bytes]]
78
-
79
-
80
- class TransportTimeout(Exception):
81
- """The request did not finish inside the hard timeout."""
82
-
83
-
84
- class TransportError(Exception):
85
- """The request failed before an HTTP status was available."""
86
-
87
-
88
- class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
89
- """A redirect surfaces as its own HTTP status instead of being followed."""
90
-
91
- def redirect_request(self, req, fp, code, msg, headers, newurl):
92
- return None
93
-
94
-
95
- _OPENER = urllib.request.build_opener(_RefuseRedirects())
96
-
97
-
98
- def _urllib_transport(url: str, headers: dict[str, str], body: bytes, timeout: float) -> tuple[int, bytes]:
99
- plain = {name: value for name, value in headers.items() if name.lower() != "authorization"}
100
- request = urllib.request.Request(url, data=body, headers=plain, method="POST")
101
- for name, value in headers.items():
102
- if name.lower() == "authorization":
103
- # Never copied onto a follow-up request, should a redirect ever be taken.
104
- request.add_unredirected_header(name, value)
105
- try:
106
- with _OPENER.open(request, timeout=timeout) as response: # noqa: S310 - fixed https endpoint
107
- return int(response.status), response.read(MAX_RESPONSE_BYTES + 1)
108
- except urllib.error.HTTPError as exc:
109
- exc.close()
110
- return int(exc.code), b""
111
- except (TimeoutError, OSError) as exc:
112
- # Exception text can carry request details; only the category leaves here.
113
- if isinstance(exc, TimeoutError) or "timed out" in str(getattr(exc, "reason", "")):
114
- raise TransportTimeout() from None
115
- raise TransportError() from None
116
-
117
-
118
- TRANSPORT: Transport = _urllib_transport
119
-
120
- _CALLS: dict[str, int] = {}
121
- _NOTED: dict[str, bool] = {}
122
- _PENDING_NOTE: dict[str, str] = {}
123
- _BANNERED: dict[str, bool] = {}
124
- _LAST_HINT: dict[str, dict[str, Any]] = {}
125
- _SKILL_ID = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
126
-
127
-
128
- def _env(env: dict[str, str] | None) -> dict[str, str]:
129
- return os.environ if env is None else env # type: ignore[return-value]
130
-
131
-
132
- def _flag_on(env: dict[str, str]) -> bool:
133
- return (env.get(FLAG) or "").strip() == "1"
134
-
135
-
136
- def _key(env: dict[str, str]) -> str:
137
- return (env.get(KEY_ENV) or "").strip()
138
-
139
-
140
- def status(env: dict[str, str] | None = None) -> str:
141
- values = _env(env)
142
- if not _flag_on(values):
143
- return "off"
144
- if not _key(values):
145
- return "flag on but TYPESAFE_API_KEY missing"
146
- return "on"
147
-
148
-
149
- def _last_hint_path(session: str) -> Path:
150
- # A hash keeps the raw session id out of the file name and out of the path.
151
- key = hashlib.sha256(session.encode("utf-8")).hexdigest()[:32]
152
- return get_hermes_home() / "lithermes" / f"{LAST_HINT_PREFIX}{key}.json"
153
-
154
-
155
- def _refuse_symlink(path: Path) -> None:
156
- if path.is_symlink():
157
- raise OSError(f"refusing to write through a symlink: {path.name}")
158
-
159
-
160
- def _append_private(path: Path, payload: dict[str, Any]) -> None:
161
- """Append one JSON line without following a symlink planted at ``path``."""
162
- assert_within_isolation(path)
163
- path.parent.mkdir(parents=True, exist_ok=True)
164
- _refuse_symlink(path)
165
- fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | _NOFOLLOW, 0o600)
166
- with os.fdopen(fd, "a", encoding="utf-8") as handle:
167
- handle.write(json.dumps(redact_obj(payload), sort_keys=True) + "\n")
168
-
169
-
170
- def _record_hint(session: str, skill_id: str, latency_ms: int) -> None:
171
- """Keep the session's last accepted hint for status and doctor: id, latency and time only."""
172
- if not session:
173
- return
174
- record = {"skill": skill_id, "latency_ms": latency_ms, "timestamp": utc_now().isoformat()}
175
- _LAST_HINT[session] = record
176
- _bound_sessions()
177
- path = _last_hint_path(session)
178
- temporary = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
179
- try:
180
- assert_within_isolation(path)
181
- path.parent.mkdir(parents=True, exist_ok=True)
182
- _refuse_symlink(temporary)
183
- fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | _NOFOLLOW, 0o600)
184
- if hasattr(os, "fchmod"):
185
- os.fchmod(fd, 0o600) # a leftover temporary keeps its old mode otherwise
186
- with os.fdopen(fd, "w", encoding="utf-8") as handle:
187
- json.dump(record, handle, sort_keys=True)
188
- # os.replace swaps a symlink at ``path`` for the file; it never writes through it.
189
- os.replace(temporary, path)
190
- except (OSError, RuntimeError): # RuntimeError: the isolation root refused the path
191
- with contextlib.suppress(OSError):
192
- temporary.unlink()
193
-
194
-
195
- def last_hint(session: str) -> tuple[str, int] | None:
196
- """Return (skill id, latency ms) of the session's last accepted hint, or None when unknown or unreadable."""
197
- if not session:
198
- return None
199
- record: Any = dict(_LAST_HINT.get(session) or {})
200
- if not record:
201
- try:
202
- with _last_hint_path(session).open("rb") as handle:
203
- record = json.loads(handle.read(MAX_LAST_HINT_BYTES + 1)[:MAX_LAST_HINT_BYTES].decode("utf-8"))
204
- except (OSError, ValueError, UnicodeDecodeError):
205
- return None
206
- if not isinstance(record, dict):
207
- return None
208
- skill, latency = record.get("skill"), record.get("latency_ms")
209
- if not isinstance(skill, str) or not _SKILL_ID.fullmatch(skill):
210
- return None
211
- if isinstance(latency, bool) or not isinstance(latency, int) or not 0 <= latency <= 600_000:
212
- return None
213
- return skill, latency
214
-
215
-
216
- def status_line(env: dict[str, str] | None = None) -> str:
217
- values = _env(env)
218
- state = status(values)
219
- if state != "on":
220
- return f"Jev skill hint: {state}"
221
- session = (values.get(SESSION_ENV) or "").strip()
222
- if not session:
223
- return "Jev skill hint: on — no session (last hints are per session)"
224
- last = last_hint(session)
225
- if last is None:
226
- return "Jev skill hint: on — no hint yet"
227
- skill, latency_ms = last
228
- return f"Jev skill hint: on — last hint {skill} ({latency_ms / 1000:.2f}s)"
229
-
230
-
231
- def _int_setting(env: dict[str, str], name: str, default: int, low: int, high: int) -> int:
232
- try:
233
- value = int((env.get(name) or "").strip())
234
- except ValueError:
235
- return default
236
- return value if low <= value <= high else default
237
-
238
-
239
- def _float_setting(env: dict[str, str], name: str, default: float) -> float:
240
- try:
241
- value = float((env.get(name) or "").strip())
242
- except ValueError:
243
- return default
244
- return value if 0.0 <= value <= 1.0 else default
245
-
246
-
247
- # A home folder, with or without a trailing separator, up to the next separator.
248
- _HOME_PATHS = (
249
- (re.compile(r"/(?:Users|home)/[^/\s]+"), "~"),
250
- (re.compile(r"[A-Za-z]:\\Users\\[^\\/\s]+\\"), "~/"),
251
- (re.compile(r"[A-Za-z]:\\Users\\[^\\/\s]+"), "~"),
252
- )
253
- _EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}")
254
- _TOKENS = (
255
- re.compile(r"-----BEGIN [A-Z0-9 ]+-----[\s\S]*?(?:-----END [A-Z0-9 ]+-----|$)"),
256
- re.compile(r"eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]*)?"),
257
- re.compile(r"(?<![A-Za-z0-9])(?:sk-ant-|sk-|ghp_|gho_|github_pat_|npm_|apikey_|xox[abprs]-)[A-Za-z0-9_-]{8,}"),
258
- re.compile(r"(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![A-Za-z0-9])"),
259
- re.compile(r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{20,}"),
260
- re.compile(r"[A-Za-z0-9+/_-]{32,}={0,2}"),
261
- )
262
- _TRAILING_TOKEN_RUN = re.compile(r"[A-Za-z0-9+/_=-]{8,}\Z")
263
-
264
-
265
- def redact_prompt(text: str) -> str:
266
- """Redact a bounded window, then cut to the prompt limit.
267
-
268
- Redacting before the cut means a secret straddling the limit is matched
269
- whole; a token-character run the cut still splits becomes ``[secret]``.
270
- """
271
- value = str(text or "")[:REDACTION_WINDOW_CHARS]
272
- for pattern, replacement in _HOME_PATHS:
273
- value = pattern.sub(replacement, value)
274
- value = _EMAIL.sub("[email]", value)
275
- for pattern in _TOKENS:
276
- value = pattern.sub("[secret]", value)
277
- if len(value) > MAX_PROMPT_CHARS:
278
- value = _TRAILING_TOKEN_RUN.sub("[secret]", value[:MAX_PROMPT_CHARS])
279
- return value
280
-
281
-
282
- def eligible(user_message: str) -> bool:
283
- """A plain top-level prompt: no slash command and no LitHermes command envelope."""
284
- text = str(user_message or "").strip()
285
- if len(re.sub(r"\s+", "", text)) < 4:
286
- return False
287
- if text.startswith("/"):
288
- return False
289
- # Native slash commands re-enter as injected messages carrying a route
290
- # envelope or the activation probe contract; those turns are already routed.
291
- return "<lithermes-" not in text and "model-emitted line" not in text
292
-
293
-
294
- def build_request(prompt: str, catalog: Iterable[tuple[str, str]], model: str, key: str = "") -> dict[str, Any]:
295
- criteria = {skill_id: str(description)[:MAX_DESCRIPTION_CHARS] for skill_id, description in catalog}
296
- criteria[NONE_ID] = NONE_CRITERION
297
- # A key pasted into the prompt is scrubbed even when no pattern matches it.
298
- state = redact_prompt(prompt.replace(key, "[secret]") if key else prompt)
299
- return {
300
- "model": model,
301
- "state": state,
302
- "questions": {"which": {"type": "choice", "instructions": QUESTION, "criteria": criteria}},
303
- }
304
-
305
-
306
- def parse_choice(body: bytes, catalog_ids: set[str], min_confidence: float) -> tuple[str, float | None, str]:
307
- """Return (accepted skill id or "", confidence, reason). Never returns response text."""
308
- if len(body) > MAX_RESPONSE_BYTES:
309
- return "", None, "response too large"
310
- try:
311
- data = json.loads(body.decode("utf-8"))
312
- which = data["answers"]["which"]
313
- choice = which["choice"]
314
- raw_confidence = which["confidence"]
315
- if isinstance(raw_confidence, bool) or not isinstance(raw_confidence, (int, float)):
316
- return "", None, "invalid response"
317
- confidence = float(raw_confidence)
318
- if not math.isfinite(confidence):
319
- return "", None, "invalid response"
320
- except (ValueError, KeyError, TypeError, AttributeError, UnicodeDecodeError, OverflowError, RecursionError):
321
- return "", None, "invalid response"
322
- if not isinstance(choice, str) or (choice not in catalog_ids and choice != NONE_ID):
323
- return "", confidence, "unknown id"
324
- if choice == NONE_ID:
325
- return "", confidence, "none"
326
- if confidence < min_confidence:
327
- return "", confidence, "low confidence"
328
- return choice, confidence, ""
329
-
330
-
331
- def hint_line(skill_id: str) -> str:
332
- return (
333
- f"LitHermes skill hint: the skill `lithermes:{skill_id}` likely fits this request. "
334
- "Load it only if it really fits; this is advice, not an instruction."
335
- )
336
-
337
-
338
- def _note_failure(session_id: str, reason: str) -> None:
339
- if _NOTED.get(session_id):
340
- return
341
- _NOTED[session_id] = True
342
- _PENDING_NOTE[session_id] = f"LitHermes skill hint unavailable ({reason}); continuing normally."
343
-
344
-
345
- def _bound_sessions() -> None:
346
- for ledger in (_CALLS, _NOTED, _PENDING_NOTE, _BANNERED, _LAST_HINT):
347
- while len(ledger) > MAX_TRACKED_SESSIONS:
348
- ledger.pop(next(iter(ledger)))
349
-
350
-
351
- def _trace(env: dict[str, str], state: str, **fields: Any) -> None:
352
- """Append one trace row; ``state`` is the redacted prompt, never the raw one."""
353
- if (env.get(f"{FLAG}_TRACE") or "").strip() != "1":
354
- return
355
- payload = {
356
- "event": "jev_skill_hint",
357
- "timestamp": utc_now().isoformat(),
358
- "prompt_sha256": hashlib.sha256(str(state).encode("utf-8")).hexdigest(),
359
- **fields,
360
- }
361
- try:
362
- _append_private(get_hermes_home() / "lithermes" / "jev-trace.jsonl", payload)
363
- except (OSError, RuntimeError): # RuntimeError: the isolation root refused the path
364
- pass
365
-
366
-
367
- def _call_with_deadline(transport: Transport, headers: dict[str, str], body: bytes, timeout: float) -> dict[str, Any]:
368
- """Run the transport on a daemon thread so DNS or a slow body cannot outlast the deadline."""
369
- outcome: dict[str, Any] = {}
370
-
371
- def run() -> None:
372
- try:
373
- outcome["result"] = transport(ENDPOINT, headers, body, timeout)
374
- except Exception as exc: # noqa: BLE001 - only the category is kept
375
- outcome["error"] = exc
376
-
377
- worker = threading.Thread(target=run, name="lithermes-jev-hint", daemon=True)
378
- worker.start()
379
- worker.join(timeout)
380
- if worker.is_alive():
381
- return {"timeout": True}
382
- return dict(outcome)
383
-
384
-
385
- def pre_llm_call(
386
- *,
387
- session_id: str,
388
- user_message: str,
389
- catalog: Iterable[tuple[str, str]],
390
- env: dict[str, str] | None = None,
391
- transport: Transport | None = None,
392
- ) -> str:
393
- """Return one advisory hint line, or "" to leave the turn unchanged."""
394
- values = _env(env)
395
- if not _flag_on(values):
396
- return ""
397
- key = _key(values)
398
- if not key or not eligible(user_message):
399
- return ""
400
- session = str(session_id or "")
401
- catalog_list = [(str(skill_id), str(description)) for skill_id, description in catalog]
402
- model = (values.get(f"{FLAG}_MODEL") or "").strip() or DEFAULT_MODEL
403
- request = build_request(str(user_message), catalog_list, model, key)
404
- state = request["state"]
405
- calls = _CALLS.get(session, 0)
406
- if calls >= _int_setting(values, f"{FLAG}_MAX_CALLS", DEFAULT_MAX_CALLS, 0, 1_000_000):
407
- _note_failure(session, "call cap reached")
408
- _trace(values, state, choice="", confidence=None, latency_ms=0, http_status=None, fallback_reason="call cap reached")
409
- return ""
410
- body = json.dumps(request, ensure_ascii=False).encode("utf-8")
411
- if len(body) > MAX_REQUEST_BYTES:
412
- _note_failure(session, "request too large")
413
- _trace(values, state, choice="", confidence=None, latency_ms=0, http_status=None, fallback_reason="request too large")
414
- return ""
415
- _CALLS[session] = calls + 1
416
- _bound_sessions()
417
- timeout_ms = _int_setting(values, f"{FLAG}_TIMEOUT_MS", DEFAULT_TIMEOUT_MS, 1, MAX_TIMEOUT_MS)
418
- headers = {
419
- "Authorization": f"Bearer {key}",
420
- "Content-Type": "application/json",
421
- "Accept": "application/json",
422
- }
423
- started = time.monotonic()
424
- http_status: int | None = None
425
- response = b""
426
- outcome = _call_with_deadline(transport or TRANSPORT, headers, body, timeout_ms / 1000.0)
427
- if "result" in outcome:
428
- http_status, response = outcome["result"]
429
- reason = "" if http_status == 200 else f"HTTP {http_status}"
430
- elif outcome.get("timeout") or isinstance(outcome.get("error"), TransportTimeout):
431
- reason = "timeout"
432
- else:
433
- reason = "network error"
434
- latency_ms = int((time.monotonic() - started) * 1000)
435
- if reason:
436
- _note_failure(session, reason)
437
- _trace(values, state, choice="", confidence=None, latency_ms=latency_ms, http_status=http_status, fallback_reason=reason)
438
- return ""
439
- min_confidence = _float_setting(values, f"{FLAG}_MIN_CONFIDENCE", DEFAULT_MIN_CONFIDENCE)
440
- choice, confidence, reason = parse_choice(response, {skill_id for skill_id, _ in catalog_list}, min_confidence)
441
- if reason in {"invalid response", "response too large"}:
442
- _note_failure(session, reason)
443
- _trace(
444
- values,
445
- state,
446
- choice=choice,
447
- confidence=confidence,
448
- latency_ms=latency_ms,
449
- http_status=http_status,
450
- fallback_reason=reason,
451
- )
452
- if not choice:
453
- return ""
454
- _record_hint(session, choice, latency_ms)
455
- return hint_line(choice)
456
-
457
-
458
- def consume_note(session_id: str) -> str:
459
- return _PENDING_NOTE.pop(str(session_id or ""), "")
460
-
461
-
462
- def consume_banner(session_id: str, env: dict[str, str] | None = None) -> str:
463
- """Return the enabled banner once per session while both switches are on, else ""."""
464
- session = str(session_id or "")
465
- if status(env) != "on" or _BANNERED.get(session):
466
- return ""
467
- _BANNERED[session] = True
468
- _bound_sessions()
469
- return BANNER
470
-
471
-
472
- def release_session(session_id: str) -> None:
473
- session = str(session_id or "")
474
- _CALLS.pop(session, None)
475
- _NOTED.pop(session, None)
476
- _PENDING_NOTE.pop(session, None)
477
- _BANNERED.pop(session, None)
478
- _LAST_HINT.pop(session, None)
479
- if session:
480
- with contextlib.suppress(OSError, RuntimeError): # RuntimeError: the isolation root refused the path
481
- path = _last_hint_path(session)
482
- assert_within_isolation(path)
483
- path.unlink() # removes a planted symlink itself, never its target