@litfamily/lithermes 1.0.20 → 1.0.21
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 +7 -7
- package/README_Ko-KR.md +7 -7
- package/assets/lithermes-plugin/core.py +9 -2
- package/assets/lithermes-plugin/lit_mark.py +8 -3
- package/assets/lithermes-plugin/payload-version.json +11 -7
- 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/assets/lithermes-plugin/workflow_map.py +206 -0
- package/package.json +1 -1
- package/readme-assets/badge-version.svg +1 -1
- package/src/cli.js +56 -2
- package/src/lib/config.js +53 -0
- package/src/lib/skins.js +39 -4
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
1
|
+
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitHermes robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
5
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
4
|
+
<a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/badge-version.svg" alt="1.0.21" /></a>
|
|
5
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/badge-license.svg" alt="MIT license" /></a>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
10
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
11
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
9
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/lucide-book-open.svg" width="16" alt="" /> Guide</a>
|
|
10
|
+
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition film</a>
|
|
11
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
# LitHermes
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
A plugin for **Hermes Agent**. Put `lit` in front of a request, and Hermes plans the task, does it, checks what it did and leaves a note that the next session can pick up.
|
|
19
19
|
|
|
20
|
-
**[README and docs on GitHub](https://github.com/wjgoarxiv/lithermes#readme)** · [한국어](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
20
|
+
**[README and docs on GitHub](https://github.com/wjgoarxiv/lithermes#readme)** · [한국어](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/README_Ko-KR.md)
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
package/README_Ko-KR.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
1
|
+
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitHermes 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
5
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
4
|
+
<a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/badge-version.svg" alt="1.0.21" /></a>
|
|
5
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/badge-license.svg" alt="MIT license" /></a>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
10
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
11
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
9
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/lucide-book-open.svg" width="16" alt="" /> 안내</a>
|
|
10
|
+
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition 영상</a>
|
|
11
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
# LitHermes
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
**Hermes Agent**용 플러그인입니다. 요청 앞에 `lit`을 붙이면 Hermes가 작업을 계획하고, 해내고, 한 일을 확인한 뒤, 다음 세션이 이어받을 수 있는 기록을 남깁니다.
|
|
19
19
|
|
|
20
|
-
**[GitHub의 README와 문서](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md)** · [English](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
20
|
+
**[GitHub의 README와 문서](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md)** · [English](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.21/README.md)
|
|
21
21
|
|
|
22
22
|
## 설치
|
|
23
23
|
|
|
@@ -96,6 +96,11 @@ try:
|
|
|
96
96
|
except (ImportError, ModuleNotFoundError):
|
|
97
97
|
import motion_receipts as _motion_receipts
|
|
98
98
|
|
|
99
|
+
try:
|
|
100
|
+
from .workflow_map import workflow_row
|
|
101
|
+
except (ImportError, ModuleNotFoundError):
|
|
102
|
+
from workflow_map import workflow_row
|
|
103
|
+
|
|
99
104
|
try:
|
|
100
105
|
from . import deliverable_hedge_guard as _deliverable_hedges
|
|
101
106
|
except (ImportError, ModuleNotFoundError):
|
|
@@ -1789,8 +1794,10 @@ def transform_llm_output(**kwargs: Any) -> str | None:
|
|
|
1789
1794
|
_PENDING_IGNITE.discard(session_id)
|
|
1790
1795
|
if not pending and not block:
|
|
1791
1796
|
return None
|
|
1792
|
-
|
|
1793
|
-
|
|
1797
|
+
if block:
|
|
1798
|
+
return acknowledge_reply(route, block)
|
|
1799
|
+
response = str(kwargs.get("response_text") or "")
|
|
1800
|
+
return acknowledge_reply(route, response, workflow=workflow_row(route, Path.cwd(), session_id))
|
|
1794
1801
|
|
|
1795
1802
|
|
|
1796
1803
|
def subagent_stop(**kwargs: Any) -> None:
|
|
@@ -152,11 +152,16 @@ def acknowledgement(route: str, *, color=False, mode=None, **options) -> str:
|
|
|
152
152
|
return "\n".join(rows)
|
|
153
153
|
|
|
154
154
|
|
|
155
|
-
def acknowledge_reply(route: str, response: str) -> str:
|
|
155
|
+
def acknowledge_reply(route: str, response: str, *, workflow: str | None = None) -> str:
|
|
156
156
|
# The host keeps only the suffix when a transformed response starts with
|
|
157
157
|
# the streamed body. Indented plain lines preserve MICRO rows in Markdown
|
|
158
|
-
# without replaying the model response or adding a fence.
|
|
159
|
-
|
|
158
|
+
# without replaying the model response or adding a fence. The optional
|
|
159
|
+
# workflow map row joins the same block; it is emoji, so a terminal that
|
|
160
|
+
# cannot draw the mark does not get it either.
|
|
161
|
+
rows = acknowledgement(route).splitlines()
|
|
162
|
+
if workflow and utf8_terminal():
|
|
163
|
+
rows.append(workflow)
|
|
164
|
+
suffix = "\n".join(f" {row}" for row in rows)
|
|
160
165
|
return f"{response}\n\n{suffix}" if response else suffix
|
|
161
166
|
|
|
162
167
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"syncedAt": "2026-10-
|
|
2
|
+
"syncedAt": "2026-10-13T08:39:35.353Z",
|
|
3
3
|
"source": "bundled-payload",
|
|
4
|
-
"sourceHash": "
|
|
4
|
+
"sourceHash": "f7d37f440581f5353eb65a779efcbab93796589ca6e8b51658b9df7cad2c81cc",
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"path": "NOTICE.md",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"path": "core.py",
|
|
36
|
-
"sha256": "
|
|
36
|
+
"sha256": "b431f7341d6e82badd752fe1ac56fd39d309f06159f13cbbfc5124728d5fa8f5"
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"path": "core_commands.py",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
},
|
|
122
122
|
{
|
|
123
123
|
"path": "lit_mark.py",
|
|
124
|
-
"sha256": "
|
|
124
|
+
"sha256": "598c83b80adac46b8e7b5d063b9736471990de521f77a4d880bbd5156cc1cbc4"
|
|
125
125
|
},
|
|
126
126
|
{
|
|
127
127
|
"path": "lit_mark_rows.json",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
},
|
|
182
182
|
{
|
|
183
183
|
"path": "plugin.yaml",
|
|
184
|
-
"sha256": "
|
|
184
|
+
"sha256": "c338bd9afaf9714d6bb491bd3642ac98cb16d4da2384664d81ef0ce86ddffa39"
|
|
185
185
|
},
|
|
186
186
|
{
|
|
187
187
|
"path": "provider_metrics.py",
|
|
@@ -1285,7 +1285,7 @@
|
|
|
1285
1285
|
},
|
|
1286
1286
|
{
|
|
1287
1287
|
"path": "skills/frontend-ui-ux/references/complete-contract.md",
|
|
1288
|
-
"sha256": "
|
|
1288
|
+
"sha256": "29b2ea0c2e961cfe5448c0126a797e7f569ad0762880f1d238f90a0d3258a654"
|
|
1289
1289
|
},
|
|
1290
1290
|
{
|
|
1291
1291
|
"path": "skills/frontend-ui-ux/references/composition.md",
|
|
@@ -4405,7 +4405,7 @@
|
|
|
4405
4405
|
},
|
|
4406
4406
|
{
|
|
4407
4407
|
"path": "skills/visual-qa/references/complete-contract.md",
|
|
4408
|
-
"sha256": "
|
|
4408
|
+
"sha256": "d3cf3ccf5c78011a150a1a4ece83a3a6268b8359a01f1c4a1caeb02350cb776f"
|
|
4409
4409
|
},
|
|
4410
4410
|
{
|
|
4411
4411
|
"path": "skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json",
|
|
@@ -4614,6 +4614,10 @@
|
|
|
4614
4614
|
{
|
|
4615
4615
|
"path": "vendor/scientific-visualization/tests/test_style_presets.py",
|
|
4616
4616
|
"sha256": "ff0e190196480848f1fea2398220038771f386ee7967a0ef122b0dfbca3aed46"
|
|
4617
|
+
},
|
|
4618
|
+
{
|
|
4619
|
+
"path": "workflow_map.py",
|
|
4620
|
+
"sha256": "8da12ac65ee0f9c9d8d07611df41dda8b07fd75eca821bf0e8b5c3dc376cd930"
|
|
4617
4621
|
}
|
|
4618
4622
|
]
|
|
4619
4623
|
}
|
|
@@ -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.21
|
|
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.21)
|
|
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.21
|
|
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.21)
|
|
506
506
|
[OK] skills bundled: 36
|
|
507
507
|
[OK] litgoal durable runtime importable
|
|
508
508
|
```
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""One plain-text workflow map row for LIT replies, read from durable state.
|
|
2
|
+
|
|
3
|
+
The reply transform is the plugin's only user-visible channel, and Hermes
|
|
4
|
+
cannot colour it, so the row carries stage emoji and a block bar instead:
|
|
5
|
+
|
|
6
|
+
🔥 LIT · lit-loop — 📝 plan ✓ › 🔨 WORK ▰▰▰▱▱▱▱ 3/7 › 🔍 · › 🏁 ·
|
|
7
|
+
|
|
8
|
+
The newest durable record in the workspace anchors the row: a plan file under
|
|
9
|
+
plans/, a run state, a bounded-work state, or the litgoal goal (the session's
|
|
10
|
+
own store first, then the legacy one). A stage with no durable signal is left
|
|
11
|
+
out, and no signal at all draws no row. The row never carries a command, a
|
|
12
|
+
path, a plan name, or any argument. Reads are bounded and never write.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from .core_plans import FINAL_VERIFIER_ROW, IMPLEMENTATION_ROW, _outside_fence_lines
|
|
21
|
+
from .litgoal import store as _litgoal_store
|
|
22
|
+
except (ImportError, ModuleNotFoundError):
|
|
23
|
+
from core_plans import FINAL_VERIFIER_ROW, IMPLEMENTATION_ROW, _outside_fence_lines
|
|
24
|
+
from litgoal import store as _litgoal_store
|
|
25
|
+
|
|
26
|
+
MAX_PLAN_BYTES = 1_000_000
|
|
27
|
+
MAX_STATE_BYTES = 2_000_000
|
|
28
|
+
BAR_CELLS = 7
|
|
29
|
+
EMOJI = {"plan": "📝", "work": "🔨", "review": "🔍", "done": "🏁"}
|
|
30
|
+
# Only the plan → work → review chain draws the map; a prose, office, motion or
|
|
31
|
+
# research reply would show an unrelated workflow.
|
|
32
|
+
WORKFLOW_ROUTES = frozenset({
|
|
33
|
+
"litwork", "lit-loop", "lit-plan", "start-work", "litgoal", "review-work",
|
|
34
|
+
"deep-interview", "lit-team", "lit-recap",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _mtime(path: Path) -> float:
|
|
39
|
+
try:
|
|
40
|
+
return path.stat().st_mtime
|
|
41
|
+
except OSError:
|
|
42
|
+
return -1.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _newest(paths) -> Path | None:
|
|
46
|
+
return max(paths, key=_mtime, default=None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _json(path: Path | None) -> dict | None:
|
|
50
|
+
try:
|
|
51
|
+
if path is None or not path.is_file() or path.stat().st_size > MAX_STATE_BYTES:
|
|
52
|
+
return None
|
|
53
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
return data if isinstance(data, dict) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolved(raw: object) -> str:
|
|
60
|
+
try:
|
|
61
|
+
return str(Path(str(raw)).expanduser().resolve()) if raw else ""
|
|
62
|
+
except (OSError, ValueError):
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _plan_counts(raw: object) -> tuple[int, int, int, int] | None:
|
|
67
|
+
"""(tasks done, tasks, checks done, checks) from a plan's column-zero rows."""
|
|
68
|
+
try:
|
|
69
|
+
path = Path(str(raw or "")).expanduser()
|
|
70
|
+
if not raw or path.suffix.lower() != ".md" or not path.is_file() or path.stat().st_size > MAX_PLAN_BYTES:
|
|
71
|
+
return None
|
|
72
|
+
text = path.read_text(encoding="utf-8")
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
return None
|
|
75
|
+
counts = [0, 0, 0, 0]
|
|
76
|
+
for _, line in _outside_fence_lines(text):
|
|
77
|
+
offset = 0 if IMPLEMENTATION_ROW.match(line) else 2 if FINAL_VERIFIER_ROW.match(line) else None
|
|
78
|
+
if offset is not None:
|
|
79
|
+
counts[offset + 1] += 1
|
|
80
|
+
counts[offset] += line[3] in "xX"
|
|
81
|
+
return tuple(counts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _goal(workspace: Path, session_id: str):
|
|
85
|
+
"""(goals.json path, active goal) from the session's store, else the legacy one."""
|
|
86
|
+
try:
|
|
87
|
+
scopes = [session_id] if _litgoal_store.normalize_session_id(session_id) else []
|
|
88
|
+
except ValueError:
|
|
89
|
+
scopes = []
|
|
90
|
+
for scope in [*scopes, ""]:
|
|
91
|
+
path = _litgoal_store.goals_path(workspace, scope)
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
goal = _litgoal_store.load_or_create(workspace, scope).active_goal()
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
return None
|
|
98
|
+
return (path, goal) if goal is not None else None
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _bar(done: int, total: int) -> str:
|
|
103
|
+
filled = min(BAR_CELLS, done * BAR_CELLS // total)
|
|
104
|
+
return "▰" * filled + "▱" * (BAR_CELLS - filled)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _stage(name: str, state: str, done: int = 0, total: int = 0) -> str:
|
|
108
|
+
emoji = EMOJI[name]
|
|
109
|
+
if state == "done":
|
|
110
|
+
return f"{emoji} {name} ✓"
|
|
111
|
+
if state == "pending":
|
|
112
|
+
return f"{emoji} ·"
|
|
113
|
+
counter = f" {_bar(done, total)} {done}/{total}" if total else ""
|
|
114
|
+
return f"{emoji} {name.upper()}{counter}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _progress_stages(plan: tuple | None, work: tuple[int, int] | None, finished: bool) -> list[str]:
|
|
118
|
+
"""Stages after a plan exists: work from `work` or plan tasks, review from plan checks."""
|
|
119
|
+
tasks = work if work is not None else (plan[:2] if plan else None)
|
|
120
|
+
checks = plan[2:] if plan else None
|
|
121
|
+
stages: list[str] = []
|
|
122
|
+
work_done = finished
|
|
123
|
+
if tasks and tasks[1]:
|
|
124
|
+
work_done = finished or tasks[0] >= tasks[1]
|
|
125
|
+
stages.append(_stage("work", "done" if work_done else "current", *tasks))
|
|
126
|
+
review_done = work_done
|
|
127
|
+
if checks and checks[1]:
|
|
128
|
+
review_done = finished or (work_done and checks[0] >= checks[1])
|
|
129
|
+
state = "done" if review_done else "current" if work_done else "pending"
|
|
130
|
+
stages.append(_stage("review", state, *checks))
|
|
131
|
+
stages.append(_stage("done", "done" if finished or (work_done and review_done) else "pending"))
|
|
132
|
+
return stages
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _goal_stages(goal) -> list[str]:
|
|
136
|
+
total = len(goal.criteria)
|
|
137
|
+
passed = sum(1 for criterion in goal.criteria if criterion.status == "pass")
|
|
138
|
+
complete = goal.status == "complete"
|
|
139
|
+
blocked = goal.status == "review_blocked" or any(not b.resolved for b in goal.review_blockers)
|
|
140
|
+
stages = []
|
|
141
|
+
work_done = complete or (total > 0 and passed >= total)
|
|
142
|
+
if total:
|
|
143
|
+
stages.append(_stage("work", "done" if work_done else "current", passed, total))
|
|
144
|
+
review = "done" if complete else "current" if blocked or work_done else "pending"
|
|
145
|
+
stages.append(_stage("review", review))
|
|
146
|
+
stages.append(_stage("done", "done" if complete else "pending"))
|
|
147
|
+
return stages
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def workflow_stages(workspace: Path, session_id: str = "") -> list[str] | None:
|
|
151
|
+
hermes = Path(workspace) / ".hermes" / "lithermes"
|
|
152
|
+
run_file = _newest((hermes / "runs").glob("*/state.json"))
|
|
153
|
+
work_file = _newest((hermes / "work").glob("*/state.json"))
|
|
154
|
+
# Checking off rows rewrites the plan a run executes; only a plan no run or
|
|
155
|
+
# work state points at can mean the user is planning again.
|
|
156
|
+
executing = {_resolved((_json(path) or {}).get("plan")) for path in (run_file, work_file) if path}
|
|
157
|
+
plan_file = _newest(path for path in (Path(workspace) / "plans").glob("*.md")
|
|
158
|
+
if path.is_file() and _resolved(path) not in executing)
|
|
159
|
+
goal = _goal(Path(workspace), str(session_id or ""))
|
|
160
|
+
candidates = [(path, kind) for path, kind in ((plan_file, "plan"), (run_file, "run"), (work_file, "work")) if path]
|
|
161
|
+
if goal:
|
|
162
|
+
candidates.append((goal[0], "goal"))
|
|
163
|
+
if not candidates:
|
|
164
|
+
return None
|
|
165
|
+
anchor, kind = max(candidates, key=lambda item: _mtime(item[0]))
|
|
166
|
+
|
|
167
|
+
if kind == "plan":
|
|
168
|
+
counts = _plan_counts(anchor)
|
|
169
|
+
stages = [_stage("plan", "current")]
|
|
170
|
+
if counts and counts[1]:
|
|
171
|
+
stages.append(_stage("work", "pending"))
|
|
172
|
+
if counts and counts[3]:
|
|
173
|
+
stages.append(_stage("review", "pending"))
|
|
174
|
+
return stages + [_stage("done", "pending")]
|
|
175
|
+
if kind == "goal":
|
|
176
|
+
run = _json(run_file) or {}
|
|
177
|
+
has_plan = _plan_counts(run.get("plan")) is not None
|
|
178
|
+
return ([_stage("plan", "done")] if has_plan else []) + _goal_stages(goal[1])
|
|
179
|
+
state = _json(anchor)
|
|
180
|
+
if state is None:
|
|
181
|
+
return None
|
|
182
|
+
counts = _plan_counts(state.get("plan"))
|
|
183
|
+
if kind == "run":
|
|
184
|
+
if counts is None or not (counts[1] or counts[3]):
|
|
185
|
+
return None
|
|
186
|
+
return [_stage("plan", "done"), *_progress_stages(counts, None, False)]
|
|
187
|
+
status = state.get("status")
|
|
188
|
+
items = state.get("progress") if isinstance(state.get("progress"), list) else []
|
|
189
|
+
if status not in ("active", "paused", "completed"):
|
|
190
|
+
return None
|
|
191
|
+
work = (sum(1 for item in items if isinstance(item, dict) and item.get("status") == "pass"), len(items)) if items else None
|
|
192
|
+
plan_tasks = (counts[0], counts[1], 0, 0) if counts else None
|
|
193
|
+
return [_stage("plan", "done"), *_progress_stages(plan_tasks, work, status == "completed")]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def workflow_row(route: str, workspace: Path, session_id: str = "") -> str | None:
|
|
197
|
+
"""The map row for this LIT reply, or None when no stage has a durable signal."""
|
|
198
|
+
if route not in WORKFLOW_ROUTES:
|
|
199
|
+
return None
|
|
200
|
+
try:
|
|
201
|
+
stages = workflow_stages(workspace, session_id)
|
|
202
|
+
except (OSError, ValueError):
|
|
203
|
+
return None
|
|
204
|
+
if not stages:
|
|
205
|
+
return None
|
|
206
|
+
return f"🔥 LIT · {route} — " + " › ".join(stages)
|
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.21"><title>release: 1.0.21</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.21</text></g></svg>
|
package/src/cli.js
CHANGED
|
@@ -9,6 +9,8 @@ const { listHud, applyHud, clearHud, previewClearHud, previewHud, promptAccent,
|
|
|
9
9
|
const { supportsColor, findAccent, skinName } = require("./lib/skins");
|
|
10
10
|
const {
|
|
11
11
|
readConfig,
|
|
12
|
+
readBellOnCompleteConfig,
|
|
13
|
+
setBellOnCompleteConfig,
|
|
12
14
|
clearDisplaySkinConfig,
|
|
13
15
|
readDisplaySkinConfig,
|
|
14
16
|
readOutputStyleConfig,
|
|
@@ -37,7 +39,7 @@ const COMMAND_SPECS = {
|
|
|
37
39
|
flags: {
|
|
38
40
|
yes: "", "dry-run": "", offline: "", json: "", force: "", "reconfigure-model": "",
|
|
39
41
|
provider: "ID", model: "ID", effort: "LEVEL", "child-provider": "ID", "child-model": "ID", "child-effort": "LEVEL",
|
|
40
|
-
spinner: "", "no-spinner": "", hud: "<accent>", "no-hud": "", style: "ID", "no-style": "",
|
|
42
|
+
spinner: "", "no-spinner": "", hud: "<accent>", "no-hud": "", style: "ID", "no-style": "", bell: "", "no-bell": "",
|
|
41
43
|
"patch-installed-hermes": "", "no-patch-installed-hermes": "", "no-auto-update": "", ...HOME, "hermes-repo": "PATH",
|
|
42
44
|
},
|
|
43
45
|
},
|
|
@@ -405,6 +407,57 @@ async function maybePickHudAccent(flags, runtime = {}, { promptForAccent = false
|
|
|
405
407
|
if (entry) console.log(applyHud(entry.accent, flags));
|
|
406
408
|
}
|
|
407
409
|
|
|
410
|
+
// Hermes rings the terminal bell (plus a desktop notice) at the end of a turn
|
|
411
|
+
// when display.bell_on_complete is true; its default is off. The installer sets
|
|
412
|
+
// it only on --bell or a "y" to the interactive question, never changes a value
|
|
413
|
+
// the user already chose, and leaves config.yaml alone when the edit is not
|
|
414
|
+
// clean or the file changed while the question was open.
|
|
415
|
+
async function maybeOfferBell(flags, runtime = {}, { prompt = false } = {}) {
|
|
416
|
+
if (flags["dry-run"] || flags["no-bell"]) return;
|
|
417
|
+
const say = flags.json ? console.error : console.log;
|
|
418
|
+
const home = resolveHome(flags);
|
|
419
|
+
const before = readConfig(home);
|
|
420
|
+
const current = readBellOnCompleteConfig(before);
|
|
421
|
+
const byHand = "set display.bell_on_complete: true by hand in config.yaml to hear a bell when a turn finishes.";
|
|
422
|
+
if (current === undefined) {
|
|
423
|
+
if (flags.bell) console.error(`Finish bell: config.yaml does not parse; left unchanged. ${byHand}`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (current !== null) {
|
|
427
|
+
if (flags.bell) say(`Finish bell: kept your display.bell_on_complete: ${current}.`);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
let wanted = Boolean(flags.bell);
|
|
431
|
+
if (!wanted && prompt) {
|
|
432
|
+
const env = runtime.env || process.env;
|
|
433
|
+
const streams = runtime.streams || { stdin: process.stdin, stdout: process.stdout };
|
|
434
|
+
const input = streams.stdin || process.stdin;
|
|
435
|
+
const output = streams.stdout || process.stdout;
|
|
436
|
+
const interactive = runtime.isInteractive ?? Boolean(input.isTTY && output.isTTY);
|
|
437
|
+
if (!interactive || "CI" in env) return;
|
|
438
|
+
wanted = await new Promise((resolve) => {
|
|
439
|
+
const rl = readline.createInterface({ input, output, terminal: false });
|
|
440
|
+
rl.on("close", () => resolve(false));
|
|
441
|
+
rl.question("Ring the terminal bell when a Hermes turn finishes? [y/N] ", (answer) => {
|
|
442
|
+
resolve(/^\s*y(es)?\s*$/i.test(answer));
|
|
443
|
+
rl.close();
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (!wanted) return;
|
|
448
|
+
const after = setBellOnCompleteConfig(before);
|
|
449
|
+
if (after === null) {
|
|
450
|
+
console.error(`Finish bell: config.yaml has a shape the installer will not edit; left unchanged. ${byHand}`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (readConfig(home) !== before) {
|
|
454
|
+
console.error(`Finish bell: config.yaml changed while asking; left unchanged. ${byHand}`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
writeConfig(home, after);
|
|
458
|
+
say("Finish bell on: Hermes rings the terminal when a turn finishes (display.bell_on_complete).");
|
|
459
|
+
}
|
|
460
|
+
|
|
408
461
|
async function main(argv, runtime = {}) {
|
|
409
462
|
const parsed = parseArgs(argv);
|
|
410
463
|
const { command, flags, positionals } = parsed;
|
|
@@ -496,6 +549,7 @@ async function main(argv, runtime = {}) {
|
|
|
496
549
|
}
|
|
497
550
|
await maybePickHudAccent(flags, runtime, { promptForAccent: prompted === "chosen" });
|
|
498
551
|
await maybePickOutputStyle(flags);
|
|
552
|
+
await maybeOfferBell(flags, runtime, { prompt: prompted === "chosen" });
|
|
499
553
|
scheduleUpdateSafely(command, flags, runtime);
|
|
500
554
|
return;
|
|
501
555
|
}
|
|
@@ -552,4 +606,4 @@ async function main(argv, runtime = {}) {
|
|
|
552
606
|
console.log(helpText());
|
|
553
607
|
}
|
|
554
608
|
|
|
555
|
-
module.exports = { COMMAND_SPECS, helpText, main, parseArgs, validateArgs };
|
|
609
|
+
module.exports = { COMMAND_SPECS, helpText, main, maybeOfferBell, parseArgs, validateArgs };
|
package/src/lib/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
|
+
const { isDeepStrictEqual } = require("node:util");
|
|
3
4
|
const { parseDocument } = require("yaml");
|
|
4
5
|
const { writeFileAtomic } = require("./files");
|
|
5
6
|
const modelConfig = require("./modelConfig");
|
|
@@ -181,6 +182,56 @@ function setTuiAgentsNudgeConfig(text) {
|
|
|
181
182
|
return joinLines(lines, eol, ["display:", " tui_agents_nudge: true"]);
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// Hermes display.bell_on_complete (default false): BEL + desktop notice when a
|
|
186
|
+
// turn ends. null = unset, undefined = config.yaml does not parse.
|
|
187
|
+
function readBellOnCompleteConfig(text) {
|
|
188
|
+
const str = String(text || "");
|
|
189
|
+
if (!str.trim()) return null;
|
|
190
|
+
try {
|
|
191
|
+
const document = parseDocument(str, { strict: true, uniqueKeys: true });
|
|
192
|
+
if (document.errors.length) return undefined;
|
|
193
|
+
const value = document.toJS()?.display?.bell_on_complete;
|
|
194
|
+
return value === undefined || value === null ? null : value;
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Add `bell_on_complete: true` only when unset. Returns null, and edits
|
|
201
|
+
// nothing, when the result would differ from the input by anything but that
|
|
202
|
+
// one key (an odd indent, a flow map, or YAML that does not parse).
|
|
203
|
+
function setBellOnCompleteConfig(text) {
|
|
204
|
+
const str = String(text || "");
|
|
205
|
+
const current = readBellOnCompleteConfig(str);
|
|
206
|
+
if (current === undefined) return null;
|
|
207
|
+
if (current !== null) return str;
|
|
208
|
+
const eol = eolOf(str);
|
|
209
|
+
const lines = str.split(/\r?\n/);
|
|
210
|
+
const block = findTopBlock(lines, "display");
|
|
211
|
+
let after;
|
|
212
|
+
if (block) {
|
|
213
|
+
openBlock(lines, block);
|
|
214
|
+
lines.splice(block.start + 1, 0, " bell_on_complete: true");
|
|
215
|
+
after = joinLines(lines, eol);
|
|
216
|
+
} else {
|
|
217
|
+
after = joinLines(lines, eol, ["display:", " bell_on_complete: true"]);
|
|
218
|
+
}
|
|
219
|
+
const parse = (value) => {
|
|
220
|
+
const document = parseDocument(value, { strict: true, uniqueKeys: true });
|
|
221
|
+
return document.errors.length ? undefined : document.toJS() || {};
|
|
222
|
+
};
|
|
223
|
+
const was = parse(str);
|
|
224
|
+
const now = parse(after);
|
|
225
|
+
if (!was || !now || now.display?.bell_on_complete !== true) return null;
|
|
226
|
+
delete now.display.bell_on_complete;
|
|
227
|
+
// An absent or empty (null) display block had nothing else in it.
|
|
228
|
+
if (!Object.keys(now.display).length && was.display == null) {
|
|
229
|
+
if (was.display === undefined) delete now.display;
|
|
230
|
+
else now.display = null;
|
|
231
|
+
}
|
|
232
|
+
return isDeepStrictEqual(was, now) ? after : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
184
235
|
function clearDisplaySkinConfig(text) {
|
|
185
236
|
const lines = String(text || "").split(/\r?\n/);
|
|
186
237
|
const block = findTopBlock(lines, "display");
|
|
@@ -296,6 +347,7 @@ module.exports = {
|
|
|
296
347
|
disableLitHermesConfig,
|
|
297
348
|
enableLitHermesConfig,
|
|
298
349
|
readConfig,
|
|
350
|
+
readBellOnCompleteConfig,
|
|
299
351
|
readDisplaySkinConfig,
|
|
300
352
|
readExternalSkillDirsConfig,
|
|
301
353
|
readStatusBarFields,
|
|
@@ -303,6 +355,7 @@ module.exports = {
|
|
|
303
355
|
suggestStatusBarFields,
|
|
304
356
|
readOutputStyleConfig,
|
|
305
357
|
readTuiAgentsNudgeConfig,
|
|
358
|
+
setBellOnCompleteConfig,
|
|
306
359
|
setDisplaySkinConfig,
|
|
307
360
|
setOutputStyleConfig,
|
|
308
361
|
setTuiAgentsNudgeConfig,
|
package/src/lib/skins.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
3
|
const { colorMode, skinLogo, microLogo } = require("./litMark");
|
|
4
|
+
const { writeFileAtomic } = require("./files");
|
|
4
5
|
|
|
5
6
|
// LitHermes HUD accents — 10 presets that drive Hermes' native skin engine.
|
|
6
7
|
// Each becomes ~/.hermes/skins/lithermes-<accent>.yaml; activated via
|
|
@@ -179,7 +180,31 @@ function recolorMarkup(rows, palette) {
|
|
|
179
180
|
// get_color path, dark_colors/light_colors are the TUI polarity pair, and
|
|
180
181
|
// spinner wings must be [left, right] pairs (get_spinner_wings drops single
|
|
181
182
|
// glyphs, so the brief's flat wing list is shipped as two pairs).
|
|
182
|
-
|
|
183
|
+
// Hermes picks one face at random for each thinking step and each tool call,
|
|
184
|
+
// so the four frames read as a flame moving through "lit" across a turn.
|
|
185
|
+
const FLAME_FACES = '["(🔥 lit)", "( 🔥lit)", "(lit🔥 )", "(lit 🔥)"]';
|
|
186
|
+
const SPINNER_LINES = Object.freeze([
|
|
187
|
+
` waiting_faces: ${FLAME_FACES}`,
|
|
188
|
+
` thinking_faces: ${FLAME_FACES}`,
|
|
189
|
+
" thinking_verbs: [igniting, lit-looping, planning, verifying, gathering evidence, checking criteria, reviewing]",
|
|
190
|
+
]);
|
|
191
|
+
// Spinner blocks earlier releases wrote, newest first. A preset whose spinner
|
|
192
|
+
// block still matches one of them byte for byte gets the flame faces on the
|
|
193
|
+
// next install; everything else in the file, and any edited spinner, stays.
|
|
194
|
+
const SHIPPED_SPINNERS = Object.freeze([
|
|
195
|
+
Object.freeze([
|
|
196
|
+
' waiting_faces: ["(lit)", "(◜)", "(◝)", "(◞)", "(◟)"]',
|
|
197
|
+
' thinking_faces: ["(lit)", "(✦)", "(◇)", "(⌁)"]',
|
|
198
|
+
" thinking_verbs: [lit-looping, planning, verifying, gathering evidence, checking criteria, reviewing]",
|
|
199
|
+
]),
|
|
200
|
+
Object.freeze([
|
|
201
|
+
' waiting_faces: ["🔥", "🔥", "🔥", "🔥"]',
|
|
202
|
+
" thinking_verbs: [igniting, forging, burning, tempering]",
|
|
203
|
+
]),
|
|
204
|
+
]);
|
|
205
|
+
const spinnerBlock = (lines) => ["spinner:", ...lines, " wings:"].join("\n");
|
|
206
|
+
|
|
207
|
+
function renderSkinYaml(entry, { spinner = SPINNER_LINES } = {}) {
|
|
183
208
|
const h = entry.hex;
|
|
184
209
|
const theme = entry.palette;
|
|
185
210
|
const colorBlocks = theme
|
|
@@ -200,9 +225,7 @@ function renderSkinYaml(entry) {
|
|
|
200
225
|
`description: LitHermes ${entry.label} accent HUD`,
|
|
201
226
|
...colorBlocks,
|
|
202
227
|
"spinner:",
|
|
203
|
-
|
|
204
|
-
' thinking_faces: ["(lit)", "(✦)", "(◇)", "(⌁)"]',
|
|
205
|
-
" thinking_verbs: [lit-looping, planning, verifying, gathering evidence, checking criteria, reviewing]",
|
|
228
|
+
...spinner,
|
|
206
229
|
" wings:",
|
|
207
230
|
' - ["◜", "◝"]',
|
|
208
231
|
' - ["◞", "◟"]',
|
|
@@ -249,11 +272,22 @@ function installSkins(hermesHome) {
|
|
|
249
272
|
written.push(file);
|
|
250
273
|
} catch (error) {
|
|
251
274
|
if (error.code !== "EEXIST") throw error;
|
|
275
|
+
if (upgradeShippedSpinner(file)) written.push(file);
|
|
252
276
|
}
|
|
253
277
|
}
|
|
254
278
|
return written;
|
|
255
279
|
}
|
|
256
280
|
|
|
281
|
+
function upgradeShippedSpinner(file) {
|
|
282
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
283
|
+
if (!stat || !stat.isFile() || stat.size > 64 * 1024) return false;
|
|
284
|
+
const text = fs.readFileSync(file, "utf8");
|
|
285
|
+
const shipped = SHIPPED_SPINNERS.map(spinnerBlock).find((block) => text.split(block).length === 2);
|
|
286
|
+
if (!shipped) return false;
|
|
287
|
+
writeFileAtomic(file, text.replace(shipped, spinnerBlock(SPINNER_LINES)), "utf8");
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
257
291
|
function hexToRgb(hex) {
|
|
258
292
|
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex));
|
|
259
293
|
if (!m) return null;
|
|
@@ -286,6 +320,7 @@ module.exports = {
|
|
|
286
320
|
skinName,
|
|
287
321
|
skinsDir,
|
|
288
322
|
renderSkinYaml,
|
|
323
|
+
SHIPPED_SPINNERS,
|
|
289
324
|
findAccent,
|
|
290
325
|
installSkins,
|
|
291
326
|
hexToRgb,
|