22 Commits

Author SHA1 Message Date
John Paul Soliva
fe8b643db6 fix(sessions): transcript exports keep compacted turns, so --delete-after-verified no longer deletes them unseen
In-place compaction is the default. It soft-archives every earlier row of
a session under the same id (active = 0, compacted = 1), and Desktop and
the dashboard still show those turns. The md/qmd export read the session
through export_session -> get_messages with the default live-only clause,
so it wrote only the compaction summary and the carried tail.
verify_export_file then compared the file with that same dict, and
delete_session removed every row of the session, including the archived
turns that never reached the file.

The md/qmd export now reads the display history (include_compacted), for
a single session and for --lineage logical. export_session and
export_session_lineage take include_compacted, off by default:
import_sessions inserts every message as live context, so the JSON export
and stranded-session adoption keep reading live rows only.

The other transcripts people read had the same hole without the delete:
`/save md` and `/save html` (CLI and gateway), `sessions export --format
html` (one session or all of them) and `--only user-prompts` each held 1
of 6 answers on a six-turn session after one compaction. They now read
the display history too (SAVE_TRANSCRIPT_FORMATS; export_all gains
include_compacted and reads per session then, since the display read
dedupes per session). `/save json`, JSONL and the dashboard's JSON export
stay live-only for the import reason above.

Before deleting, the verify step also re-counts the store's display rows
for every session the file covers and refuses on a mismatch. A message
that lands while the files are written, or a later export change that
reads a narrower view, now refuses the delete instead of being removed
unseen. Like the adoption retire loop, the re-count runs just before
delete_session, not inside its transaction. Rewind rows (undone turns,
the superseded originals of a carried tail) are still deleted without
being exported, as `hermes sessions delete` does: they are not part of
the history the session shows.

Measured through the real CLI on a session with 6 turns and one default
in-place compaction (15 rows, 13 shown): before, 3 messages were exported
and all 15 rows deleted, with answers 1-5 missing from the file; after,
13 messages are exported in display order, then deleted.

