Skip to content

fix: extract <think> blocks to m.reasoning + populate LLM Wiki Last writer (#1257) - #3455

Closed
gsurenull wants to merge 2 commits into
nesquena:masterfrom
gsurenull:master
Closed

fix: extract <think> blocks to m.reasoning + populate LLM Wiki Last writer (#1257)#3455
gsurenull wants to merge 2 commits into
nesquena:masterfrom
gsurenull:master

Conversation

@gsurenull

Copy link
Copy Markdown

Two related fixes for v0.51.222+.

1. Reasoning-only thinking-block extraction (commit 1)

Reasoning-only providers (e.g. MiniMax-M3) were leaving the thinking trace
inline in m.content, bloating persisted session files by 30-50% and
bypassing m.reasoning that the thinking card reads on reload.

  • static/messages.js: new _splitThinkFromContent() handles <think>...,
    <|channel>thought\n...<channel|>, <|turn|>thinking\n...<turn|>
  • api/config.py: register MiniMax-M3 in the minimax (international)
    catalog, bump _MODELS_CACHE_SCHEMA_VERSION 3 → 4
  • Note: MiniMax-M3 is intentionally not added to minimax-cn — the
    China endpoint catalog is sourced from hermes-agent's catalog registry
    and does not yet expose M3

Refs #1852

2. LLM Wiki status panel Last writer (Issue #1257, commit 2)

The status panel was always rendering "Not available" for last_writer
because the field was reserved as None with no reader wired up.

  • api/routes.py: new _llm_wiki_last_writer() with 3-tier fallback
    1. most-recent page frontmatter updated_by / writer / author
    2. most recent log.md action verb (returns "ai-agent (<action>)")
    3. static "ai-agent" fallback so the UI never shows "Not available"
      for a configured wiki
  • tests/test_issue1257_llm_wiki_status.py: assert the new behavior
    instead of the locked "is None" baseline

Reads only page frontmatter and log.md headings, never page bodies, so
the private-safe status contract is preserved.

Closes #1257

Senna added 2 commits June 3, 2026 08:58
Reasoning-only providers (e.g. MiniMax-M3) were leaving the thinking trace
inline in m.content, bloating persisted session files by 30-50% and
bypassing m.reasoning that the thinking card reads on reload.

- static/messages.js: new _splitThinkFromContent() handles <think>...,
  <|channel>thought...<|channel|>, <|turn|>thinking...<|turn|>
- api/config.py: register MiniMax-M3 in the minimax (international) provider
  catalog and bump _MODELS_CACHE_SCHEMA_VERSION 3 → 4
- CHANGELOG.md: [Unreleased] entry

Note: MiniMax-M3 is intentionally NOT added to the minimax-cn provider
catalog — the China endpoint catalog is sourced from hermes-agent's
catalog registry and does not yet expose M3.

Refs #1852
The status panel was always rendering "Not available" for `last_writer`
because the field was reserved as None with no reader wired up.

- api/routes.py: new _llm_wiki_last_writer() with 3-tier fallback
  1) most-recent page frontmatter updated_by/writer/author
  2) most recent log.md action verb (returns "ai-agent (<action>)")
  3) static "ai-agent" fallback so the UI never shows "Not available"
- tests/test_issue1257_llm_wiki_status.py: assert the new behavior
  instead of the locked "is None" baseline
- CHANGELOG.md: [Unreleased] entry

Reads only page frontmatter and log.md headings, never page bodies, so
the private-safe status contract is preserved.

Closes #1257
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Two commits here — I read both at HEAD (7a8833e4 think-extraction, b90caa0f wiki last-writer) against origin/master, plus the agent's wiki skill to check the contract. The <think>-extraction half (commit 1) is sound: _splitThinkFromContent in static/messages.js reuses the existing _thinkPairs table (messages.js:716-720) and mirrors _parseStreamState's lstrip-then-startswith semantics (messages.js:1030-1056), so the live renderer and the persist path agree on what counts as a think block. Adding MiniMax-M3 to the minimax catalog with the _MODELS_CACHE_SCHEMA_VERSION 3→4 bump (api/config.py:1110, :2649) is the right way to force a cache rebuild. No notes there.

The wiki last-writer half (commit 2) has a real correctness bug. Let me ground it in the agent's actual wiki format.

Code reference

api/routes.py _llm_wiki_last_writer, Priority 2 branch:

for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
    stripped = line.strip()
    if not stripped.startswith("## ["):
        continue
    if "|" not in stripped:
        continue
    tail = stripped.split("]", 1)[1].strip() if "]" in stripped else ""
    action = tail.split()[0] if tail else "update"
    return f"ai-agent ({action})"

