@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0-beta.1

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.
Files changed (99) hide show
  1. package/package.json +2 -2
  2. package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
  3. package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
  4. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
  5. package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
  6. package/vendor/agent-frontend-shell/api/config.py +145 -104
  7. package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
  8. package/vendor/agent-frontend-shell/api/helpers.py +4 -2
  9. package/vendor/agent-frontend-shell/api/models.py +202 -20
  10. package/vendor/agent-frontend-shell/api/paths.py +77 -0
  11. package/vendor/agent-frontend-shell/api/plugins.py +185 -0
  12. package/vendor/agent-frontend-shell/api/profiles.py +95 -16
  13. package/vendor/agent-frontend-shell/api/routes.py +831 -30
  14. package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
  15. package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
  16. package/vendor/agent-frontend-shell/api/streaming.py +211 -56
  17. package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
  18. package/vendor/agent-frontend-shell/api/updates.py +30 -3
  19. package/vendor/agent-frontend-shell/api/upload.py +251 -18
  20. package/vendor/agent-frontend-shell/api/workspace.py +323 -65
  21. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
  22. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
  23. package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
  24. package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
  25. package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
  26. package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
  27. package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
  28. package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
  29. package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
  30. package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
  31. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
  32. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
  33. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
  34. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
  35. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
  36. package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
  37. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
  38. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
  39. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
  40. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
  41. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
  42. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
  43. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
  44. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
  45. package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
  46. package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
  47. package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
  48. package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
  49. package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
  50. package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
  51. package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
  52. package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
  53. package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
  54. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
  55. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
  56. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
  57. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
  58. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
  59. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
  60. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
  61. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
  62. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
  63. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
  64. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
  65. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
  66. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
  67. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
  68. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
  69. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
  70. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
  71. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
  72. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
  73. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
  74. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
  75. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
  76. package/vendor/agent-frontend-shell/build-release.sh +62 -0
  77. package/vendor/agent-frontend-shell/ctl.sh +1 -0
  78. package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
  79. package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
  80. package/vendor/agent-frontend-shell/docker_init.bash +1 -0
  81. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
  82. package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
  83. package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
  84. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
  85. package/vendor/agent-frontend-shell/readme-simple.md +103 -0
  86. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  87. package/vendor/agent-frontend-shell/server.py +7 -0
  88. package/vendor/agent-frontend-shell/static/boot.js +53 -1
  89. package/vendor/agent-frontend-shell/static/commands.js +20 -10
  90. package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
  91. package/vendor/agent-frontend-shell/static/index.html +13 -3
  92. package/vendor/agent-frontend-shell/static/messages.js +48 -3
  93. package/vendor/agent-frontend-shell/static/panels.js +199 -30
  94. package/vendor/agent-frontend-shell/static/sessions.js +249 -39
  95. package/vendor/agent-frontend-shell/static/style.css +46 -2
  96. package/vendor/agent-frontend-shell/static/ui.js +323 -79
  97. package/vendor/agent-frontend-shell/static/workspace.js +185 -7
  98. package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
  99. package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
