@litfamily/lithermes 1.0.9 → 1.0.11

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.
@@ -110,6 +110,31 @@ CLI/plugin name, not the npm package name.
110
110
  path. Noninteractive, CI, JSON, dry-run, HUD/help/version/uninstall, and
111
111
  delegate sessions remain no-op. The update bridge never receives npm
112
112
  credentials and never uses the unsupported `--home` option.
113
+ - Optional Jev skill hint (`jev_hint.py`), off by default. When
114
+ `LITHERMES_JEV=1` and `TYPESAFE_API_KEY` are both set in the environment,
115
+ `pre_llm_call` sends a top-level prompt that no LitHermes route claimed (not a
116
+ slash command, not a command envelope, at least 4 non-space characters) to
117
+ TypeSafe's Jev: one POST that refuses redirects, redacted over an
118
+ 8,000-character window and then cut to 2,000, with the `PORTED_SKILLS`
119
+ catalog plus `none`, a 1.5 s deadline and no retry. A hook failure there
120
+ never costs the turn its other context. Only an exact catalog id
121
+ at confidence 0.35 or higher becomes one fixed advisory line naming
122
+ `lithermes:<id>`; response text never enters context. Failures leave the turn
123
+ unchanged, and `transform_llm_output` prefixes one short note per session.
124
+ While both switches are on, the same transform also opens the first reply of
125
+ each session with the plain line `✦ Jev skill hint ON` (banner first, note on
126
+ the next line when both are due); it is released with the session.
127
+ `LITHERMES_JEV_MODEL`, `_TIMEOUT_MS` (max 3000), `_MAX_CALLS` (default 200
128
+ per session) and `_MIN_CONFIDENCE` tune it; `LITHERMES_JEV_TRACE=1` appends
129
+ the redacted prompt's hash, ids and timings (no text, key or body) to Hermes home
130
+ `lithermes/jev-trace.jsonl`. Status and doctor print `Jev skill hint: ...`;
131
+ when on, they add the last hinted skill id and latency of the session named
132
+ by `HERMES_SESSION_ID` (`on — last hint lit-humanizer (0.43s)`) or `on — no
133
+ hint yet`, and `on — no session` outside any session. The CLI runs in its own
134
+ process, so an accepted hint also writes only that id, the latency and a
135
+ timestamp to Hermes home `lithermes/jev-last-<session hash>.json` (mode 0600,
136
+ atomic), one file per session, removed when the session ends. Neither file is
137
+ written through a symlink.
113
138
  - The `post_tool_call` hook is an observer — Hermes discards its return value — so
114
139
  it records the paths a completed `write_file` / `patch` call mutated and
115
140
  `pre_llm_call` renders them on the next turn. A source-code edit names
@@ -12,6 +12,7 @@ from . import bounded_work
12
12
  from . import bounded_work_tools
13
13
  from . import deliverable_hedge_guard
14
14
  from . import handoff
15
+ from . import jev_hint
15
16
  from . import knowledge
16
17
  from . import knowledge_tools
17
18
  from . import scientific_visualization
@@ -367,9 +368,24 @@ def _pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
367
368
  parts.append(str(base["context"]))
368
369
  elif isinstance(base, str) and base.strip():
369
370
  parts.append(base)
371
+ routed = bool(parts)
370
372
  snapshot = litgoal_hook.snapshot_context(**kwargs)
371
373
  if snapshot:
372
374
  parts.append(snapshot)
375
+ if not routed:
376
+ # No deterministic route claimed this turn; the optional Jev hint may
377
+ # add one advisory line. It is inert unless LITHERMES_JEV and the key are set,
378
+ # and an unexpected failure there never costs the turn its other context.
379
+ try:
380
+ skill_hint = jev_hint.pre_llm_call(
381
+ session_id=str(kwargs.get("session_id") or ""),
382
+ user_message=str(kwargs.get("user_message") or ""),
383
+ catalog=PORTED_SKILLS,
384
+ )
385
+ except Exception: # noqa: BLE001 - advisory only
386
+ skill_hint = ""
387
+ if skill_hint:
388
+ parts.append(skill_hint)
373
389
  if not parts:
374
390
  return _merge_knowledge(_merge_post_edit(None, post_edit), knowledge_context)
