@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
@@ -637,18 +637,6 @@ class Session:
637
637
  def path(self):
638
638
  return SESSION_DIR / f'{self.session_id}.json'
639
639
 
640
- def _maybe_clear_truncation_watermark(self) -> None:
641
- watermark = _message_timestamp_as_float({"timestamp": self.truncation_watermark})
642
- if watermark is None:
643
- return
644
- max_message_timestamp = None
645
- for msg in self.messages or []:
646
- timestamp = _message_timestamp_as_float(msg)
647
- if timestamp is not None:
648
- max_message_timestamp = timestamp if max_message_timestamp is None else max(max_message_timestamp, timestamp)
649
- if max_message_timestamp is not None and max_message_timestamp > watermark:
650
- self.truncation_watermark = None
651
-
652
640
  def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
653
641
  if not is_safe_session_id(self.session_id):
654
642
  raise ValueError(f"Unsafe session_id {self.session_id!r}; refusing to write outside session store")
@@ -671,7 +659,6 @@ class Session:
671
659
  )
672
660
  if touch_updated_at:
673
661
  self.updated_at = time.time()
674
- self._maybe_clear_truncation_watermark()
675
662
  # Write metadata fields first so load_metadata_only() can read them
676
663
  # without parsing the full messages array (which may be 400KB+).
677
664
  # Fields are listed in the order they should appear in the JSON file.
@@ -2655,6 +2642,68 @@ def _refresh_index_rows_from_sidecar_metadata(sessions: list[dict]) -> list[dict
2655
2642
  return out
2656
2643
 
2657
2644
 
2645
+ def state_db_has_session(sid: str) -> bool:
2646
+ """Return True when ``sid`` exists in the active state.db sessions table.
2647
+
2648
+ Used by file-manager handlers to fall back to a state.db lookup when
2649
+ ``get_session`` raises ``KeyError`` because the session was created by
2650
+ Telegram/CLI (external) rather than the WebUI (issue #3280). The state.db
2651
+ schema stores only metadata (id/title/model/source/...), not a workspace
2652
+ path — the workspace is shared across session storage backends and is
2653
+ resolved separately via ``get_last_workspace()``.
2654
+ """
2655
+ if not sid:
2656
+ return False
2657
+ try:
2658
+ import sqlite3
2659
+ except ImportError:
2660
+ return False
2661
+ db_path = _active_state_db_path()
2662
+ if not db_path.exists():
2663
+ return False
2664
+ try:
2665
+ with closing(sqlite3.connect(str(db_path))) as conn:
2666
+ cur = conn.cursor()
2667
+ cur.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (str(sid),))
2668
+ return cur.fetchone() is not None
2669
+ except Exception:
2670
+ return False
2671
+
2672
+
2673
+ class _ExternalSessionView:
2674
+ """Minimal session-shaped view for external (Telegram/CLI) sessions.
2675
+
2676
+ Only exposes the fields file-manager handlers need (``session_id`` and
2677
+ ``workspace``). The workspace falls back to the WebUI's last-used
2678
+ workspace because state.db does not persist a per-session workspace path
2679
+ and the file browser is intentionally workspace-scoped, not
2680
+ session-storage-scoped (issue #3280).
2681
+ """
2682
+
2683
+ __slots__ = ("session_id", "workspace")
2684
+
2685
+ def __init__(self, session_id: str, workspace: str):
2686
+ self.session_id = session_id
2687
+ self.workspace = workspace
2688
+
2689
+
2690
+ def get_session_for_file_ops(sid: str):
2691
+ """Return a session-like object for file-manager handlers.
2692
+
2693
+ Tries ``get_session`` first (preserves all existing behavior for WebUI
2694
+ sessions). If that raises ``KeyError``, checks state.db; when the session
2695
+ exists there, returns an ``_ExternalSessionView`` whose ``workspace`` is
2696
+ the active WebUI workspace. If neither has the session, re-raises
2697
+ ``KeyError`` so callers continue to return their existing 404.
2698
+ """
2699
+ try:
2700
+ return get_session(sid, metadata_only=True)
2701
+ except KeyError:
2702
+ if state_db_has_session(sid):
2703
+ return _ExternalSessionView(str(sid), str(get_last_workspace()))
2704
+ raise
2705
+
2706
+
2658
2707
  def _active_state_db_path() -> Path:
2659
2708
  """Return state.db for the active Hermes profile, degrading to HERMES_HOME."""
2660
2709
  try:
@@ -2665,6 +2714,49 @@ def _active_state_db_path() -> Path:
2665
2714
  return hermes_home / 'state.db'
2666
2715
 
2667
2716
 
2717
+ def agent_session_row_exists(session_id: str, *, profile=None) -> bool:
2718
+ """Return True if ``session_id`` still has a backing row in the agent state.db.
2719
+
2720
+ Used to detect orphaned imported-CLI sidecars (#3238): the WebUI sidebar
2721
+ must NOT rely on the session's presence in ``get_cli_sessions()`` to decide
2722
+ whether its backing CLI row still exists, because that helper caps at
2723
+ ``CLI_VISIBLE_SESSION_LIMIT`` (20) rows — a still-existing session can fall
2724
+ out of the recent window and look "deleted." This is an exact, uncapped
2725
+ existence probe against the ``sessions`` table.
2726
+
2727
+ Degrades safely to ``True`` (assume present) on any error or when the DB is
2728
+ unreadable, so a transient failure never causes a stale-pruning data loss.
2729
+ """
2730
+ sid = str(session_id or "").strip()
2731
+ if not sid:
2732
+ return False
2733
+ try:
2734
+ import sqlite3
2735
+ except ImportError:
2736
+ return True
2737
+ if isinstance(profile, str) and profile:
2738
+ db_path = _get_profile_home(profile) / 'state.db'
2739
+ if not db_path.exists():
2740
+ db_path = _active_state_db_path()
2741
+ else:
2742
+ db_path = _active_state_db_path()
2743
+ if not db_path.exists():
2744
+ # No agent DB at all on this instance — can't claim the row is gone.
2745
+ return True
2746
+ try:
2747
+ with closing(sqlite3.connect(str(db_path))) as conn:
2748
+ cur = conn.cursor()
2749
+ cur.execute("PRAGMA table_info(sessions)")
2750
+ cols = {str(row[1]) for row in cur.fetchall()}
2751
+ if 'id' not in cols:
2752
+ return True
2753
+ cur.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (sid,))
2754
+ return cur.fetchone() is not None
2755
+ except Exception:
2756
+ logger.debug("agent_session_row_exists probe failed for %s", sid, exc_info=True)
2757
+ return True
2758
+
2759
+
2668
2760
  def _sidebar_title_is_generic_webui(title: str | None) -> bool:
2669
2761
  text = ' '.join(str(title or '').split())
2670
2762
  if text == 'Hermes WebUI':
@@ -2858,6 +2950,10 @@ def all_sessions(diag=None):
2858
2950
  return result
2859
2951
 
2860
2952
 
2953
+ def _strip_attached_files_marker(text: str) -> str:
2954
+ return re.sub(r"\n\n\[Attached files: [^\]]+\]$", "", str(text or "")).strip()
2955
+
2956
+
2861
2957
  def title_from(messages, fallback: str='Untitled'):
2862
2958
  """Derive a session title from the first user message."""
2863
2959
  for m in messages:
@@ -2865,7 +2961,7 @@ def title_from(messages, fallback: str='Untitled'):
2865
2961
  c = m.get('content', '')
2866
2962
  if isinstance(c, list):
2867
2963
  c = ' '.join(p.get('text', '') for p in c if isinstance(p, dict) and p.get('type') == 'text')
2868
- text = str(c).strip()
2964
+ text = _strip_attached_files_marker(str(c))
2869
2965
  if text:
2870
2966
  return text[:64]
2871
2967
  return fallback
@@ -3810,6 +3906,29 @@ def _session_message_merge_key(msg: dict):
3810
3906
  )
3811
3907
 
3812
3908
 