This returns on the first ## [ heading. But log.md is append-only and chronological (oldest first). The agent's skill spells this out — skills/research/llm-wiki/SKILL.md:245 ("Append-only") and the template at :250 always seeds the file with:

## [YYYY-MM-DD] create | Wiki initialized

So for any wiki that has done more than initialize, this field — labeled "Last writer" in static/panels.js:3308 — will permanently render ai-agent (create), i.e. the oldest action, the literal opposite of "last". The test fixture (tests/test_issue1257_llm_wiki_status.py:24) only has a single ## [...] update heading, so it passes — but it doesn't exercise the multi-entry log that every real wiki has.

Fix sketch

Walk to the last matching heading instead of returning on the first:

last_action = None
for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
    stripped = line.strip()
    if not stripped.startswith("## [") or "|" not in stripped:
        continue
    tail = stripped.split("]", 1)[1].strip() if "]" in stripped else ""
    last_action = tail.split()[0] if tail else "update"
if last_action:
    return f"ai-agent ({last_action})"

and add a fixture entry (a create line before the update line) so the test proves last-wins rather than first-wins.

Second, smaller note — Priority 1 is dead code

The frontmatter scan looks for updated_by / writer / author. I grepped the agent's wiki skill: the page frontmatter schema (SKILL.md:131-144) is title, created, updated, type, tags, sources, confidence, contested, contradictions — there is no author/writer key, and nothing in the skill ever writes one. So for any Hermes-authored wiki, Priority 1 never matches and every result falls through to the log.md branch (the one with the bug above). Not harmful, but it means the frontmatter path can't be the thing that saves you — the log.md scan has to be correct. Worth either dropping Priority 1 or aligning it with a key the agent actually emits (updated: is the closest real signal, though it's a date, not a writer).

Verification

After the fix: a fixture whose log.md has ## [2026-05-01] create | … then ## [2026-05-04] update | … should yield ai-agent (update), and adding a later ## [2026-05-06] ingest | … should yield ai-agent (ingest). That's the assertion that would have caught this.

nesquena-hermes added a commit that referenced this pull request Jun 3, 2026
…oning #3455 + LLM Wiki last-writer #1257) (#3466)

* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)

Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).

* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)

Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.

* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests

Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.

* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)

Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.

* test: update 3 save-path source-assertion tests for #3455 server-side think-split

The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
  + _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
  and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
  bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
  pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
  unchanged.

---------

Co-authored-by: nesquena-hermes <[email protected]>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.230 ✅ — both fixes cherry-picked onto fresh master.

1. <think> extraction → m.reasoning: evolved during review from client-side-only to client + server-side at save time_split_thinking_from_content() in api/streaming.py runs on the final assistant message before s.save(), so the persisted session file (not just the browser copy) is compacted. Matches the live renderer exactly: leading-only single block, mid-body/code-fence tags stay as content, partial blocks left intact, existing on_reasoning preserved + merged.