@@ -262,6 +262,7 @@ def stale_interrupted_event(session_id: str, run_id: str, *, after_seq: int | No
262
262
  return None
263
263
  payload = {
264
264
  "type": "interrupted",
265
+ "recovery_control": True,
265
266
  "message": "The live worker stopped before this run finished.",
266
267
  "hint": "The transcript was restored to the last journaled event. Start a new turn if you still need the task to continue.",
267
268
  "session_id": session_id,
@@ -16,11 +16,12 @@ any double-counting risk.
16
16
  import logging
17
17
  import os
18
18
  from pathlib import Path
19
+ from typing import Optional
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
22
23
 
23
- def _get_state_db(profile: str=None):
24
+ def _get_state_db(profile: Optional[str] = None):
24
25
  """Get a SessionDB instance for a profile's state.db.
25
26
 
26
27
  When ``profile`` is provided the function resolves *that* profile's
@@ -104,7 +105,7 @@ def _get_state_db(profile: str=None):
104
105
  return None
105
106
 
106
107
 
107
- def sync_session_start(session_id: str, model=None, profile: str=None) -> None:
108
+ def sync_session_start(session_id: str, model=None, profile: Optional[str] = None) -> None:
108
109
  """Register a WebUI session in state.db (idempotent).
109
110
  Called when a session's first message is sent.
110
111
 
@@ -132,8 +133,8 @@ def sync_session_start(session_id: str, model=None, profile: str=None) -> None:
132
133
 
133
134
 
134
135
  def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
135
- estimated_cost=None, model=None, title: str=None,
136
- message_count: int=None, profile: str=None) -> None:
136
+ estimated_cost=None, model=None, title: Optional[str] = None,
137
+ message_count: Optional[int] = None, profile: Optional[str] = None) -> None:
137
138
  """Update token usage and title for a WebUI session in state.db.
138
139
  Called after each turn completes. Uses absolute=True to set totals
139
140
  (the WebUI Session already accumulates across turns).
@@ -203,7 +203,8 @@ WebUI progress guidance:
203
203
  - Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed.
204
204
  - Keep updates concise, factual, and in the user's language. One or two short sentences are enough.
205
205
  - Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output.
206
- - Do not include terse planning fragments or scratchpad shorthand in visible assistant text. Avoid fragments like "Need check logs", "Need inspect email", or "maybe invite"; either omit them or rewrite them as clear user-facing progress.
206
+ - Final visible assistant replies must be clear, user-facing, and in the user's language, not private planning notes.
207
+ - Do not include terse planning fragments or scratchpad shorthand in visible assistant text. Avoid fragments like "Need script", "Need check logs", "Need inspect email", or "maybe invite"; either omit them or rewrite them as clear user-facing progress.
207
208
  - For direct answers or very short tasks, skip progress updates and answer normally.
208
209
  """.strip()
209
210
 
@@ -244,6 +245,7 @@ def _webui_surface_context_prompt(surface_context: Optional[dict]) -> str:
244
245
  def _webui_ephemeral_system_prompt(
245
246
  personality_prompt: Optional[str],
246
247
  surface_context: Optional[dict] = None,
248
+ config_data: Optional[dict] = None,
247
249
  ) -> str:
248
250
  """Build WebUI-only runtime instructions that are not persisted to history."""
249
251
  parts = []
@@ -253,6 +255,9 @@ def _webui_ephemeral_system_prompt(
253
255
  if surface_prompt:
254
256
  parts.append(surface_prompt)
255
257
  parts.append(_WEBUI_PROGRESS_PROMPT)
258
+ delivery_prompt = _webui_delivery_context_prompt(config_data)
259
+ if delivery_prompt:
260
+ parts.append(delivery_prompt)
256
261
  return "\n\n".join(part for part in parts if part)
257
262
 
258
263
 
@@ -497,48 +502,45 @@ def _public_prefill_context_status(prefill_context: dict) -> dict:
497
502
  }
498
503
 
499
504
 
500
- def _webui_session_context_message(config_data: Optional[dict] = None) -> dict:
501
- """Return a compact browser-session context message for WebUI agents.
505
+ def _webui_delivery_context_prompt(config_data: Optional[dict] = None) -> str:
506
+ """Return platform/delivery context for the ephemeral system prompt.
507
+
508
+ Connected platforms, home channels, and scheduled-task delivery hints
509
+ are injected into the system prompt (safe for role alternation) rather
510
+ than as a prefill ``user`` message, which strict chat templates (Mistral,
511
+ Gemma) reject.
502
512
 
