@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.
@@ -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-27T15:37:16.086Z",
2
+ "syncedAt": "2026-09-28T13:02:05.329Z",
3
3
  "source": "bundled-payload",
4
- "sourceHash": "fa295d297cc5aab838c7385b6737fd929c36038892c44bef8e455a7bd5b42bb8",
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": "9a63a21fd7a5b936c97983a3d44733c98ec01fc702da18e0ae51410f08ff7fc4"
12
+ "sha256": "d5e86062162e985d606b0ca4d8a53fdc6a4d527ddf63fb493a8ec693e9c00ca8"
13
13
  },
14
14
  {
15
15
  "path": "__init__.py",
16
- "sha256": "1333523e882ac6acbaa4cde7c9f3c6695e8f94b9f8d1e80e2069aa0df72dd871"
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": "0fba9bdf9c6e23432f28df5dda489b982c1e9068a92578776466073bcb7450e9"
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": "cbd2799e9e53cd167ee82716d4210342295e5d714da60e5ca2ae4f969a4ec6b8"
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": "d07e0183dd2e123536404970df0004dcfacecbd532475a421c887c8251298f84"
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": "9046142ac5c30f194c59d9c327b15414de3078963d7d15d3a966ac26d54fecca"
4152
+ "sha256": "49c49366d70b54ea34580f64b960d6633bad4ea5f7b5d50d992b1004b11c9853"
4149
4153
  },
4150
4154
  {
4151
4155
  "path": "skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json",
@@ -1,5 +1,5 @@
1
1
  name: lithermes
2
- version: 1.0.9
2
+ version: 1.0.10
3
3
  description: "Hermes-native workflow toolkit: litgoal durable runtime, 5-lane review orchestrator, Litwork commands, skills, and prompt steering."
4
4
  author: "LitHermes contributors"
5
5
  kind: standalone
@@ -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.9
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.9)
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.9
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.9)
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/lithermes",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "npx/bunx installer for the LitHermes Hermes plugin",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.9"><title>release: 1.0.9</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.9</text></g></svg>
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>