@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.
- package/README.md +62 -419
- package/README_Ko-KR.md +62 -420
- package/assets/lithermes-plugin/README.md +25 -0
- package/assets/lithermes-plugin/__init__.py +30 -5
- package/assets/lithermes-plugin/diagnostics.py +9 -0
- package/assets/lithermes-plugin/humanizer_detector.py +1 -1
- package/assets/lithermes-plugin/jev_hint.py +483 -0
- package/assets/lithermes-plugin/payload-version.json +13 -9
- 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
|
@@ -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
|
|
531
|
-
|
|
532
|
-
named
|
|
533
|
-
|
|
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
|
-
|
|
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
|