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