2. LLM Wiki Last writer: now populated via the 3-tier frontmatter/log/static fallback (closes #1257).

Note: the original branch re-added MiniMax-M3 + bumped _MODELS_CACHE_SCHEMA_VERSION — both dropped as stale (MiniMax-M3 already shipped in v0.51.223). Gates: Opus + Codex SAFE, full suite 7422/0, browser smoke + QA harness clean. Thanks @gsurenull! Closing as merged-via-release.

eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Jun 3, 2026
…➔ 0.51.230) (#798)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/nesquena/hermes-webui](https://github.com/nesquena/hermes-webui) | patch | `0.51.210` → `0.51.230` |

---

### Release Notes

<details>
<summary>nesquena/hermes-webui (ghcr.io/nesquena/hermes-webui)</summary>

### [`v0.51.230`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051230--2026-06-03--Release-GX-stage-p14--extract-think-blocks-to-mreasoning--LLM-Wiki-last-writer)

[Compare Source](nesquena/hermes-webui@v0.51.229...v0.51.230)

##### Fixed

- Assistant message `<think>…</think>` blocks are now extracted into `m.reasoning` instead of being stored inline in `m.content` — **both client-side (streaming/inflight state) and server-side at save time**. Reasoning-only providers such as `MiniMax-M3` (OpenAI-compat) previously left the thinking trace inside the assistant content, bloating persisted session files by 30–50% and bypassing the `m.reasoning` field the thinking card reads on reload. A new `_splitThinkFromContent()` (in `static/messages.js`) and its server-side twin `_split_thinking_from_content()` (in `api/streaming.py`, applied to the final assistant message before `s.save()`) extract a single **leading** block (after lstrip) for all three known tag pairs, matching the live renderer's `_streamDisplay`/`_parseStreamState` semantics exactly: a closed `<think>…</think>` that appears mid-body (e.g. a literal tag inside a fenced code block) stays visible content and is never moved into reasoning, a partial/unclosed block is left intact, and any pre-existing `m.reasoning` (from a separate `on_reasoning` stream) is preserved/merged. So the persisted session file — not just the in-browser copy — is compacted on reload ([#&#8203;3455](nesquena/hermes-webui#3455) part 1, [@&#8203;gsurenull](https://github.com/gsurenull)).
- The LLM Wiki status panel's `Last writer` field is now populated (it always showed `Not available` since the panel shipped in [#&#8203;1257](nesquena/hermes-webui#1257)). The reader uses a 3-tier fallback — most-recent page frontmatter (`updated_by`/`writer`/`author`), the most recent `log.md` action verb, then a static `ai-agent` fallback — and reads only frontmatter + log headings, never page bodies, preserving the private-safe status contract ([#&#8203;3455](nesquena/hermes-webui#3455) part 2, [@&#8203;gsurenull](https://github.com/gsurenull); closes [#&#8203;1257](nesquena/hermes-webui#1257)).

### [`v0.51.229`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051229--2026-06-03--Release-GW-stage-p13--model-never-silently-snaps-a-versioned-name-to-a--tier-variant)

[Compare Source](nesquena/hermes-webui@v0.51.228...v0.51.229)

##### Fixed

- `/model <name>` no longer silently snaps a complete versioned model name to a longer `-tier` variant (and a different price tier). When the typed name ends in a version number (e.g. `mimo-v2.5`) and the catalog has only a longer suffixed variant (e.g. `xiaomi/mimo-v2.5-pro`), both the dropdown matcher (`_findModelInDropdown`) and the command fallback (`_bestModelMatch`) now reject the snap unless the extra text *continues the version* (`.` + digit), rather than upgrading the user to a `-pro`/`-flash` tier they did not type. When nothing matches cleanly, `/model` now shows a *"No model matching … — did you mean …?"* suggestion toast instead of silently switching. Legitimate fuzzy shorthand is preserved (`/model gpt-5` → `gpt-5.4-mini`, `/model claude` → `claude-opus-4.6`, `/model mimo-v2` → `mimo-v2.5-pro`), as is exact-match priority ([#&#8203;3368](nesquena/hermes-webui#3368), with [@&#8203;garyd9](https://github.com/garyd9); thanks [@&#8203;yutaotie](https://github.com/yutaotie) for confirmation).

### [`v0.51.228`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051228--2026-06-03--Release-GV-stage-p12--workspace-file-tree-drop--large-markdown-preview)

[Compare Source](nesquena/hermes-webui@v0.51.227...v0.51.228)

##### Fixed

- Dropping an OS file onto the **workspace file tree** now uploads it into the workspace only, instead of *also* attaching it to the chat composer. The tree's drag handlers now stop event propagation for OS `Files` drops so the document-level composer drop handler no longer fires for the same drop ([#&#8203;3411](nesquena/hermes-webui#3411), [@&#8203;pamnard](https://github.com/pamnard)).
- Moderately large Markdown documents in the **workspace preview** are no longer forced into plain-text too early. The rich-render ceiling is raised (64 KB / 1500 lines → 256 KB / 5000 lines, and the backend file-read limit 200 KB → 400 KB), and files above the limit gain a **"Render as markdown anyway"** button that force-renders the already-loaded content without a second fetch ([#&#8203;3378](nesquena/hermes-webui#3378), [@&#8203;starGazerK](https://github.com/starGazerK)).

### [`v0.51.227`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051227--2026-06-03--Release-GU-stage-p11--keep-the-active-New-Chat-visible-in-the-sidebar)

[Compare Source](nesquena/hermes-webui@v0.51.226...v0.51.227)

##### Fixed

- A freshly-created **New Chat** now stays visible and selected in the sidebar before its first message is sent. The sidebar intentionally filters inactive 0-message sessions, but that filter also hid the *currently active* blank chat until the user sent a turn — so starting a New Chat could make the selected row vanish from the list. The active ephemeral session is now injected into the sidebar render rows (only when the server-side list omits it), while inactive empty sessions stay filtered as before. Starting a New Chat from a CLI-filtered sidebar also switches the source filter back to WebUI so the active chat isn't immediately hidden ([#&#8203;3408](nesquena/hermes-webui#3408), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.226`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051226--2026-06-03--Release-GT-stage-p9--mobile-composer-context-usage-ring--activity-feed-default-expand-setting)

[Compare Source](nesquena/hermes-webui@v0.51.225...v0.51.226)

##### Added

- **Settings → Appearance: "Expand activity feed by default"** — a new checkbox (default off) that expands new Activity disclosures by default as turns arrive. Manual per-turn collapse/expand still wins (an explicit user toggle is preserved), and live "Waiting on model" rows now explain what the agent is doing before and after tool calls ([#&#8203;3080](nesquena/hermes-webui#3080), [@&#8203;AJV20](https://github.com/AJV20)).

##### Changed

- The mobile composer's config button now shows a **context-usage ring** (an SVG progress ring with a centered percentage) in place of the static sliders icon, color-coded green (≤50%) / orange (≤85%) / red (>85%) and reset to 0% on a new session, so context-window pressure is visible at a glance on mobile ([#&#8203;3062](nesquena/hermes-webui#3062), [@&#8203;NottheGuy007](https://github.com/NottheGuy007)).

### [`v0.51.225`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051225--2026-06-03--Release-GS-stage-p7--remote-gateway-health-probe-resolves-gatewaystate)

[Compare Source](nesquena/hermes-webui@v0.51.224...v0.51.225)

##### Fixed

- The remote-gateway health probe now correctly reports `gateway_state`, so the Tasks/Cron banner lights up for Docker / remote-gateway deployments. The probe previously hit `/health` and `/status` (neither returns `gateway_state`) and never queried `/health/detailed` (which does), so `gateway_state == "running"` was never observed remotely. The probe now tries `/health/detailed` first, parses the JSON body of a 2xx response to extract `gateway_state`, and unifies the gateway base-URL env precedence to `GATEWAY_HEALTH_URL` > `HERMES_GATEWAY_HEALTH_URL` > `HERMES_API_URL` ([#&#8203;3355](nesquena/hermes-webui#3355), [@&#8203;rodboev](https://github.com/rodboev)).

### [`v0.51.224`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051224--2026-06-03--Release-GR-stage-p6--profile-toolskill-config-authoritative-on-the-streaming-worker)

[Compare Source](nesquena/hermes-webui@v0.51.223...v0.51.224)

##### Fixed

- Profile tool/skill restrictions are now respected for WebUI chats even when the per-session "Tool Restrictions" field is left blank. The streaming agent runs on a detached worker thread that does not inherit the per-request thread-local profile context, so the ambient `get_config()` resolved the process-global `default` profile and loaded its `platform_toolsets.cli` (all tools) instead of the session profile's configured list — inflating a tools-disabled profile's prompt from \~400 to \~15K input tokens. The worker now reads the session's own profile config explicitly via a new `get_config_for_profile_home()` helper (a race-free direct disk read with no shared-cache mutation), so toolsets, prefill context, and fallback chains all match the profile the session actually runs under ([#&#8203;3294](nesquena/hermes-webui#3294), [@&#8203;nesquena-hermes](https://github.com/nesquena-hermes)).

### [`v0.51.223`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051223--2026-06-02--Release-GQ-stage-p5--openai-api-first-class-picker-provider--MiniMax-M3)

[Compare Source](nesquena/hermes-webui@v0.51.222...v0.51.223)

##### Fixed

- GPT models now appear in the model picker when hermes-agent exposes its built-in OpenAI provider under the `openai-api` slug (the one activated by `OPENAI_API_KEY` / `OPENAI_BASE_URL`, distinct from `openai-codex`). `openai-api` is now a first-class picker provider in `_PROVIDER_DISPLAY` / `_PROVIDER_MODELS` rather than an alias of `openai` — an alias would have fixed the display but broken the send path, since the agent registry has `openai-api` and not `openai`. Env detection for `OPENAI_API_KEY` was also corrected to surface `openai-api` instead of a bare `openai` the agent registry can't resolve ([#&#8203;3443](nesquena/hermes-webui#3443), [@&#8203;rodboev](https://github.com/rodboev)).

##### Changed

- MiniMax default model catalog upgraded to M3 in the model picker ([#&#8203;3374](nesquena/hermes-webui#3374), [@&#8203;octo-patch](https://github.com/octo-patch)).

### [`v0.51.222`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051222--2026-06-02--Release-GP-stage-p4--backend-bugfix-batch-title-language-drift--orphaned-CLI-sidecar-prune--pin-quota-lineage)

[Compare Source](nesquena/hermes-webui@v0.51.221...v0.51.222)

##### Fixed

- Auto-generated session titles no longer persist in the wrong language. The title-language guard previously only rejected English titles for *German* conversation starts, so an English chat whose LLM-generated title came back in Chinese, Russian, or another script sailed through and was saved. `_title_language_mismatch` now also does a language-agnostic cross-script check: when the conversation start has a clear dominant writing script and the generated title introduces a substantial amount of a different script (CJK / Cyrillic / Arabic / etc.), the title is rejected and generation falls back to the deterministic topic title. The threshold tolerates a borrowed technical term (a CJK title with one English word still trips; an English title with a single foreign place-name does not), and the legacy German→English heuristic is preserved ([#&#8203;3293](nesquena/hermes-webui#3293)).
- WebUI sidebar now reconciles orphaned imported-CLI sessions. When a CLI/agent session is opened in the WebUI it gets a WebUI-owned sidecar so it can render and reopen; previously, if the user then deleted that session from the CLI / local Hermes storage, nothing pruned the sidecar and the stale row lingered in the sidebar indefinitely (there is no WebUI delete affordance for CLI rows). Orphaned sidecars whose backing session no longer exists are now pruned on reconciliation ([#&#8203;3238](nesquena/hermes-webui#3238)).
- Pin quota is now counted by visible session lineage rather than raw session rows, so continuation siblings in the same sidebar-visible lineage no longer each consume a separate pin slot. Previously a pinned session that had been compressed/continued into multiple rows could exhaust the pin limit with what the user sees as a single pinned conversation. The limit check now collapses each lineage to its visible root before counting against `pinned_sessions_limit` ([#&#8203;3288](nesquena/hermes-webui#3288), [@&#8203;andrewkangkr](https://github.com/andrewkangkr)).

### [`v0.51.221`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051221--2026-06-02--Release-GO-stage-p3e--block-all-workspace-symlink-escapes-security)

[Compare Source](nesquena/hermes-webui@v0.51.220...v0.51.221)

##### Security

- The workspace file API now blocks **all** symlink escapes from the selected workspace, not just symlinks pointing at system directories. Previously a symlink placed inside a workspace could resolve to an arbitrary external host path (e.g. `~/.ssh`, `~/.hermes/auth.json`) and be read through `/api/list` / `read_file_content` — and since that API is reachable by LLM agent tool calls, an imported or crafted workspace could expose credentials. `safe_resolve_ws` now requires the resolved path stay under the workspace root, `list_dir` hides escaping symlinks (they could never be opened anyway), and `read_file_content` rejects them. Symlinks that resolve back under the workspace still work normally. The directory-list, file-read, file-upload, and archive-extraction paths are additionally hardened against a symlink-swap **TOCTOU** race: each path is opened component-by-component from the workspace root with `O_NOFOLLOW` (an anchored `openat` walk on Linux/macOS, with a plain-open fallback on platforms without `dir_fd` support such as Windows, where creating symlinks needs admin anyway), so a symlink raced into any component after the containment check cannot redirect the read/list/write outside the workspace. Note: an intentional in-workspace symlink pointing to an external directory is no longer followed ([#&#8203;3398](nesquena/hermes-webui#3398), [@&#8203;Hinotoi-agent](https://github.com/Hinotoi-agent)).

### [`v0.51.220`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051220--2026-06-02--Release-GN-stage-p3c--fix-aux-title-generation-with-provider-model-ids)

[Compare Source](nesquena/hermes-webui@v0.51.219...v0.51.220)

##### Fixed

- Manual session-title regeneration and background auxiliary title generation no longer fail with `422` / `llm_error_aux` when `auxiliary.title_generation.model` in `config.yaml` is set using the WebUI model-picker's `@provider:model` format (e.g. `@gemini:gemini-3.1-flash-lite`). The `@provider:` prefix is now normalized away via the canonical helper before the id reaches the provider API ([#&#8203;3430](nesquena/hermes-webui#3430), [@&#8203;pamnard](https://github.com/pamnard)).

### [`v0.51.219`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051219--2026-06-02--Release-GM-stage-p3b--extend-URI-scheme-model-ID-fix-to-backend-normalization--matching)

[Compare Source](nesquena/hermes-webui@v0.51.218...v0.51.219)

##### Fixed

- Extended the [#&#8203;3429](nesquena/hermes-webui#3429) URI-scheme fix beyond the visible model chip (fixed in v0.51.218) to the model-identity normalization and matching paths: `api/config.py` `_norm_model_id` / `_get_label_for_model` and `static/ui.js` `_normalizeConfiguredModelKey` no longer strip the first `/`-segment of a `scheme://` id (e.g. `gpt://${FOLDER}/model/latest`), where the slashes are path separators rather than a provider prefix. This prevents the [#&#8203;3360](nesquena/hermes-webui#3360 identity collision/mislabel for URI-shaped model IDs in dropdown matching, badge assignment, and configured-entry dedup. Backend/front-end parity is covered by tests ([#&#8203;3436](nesquena/hermes-webui#3436), [@&#8203;b3nw](https://github.com/b3nw)).

### [`v0.51.218`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051218--2026-06-02--Release-GL-stage-p3a--fix-getModelLabel-mangling-URI-scheme-model-IDs)

[Compare Source](nesquena/hermes-webui@v0.51.217...v0.51.218)

##### Fixed

- The composer model chip no longer shows env-var path junk for model IDs that use a URI scheme (e.g. Yandex `gpt://${FOLDER}/deepseek-v4-flash/latest`). A regression from [#&#8203;3366](nesquena/hermes-webui#3366) (v0.51.210): `getModelLabel()` stripped the first `/`-segment, which for a `scheme://` id landed inside the `://` and left `/${FOLDER}/…`. The label now detects a URI scheme, drops scheme + authority, and takes the last meaningful path segment (skipping `${…}` placeholders and bare version tails like `latest`); non-URI multi-slash IDs keep their [#&#8203;3360](nesquena/hermes-webui#3360) behavior ([#&#8203;3429](nesquena/hermes-webui#3429)).

### [`v0.51.217`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051217--2026-06-02--Release-GK-stage-p2f--decode-and-complete-zh-Hant-locale-strings)

[Compare Source](nesquena/hermes-webui@v0.51.216...v0.51.217)

##### Changed

- Decoded the `zh-Hant` (Traditional Chinese) locale block from `\u`-escaped sequences to literal Chinese text and backfilled missing keys so `zh-Hant` now has full coverage of the English key set. Makes future locale review readable and prevents newer UI keys from falling back to English for Traditional Chinese users. Locale-only — no runtime behavior change ([#&#8203;3414](nesquena/hermes-webui#3414), [@&#8203;PeterDaveHello](https://github.com/PeterDaveHello)).

##### Fixed

- Added the missing `provider_mismatch_warning` string to the French (`fr`) locale. It was absent entirely; the gap was masked by a stale duplicate of the same key in the `zh-Hant` block that [#&#8203;3414](nesquena/hermes-webui#3414) removed, so all locales now carry the key.

### [`v0.51.216`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051216--2026-06-02--Release-GJ-stage-p2e--fix-consecutive-user-turn-rejection-on-strict-chat-templates)

[Compare Source](nesquena/hermes-webui@v0.51.215...v0.51.216)

##### Fixed

- WebUI session/delivery context (connected platforms, home channels, scheduled-task delivery hints) is now injected into the ephemeral **system prompt** instead of being appended as a prefill `user` message. The old prefill produced two consecutive `user` turns (session context + the actual message), which models with strict chat templates (Mistral, Gemma via llama.cpp) reject with a Jinja 500. The same context is preserved — just delivered in a role-alternation-safe place ([#&#8203;3324](nesquena/hermes-webui#3324), [@&#8203;aether-agent](https://github.com/aether-agent), closes [#&#8203;3276](nesquena/hermes-webui#3276)).

### [`v0.51.215`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051215--2026-06-02--Release-GI-stage-p2d--deduplicate-legacy-messages-in-append-only-merge)

[Compare Source](nesquena/hermes-webui@v0.51.214...v0.51.215)

##### Fixed

- `merge_session_messages_append_only` now deduplicates true duplicate legacy messages (same role, content, AND exact timestamp) that could accumulate in state, while preserving legitimately-repeated identical turns whose timestamps differ even slightly. This avoids both the stale-duplicate buildup and the data-loss class where collapsing same-second distinct turns would drop real messages ([#&#8203;3393](nesquena/hermes-webui#3393), [@&#8203;thanhtoantnt](https://github.com/thanhtoantnt), closes [#&#8203;3346](nesquena/hermes-webui#3346)).

### [`v0.51.214`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051214--2026-06-02--Release-GH-stage-p2c--preserve-loaded-transcript-width-on-same-session-external-refresh)

[Compare Source](nesquena/hermes-webui@v0.51.213...v0.51.214)

##### Fixed

- A same-session external refresh (e.g. a background poll triggering a force-reload of the conversation you're reading) no longer collapses a long transcript back to the default 30-message tail window and jumps the viewport to a different slice. The already-loaded transcript width and scroll position are now captured before the in-memory transcript is cleared and preserved across the authoritative reload ([#&#8203;3326](nesquena/hermes-webui#3326), [@&#8203;viraatdas](https://github.com/viraatdas), closes [#&#8203;3239](nesquena/hermes-webui#3239)).

### [`v0.51.213`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051213--2026-06-02--Release-GG-stage-p2b--keep-gateway-context-visible-in-chat-transcripts)

[Compare Source](nesquena/hermes-webui@v0.51.212...v0.51.213)

##### Fixed

- Gateway-backed chat now backfills model-context turns into the visible transcript before saving the latest reply, while keeping hidden `[context compaction]` markers out of the visible transcript. Previously a context-compacted gateway session could collapse the sidebar/header message count to a two-message conversation (and drop older visible turns) while the assistant was responding to hidden prior context. Older visible turns are preserved and compaction markers stay hidden from `saved.messages` ([#&#8203;3300](nesquena/hermes-webui#3300), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.212`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051212--2026-06-02--Release-GF-stage-batch2--i18n-regenerate-title-strings--self-restart-argv--todos-cold-load)

[Compare Source](nesquena/hermes-webui@v0.51.211...v0.51.212)

##### Fixed

- Localized the five `session_title_regenerate*` session-menu strings (the "Regenerate title" action, its description, and the regenerating/regenerated/failed states) that shipped as English text in every non-English locale. Translated across it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, and tr, matching each locale's existing terminology; `zh`/`zh-Hant` keep the `\u`-escaped style of those blocks ([#&#8203;3396](nesquena/hermes-webui#3396), [@&#8203;vanshaj-pahwa](https://github.com/vanshaj-pahwa), closes [#&#8203;3364](nesquena/hermes-webui#3364)).
- Self-update re-exec now distinguishes source checkouts from frozen/packaged builds: a frozen binary (`sys.frozen`) re-execs with `sys.argv` as-is, while source checkouts keep the `[sys.executable] + sys.argv` CPython idiom. Previously the frozen path re-inserted the binary as `argv[1]`, turning re-exec into a no-op that left the WebUI stuck "offline" after every self-update ([#&#8203;3395](nesquena/hermes-webui#3395), [@&#8203;PatrickNoFilter](https://github.com/PatrickNoFilter)).
- The Todos panel now hydrates correctly on a cold session load (page refresh) even when the latest todo tool result is outside the truncated display window: `/api/session` derives a compact `todo_state` sidecar from the full settled transcript, and an explicit empty todo list is honored as the current state instead of falling through to an older non-empty write. A malformed historical tool message can never break session loading ([#&#8203;3373](nesquena/hermes-webui#3373), [@&#8203;v2psv](https://github.com/v2psv)).

### [`v0.51.211`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051211--2026-06-02--Release-GE-stage-batch1--reasoning-heuristics--model-shortest-match--Copilot-env-token-filter)

[Compare Source](nesquena/hermes-webui@v0.51.210...v0.51.211)

##### Fixed

- Generalized reasoning-effort capability checks in `_candidate_supports_reasoning` to target whole model families (GPT-5+, Claude 4/3.7, Qwen-3, Kimi, Minimax, Mimo, GLM, Step, and DeepSeek) instead of anchoring on hardcoded version numbers or vendor formats. This prevents the thinking-level configuration selector from being hidden on custom providers, new model releases, or when names carry suffixes like `-free` or `:free` (common on integrations such as Kilo Code or OpenCode Zen). The GPT heuristic is now version-anchored (5+) to avoid falsely enabling reasoning\_effort for gpt-4o/4.1/3.5 on aggregator providers ([#&#8203;3379](nesquena/hermes-webui#3379), [@&#8203;b3nw](https://github.com/b3nw), closes [#&#8203;3377](nesquena/hermes-webui#3377)).
- The `/model` slash command no longer selects a longer model variant when a shorter name is a prefix of it (e.g. `/model mimo-v2.5` selecting `mimo-v2.5-pro`). The fuzzy fallback now prefers an exact id/label match and otherwise the shortest matching option, applied to both the main and bare-name (`provider/...`) fallbacks ([#&#8203;3394](nesquena/hermes-webui#3394), [@&#8203;vanshaj-pahwa](https://github.com/vanshaj-pahwa), closes [#&#8203;3368](nesquena/hermes-webui#3368)).
- `GITHUB_TOKEN` and `GH_TOKEN` environment variables are now filtered from the Copilot credential pool alongside the seeded `gh`-CLI token, so a classic PAT (`ghp_*`) auto-detected from the environment no longer makes Copilot appear in the model picker when the Copilot API can't use it. User-specific `COPILOT_GITHUB_TOKEN` is still respected ([#&#8203;3382](nesquena/hermes-webui#3382), [@&#8203;happy5318](https://github.com/happy5318)).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/798
nesquena-hermes pushed a commit to rodboev/hermes-webui that referenced this pull request Jun 8, 2026
…e unclosed handling

Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):

1. Code-span unawareness: the scanner only protected triple fences, so a
   literal <think> in an inline single-backtick code span or an indented
   (>=4-space/tab) code block got silently extracted into reasoning. Added
   _inline_thinking_indented_code_at + inline-backtick tracking (Python +
   the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
   thinking tags visible.

2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
   into reasoning. Now position-aware — a LEADING unclosed block (cut off
   mid-thought) is still reasoning (nesquena#3455 intent), but an unclosed tag AFTER
   visible content stays visible so literal typed tags don't truncate prose.
   Gated partial handling on the previously-unused options.streaming param
   (live streaming keeps 'still thinking' behavior; persist/reload does not).

Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
nesquena-hermes added a commit that referenced this pull request Jun 9, 2026
…3633) (#3853)

* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)

# Conflicts:
#	api/streaming.py
#	static/messages.js
#	static/ui.js

* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling

Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):

1. Code-span unawareness: the scanner only protected triple fences, so a
   literal <think> in an inline single-backtick code span or an indented
   (>=4-space/tab) code block got silently extracted into reasoning. Added
   _inline_thinking_indented_code_at + inline-backtick tracking (Python +
   the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
   thinking tags visible.

2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
   into reasoning. Now position-aware — a LEADING unclosed block (cut off
   mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
   visible content stays visible so literal typed tags don't truncate prose.
   Gated partial handling on the previously-unused options.streaming param
   (live streaming keeps 'still thinking' behavior; persist/reload does not).

Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): recognize fenced code blocks indented 1-3 spaces

Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload

Round-4 Codex deep-review caught two real issues in my own fixes:

1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
   _thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
   plus the leading check sliced+stripped the whole prefix per unclosed tag. On
   long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
   on the streaming path). Replaced with incremental O(1)-per-iteration line
   state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
   starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.

2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
   so a message with BOTH an inline <think> block AND a separate m.reasoning
   payload showed only the inline part — the separate payload was dropped
   because the !thinkingText worklog resolution was then skipped. Now seeds with
   the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
   two MERGE (deduped); separate-only reasoning is preserved without promoting
   it into visible prose.

Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix

Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.

The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.

The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): only lstrip extracted content when a leading block was removed

Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).

Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): reconnect restore prefers raw inflight accumulator

Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)

Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…oning nesquena#3455 + LLM Wiki last-writer nesquena#1257) (nesquena#3466)

* Release v0.51.230 (stage-p14): extract <think> to m.reasoning nesquena#3455 + LLM Wiki last-writer (nesquena#1257)

Salvage of nesquena#3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via nesquena#3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since nesquena#1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).

* fix(nesquena#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)

Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.

* fix(nesquena#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests

Codex re-review finding nesquena#2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding nesquena#1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.

* feat(nesquena#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex nesquena#1)

Codex finding nesquena#1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.

* test: update 3 save-path source-assertion tests for nesquena#3455 server-side think-split

The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
  + _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
  and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
  bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
  pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
  unchanged.

---------

Co-authored-by: nesquena-hermes <[email protected]>
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…esquena#3633) (nesquena#3853)

* fix(streaming): normalize inline thinking extraction across live and persisted turns (nesquena#3599)

# Conflicts:
#	api/streaming.py
#	static/messages.js
#	static/ui.js

* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling

Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):

1. Code-span unawareness: the scanner only protected triple fences, so a
   literal <think> in an inline single-backtick code span or an indented
   (>=4-space/tab) code block got silently extracted into reasoning. Added
   _inline_thinking_indented_code_at + inline-backtick tracking (Python +
   the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
   thinking tags visible.

2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
   into reasoning. Now position-aware — a LEADING unclosed block (cut off
   mid-thought) is still reasoning (nesquena#3455 intent), but an unclosed tag AFTER
   visible content stays visible so literal typed tags don't truncate prose.
   Gated partial handling on the previously-unused options.streaming param
   (live streaming keeps 'still thinking' behavior; persist/reload does not).

Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): recognize fenced code blocks indented 1-3 spaces

Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload

Round-4 Codex deep-review caught two real issues in my own fixes:

1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
   _thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
   plus the leading check sliced+stripped the whole prefix per unclosed tag. On
   long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
   on the streaming path). Replaced with incremental O(1)-per-iteration line
   state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
   starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.

2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
   so a message with BOTH an inline <think> block AND a separate m.reasoning
   payload showed only the inline part — the separate payload was dropped
   because the !thinkingText worklog resolution was then skipped. Now seeds with
   the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
   two MERGE (deduped); separate-only reasoning is preserved without promoting
   it into visible prose.

Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix

Codex round-4 finding nesquena#2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.

The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.

The O(n) perf fix (finding nesquena#1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): only lstrip extracted content when a leading block was removed

Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).

Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): reconnect restore prefers raw inflight accumulator

Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* Release v0.51.335 — Release KY (normalize inline thinking extraction, nesquena#3633)

Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (nesquena#3599/nesquena#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: LLM Wiki status panel with on/off toggle

2 participants