@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.
- package/package.json +2 -2
- package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
- package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
- package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +145 -104
- package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
- package/vendor/agent-frontend-shell/api/helpers.py +4 -2
- package/vendor/agent-frontend-shell/api/models.py +202 -20
- package/vendor/agent-frontend-shell/api/paths.py +77 -0
- package/vendor/agent-frontend-shell/api/plugins.py +185 -0
- package/vendor/agent-frontend-shell/api/profiles.py +95 -16
- package/vendor/agent-frontend-shell/api/routes.py +831 -30
- package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
- package/vendor/agent-frontend-shell/api/streaming.py +211 -56
- package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
- package/vendor/agent-frontend-shell/api/updates.py +30 -3
- package/vendor/agent-frontend-shell/api/upload.py +251 -18
- package/vendor/agent-frontend-shell/api/workspace.py +323 -65
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/build-release.sh +62 -0
- package/vendor/agent-frontend-shell/ctl.sh +1 -0
- package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
- package/vendor/agent-frontend-shell/docker_init.bash +1 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
- package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
- package/vendor/agent-frontend-shell/readme-simple.md +103 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +7 -0
- package/vendor/agent-frontend-shell/static/boot.js +53 -1
- package/vendor/agent-frontend-shell/static/commands.js +20 -10
- package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
- package/vendor/agent-frontend-shell/static/index.html +13 -3
- package/vendor/agent-frontend-shell/static/messages.js +48 -3
- package/vendor/agent-frontend-shell/static/panels.js +199 -30
- package/vendor/agent-frontend-shell/static/sessions.js +249 -39
- package/vendor/agent-frontend-shell/static/style.css +46 -2
- package/vendor/agent-frontend-shell/static/ui.js +323 -79
- package/vendor/agent-frontend-shell/static/workspace.js +185 -7
- package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
- 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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4049
|
-
|
|
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
|