503
- Messaging gateway sessions get a small "Current Session Context" block that
504
- tells the agent where the turn came from, which platforms are connected, and
505
- how scheduled-task delivery should be interpreted. Browser-originated WebUI
506
- turns do not have a Gateway ``SessionSource``, but they still benefit from a
507
- safe equivalent so the model understands that this is a WebUI session, not a
508
- literal Telegram thread, while retaining access to configured messaging
509
- delivery targets.
513
+ NOTE: This function only covers platform/delivery info. The session
514
+ framing (\"Source: WebUI\", \"Session ID\", \"Profile\", \"Workspace\") is
515
+ emitted by ``_webui_surface_context_prompt()``, which is called from
516
+ ``_webui_ephemeral_system_prompt()`` before this helper. If you
517
+ refactor this area, keep that surface call in place the two helpers
518
+ together produce the full session context block.
510
519
  """
511
520
  cfg = config_data if isinstance(config_data, dict) else get_config()
512
- lines = [
513
- "## Current Session Context",
514
- "",
515
- "**Source:** WebUI (browser session)",
516
- "**Session type:** Browser-originated Hermes WebUI chat. This is a separate WebUI transcript, not the same live Telegram/Discord/other messaging thread.",
517
- ]
521
+ lines: list[str] = []
518
522
 
523
+ display_hermes_home = None
519
524
  try:
520
- from api.profiles import get_active_profile_name
521
-
522
- profile_name = get_active_profile_name() or "default"
525
+ from hermes_constants import get_hermes_home, display_hermes_home as _dh
526
+ display_hermes_home = _dh
523
527
  except Exception:
524
- profile_name = "default"
525
- lines.append(f"**Active Hermes profile:** {profile_name}")
528
+ get_hermes_home = None # type: ignore[assignment]
526
529
 
527
530
  connected = ["local (files on this machine)"]
528
531
  try:
529
- from hermes_constants import get_hermes_home, display_hermes_home
530
-
531
- state_path = get_hermes_home() / "gateway_state.json"
532
- if state_path.exists():
533
- raw_state = json.loads(state_path.read_text(encoding="utf-8"))
534
- platforms = raw_state.get("platforms") if isinstance(raw_state, dict) else {}
535
- if isinstance(platforms, dict):
536
- for name in sorted(platforms):
537
- pdata = platforms.get(name) or {}
538
- if isinstance(pdata, dict) and pdata.get("state") == "connected" and name != "local":
539
- connected.append(f"{name}: Connected ✓")
532
+ if get_hermes_home is not None:
533
+ state_path = get_hermes_home() / "gateway_state.json"
534
+ if state_path.exists():
535
+ raw_state = json.loads(state_path.read_text(encoding="utf-8"))
536
+ platforms = raw_state.get("platforms") if isinstance(raw_state, dict) else {}
537
+ if isinstance(platforms, dict):
538
+ for name in sorted(platforms):
539
+ pdata = platforms.get(name) or {}
540
+ if isinstance(pdata, dict) and pdata.get("state") == "connected" and name != "local":
541
+ connected.append(f"{name}: Connected ")
540
542
  except Exception:
541
- display_hermes_home = None # type: ignore[assignment]
543
+ pass
542
544
  lines.append(f"**Connected Platforms:** {', '.join(connected)}")
543
545
 
544
546
  home_channels = {}
@@ -566,7 +568,7 @@ def _webui_session_context_message(config_data: Optional[dict] = None) -> dict:
566
568
  lines.append("**Delivery options for scheduled tasks:**")
567
569
  lines.append("- `\"origin\"` → Back to this WebUI/browser session when the WebUI runtime supports origin delivery; otherwise prefer an explicit platform target.")
568
570
  try:
569
- home_display = display_hermes_home() if display_hermes_home else "~/.hermes" # type: ignore[name-defined]
571
+ home_display = display_hermes_home() if display_hermes_home else "~/.hermes"
570
572
  except Exception:
571
573
  home_display = "~/.hermes"
572
574
  lines.append(f"- `\"local\"` → Save to local files only ({home_display}/cron/output/)")
@@ -575,14 +577,19 @@ def _webui_session_context_message(config_data: Optional[dict] = None) -> dict:
575
577
  lines.append("")
576
578
  lines.append("*For explicit targeting, use `\"platform:chat_id\"` format if the user provides a specific chat ID. Do not invent private IDs.*")
577
579
 
578
- return {"role": "user", "content": "\n".join(lines)}
580
+ return "\n".join(lines)
579
581
 
580
582
 
581
583
  def _prefill_messages_with_webui_context(prefill_context: dict, config_data: Optional[dict] = None) -> list[dict]:
582
- """Combine recall prefill with WebUI's gateway-like session context."""
583
- messages = list(prefill_context.get("messages") or [])
584
- messages.append(_webui_session_context_message(config_data))
585
- return messages
584
+ """Combine recall prefill with WebUI session context.
585
+
586
+ The session context (connected platforms, delivery hints) is injected
587
+ via ``_webui_ephemeral_system_prompt`` / ``ephemeral_system_prompt``
588
+ instead of as a prefill ``user`` message. Adding it as a user message
589
+ creates two consecutive user turns (prefill + actual) which strict chat
590
+ templates (Mistral, Gemma) reject with a Jinja 500.
591
+ """
592
+ return list(prefill_context.get("messages") or [])
586
593
 
587
594
 
588
595
  def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool:
@@ -1569,24 +1576,109 @@ def _detect_title_language(text: str) -> str:
1569
1576
  return ''
1570
1577
 
1571
1578
 
1579
+ def _script_counts(text: str) -> dict:
1580
+ """Return per-script alphabetic character counts for *text*.
1581
+
1582
+ Buckets: ``latin``, ``cjk`` (Han/Hiragana/Katakana/Hangul), ``cyrillic``,
1583
+ ``arabic``, ``hebrew``, ``greek``, ``devanagari``. Non-alphabetic and
1584
+ unclassified characters are ignored.
1585
+ """
1586
+ counts: dict[str, int] = {}
1587
+ for ch in str(text or ''):
1588
+ if not ch.isalpha():
1589
+ continue
1590
+ o = ord(ch)
1591
+ if (0x0041 <= o <= 0x024F) or (0x1E00 <= o <= 0x1EFF):
1592
+ bucket = 'latin'
1593
+ elif (
1594
+ (0x4E00 <= o <= 0x9FFF) or (0x3400 <= o <= 0x4DBF) # Han
1595
+ or (0x3040 <= o <= 0x30FF) # Hiragana/Katakana
1596
+ or (0xAC00 <= o <= 0xD7A3) or (0x1100 <= o <= 0x11FF) # Hangul
1597
+ ):
1598
+ bucket = 'cjk'
1599
+ elif 0x0400 <= o <= 0x04FF:
1600
+ bucket = 'cyrillic'
1601
+ elif (0x0600 <= o <= 0x06FF) or (0x0750 <= o <= 0x077F):
1602
+ bucket = 'arabic'
1603
+ elif 0x0590 <= o <= 0x05FF:
1604
+ bucket = 'hebrew'
1605
+ elif 0x0370 <= o <= 0x03FF:
1606
+ bucket = 'greek'
1607
+ elif 0x0900 <= o <= 0x097F:
1608
+ bucket = 'devanagari'
1609
+ else:
1610
+ continue
1611
+ counts[bucket] = counts.get(bucket, 0) + 1
1612
+ return counts
1613
+
1614
+
1615
+ def _dominant_script(text: str) -> str:
1616
+ """Return a coarse writing-script bucket for *text*, or '' when undecidable.
1617
+
1618
+ Script-level (not language-level) classification is cheap and dependency-free.
1619
+ Returns the dominant script only when it holds a clear (≥60%) majority of the
1620
+ alphabetic characters, so mixed/borrowed text doesn't flip the bucket. Used
1621
+ to establish the conversation start's expected script for cross-script title
1622
+ drift detection (#3293).
1623
+ """
1624
+ counts = _script_counts(text)
1625
+ total = sum(counts.values())
1626
+ if total < 2:
1627
+ return ''
1628
+ top, top_n = max(counts.items(), key=lambda kv: kv[1])
1629
+ if top_n / total >= 0.6:
1630
+ return top
1631
+ return ''
1632
+
1633
+
1572
1634
  def _title_prompt_language_rule(user_text: str) -> str:
1573
1635
  return "Match the language of the user question.\n"
1574
1636
 
1575
1637
 
1576
1638
  def _title_language_mismatch(user_text: str, title: str) -> bool:
1577
- """Reject obvious English titles for German conversation starts."""
1578
- if _detect_title_language(user_text) != 'de':
1579
- return False
1580
- candidate = str(title or '').strip().lower()
1639
+ """Reject titles whose language clearly diverges from the conversation start.
1640
+
1641
+ Two independent signals:
1642
+ 1. Cross-script drift (#3293): when the conversation start has a clear
1643
+ dominant writing script (e.g. latin/English) and the generated title
1644
+ introduces a *substantial* amount of a different script (e.g. CJK or
1645
+ Cyrillic), reject. This is language-agnostic and catches the common
1646
+ "English chat -> Chinese/Spanish/Russian title" drift. Because titles are
1647
+ short and frequently embed a borrowed Latin technical term (e.g. a CJK
1648
+ title containing the word "Python"), the title side uses a proportion
1649
+ threshold (>=35% of the title's alphabetic characters in a non-start
1650
+ script, min 2 chars) rather than a strict majority -- so a CJK title with
1651
+ one English word still trips, while an English title with a single
1652
+ foreign place-name does not.
1653
+ 2. The legacy German-start → English-title heuristic, preserved verbatim so
1654
+ the original behavior keeps working for same-script (latin) drift that
1655
+ the script check can't see.
1656
+ """
1657
+ candidate = str(title or '').strip()
1581
1658
  if not candidate:
1582
1659
  return False
1583
- if _detect_title_language(candidate) == 'de':
1660
+
1661
+ # (1) Cross-script mismatch — language-agnostic.
1662
+ user_script = _dominant_script(user_text)
1663
+ if user_script:
1664
+ title_counts = _script_counts(candidate)
1665
+ title_total = sum(title_counts.values())
1666
+ if title_total >= 2:
1667
+ for script, n in title_counts.items():
1668
+ if script != user_script and n >= 2 and (n / title_total) >= 0.35:
1669
+ return True
1670
+
1671
+ # (2) Legacy same-script German→English heuristic.
1672
+ if _detect_title_language(user_text) != 'de':
1673
+ return False
1674
+ candidate_lower = candidate.lower()
1675
+ if _detect_title_language(candidate_lower) == 'de':
1584
1676
  return False
1585
1677
  english_markers = {
1586
1678
  'old', 'image', 'display', 'issue', 'problem', 'discussion', 'conversation',
1587
1679
  'session', 'title', 'fix', 'bug', 'attachment', 'attachments', 'context',
1588
1680
  }
1589
- tokens = re.findall(r'[a-z]+', candidate)
1681
+ tokens = re.findall(r'[a-z]+', candidate_lower)
1590
1682
  english_hits = sum(1 for tok in tokens if tok in english_markers)
1591
1683
  return english_hits >= 2
1592
1684
 
@@ -1794,6 +1886,17 @@ def generate_title_raw_via_aux(
1794
1886
  provider = ''
1795
1887
  model = model or configured.get('model', '') or ''
1796
1888
  base_url = base_url or configured.get('base_url', '') or ''
1889
+ try:
1890
+ from api.profiles import _split_webui_provider_model_value
1891
+
1892
+ normalized_model, normalized_provider = _split_webui_provider_model_value(
1893
+ model or None,
1894
+ provider or None,
1895
+ )
1896
+ model = normalized_model or ''
1897
+ provider = normalized_provider or ''
1898
+ except ValueError:
1899
+ pass
1797
1900
  api_key = ''
1798
1901
  if not caller_supplied_route:
1799
1902
  api_key = str(configured.get('api_key', '') or '').strip()
@@ -2262,6 +2365,35 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex
2262
2365
  logger.debug("Background title refresh failed for session %s", session_id, exc_info=True)
2263
2366
 
2264
2367
 
2368
+
2369
+
2370
+ def generate_session_title_for_session(session, *, prefer_latest: bool = False, agent=None) -> tuple[Optional[str], str, str]:
2371
+ """Generate a session title on demand from persisted conversation messages.
2372
+
2373
+ This helper powers explicit UI title-regeneration controls. It intentionally
2374
+ does not inspect or mutate ``llm_title_generated``; callers decide whether
2375
+ replacing the current title is allowed, then persist the returned title.
2376
+ """
2377
+ messages = getattr(session, 'messages', None) or []
2378
+ if prefer_latest:
2379
+ user_text, assistant_text = _latest_exchange_snippets(messages)
2380
+ else:
2381
+ user_text, assistant_text = _first_exchange_snippets(messages)
2382
+ if not user_text:
2383
+ return None, 'empty_user_message', ''
2384
+ from api import profiles as profiles_api
2385
+
2386
+ with profiles_api.profile_env_for_background_worker(session, "manual title regeneration", logger_override=logger):
2387
+ next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent)
2388
+ if next_title:
2389
+ return next_title, llm_status, raw_preview
2390
+ fallback_title = _fallback_title_from_exchange(user_text, assistant_text)
2391
+ if fallback_title and not _is_generic_fallback_title(fallback_title):
2392
+ reason = f'local_summary:{llm_status}' if llm_status else 'local_summary'
2393
+ return fallback_title, reason, raw_preview
2394
+ return None, llm_status or 'empty_title', raw_preview
2395
+
2396
+
2265
2397
  def _preserve_pre_compression_snapshot(s, old_sid: str) -> None:
2266
2398
  """Persist old_sid as a read-only pre-compression snapshot.
2267
2399
 
@@ -3081,14 +3213,25 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
3081
3213
  # match are context-only gaps that get spliced in before the display msg.
3082
3214
  if previous_display and previous_context:
3083
3215
  _display_id_set = {_message_identity(m) for m in previous_display}
3084
- _context_id_set = {_message_identity(m) for m in previous_context}
3216
+ _context_id_set = {
3217
+ _message_identity(m)
3218
+ for m in previous_context
3219
+ if not _is_context_compression_marker(m)
3220
+ }
3085
3221
  _has_context_only_turns = bool(_context_id_set - _display_id_set)
3086
3222
  if _has_context_only_turns:
3087
3223
  context_keys = [_message_identity(m) for m in previous_context]
3088
3224
  _backfilled = []
3089
- _emitted = set()
3225
+ # #3300 fix: track ONLY context rows we splice in, so the
3226
+ # visible-display backbone is never suppressed. Sharing one set
3227
+ # between context inserts and display rows (and _message_identity
3228
+ # ignoring timestamps) dropped a legitimate second identical visible
3229
+ # user turn. Display rows are always appended in order; a context
3230
+ # row is backfilled only if it isn't already a display row and
3231
+ # hasn't already been inserted.
3232
+ _context_inserted = set()
3090
3233
  _cursor = 0
3091
- for _dmsg in previous_display:
3234
+ for _display_idx, _dmsg in enumerate(previous_display):
3092
3235
  _dkey = _message_identity(_dmsg)
3093
3236
  if _dkey is not None:
3094
3237
  _j = _cursor
@@ -3098,21 +3241,32 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
3098
3241
  for _k in range(_cursor, _j):
3099
3242
  _ckey = context_keys[_k]
3100
3243
  _cmsg = previous_context[_k]
3101
- if _ckey is not None and _ckey not in _emitted and not _is_context_compression_marker(_cmsg):
3244
+ if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker(_cmsg):
3102
3245
  _backfilled.append(copy.deepcopy(_cmsg))
3103
- _emitted.add(_ckey)
3246
+ _context_inserted.add(_ckey)
3104
3247
  _cursor = _j + 1
3105
- if _dkey not in _emitted:
3106
- _backfilled.append(_dmsg)
3107
- if _dkey is not None:
3108
- _emitted.add(_dkey)
3248
+ elif not any(
3249
+ _message_identity(_future_dmsg) in context_keys[_cursor:]
3250
+ for _future_dmsg in previous_display[_display_idx + 1:]
3251
+ ):
3252
+ for _k in range(_cursor, len(context_keys)):
3253
+ _ckey = context_keys[_k]
3254
+ _cmsg = previous_context[_k]
3255
+ if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker(_cmsg):
3256
+ _backfilled.append(copy.deepcopy(_cmsg))
3257
+ _context_inserted.add(_ckey)
3258
+ _cursor = len(context_keys)
3259
+ # The display row is the visible backbone — always preserve it,
3260
+ # in order, even when an earlier (identical-content) turn or a
3261
+ # backfilled context row shares its timestamp-less identity.
3262
+ _backfilled.append(_dmsg)
3109
3263
  while _cursor < len(context_keys):
3110
3264
  _ckey = context_keys[_cursor]
3111
3265
  _cmsg = previous_context[_cursor]
3112
3266
  _cursor += 1
3113
- if _ckey is not None and _ckey not in _emitted and not _is_context_compression_marker(_cmsg):
3267
+ if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker(_cmsg):
3114
3268
  _backfilled.append(copy.deepcopy(_cmsg))
3115
- _emitted.add(_ckey)
3269
+ _context_inserted.add(_ckey)
3116
3270
  if len(_backfilled) > len(previous_display):
3117
3271
  logger.debug(
3118
3272
  "Backfilled %d context-only turns into previous_display (was %d, now %d)",
@@ -5146,6 +5300,7 @@ def _run_agent_streaming(
5146
5300
  'profile': getattr(s, 'profile', None),
5147
5301
  'workspace': s.workspace,
5148
5302
  },
5303
+ config_data=_cfg,
5149
5304
  )
5150
5305
  _pending_started_at = getattr(s, 'pending_started_at', None)
5151
5306
  # Normal chat-start sets pending_started_at before spawning this thread;
@@ -0,0 +1,122 @@
1
+ """Derive settled todo snapshots for session GET responses.
2
+
3
+ The browser's Todos panel currently reconstructs state by reverse-scanning
4
+ loaded tool messages. That works only when the latest todo tool result is inside
5
+ the returned message window. This helper gives ``/api/session`` a compact,
6
+ explicit ``todo_state`` sidecar derived from the full settled transcript.
7
+
8
+ The detector intentionally mirrors ``run_agent.AIAgent._hydrate_todo_store``:
9
+ walk messages newest-first and use the first tool message whose JSON content has
10
+ a ``todos`` list. Empty ``todos`` is a valid snapshot so a cleared task list does
11
+ not fall through to an older non-empty write.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from typing import Any, Iterable, Optional, Sequence
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ VERSION = 1
23
+ PAYLOAD_KEY = "todo_state"
24
+
25
+
26
+ def _normalize_snapshot(data: Any) -> Optional[dict]:
27
+ """Return the canonical todo snapshot shape, or ``None`` for non-todo data."""
28
+ if not isinstance(data, dict):
29
+ return None
30
+ todos = data.get("todos")
31
+ if not isinstance(todos, list):
32
+ return None
33
+ summary = data.get("summary")
34
+ if not isinstance(summary, dict):
35
+ summary = {}
36
+ return {
37
+ "todos": todos,
38
+ "summary": summary,
39
+ "version": VERSION,
40
+ }
41
+
42
+
43
+ def parse_todo_tool_result(function_result: Any) -> Optional[dict]:
44
+ """Parse a todo tool result JSON string or pre-parsed dict into a snapshot."""
45
+ data: Any = function_result
46
+ if isinstance(function_result, str):
47
+ try:
48
+ data = json.loads(function_result)
49
+ except (TypeError, ValueError):
50
+ return None
51
+ return _normalize_snapshot(data)
52
+
53
+
54
+ def _message_ts_float(ts_raw: Any) -> float:
55
+ """Coerce a message ``timestamp`` field to a positive float, or 0.0."""
56
+ try:
57
+ return float(ts_raw) if ts_raw is not None else 0.0
58
+ except (TypeError, ValueError):
59
+ return 0.0
60
+
61
+
62
+ def _max_timestamp_through(messages: Sequence[Any], upto_idx: int) -> float:
63
+ """Return the largest valid timestamp at or before ``upto_idx``."""
64
+ best = 0.0
65
+ end = min(upto_idx, len(messages) - 1)
66
+ for i in range(end, -1, -1):
67
+ msg = messages[i]
68
+ if not isinstance(msg, dict):
69
+ continue
70
+ best = max(best, _message_ts_float(msg.get("timestamp")))
71
+ return best
72
+
73
+
74
+ def derive_todo_state(messages: Optional[Iterable[dict]]) -> Optional[dict]:
75
+ """Derive the latest settled todo snapshot from conversation history.
76
+
77
+ Returns ``None`` when the session has no todo writes. Malformed or
78
+ non-string tool contents are skipped so unrelated tool results never break
79
+ session loading.
80
+ """
81
+ if not messages:
82
+ return None
83
+ if not isinstance(messages, (list, tuple)):
84
+ messages = list(messages)
85
+
86
+ for idx in range(len(messages) - 1, -1, -1):
87
+ msg = messages[idx]
88
+ if not isinstance(msg, dict) or msg.get("role") != "tool":
89
+ continue
90
+ content = msg.get("content", "")
91
+ if not isinstance(content, str) or '"todos"' not in content:
92
+ continue
93
+ snapshot = parse_todo_tool_result(content)
94
+ if snapshot is None:
95
+ continue
96
+
97
+ ts_val = _message_ts_float(msg.get("timestamp"))
98
+ if ts_val <= 0:
99
+ ts_val = _max_timestamp_through(messages, idx)
100
+ if ts_val > 0:
101
+ snapshot["ts"] = ts_val
102
+ return snapshot
103
+ return None
104
+
105
+
106
+ def attach_todo_state(payload: dict, messages: Optional[Iterable[dict]]) -> bool:
107
+ """Attach ``todo_state`` to a session payload when one can be derived.
108
+
109
+ Mutates ``payload`` in place. Errors are swallowed deliberately: a malformed
110
+ historical tool message must not make ``/api/session`` fail.
111
+ """
112
+ if not messages:
113
+ return False
114
+ try:
115
+ snapshot = derive_todo_state(messages)
116
+ if snapshot is None:
117
+ return False
118
+ payload[PAYLOAD_KEY] = snapshot
119
+ return True
120
+ except Exception:
121
+ logger.debug("todo_state attach failed", exc_info=True)
122
+ return False
@@ -1049,10 +1049,37 @@ def _schedule_restart(delay: float = 2.0) -> None:
1049
1049
  with _apply_lock:
1050
1050
  _wait_until_restart_safe()
1051
1051
  try:
1052
- os.execv(sys.executable, [sys.executable] + sys.argv)
1052
+ # Re-exec into the just-pulled image.
1053
+ #
1054
+ # sys.argv[0]'s meaning depends on how the server was launched:
1055
+ #
1056
+ # * Source checkout (`python server.py` via bootstrap.py /
1057
+ # ctl.sh / start.sh): sys.argv[0] is the SCRIPT path
1058
+ # (e.g. "/root/hermes-webui/server.py"), sys.executable is
1059
+ # the interpreter. CPython treats argv[1] as the script to
1060
+ # run, so we must pass [sys.executable] + sys.argv.
1061
+ #
1062
+ # * Frozen/packaged build (PyInstaller, embedded zipapp,
1063
+ # etc.): sys.argv[0] == sys.executable == <binary>. Passing
1064
+ # [sys.executable] + sys.argv would re-insert the binary as
1065
+ # argv[1] — the kernel launches it, the interpreter treats
1066
+ # the binary itself as the "script" to run, and execv
1067
+ # effectively becomes a recursive no-op that never reaches
1068
+ # bind(), leaving the WebUI stuck "offline" after every
1069
+ # self-update. Pass argv as-is instead.
1070
+ #
1071
+ # Distinguish the two cases with sys.frozen (set by
1072
+ # PyInstaller / zipapp / similar). For source checkouts the
1073
+ # `[sys.executable] + sys.argv` form is the canonical CPython
1074
+ # re-exec idiom (same shape Flask/Django reloaders use) and
1075
+ # is the correct path.
1076
+ if getattr(sys, "frozen", False):
1077
+ os.execv(sys.executable, sys.argv)
1078
+ else:
1079
+ os.execv(sys.executable, [sys.executable] + sys.argv)
1053
1080
  except Exception:
1054
- # Last-resort: if execv fails (e.g. frozen binary), just exit
1055
- # so the process supervisor (start.sh / Docker) restarts us.
1081
+ # Last-resort: if execv fails for any reason, just exit so the
1082
+ # process supervisor (start.sh / Docker) restarts us.
1056
1083
  os._exit(0)
1057
1084
 
1058
1085
  threading.Thread(target=_do, daemon=True).start()