375
391
  return _merge_knowledge(
@@ -527,12 +543,20 @@ def _command_handler(ctx, handler, route=None):
527
543
 
528
544
  def _transform_llm_output(**kwargs: Any) -> str | None:
529
545
  named = handoff.transform_llm_output(**kwargs)
530
- if named is not None:
531
- return named
532
- named = scientific_visualization.transform_llm_output(**kwargs)
533
- if named is not None:
546
+ if named is None:
547
+ named = scientific_visualization.transform_llm_output(**kwargs)
548
+ if named is None:
549
+ named = core.transform_llm_output(**kwargs)
550
+ # The reply transform is the plugin's only user-visible channel, so the
551
+ # once-per-session Jev banner and fallback note are prefixed here, banner
552
+ # first; the reply stays intact.
553
+ session_id = str(kwargs.get("session_id") or "")
554
+ lines = (jev_hint.consume_banner(session_id), jev_hint.consume_note(session_id))
555
+ prefix = "\n".join(line for line in lines if line)
556
+ if not prefix:
534
557
  return named
535
- return core.transform_llm_output(**kwargs)
558
+ reply = named if named is not None else str(kwargs.get("response_text") or "")
559
+ return f"{prefix}\n\n{reply}"
536
560
 
537
561
 
538
562
  def _pre_tool_call(**kwargs: Any) -> dict[str, str] | None:
@@ -568,6 +592,7 @@ def _release_bounded_session(**kwargs: Any) -> None:
568
592
  core.release_browser_drive_state(raw_session_id)
569
593
  session_id = raw_session_id if isinstance(raw_session_id, str) else ""
570
594
  bounded_work.release_bounded_session(session_id)
595
+ jev_hint.release_session(session_id)
571
596
  # A finalized/reset session must not leave its rule dedup ledger behind: the
572
597
  # next session reusing the id would see every rule as "already injected".
573
598
  core.release_rules_session(session_id)
@@ -21,6 +21,11 @@ try:
21
21
  except (ImportError, ModuleNotFoundError):
22
22
  import provider_metrics as _provider_metrics # type: ignore
23
23
 
24
+ try:
25
+ from . import jev_hint as _jev_hint
26
+ except (ImportError, ModuleNotFoundError):
27
+ import jev_hint as _jev_hint # type: ignore
28
+
24
29
  try:
25
30
  from .core_contract import KOREAN_PROSE_ALIASES, KOREAN_PROSE_COMMANDS
26
31
  except (ImportError, ModuleNotFoundError):
@@ -477,6 +482,7 @@ def status_report(host_version: str | None = None) -> str:
477
482
  "litgoal state: .hermes/lithermes/litgoal/ (drive via: hermes lithermes goal status)",
478
483
  _knowledge.status_line(Path.cwd()),
479
484
  _provider_metrics.capability_line(),
485
+ _jev_hint.status_line(),
480
486
  f"litgoal evidence kinds: {', '.join(_evidence_kinds())}",
481
487
  f"scientific visualization: {_science.dependency_summary()}",
482
488
  *_motion_runtime_lines(),
@@ -562,6 +568,9 @@ def doctor_report(host_version: str | None = None) -> tuple[list[str], int]:
562
568
  )
563
569
  healthy = healthy and knowledge_ok
564
570
  lines.append(f"[NOTE] {_provider_metrics.capability_line()}")
571
+ jev_state = _jev_hint.status()
572
+ jev_tag = {"on": "OK", "off": "NOTE"}.get(jev_state, "WARN")
573
+ lines.append(f"[{jev_tag}] {_jev_hint.status_line()}")
565
574
  if version != "unknown":
566
575
  lines.append(f"[OK] Hermes host detected (v{version})")
567
576
  else:
@@ -20,7 +20,7 @@ VALID_SCOPES = {"line", "sentence", "document"}
20
20
 
21
21
 
22
22
  def _js_compatible_pattern(pattern: str) -> str:
23
- """Port JavaScript ASCII word classes without narrowing Unicode ``\s``."""
23
+ r"""Port JavaScript ASCII word classes without narrowing Unicode ``\s``."""
24
24
  result: list[str] = []
25
25
  in_class = False
26
26
  index = 0
@@ -0,0 +1,483 @@
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