@litfamily/lithermes 1.0.19 β 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/README.md +11 -7
- package/assets/lithermes-plugin/core.py +9 -2
- package/assets/lithermes-plugin/diagnostics.py +3 -11
- package/assets/lithermes-plugin/lit_mark.py +8 -3
- package/assets/lithermes-plugin/payload-version.json +17 -13
- 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/lit-pptx/runtime/package-lock.json +167 -119
- package/assets/lithermes-plugin/skills/lit-pptx/runtime/package.json +1 -1
- package/assets/lithermes-plugin/skills/readme-studio/templates/hyperframes/package-lock.json +169 -121
- package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
- 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/modelRoutePolicy.js +44 -37
- package/src/lib/skins.js +39 -4
|
@@ -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,
|
|
@@ -10,30 +10,36 @@ const LEGACY_SOL_MODEL = "gpt-5.6-sol";
|
|
|
10
10
|
const LEGACY_CHILD_MODEL = "gpt-5.6-luna";
|
|
11
11
|
const CHILD_EFFORT = "max";
|
|
12
12
|
const GPT6_LUNA_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
13
|
-
const LEGACY_LUNA_EFFORTS =
|
|
13
|
+
const LEGACY_LUNA_EFFORTS = GPT6_LUNA_EFFORTS;
|
|
14
14
|
const LUNA_EFFORTS = LEGACY_LUNA_EFFORTS;
|
|
15
15
|
const LUNA_MODELS = new Set([CHILD_MODEL, LEGACY_CHILD_MODEL]);
|
|
16
16
|
const TERRA_MODEL = "gpt-5.6-terra";
|
|
17
|
-
const TERRA_EFFORTS = new Set(["high", "xhigh", "max"]);
|
|
18
|
-
const LEGACY_SOL_EFFORTS = new Set(["high", "xhigh"]);
|
|
19
17
|
const MODEL_EFFORTS = new Map([
|
|
20
18
|
[ASTRA_MODEL, GPT6_LEAD_EFFORTS],
|
|
21
19
|
[SOL_MODEL, GPT6_LEAD_EFFORTS],
|
|
22
20
|
[PREVIOUS_SOL_MODEL, GPT6_LEAD_EFFORTS],
|
|
23
21
|
[CHILD_MODEL, GPT6_LUNA_EFFORTS],
|
|
24
|
-
[LEGACY_SOL_MODEL,
|
|
22
|
+
[LEGACY_SOL_MODEL, GPT6_LEAD_EFFORTS],
|
|
25
23
|
[LEGACY_CHILD_MODEL, LEGACY_LUNA_EFFORTS],
|
|
26
24
|
["gpt-5.6", new Set(["high"])],
|
|
27
|
-
[TERRA_MODEL,
|
|
25
|
+
[TERRA_MODEL, GPT6_LEAD_EFFORTS],
|
|
28
26
|
]);
|
|
27
|
+
// When only a model is given, these keep the effort they resolved to before their
|
|
28
|
+
// full effort sets were listed, so widening a menu never moves a default.
|
|
29
|
+
const MODEL_ONLY_EFFORTS = {
|
|
30
|
+
lead: new Map([[LEGACY_CHILD_MODEL, CHILD_EFFORT]]),
|
|
31
|
+
child: new Map([[LEGACY_SOL_MODEL, "high"]]),
|
|
32
|
+
};
|
|
29
33
|
const CHILD_TRANSPORT_KEYS = ["provider", "base_url", "api_mode"];
|
|
30
34
|
const { OPENAI_PROVIDER: OPENAI_PROVIDER_ID } = require("./modelConfigBoundary");
|
|
31
35
|
const XAI_PROVIDER_ID = "xai";
|
|
32
36
|
// Installer choice catalog (plans/references/installer-choice-contract.md, LitHermes row).
|
|
33
|
-
// OpenAI rows
|
|
34
|
-
// GPT-5.6 routes.
|
|
35
|
-
// (
|
|
36
|
-
//
|
|
37
|
+
// OpenAI rows offer every effort the live model catalog lists for each model
|
|
38
|
+
// (supported_reasoning_levels), including the selectable legacy GPT-5.6 routes. Hermes
|
|
39
|
+
// accepts each of them in config but may send a lower level (ultra as max; max and ultra
|
|
40
|
+
// as xhigh on gpt-6.1-sol, gpt-6-sol and gpt-6-luna). xAI rows use the reasoning-capable models
|
|
41
|
+
// of the Hermes 0.19 catalog (hermes_cli/models.py `_XAI_STATIC_FALLBACK`), newest first,
|
|
42
|
+
// with the efforts Hermes sends to Grok before 4.6 (agent/reasoning_effort.py XAI_LEGACY_EFFORTS).
|
|
37
43
|
const ROUTE_CATALOG = {
|
|
38
44
|
[OPENAI_PROVIDER_ID]: {
|
|
39
45
|
credentialEnv: null,
|
|
@@ -67,18 +73,25 @@ const ROUTE_CATALOG = {
|
|
|
67
73
|
? "balanced, lighter effort"
|
|
68
74
|
: "Luna Responses effort",
|
|
69
75
|
})),
|
|
70
|
-
...[
|
|
76
|
+
...[...GPT6_LEAD_EFFORTS].map((effort) => ({
|
|
71
77
|
effort,
|
|
72
78
|
model: LEGACY_SOL_MODEL,
|
|
73
79
|
note: "previous-generation Sol lead route",
|
|
74
80
|
})),
|
|
75
|
-
...[
|
|
81
|
+
...[...GPT6_LEAD_EFFORTS].map((effort) => ({
|
|
76
82
|
effort,
|
|
77
83
|
model: TERRA_MODEL,
|
|
78
84
|
note: "previous-generation Terra route",
|
|
79
85
|
})),
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
...[...GPT6_LUNA_EFFORTS].map((effort) => ({
|
|
87
|
+
effort,
|
|
88
|
+
model: LEGACY_CHILD_MODEL,
|
|
89
|
+
note: effort === CHILD_EFFORT
|
|
90
|
+
? "previous-generation balanced helper"
|
|
91
|
+
: effort === "high"
|
|
92
|
+
? "previous-generation lighter helper"
|
|
93
|
+
: "previous-generation Luna effort",
|
|
94
|
+
})),
|
|
82
95
|
{ effort: "high", model: "gpt-5.6", note: "general model" },
|
|
83
96
|
],
|
|
84
97
|
lead: { effort: PARENT_EFFORT, model: PARENT_MODEL },
|
|
@@ -88,11 +101,15 @@ const ROUTE_CATALOG = {
|
|
|
88
101
|
credentialEnv: "XAI_API_KEY",
|
|
89
102
|
label: "xAI Grok",
|
|
90
103
|
rows: [
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
],
|
|
104
|
+
["grok-build-0.1", "newest coding model (recommended lead)"],
|
|
105
|
+
["grok-4.5", "flagship reasoning"],
|
|
106
|
+
["grok-4.3", "fast reasoning (recommended helper)"],
|
|
107
|
+
["grok-4.20-0309-reasoning", "previous reasoning generation"],
|
|
108
|
+
].flatMap(([model, note]) => ["low", "medium", "high"].map((effort) => ({
|
|
109
|
+
effort,
|
|
110
|
+
model,
|
|
111
|
+
note: effort === "high" ? note : "Grok reasoning effort",
|
|
112
|
+
}))),
|
|
96
113
|
lead: { effort: "high", model: "grok-build-0.1" },
|
|
97
114
|
helper: { effort: "high", model: "grok-4.3" },
|
|
98
115
|
},
|
|
@@ -137,28 +154,19 @@ function inspectEffectiveRouteSafety(document, expectedProvider) {
|
|
|
137
154
|
if (modelEfforts && !modelEfforts.has(route.effort)) {
|
|
138
155
|
const isAstra = route.model === ASTRA_MODEL;
|
|
139
156
|
const isLuna = LUNA_MODELS.has(route.model);
|
|
140
|
-
const isLegacyLunaXhigh = route.model === LEGACY_CHILD_MODEL && route.effort === "xhigh";
|
|
141
157
|
return {
|
|
142
158
|
classificationClass: isAstra
|
|
143
159
|
? "astra_effort_unsupported"
|
|
144
|
-
:
|
|
145
|
-
? "
|
|
146
|
-
:
|
|
147
|
-
? "luna_effort_below_high"
|
|
148
|
-
: route.model === TERRA_MODEL
|
|
149
|
-
? "terra_effort_below_high"
|
|
150
|
-
: "model_effort_unsupported",
|
|
160
|
+
: isLuna
|
|
161
|
+
? "luna_effort_unsupported"
|
|
162
|
+
: "model_effort_unsupported",
|
|
151
163
|
code: UNSAFE_ROUTE_CODE,
|
|
152
164
|
observedSource: `${route.route} model, effort, and provider config`,
|
|
153
165
|
reason: isAstra
|
|
154
166
|
? `${route.route} Astra effort is unsupported or missing; choose one of: ${[...modelEfforts].join(", ")}`
|
|
155
|
-
:
|
|
156
|
-
? `${route.route} Luna
|
|
157
|
-
:
|
|
158
|
-
? `${route.route} Luna effort is unsupported; choose one of: ${[...modelEfforts].join(", ")}`
|
|
159
|
-
: route.model === TERRA_MODEL
|
|
160
|
-
? `${route.route} TERRA effort is below high or missing`
|
|
161
|
-
: `${route.route} ${route.model} effort is unsupported or missing; choose one of: ${[...modelEfforts].join(", ")}`,
|
|
167
|
+
: isLuna
|
|
168
|
+
? `${route.route} Luna effort is unsupported; choose one of: ${[...modelEfforts].join(", ")}`
|
|
169
|
+
: `${route.route} ${route.model} effort is unsupported or missing; choose one of: ${[...modelEfforts].join(", ")}`,
|
|
162
170
|
route: route.route,
|
|
163
171
|
status: "blocked",
|
|
164
172
|
};
|
|
@@ -202,9 +210,6 @@ function resolveRow(provider, role, model, effort) {
|
|
|
202
210
|
const hasModel = model !== undefined;
|
|
203
211
|
const hasEffort = effort !== undefined;
|
|
204
212
|
const resolvedModel = hasModel ? String(model) : preset.model;
|
|
205
|
-
if (resolvedModel === LEGACY_CHILD_MODEL && hasEffort && effort === "xhigh") {
|
|
206
|
-
throw new Error(`${role} route ${resolvedModel} xhigh is a forbidden pair; legacy Luna runs at high or max`);
|
|
207
|
-
}
|
|
208
213
|
const rowsForModel = catalog.rows.filter((row) => row.model === resolvedModel);
|
|
209
214
|
if (!rowsForModel.length) {
|
|
210
215
|
const known = PROVIDER_IDS.some((id) => ROUTE_CATALOG[id].rows.some((row) => row.model === resolvedModel));
|
|
@@ -215,7 +220,9 @@ function resolveRow(provider, role, model, effort) {
|
|
|
215
220
|
// An explicitly selected model keeps that model's cataloged preset effort
|
|
216
221
|
// when no effort flag is supplied. This matters for Astra: its recommended
|
|
217
222
|
// lead row is xhigh, while its catalog is intentionally listed low-first.
|
|
218
|
-
const modelDefault =
|
|
223
|
+
const modelDefault = MODEL_ONLY_EFFORTS[role]?.get(resolvedModel)
|
|
224
|
+
?? rowsForModel.find((row) => row.effort === preset.effort)?.effort
|
|
225
|
+
?? rowsForModel[0].effort;
|
|
219
226
|
const resolvedEffort = hasEffort ? String(effort) : (hasModel ? modelDefault : preset.effort);
|
|
220
227
|
if (!rowsForModel.some((row) => row.effort === resolvedEffort)) {
|
|
221
228
|
throw new Error(`${role} effort ${resolvedEffort} is not offered for ${resolvedModel}; choose one of: ${rowsForModel.map((row) => row.effort).join(", ")}`);
|
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,
|