3909
+ def _session_message_dedup_key(msg: dict):
3910
+ """Like _session_message_merge_key but preserves full-precision timestamp.
3911
+
3912
+ Two messages are true duplicates only if role, content, AND exact
3913
+ timestamp all match. Sub-second timestamp differences indicate
3914
+ legitimately distinct messages (e.g. two assistant turns within the
3915
+ same wall-clock second).
3916
+ """
3917
+ if not isinstance(msg, dict):
3918
+ return ("non_dict", repr(msg))
3919
+ message_identity = msg.get("id") or msg.get("message_id")
3920
+ if message_identity:
3921
+ return ("message_id", str(message_identity))
3922
+ return (
3923
+ "legacy",
3924
+ str(msg.get("role") or ""),
3925
+ str(msg.get("content") or ""),
3926
+ str(msg.get("timestamp") or ""),
3927
+ str(msg.get("tool_call_id") or ""),
3928
+ str(msg.get("tool_name") or msg.get("name") or ""),
3929
+ )
3930
+
3931
+
3813
3932
  def _normalized_session_message_content(msg: dict) -> str:
3814
3933
  if not isinstance(msg, dict):
3815
3934
  return repr(msg)
@@ -3947,17 +4066,34 @@ def merge_session_messages_append_only(
3947
4066
  return sidecar_messages
3948
4067
  if not sidecar_messages:
3949
4068
  if watermark_timestamp is not None:
3950
- return [
4069
+ filtered = [
3951
4070
  msg for msg in state_messages
3952
4071
  if (
3953
4072
  (timestamp := _message_timestamp_as_float(msg)) is not None
3954
4073
  and timestamp <= watermark_timestamp
3955
4074
  )
3956
4075
  ]
3957
- return state_messages
4076
+ else:
4077
+ filtered = state_messages
4078
+ # Deduplicate true duplicates (same role, content, exact timestamp)
4079
+ # without collapsing legitimately-repeated identical turns (#3346).
4080
+ # Note: rows whose timestamps were mutated by compaction/recovery to
4081
+ # microsecond-different values will not be folded — only byte-identical
4082
+ # timestamps are treated as the same message. This is intentional;
4083
+ # collapsing same-second distinct turns would be worse than retaining
4084
+ # a compaction-restamped duplicate.
4085
+ seen = set()
4086
+ deduped = []
4087
+ for msg in filtered:
4088
+ key = _session_message_dedup_key(msg)
4089
+ if key not in seen:
4090
+ seen.add(key)
4091
+ deduped.append(msg)
4092
+ return deduped
3958
4093
 
3959
4094
  merged_messages = []
3960
4095
  seen_message_keys = set()
4096
+ seen_dedup_keys = set()
3961
4097
  seen_content_keys = set()
3962
4098
  seen_visible_keys = set()
3963
4099
  sidecar_visible_sequence = []
@@ -3970,6 +4106,7 @@ def merge_session_messages_append_only(
3970
4106
  max_sidecar_timestamp = timestamp if max_sidecar_timestamp is None else max(max_sidecar_timestamp, timestamp)
3971
4107
  key = _session_message_merge_key(msg)
3972
4108
  seen_message_keys.add(key)
4109
+ seen_dedup_keys.add(_session_message_dedup_key(msg))
3973
4110
  seen_content_keys.add(_session_message_content_key(msg))
3974
4111
  visible_key = _session_message_visible_key(msg)
3975
4112
  seen_visible_keys.add(visible_key)
@@ -4002,20 +4139,65 @@ def merge_session_messages_append_only(
4002
4139
  skipped_state_visible_counts[matched_visible_key] = (
4003
4140
  skipped_state_visible_counts.get(matched_visible_key, 0) + 1
4004
4141
  )
4142
+ # Record dedup key so later duplicates of this replayed message
4143
+ # are caught by the dedup guard (#3346).
4144
+ seen_dedup_keys.add(_session_message_dedup_key(msg))
4005
4145
  continue
4146
+ # Skip rows ABOVE the watermark only while the sidecar has NOT advanced
4147
+ # past the watermark. Because Session.save() no longer auto-clears the
4148
+ # watermark, an unconditional `timestamp > watermark` skip would become
4149
+ # permanent and silently drop legitimate future state.db-only recovery
4150
+ # rows once the session moves forward past the edit boundary. Once the
4151
+ # sidecar's own max timestamp is beyond the watermark (the session has
4152
+ # advanced), allow state rows newer than the sidecar tail to merge.
4153
+ sidecar_advanced_past_watermark = (
4154
+ watermark_timestamp is not None
4155
+ and max_sidecar_timestamp is not None
4156
+ and max_sidecar_timestamp > watermark_timestamp
4157
+ )
4006
4158
  if (
4007
4159
  watermark_timestamp is not None
4008
4160
  and timestamp is not None
4009
4161
  and timestamp > watermark_timestamp
4010
4162
  and key not in seen_message_keys
4163
+ and (
4164
+ not sidecar_advanced_past_watermark
4165
+ or (max_sidecar_timestamp is not None and timestamp <= max_sidecar_timestamp)
4166
+ )
4167
+ ):
4168
+ continue
4169
+ # When a truncation watermark is active, state.db may contain original
4170
+ # messages that were replaced by Edit (old content with old timestamp).
4171
+ # The timestamp-based filter above catches messages AFTER the watermark,
4172
+ # but messages BEFORE it (like the original pre-edit content) slip through.
4173
+ # If a state.db message's content is not present in the sidecar and its
4174
+ # timestamp is before the watermark, it's a replaced/stale row — skip it.
4175
+ if (
4176
+ watermark_timestamp is not None
4177
+ and timestamp is not None
4178
+ and timestamp < watermark_timestamp
4179
+ and key not in seen_message_keys
4180
+ and _session_message_content_key(msg) not in seen_content_keys
4011
4181
  ):
4012
4182
  continue
4183
+ # Check for true duplicates using full-precision timestamp (#3346).
4184
+ # Must run before the merge-key guards so that legitimately distinct
4185
+ # sub-second messages with the same second-level merge key are not
4186
+ # collapsed. The merge key truncates to seconds; the dedup key does
4187
+ # not.
4188
+ dedup_key = _session_message_dedup_key(msg)
4189
+ if dedup_key in seen_dedup_keys:
4190
+ continue
4013
4191
  if max_sidecar_timestamp is not None and timestamp is not None and timestamp <= max_sidecar_timestamp:
4014
- if key in seen_message_keys:
4192
+ # For message_id keys the merge key is authoritative — skip if
4193
+ # already seen. For legacy keys the dedup check above already
4194
+ # handled true duplicates; same-second distinct messages must
4195
+ # fall through.
4196
+ if key in seen_message_keys and key[0] == "message_id":
4015
4197
  continue
4016
4198
  if not (isinstance(key, tuple) and key[:1] == ("message_id",)):
4017
4199
  continue
4018
- if key in seen_message_keys:
4200
+ if key in seen_message_keys and key[0] == "message_id":
4019
4201
  continue
4020
4202
  matched_visible_key = _matching_visible_duplicate(
4021
4203
  visible_key,
@@ -4045,8 +4227,8 @@ def merge_session_messages_append_only(
4045
4227
  and timestamp <= max_sidecar_timestamp
4046
4228
  ):
4047
4229
  continue
4048
- if key[0] == "message_id":
4049
- seen_message_keys.add(key)
4230
+ seen_message_keys.add(key)
4231
+ seen_dedup_keys.add(dedup_key)
4050
4232
  seen_content_keys.add(_session_message_content_key(msg))
4051
4233
  seen_visible_keys.add(visible_key)
4052
4234
  merged_messages.append(msg)
@@ -0,0 +1,77 @@
1
+ """Shared path helpers for Hermes WebUI.
2
+
3
+ Keep low-level filesystem defaults here instead of in ``api.config`` so modules
4
+ that need the default Hermes home can import them without triggering config's
5
+ larger startup side effects.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ HOME = Path.home()
12
+
13
+
14
+ def _hermes_home_has_webui_state(base: Path) -> bool:
15
+ """Return True when *base* holds real WebUI state under its ``webui/`` dir.
16
+
17
+ Used only on Windows to detect a pre-v0.51.134 install at the legacy
18
+ ``%USERPROFILE%\\.hermes`` location so we don't strand the user's existing
19
+ sessions/pins/settings when the default moved to ``%LOCALAPPDATA%\\hermes``
20
+ (#2905).
21
+
22
+ We intentionally check ONLY WebUI-owned artifacts (the ``webui/`` subtree),
23
+ NOT agent-owned files like ``config.yaml`` / ``auth.json``. The agent has
24
+ defaulted to ``%LOCALAPPDATA%\\hermes`` on Windows since before #2897, so a
25
+ long-time agent user who never ran WebUI at the legacy location would have a
26
+ stray ``auth.json`` there — keying on that would wrongly divert a *fresh*
27
+ WebUI install to the legacy dir. Only ``webui/`` state is what actually
28
+ gets stranded by the move, so it is the correct and narrow signal.
29
+ Cheap stat-only checks; never raises.
30
+ """
31
+ try:
32
+ if not base.is_dir():
33
+ return False
34
+ markers = (
35
+ base / "webui" / "sessions", # WebUI session store
36
+ base / "webui" / "settings.json", # WebUI UI settings + pins
37
+ base / "webui", # WebUI state dir at all
38
+ )
39
+ return any(m.exists() for m in markers)
40
+ except OSError:
41
+ return False
42
+
43
+
44
+ def _platform_default_hermes_home() -> Path:
45
+ """Return the platform-aware default Hermes home when HERMES_HOME is unset.
46
+
47
+ Native Windows Hermes Agent installs default to %LOCALAPPDATA%\\hermes,
48
+ while POSIX installs use ~/.hermes.
49
+
50
+ Windows migration safety (#2905): v0.51.134 moved the Windows default from
51
+ ``%USERPROFILE%\\.hermes`` to ``%LOCALAPPDATA%\\hermes`` to match the agent.
52
+ Upgrading users whose WebUI state still lives at the old location saw an
53
+ empty app (sessions/pins/settings "lost" — actually just at an address the
54
+ new build no longer reads). To avoid stranding that data, prefer the
55
+ legacy ``%USERPROFILE%\\.hermes`` ONLY when it is populated AND the new
56
+ ``%LOCALAPPDATA%\\hermes`` location is not yet established. This is a
57
+ non-destructive, self-healing fallback: no files are moved, and once the
58
+ new location has state (fresh installs, or users who set HERMES_HOME) the
59
+ legacy path is never preferred. Explicit HERMES_HOME / HERMES_WEBUI_STATE_DIR
60
+ overrides take precedence upstream and are unaffected.
61
+ """
62
+ if os.name == "nt":
63
+ local_app_data = os.getenv("LOCALAPPDATA", "").strip()
64
+ if local_app_data:
65
+ new_home = Path(local_app_data) / "hermes"
66
+ legacy_home = HOME / ".hermes"
67
+ # Only fall back to the legacy home if it actually holds state and
68
+ # the new location has not been established yet — the exact
69
+ # post-upgrade fingerprint from #2905.
70
+ if (
71
+ legacy_home != new_home
72
+ and not _hermes_home_has_webui_state(new_home)
73
+ and _hermes_home_has_webui_state(legacy_home)
74
+ ):
75
+ return legacy_home
76
+ return new_home
77
+ return HOME / ".hermes"
@@ -0,0 +1,185 @@
1
+ """
2
+ Plugin discovery and static serving for Hermes Web UI.
3
+
4
+ Scans ~/.hermes/plugins/<name>/dashboard/ for manifest.json files,
5
+ matching the official Hermes dashboard plugin format.
6
+
7
+ Each plugin may have:
8
+ dashboard/
9
+ manifest.json -- tab definition and entry point
10
+ dist/
11
+ index.js -- plugin JS bundle (IIFE)
12
+ style.css -- optional plugin stylesheet
13
+ plugin_api.py -- optional backend API (not used in WebUI MVP)
14
+ """
15
+ import json
16
+ import logging
17
+ import os
18
+ import re
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Valid dashboard-plugin name: a safe slug (it becomes a URL path component and
24
+ # a settings key). Lowercase alnum + - / _, 1-64 chars, must start with a letter.
25
+ _VALID_PLUGIN_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
26
+
27
+ # Valid tab.path: a clean same-origin absolute path. Must start with a single
28
+ # '/' (NOT '//' — a leading '//' is a protocol-relative URL that would resolve
29
+ # to a remote origin when assigned to iframe.src), then only safe path chars —
30
+ # no quotes, whitespace, control chars, query ('?') or fragment ('#').
31
+ _VALID_PLUGIN_TAB_PATH = re.compile(r"^/(?!/)[A-Za-z0-9._~/-]{0,255}$")
32
+
33
+ # plugin_name -> manifest dict (as loaded from manifest.json)
34
+ PLUGIN_MANIFESTS: dict[str, dict] = {}
35
+
36
+ # plugin_name -> resolved static root dir
37
+ _PLUGIN_STATIC_ROOTS: dict[str, Path] = {}
38
+
39
+
40
+ def _get_plugin_base() -> Path:
41
+ return Path(os.environ.get("HERMES_WEBUI_PLUGINS_DIR", str(Path.home() / ".hermes" / "plugins")))
42
+
43
+
44
+ def load_plugins() -> None:
45
+ """Scan plugin directories and load manifest.json for each dashboard plugin."""
46
+ plugin_base = _get_plugin_base()
47
+ if not plugin_base.is_dir():
48
+ logger.debug("No plugins directory at %s", plugin_base)
49
+ return
50
+
51
+ for entry in sorted(plugin_base.iterdir()):
52
+ if not entry.is_dir():
53
+ continue
54
+ manifest_path = entry / "dashboard" / "manifest.json"
55
+ if not manifest_path.is_file():
56
+ continue
57
+
58
+ try:
59
+ manifest = json.loads(manifest_path.read_text())
60
+ except Exception:
61
+ logger.exception("Failed to parse manifest for plugin %s", entry.name)
62
+ continue
63
+
64
+ name = manifest.get("name") or entry.name
65
+
66
+ # Validate the plugin name: it becomes a URL path component
67
+ # (/dashboard-plugins/<name>/...) and a settings key. Restrict to a safe
68
+ # slug so a manifest like name:"../foo" can't make the URL-space ambiguous.
69
+ if not _VALID_PLUGIN_NAME.match(str(name)):
70
+ logger.warning("Skipping plugin with invalid name %r (must match %s)", name, _VALID_PLUGIN_NAME.pattern)
71
+ continue
72
+
73
+ tab = manifest.get("tab", {})
74
+ tab_path = tab.get("path", f"/{name}")
75
+
76
+ # Validate tab.path: it's a same-origin route the plugin page is served
77
+ # at AND a value passed into client-side navigation. Require a clean
78
+ # absolute path — no quotes/control chars/query/fragment — so a hostile
79
+ # manifest can't shadow odd routes or inject via the path.
80
+ if not _VALID_PLUGIN_TAB_PATH.match(str(tab_path)):
81
+ logger.warning("Skipping plugin %s with invalid tab.path %r (must match %s)", name, tab_path, _VALID_PLUGIN_TAB_PATH.pattern)
82
+ continue
83
+
84
+ if name in PLUGIN_MANIFESTS:
85
+ logger.warning("Duplicate plugin name skipped: %s (already loaded)", name)
86
+ continue
87
+ if tab_path in (m.get("tab", {}).get("path") for m in PLUGIN_MANIFESTS.values()):
88
+ logger.warning("Plugin %s tab.path %r conflicts with another plugin; skipped", name, tab_path)
89
+ continue
90
+
91
+ PLUGIN_MANIFESTS[name] = manifest
92
+ logger.info("Loaded dashboard plugin: %s (label=%s)", name, manifest.get("label", ""))
93
+
94
+ # Pre-compute static root for fast serving (points to dashboard/)
95
+ dashboard_dir = entry / "dashboard"
96
+ if dashboard_dir.is_dir():
97
+ _PLUGIN_STATIC_ROOTS[name] = dashboard_dir.resolve()
98
+
99
+
100
+ def serve_plugin_static(plugin_name: str, rel_path: str) -> tuple[bytes, str] | None:
101
+ """
102
+ Serve a built static asset from a plugin's dashboard/dist/ (or static/) dir.
103
+
104
+ Returns (file_bytes, content_type) on success, None on not found.
105
+
106
+ Security: _PLUGIN_STATIC_ROOTS points at the plugin's whole dashboard/ dir
107
+ (the page route needs that), but the asset route must NOT expose plugin
108
+ source/config — e.g. dashboard/plugin_api.py, manifest.json, .env. So we
109
+ constrain served files to the built-asset subtrees (dist/ or static/), reject
110
+ dotfiles, and require a known static extension.
111
+ """
112
+ root = _PLUGIN_STATIC_ROOTS.get(plugin_name)
113
+ if not root:
114
+ return None
115
+
116
+ safe = (root / rel_path.lstrip("/")).resolve()
117
+ try:
118
+ safe.relative_to(root)
119
+ except ValueError:
120
+ return None # path traversal attempt
121
+
122
+ # Only built-asset subtrees are servable (not the dashboard root itself,
123
+ # which holds plugin_api.py / manifest.json / config).
124
+ rel = safe.relative_to(root)
125
+ if not rel.parts or rel.parts[0] not in ("dist", "static"):
126
+ return None
127
+ # No dotfiles (.env, .git, etc.) anywhere in the path.
128
+ if any(part.startswith(".") for part in rel.parts):
129
+ return None
130
+
131
+ if not safe.is_file():
132
+ return None
133
+
134
+ # Allowlist of static asset extensions — refuse source/config (.py, .json,
135
+ # .toml, .env, .sh, ...) even if somehow placed under dist/.
136
+ ext = os.path.splitext(rel_path.lower())[1]
137
+ _STATIC_EXTS = {
138
+ ".js", ".css", ".html", ".png", ".jpg", ".jpeg", ".gif", ".svg",
139
+ ".ico", ".webp", ".woff", ".woff2", ".ttf", ".otf", ".map", ".txt",
140
+ }
141
+ if ext not in _STATIC_EXTS:
142
+ return None
143
+
144
+ data = safe.read_bytes()
145
+ content_type = {
146
+ ".js": "application/javascript; charset=utf-8",
147
+ ".css": "text/css; charset=utf-8",
148
+ ".html": "text/html; charset=utf-8",
149
+ ".json": "application/json; charset=utf-8",
150
+ ".png": "image/png",
151
+ ".svg": "image/svg+xml",
152
+ ".ico": "image/x-icon",
153
+ }.get(ext, "application/octet-stream")
154
+
155
+ return data, content_type
156
+
157
+
158
+ def get_plugin_metadata() -> list[dict]:
159
+ """
160
+ Return a list of plugin metadata suitable for the Settings → Plugins tab.
161
+ Each entry includes name, key, version, description, and tab info for linking.
162
+
163
+ Per-plugin enabled state is stored in settings.json under `dashboard_plugins`.
164
+ A plugin is enabled only if the user has explicitly toggled it on (default off).
165
+ """
166
+ from api.config import load_settings
167
+
168
+ plugin_settings = load_settings().get("dashboard_plugins", {})
169
+ plugins = []
170
+ for name, manifest in sorted(PLUGIN_MANIFESTS.items()):
171
+ tab = manifest.get("tab", {})
172
+ path = tab.get("path", f"/{name}")
173
+ plugins.append({
174
+ "name": manifest.get("label") or manifest.get("name") or name,
175
+ "key": name,
176
+ "version": manifest.get("version", "0.0.0"),
177
+ "description": manifest.get("description", ""),
178
+ "tab": {
179
+ "path": path,
180
+ "label": tab.get("label") or manifest.get("label") or name,
181
+ },
182
+ "enabled": bool(plugin_settings.get(name, False)),
183
+ "hooks": [],
184
+ })
185
+ return plugins