(cherry picked from commit adeaff1e33f1ae2b8a066ef2374bb29ade50b3b8)
2026-09-23 22:22:32 +05:30
Aaron08140
8e4c943477 fix(cli): clear-screen fallback spawns no shell and no console window (#116904)
_clear_terminal_on_exit() fell back to os.system('cls'/'clear') when the
escape sequence was rejected. os.system() spawns a shell: on Windows a
conhost window flashes before the process exits, and in minimal POSIX
containers without `clear` on PATH the shell fails silently while the
except-pass hides it. The repo already standardises on windows_hide_flags()
(CREATE_NO_WINDOW) for short-lived argv spawns; use it here too.

- Windows: subprocess.run(['cmd', '/c', 'cls'], creationflags=windows_hide_flags())
  (cls is a cmd builtin, so probing a `cls` exe would be wrong).
- POSIX: shutil.which('clear') first; skip the spawn entirely when absent
  instead of letting a shell swallow a 127.
- stdin=DEVNULL so a child that reads can never block CLI shutdown.
- Regression tests: parametrised nt/posix argv + creationflags assertions
  (RED on the os.system path), plus the no-`clear` skip case.
2026-09-20 15:56:33 -07:00
teknium1
ae1b5d79b2 fix(sessions): one-shot runs get a distinct oneshot source that pickers hide
`hermes chat -q`/`--oneshot`/`-Q` and `hermes -z` (both set HERMES_SINGLE_QUERY_SESSION=1)
persisted their session as `cli` — and, before the first pass, as the inherited
`tui`/`desktop` transport label — so finite automation runs sat in the TUI, Desktop and
dashboard session pickers next to real conversations (#112550).

- run_agent._session_source_for_agent: a single-query run whose source is empty (or an
  inherited UI transport label without an explicit --source) resolves to `oneshot`; the
  platform gate keeps delegate children (`subagent`) untouched; an explicit `--source`
  (HERMES_SESSION_SOURCE_EXPLICIT=1 from main.py) still wins.
- hermes_state_sessions.INTERNAL_LISTING_SOURCES = (kanban, tool, oneshot) replaces the
  three copied `["kanban", "tool"]` literals (tui_gateway session.list, console
  `sessions list`/`stats`, in-chat /sessions), and the Desktop project tree / sidebar
  recents and the dashboard automation set exclude `oneshot` too.
- `hermes -c` / `--resume latest` still chain on the previous one-shot (PR #105957's
  documented flow): the CLI MRU lookup matches the cli family {cli, oneshot} and
  search_sessions accepts several sources; one-shots keep stamping their launch cwd so the
  workspace-scoped lookup keeps working.
- Compression child: the rotated child is published with the PARENT ROW's persisted source
  instead of bare agent.platform, so a `--source tool` / `oneshot` / inherited `kanban`
  session does not degrade to a picker-visible `cli` row after compaction.
- Docs: sessions source table (+ oneshot/kanban/tool rows, compression note) and the
  `--source` flag reference (explicit flag always stored as given).
2026-09-17 09:06:59 -07:00
teknium1
b002dfc04d fix: pruned skill_view/read_file results reload after a proactive prune
A committed proactive tool-result prune (`prune_tool_results_only`, driven from
`agent/turn_preflight.py::compress_after_tool_results`) demotes old skill_view and
read_file bodies to one-line markers, but only the full-compaction path called
`_reset_read_dedup_caches`. The repeat-view dedup therefore kept answering
"unchanged / content_returned: false" for content that no longer existed in the
transcript, so the `[SKILL_PRUNED: ... reload with skill_view(...)]` marker asked
for a reload the tool then refused (#112763). Treat the committed prune as the same
content-loss boundary compaction already is: reset the task's read/skill dedup right
where the pruned list is committed.

Sibling surface: manual `/compress` on CLI, TUI and the messaging gateway called
`compress_now()` without `task_id`, so the boundary reset hit the "default" bucket
instead of the session's. Pass the session-scoped task id on all three.

Test double in tests/hermes_cli/test_cli_manual_compress.py gains the `task_id`
kwarg the real `_compress_context` facade already accepts.

Supersedes #103268 (@jo0wz), which reached the same path with a process-global
ghost registry (not task-keyed, skill_view only).
2026-09-16 17:23:44 -07:00
teknium1
77f5de23dd fix(cli): /model switch, session restore and /new re-resolve reasoning effort for the new model
The classic CLI resolves `reasoning_config` once at startup for the launch model and
passes that field into every lazily built agent. `_stage_and_swap_model` (typed /model
and picker), `_restore_session_model` (--resume, /resume) and `new_session` (/new)
moved `self.model` without re-running the chokepoint, so the first turn after a switch
issued before the first message went out with the OLD model's effort — always-thinking
models that accept only their own level set (GLM/ARK: low/high/max) reject that with a
non-retryable HTTP 400. `agent.switch_model` already re-resolves its own copy, so only
the CLI-level field was stale.

- `_resolve_cli_reasoning(cli)` runs `resolve_reasoning_config(CLI_CONFIG, cli.model)`
  and is called BEFORE the agent branch on all three paths, covering the lazy-build case.
- `reasoning_config` joins `_RUNTIME_FIELDS`, so a failed in-place swap and the
  `/model X --once` restore roll it back with the rest of the route (drops the explicit
  one-turn restore line the field loop now covers).
- `/new` resolves after the config-default model reset, so the default model's
  per-model override is kept while a `/reasoning` session override is still dropped.
- Docs: the override list names the switch-before-first-message, resume and /new paths.

Slimmer redo of #96023 (@liuhao1024): same core hunk, without the `--reasoning`-pins-
for-the-whole-run flag (the live-agent path already re-resolves on switch, so the CLI
field follows the same rule) and with the two entry points #96023 missed
(`_restore_session_model`, `_RUNTIME_FIELDS`), which @catecholamin identified on
#112921. #112924 (@li-lizhe) covers the same `_stage_and_swap_model` hunk.

Fixes #112921
Fixes #96012

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: li-lizhe <li-lizhe@noreply.github.com>
2026-09-16 17:02:56 -07:00
teknium1
f537c0b4c3 refactor(status): CLI, gateway and TUI /status render the same field set from hermes_cli/status_report.py
The three /status renderers (hermes_cli/cli_session_mixin.py::_show_session_status,
gateway/slash_commands_status.py::_handle_status_command, tui_gateway/methods_session.py
session.status) each hand-built Session ID / Path / Title / Model (provider) / Created /
Last Activity / Tokens / Agent Running with their own getattr(agent, "model") fallback
chain, their own updated_at/last_updated_at/last_activity_at scan and their own timestamp
format. A fix to one (a new last-activity column, a placeholder change) silently missed the
other two.

hermes_cli/status_report.py::build_status_fields now derives the common facts once and
returns them as structured, display-ready data; status_lines() renders the English
"Label: value" form for the CLI and TUI. The gateway keeps translating through its
existing t("gateway.status.*") catalog keys (no locale change); the CLI keeps reasoning /
approvals / context, the gateway keeps free-tier / context / queue depth / Matrix scope,
the TUI keeps its Project line. tui_gateway/methods_session.py::_status_dt and the
CLI's inline updated_at loop are gone; cli_session_mixin._timestamp_or stays for its
remaining history-timestamp caller.

Behavior change: none intended for populated sessions. Unified edge cases: a
SessionDB row with an unparseable started_at now falls back to now() on the TUI as it
already did on the CLI, and the TUI's fallback on a bad updated_at is the created stamp on
both surfaces.

Test: tests/hermes_cli/test_status_report_contract.py drives the three real renderers with
one session (distinctive model, provider, title, stamps, token count) and asserts each
output carries every common value. Sabotage-verified: builder dropping tokens -> red;
TUI hand-formatting the model line -> red; restored -> green.
2026-09-13 05:21:02 -07:00
teknium1
991b23ad8d refactor(sessions): one session-id minter; QQ update-prompt key from build_session_key
Nine f-string sites minted `YYYYMMDD_HHMMSS_<hex>` independently with the hex width already
drifted (6 on CLI/TUI/agent/import, 8 in the gateway store, 12 in portability imports).
hermes_cli/session_lost_and_found.py classifies schema-less salvage rows by that shape, so a
site drifting the prefix would silently change recovery. hermes_state_ids.new_session_id(now,
hex_len=) is now the only writer and owns SESSION_ID_PATTERN; stdlib-only so agent/, cli.py and
gateway/ can import it without the SessionDB graph.

Widths are kept per site on purpose: the Desktop's session-id candidate regex is pinned to 6 hex
chars for interactive ids; the gateway store and portability importer keep 8/12 (more rows per
second). Not a bug, so not "fixed".

gateway/platforms/qqbot/adapter.py hard-coded `agent:main:qqbot:<scene>:<chat>` for the
update-prompt authz key, ignoring the profile namespace build_session_key applies; a secondary
bot in a multiplexed gateway got `agent:<profile>:...` keys and its clicks were rejected. The key
now comes from the one builder via BasePlatformAdapter._source_session_key.

Behavior change: QQ update-prompt clicks are authorized under the profile-namespaced key
(byte-identical `agent:main:` for the default profile).
2026-09-13 05:21:02 -07:00
teknium1
c3de99bbf0 refactor(state): one carrier-aware user-turn rewind behind CLI /undo, /retry, gateway and TUI
CLI `_rewind_persisted_user_turn`, TUI `_rewind_active_session_history` and gateway
`rewind_session` each re-ran get_active_message_ids -> get_messages_as_conversation ->
split_user_originated_turn -> rewind_to_message with their own warm/durable comparison
helpers and three different out-of-range contracts (RuntimeError / ValueError / None).

The durable transcript is the authority for a rewind, so the implementation now lives
with the data: `SessionDB.rewind_user_turn` (hermes_state_rewind.py) with one typed
out-of-range error (`RewindTargetUnavailableError`). Surfaces keep only lock, eviction
and rendering glue and map that error to their own message.
2026-09-13 05:20:26 -07:00
bixycler
70d0f556d7 fix(branding): use the Caduceus ☤ (U+2624), not the Rod of Asclepius ⚕ (U+2625)
Every inline glyph — CLI banner/status bar/response labels/goodbye, setup
and doctor boxes, gateway update prompts, WhatsApp reply prefix, TUI theme,
locale strings and the docs — used ⚕, the staff of Asclepius (medicine).
Hermes carries the Caduceus ☤. The ASCII-art logo was already correct.

Mechanical swap across 60 files (no logic change); both glyphs are
East-Asian-width Neutral so no layout shifts. Skins that set their own
`response_label` / `goodbye` are unaffected.

Direction from PR #7064 (@bixycler), the earliest of #7064 / #9611 / #15574,
redone against current main.

Fixes #9565
2026-09-12 08:25:54 -07:00
teknium1
496eb13bd7 fix(state): one corrupt timestamp row no longer kills sessions list, export or insights
SQLite dynamic typing lets a TEXT cell ('not-a-timestamp'), inf/nan or a
garbage double (8.4e252 salvaged from a damaged page) sit in a REAL
timestamp column. Every reader called datetime.fromtimestamp()/float
arithmetic on the raw cell, so ONE bad row raised TypeError/OverflowError
out of the row loop and took down the whole `hermes sessions list`/browse
table (#102399), all three exporters — JSONL/MD, QMD, HTML (#102352) —
and `hermes insights` (#99959).

Fix the class with ONE helper, hermes_cli.timefmt.coerce_epoch(): a
stored cell becomes float epoch seconds inside a sane 1970..2103 window
or None after a WARNING that names the session id. Every reader routes
through it — relative_time (list/browse/resume picker), format_epoch
(prune/candidates tables), the three exporters' timestamp formatters,
insights' _get_sessions/_day/period range — so a bad row renders as
'?'/'N/A'/raw text for that one cell and the command completes.

Write side: hermes_state_messages._coerce_timestamp (append_message,
append_messages_batch, import) and the import path's started_at now use
the same window, so a new out-of-range timestamp falls back to now()
instead of being persisted — new bad rows cannot be written by Hermes.

Reported-by: #102399, #102352, #99959 reporters; kokhlo's insights
analysis pointed at every reporting site, not just line 860.
2026-09-11 06:24:54 -07:00
Siddharth Balyan
cbcf7b72f7 feat(gateway): sign in with a Nous account from a chat (/login), one shared sign-in flow (#105261)
* refactor(auth): one sign-in flow behind SignInState, rendered by the CLI and the desktop

* feat(gateway): /signin signs the free tier into a Nous account from a DM

* feat(cli): chat surfaces name /signin as the sign-in verb

* fix(auth): review follow-ups for the shared sign-in flow and /signin

* fix(i18n): carry the /status free-tier line in every locale catalog

* refactor(cli): the chat sign-in command is /login

* fix(auth): durable override cleanup in the /login sweep, and the sign-in flow in its own modules
2026-09-11 03:45:33 +05:30
kshitijk4poor
872be057e1 refactor(cli): class-level disabled_toolsets default instead of per-site getattr
Inspection surfaces run on partially built HermesCLI instances (tests use
HermesCLI.__new__), so the attribute needs a default; declare it once on
the class next to _seeded_first_message rather than getattr at five sites.
2026-09-05 12:21:28 +05:30
aydnOktay
e2d738f89d fix(cli): guard disabled_toolsets on partial CLI instances
show_banner/_show_status call get_tool_definitions with disabled_toolsets,
but test and early-init paths build HermesCLI via __new__ without running
__init__, so self.disabled_toolsets was missing and CI failed with
AttributeError in test_cli_context_warning.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 12:21:28 +05:30
aydnOktay
2494eadb39 fix(tools): align inspection surfaces with disabled composite toolsets
hermes tools --summary and CLI /tools used name-level disabled_toolsets subtraction, so disabling debugging still showed terminal/web/file as enabled while runtime stripped those tools. Prune platform toolsets after tool-level subtraction and pass disabled_toolsets into CLI get_tool_definitions calls.

Fixes #97015

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 12:21:28 +05:30
Teknium
14791b4d4e simplify(compat): approval — drop 43 facade re-exports + _command_detection_variants late-bind seam, repoint 30 callers + 46 test files
tools/approval.py no longer re-exports sibling names (approval_context/prompt/floors/detection/
human_wait/smart/gateway_wait); it imports only what it uses. Siblings reference sibling-defined
names directly (module-attribute reads on tools.approval_context so patching the defining module
still works); only facade-owned state (_lock, _gateway_queues, _permanent_approved, _denied,
_denial_breaker_addendum, _gateway_notify_cb) is still read back through tools.approval.
approval_detection calls its own _command_detection_variants instead of late-binding through the facade.
2026-09-03 13:49:57 -07:00
Teknium
eeb7671e69 simplify(compat): hermes_cli small facades — drop 7 re-exports/aliases (+relay_runtime alias module), repoint 12 callers/tests 2026-09-03 13:05:57 -07:00
Teknium
e83816a4d1 review-fix(comments): restore lost #NNNN rationale comments across non-test source (mechanical sweep, condensed, code unchanged)
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
2026-09-03 09:44:26 -07:00
Teknium
fb14bc4e11 review-fix(whitespace): strip trailing whitespace and EOF blank lines introduced by this PR
Trailing-whitespace-only edits so 'git diff --check BASE HEAD' is clean
(16 diagnostics across 11 files). No code changes.
2026-09-03 09:31:54 -07:00
Teknium
3494f7cf23 refactor(hermes_cli): AST-neutral closer hugging across r3-17 slice files 2026-09-02 23:58:31 -07:00
Teknium
205d0f8ab5 refactor(hermes_cli): collapse try/except-pass to contextlib.suppress, threshold-style ladder, shared status-bar tail assembly 2026-09-02 21:27:56 -07:00
Teknium
5de687bc47 refactor(hermes_cli): unify status-bar width tiers, dedupe session mixin rewind/resume paths, compact docs 2026-09-02 20:56:52 -07:00
Teknium
eb74a00c71 refactor(cli): split HermesCLI into 10 cohesive mixins (cli.py 22,284 -> 9,150)
326 methods lifted by AST (bodies identical; ast.dump-verified) into
hermes_cli/cli_{tui,status_bar,voice,model_switch,session,stream,modal,
terminal,info,loops}_mixin.py. cli.py-internal symbols resolve via lazy
'from cli import ...' inside each method (no import cycle; patch('cli.X')
keeps working). The three 'global' writers (_skill_commands, _cli_wake_owner)
now write the cli module attribute explicitly so the origin's readers still
see them. Dropped imports left unused in cli.py; kept display_hermes_home /
build_welcome_banner as re-exports (mixins + tests resolve them via cli).
Repointed two AST change-detector tests to cli_tui_mixin.py; one test
fixture now keeps 'cli' in sys.modules across its patch.dict scope.
2026-09-02 15:42:24 -07:00