@bitseek/hermes-webui 0.1.0-beta.0
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/README.md +213 -0
- package/bin/hermes-webui.mjs +588 -0
- package/package.json +25 -0
- package/scripts/sync-vendor.mjs +74 -0
- package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
- package/templates/systemd/hermes-webui.service +13 -0
- package/templates/windows/hermes-webui-task.ps1 +3 -0
- package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
- package/vendor/agent-frontend-shell/.dockerignore +7 -0
- package/vendor/agent-frontend-shell/.env.docker.example +89 -0
- package/vendor/agent-frontend-shell/.env.example +34 -0
- package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
- package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
- package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
- package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
- package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
- package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
- package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
- package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
- package/vendor/agent-frontend-shell/AGENTS.md +80 -0
- package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
- package/vendor/agent-frontend-shell/BUGS.md +52 -0
- package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
- package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
- package/vendor/agent-frontend-shell/DESIGN.md +173 -0
- package/vendor/agent-frontend-shell/Dockerfile +91 -0
- package/vendor/agent-frontend-shell/LICENSE +21 -0
- package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
- package/vendor/agent-frontend-shell/README.md +705 -0
- package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
- package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
- package/vendor/agent-frontend-shell/TESTING.md +1932 -0
- package/vendor/agent-frontend-shell/THEMES.md +170 -0
- package/vendor/agent-frontend-shell/api/__init__.py +1 -0
- package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
- package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
- package/vendor/agent-frontend-shell/api/auth.py +592 -0
- package/vendor/agent-frontend-shell/api/background.py +87 -0
- package/vendor/agent-frontend-shell/api/clarify.py +238 -0
- package/vendor/agent-frontend-shell/api/commands.py +124 -0
- package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +5178 -0
- package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
- package/vendor/agent-frontend-shell/api/extensions.py +253 -0
- package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
- package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
- package/vendor/agent-frontend-shell/api/goals.py +608 -0
- package/vendor/agent-frontend-shell/api/helpers.py +474 -0
- package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
- package/vendor/agent-frontend-shell/api/metering.py +194 -0
- package/vendor/agent-frontend-shell/api/models.py +4210 -0
- package/vendor/agent-frontend-shell/api/oauth.py +770 -0
- package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
- package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
- package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
- package/vendor/agent-frontend-shell/api/providers.py +2175 -0
- package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
- package/vendor/agent-frontend-shell/api/rollback.py +320 -0
- package/vendor/agent-frontend-shell/api/routes.py +13990 -0
- package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
- package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
- package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
- package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
- package/vendor/agent-frontend-shell/api/session_events.py +45 -0
- package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
- package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
- package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
- package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
- package/vendor/agent-frontend-shell/api/startup.py +128 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
- package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
- package/vendor/agent-frontend-shell/api/system_health.py +167 -0
- package/vendor/agent-frontend-shell/api/terminal.py +410 -0
- package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
- package/vendor/agent-frontend-shell/api/updates.py +1261 -0
- package/vendor/agent-frontend-shell/api/upload.py +322 -0
- package/vendor/agent-frontend-shell/api/usage.py +26 -0
- package/vendor/agent-frontend-shell/api/workspace.py +867 -0
- package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
- package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
- package/vendor/agent-frontend-shell/bootstrap.py +492 -0
- package/vendor/agent-frontend-shell/ctl.sh +427 -0
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
- package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
- package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
- package/vendor/agent-frontend-shell/docker_init.bash +459 -0
- package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
- package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
- package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
- package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
- package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
- package/vendor/agent-frontend-shell/docs/docker.md +337 -0
- package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
- package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
- package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
- package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
- package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
- package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
- package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
- package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
- package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
- package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
- package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
- package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
- package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
- package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
- package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
- package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
- package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
- package/vendor/agent-frontend-shell/extensions/index.js +67 -0
- package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
- package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
- package/vendor/agent-frontend-shell/install.sh +63 -0
- package/vendor/agent-frontend-shell/mcp_server.py +567 -0
- package/vendor/agent-frontend-shell/package.json +12 -0
- package/vendor/agent-frontend-shell/pyproject.toml +56 -0
- package/vendor/agent-frontend-shell/pytest.ini +3 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +624 -0
- package/vendor/agent-frontend-shell/start.ps1 +210 -0
- package/vendor/agent-frontend-shell/start.sh +65 -0
- package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/static/boot.js +1990 -0
- package/vendor/agent-frontend-shell/static/commands.js +1402 -0
- package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
- package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
- package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
- package/vendor/agent-frontend-shell/static/icons.js +92 -0
- package/vendor/agent-frontend-shell/static/index.html +1506 -0
- package/vendor/agent-frontend-shell/static/login.js +177 -0
- package/vendor/agent-frontend-shell/static/manifest.json +53 -0
- package/vendor/agent-frontend-shell/static/messages.js +3521 -0
- package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
- package/vendor/agent-frontend-shell/static/panels.js +7995 -0
- package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
- package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
- package/vendor/agent-frontend-shell/static/style.css +4774 -0
- package/vendor/agent-frontend-shell/static/sw.js +173 -0
- package/vendor/agent-frontend-shell/static/terminal.js +632 -0
- package/vendor/agent-frontend-shell/static/ui.js +8997 -0
- package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
- package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
- package/vendor/agent-frontend-shell/static/workspace.js +680 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""Shared helpers for reading Hermes Agent sessions from state.db."""
|
|
2
|
+
import logging
|
|
3
|
+
import sqlite3
|
|
4
|
+
from contextlib import closing
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MESSAGING_SOURCES = {
|
|
11
|
+
'discord',
|
|
12
|
+
'email',
|
|
13
|
+
'slack',
|
|
14
|
+
'telegram',
|
|
15
|
+
'weixin',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
CLI_MIN_UNTITLED_MESSAGE_COUNT = 6
|
|
19
|
+
CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2
|
|
20
|
+
|
|
21
|
+
SOURCE_LABELS = {
|
|
22
|
+
'api_server': 'API',
|
|
23
|
+
'cli': 'CLI',
|
|
24
|
+
'cron': 'Cron',
|
|
25
|
+
'discord': 'Discord',
|
|
26
|
+
'email': 'Email',
|
|
27
|
+
'slack': 'Slack',
|
|
28
|
+
'telegram': 'Telegram',
|
|
29
|
+
'tool': 'Tool',
|
|
30
|
+
'webui': 'WebUI',
|
|
31
|
+
'weixin': 'Weixin',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_agent_session_source(raw_source: str | None) -> dict:
|
|
36
|
+
"""Return stable source metadata for Hermes Agent session rows.
|
|
37
|
+
|
|
38
|
+
``sessions.source`` is an Agent-level raw value. WebUI needs a smaller,
|
|
39
|
+
durable contract so routes, SSE snapshots, and future sidebar policies do
|
|
40
|
+
not each reimplement raw-source checks.
|
|
41
|
+
"""
|
|
42
|
+
raw = str(raw_source or '').strip().lower() or 'unknown'
|
|
43
|
+
|
|
44
|
+
if raw == 'webui':
|
|
45
|
+
session_source = 'webui'
|
|
46
|
+
elif raw == 'cli':
|
|
47
|
+
session_source = 'cli'
|
|
48
|
+
elif raw in MESSAGING_SOURCES:
|
|
49
|
+
session_source = 'messaging'
|
|
50
|
+
elif raw == 'cron':
|
|
51
|
+
session_source = 'cron'
|
|
52
|
+
elif raw == 'tool':
|
|
53
|
+
session_source = 'tool'
|
|
54
|
+
elif raw == 'api_server':
|
|
55
|
+
session_source = 'api'
|
|
56
|
+
else:
|
|
57
|
+
session_source = 'other'
|
|
58
|
+
|
|
59
|
+
label = SOURCE_LABELS.get(raw)
|
|
60
|
+
if not label:
|
|
61
|
+
label = raw.replace('_', ' ').title() if raw != 'unknown' else 'Agent'
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
'raw_source': None if raw == 'unknown' else raw,
|
|
65
|
+
'session_source': session_source,
|
|
66
|
+
'source_label': label,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _with_normalized_source(row: dict) -> dict:
|
|
71
|
+
normalized = normalize_agent_session_source(row.get('source'))
|
|
72
|
+
return {**row, **normalized}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str:
|
|
76
|
+
return f"s.{name}" if name in columns else f"{fallback} AS {name}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe_lower(value) -> str:
|
|
80
|
+
return str(value or "").strip().lower()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _normalize_source_name(value: object) -> str:
|
|
84
|
+
source = _safe_lower(value)
|
|
85
|
+
if not source:
|
|
86
|
+
return ""
|
|
87
|
+
if source.endswith(" session"):
|
|
88
|
+
source = source[:-len(" session")].strip()
|
|
89
|
+
return source
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _looks_like_default_cli_title(row: dict) -> bool:
|
|
93
|
+
"""Return True when a CLI row looks like framework-generated metadata."""
|
|
94
|
+
title = _safe_lower(row.get("title"))
|
|
95
|
+
if not title or title == "untitled":
|
|
96
|
+
return True
|
|
97
|
+
if title in {"cli", "cli session"}:
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
source_candidates = {
|
|
101
|
+
_normalize_source_name(row.get("source")),
|
|
102
|
+
_normalize_source_name(row.get("session_source")),
|
|
103
|
+
_normalize_source_name(row.get("source_tag")),
|
|
104
|
+
_normalize_source_name(row.get("raw_source")),
|
|
105
|
+
_normalize_source_name(row.get("source_label")),
|
|
106
|
+
}
|
|
107
|
+
source_candidates.discard("")
|
|
108
|
+
source_candidates.add("cli")
|
|
109
|
+
return any(title == f"{candidate} session" for candidate in source_candidates)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _as_positive_int(value) -> int:
|
|
113
|
+
try:
|
|
114
|
+
return max(0, int(float(value)))
|
|
115
|
+
except (TypeError, ValueError):
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _count_user_turns(row: dict) -> int:
|
|
120
|
+
user_turns = row.get("actual_user_message_count")
|
|
121
|
+
if user_turns is None:
|
|
122
|
+
user_turns = row.get("user_message_count")
|
|
123
|
+
if user_turns is None:
|
|
124
|
+
messages = row.get("messages") or []
|
|
125
|
+
if isinstance(messages, list):
|
|
126
|
+
return sum(
|
|
127
|
+
1
|
|
128
|
+
for msg in messages
|
|
129
|
+
if _safe_lower(msg.get("role") if isinstance(msg, dict) else msg) == "user"
|
|
130
|
+
)
|
|
131
|
+
return 0
|
|
132
|
+
return _as_positive_int(user_turns)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _has_cli_lineage(row: dict) -> bool:
|
|
136
|
+
segment_count = _as_positive_int(row.get("_compression_segment_count"))
|
|
137
|
+
return segment_count > 1 or bool(row.get("_lineage_root_id"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_cli_session_row(row: dict) -> bool:
|
|
141
|
+
"""Return True for rows that should be treated as CLI-imported sessions."""
|
|
142
|
+
if not isinstance(row, dict):
|
|
143
|
+
return False
|
|
144
|
+
source = _safe_lower(row.get("session_source"))
|
|
145
|
+
source_tag = _safe_lower(row.get("source_tag"))
|
|
146
|
+
raw_source = _safe_lower(row.get("raw_source"))
|
|
147
|
+
source_name = _safe_lower(row.get("source"))
|
|
148
|
+
source_label = _safe_lower(row.get("source_label"))
|
|
149
|
+
if "webui" in {source, source_tag, raw_source, source_name, source_label}:
|
|
150
|
+
return False
|
|
151
|
+
if source == "messaging":
|
|
152
|
+
return False
|
|
153
|
+
if source == "cli":
|
|
154
|
+
return True
|
|
155
|
+
if source_tag == "cli" or raw_source == "cli" or source_name == "cli" or source_label == "cli":
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
# Legacy imported CLI rows may only be marked as CLI in sidebar metadata.
|
|
159
|
+
# Keep this conservative to avoid treating messaging sessions as CLI.
|
|
160
|
+
return bool(
|
|
161
|
+
row.get("is_cli_session")
|
|
162
|
+
and source not in MESSAGING_SOURCES
|
|
163
|
+
and source_tag not in MESSAGING_SOURCES
|
|
164
|
+
and raw_source not in MESSAGING_SOURCES
|
|
165
|
+
and source_name not in MESSAGING_SOURCES
|
|
166
|
+
and _looks_like_default_cli_title(row)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def is_cli_session_row_visible(row: dict) -> bool:
|
|
171
|
+
"""Return whether a CLI-related row should remain visible in the sidebar."""
|
|
172
|
+
if not isinstance(row, dict):
|
|
173
|
+
return False
|
|
174
|
+
if not is_cli_session_row(row):
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
message_count = _as_positive_int(row.get("actual_message_count") or row.get("message_count"))
|
|
178
|
+
if message_count <= 0:
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
if _has_cli_lineage(row):
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
if not _looks_like_default_cli_title(row):
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
|
191
|
+
"""Return True when ``child`` is the next segment of the same conversation.
|
|
192
|
+
|
|
193
|
+
Compression rotates session ids automatically. A manual CLI close followed
|
|
194
|
+
by ``hermes -c`` also records a new child session; for sidebar projection it
|
|
195
|
+
should continue the same visible conversation rather than becoming a
|
|
196
|
+
separate child-session row. Plain parent/child links that started before the
|
|
197
|
+
parent's ended boundary remain child sessions.
|
|
198
|
+
|
|
199
|
+
Do not collapse lineage across raw sources. A WebUI session that continues
|
|
200
|
+
from a Telegram/CLI/etc. parent must remain visible as its own surface-owned
|
|
201
|
+
conversation; otherwise the tip inherits the root's title/source metadata and
|
|
202
|
+
can disappear under messaging/sidebar policies.
|
|
203
|
+
"""
|
|
204
|
+
if not parent or not child:
|
|
205
|
+
return False
|
|
206
|
+
if str(child.get('session_source') or '').strip().lower() == 'fork':
|
|
207
|
+
return False
|
|
208
|
+
parent_source = str(parent.get('source') or '').strip().lower()
|
|
209
|
+
child_source = str(child.get('source') or '').strip().lower()
|
|
210
|
+
if parent_source and child_source and parent_source != child_source:
|
|
211
|
+
return False
|
|
212
|
+
if parent.get('end_reason') not in {'compression', 'cli_close'}:
|
|
213
|
+
return False
|
|
214
|
+
ended_at = parent.get('ended_at')
|
|
215
|
+
if ended_at is None:
|
|
216
|
+
# Older state.db rows/tests may not have ended_at populated. Preserve
|
|
217
|
+
# the historical contract that compression/cli_close parent links are
|
|
218
|
+
# continuations when no boundary timestamp is available.
|
|
219
|
+
return True
|
|
220
|
+
try:
|
|
221
|
+
return float(child.get('started_at') or 0) >= float(ended_at)
|
|
222
|
+
except (TypeError, ValueError):
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _continuation_root_id(rows_by_id: dict[str, dict], session_id: str | None) -> str | None:
|
|
227
|
+
"""Return the visible lineage root for ``session_id`` by walking continuations."""
|
|
228
|
+
if not session_id:
|
|
229
|
+
return None
|
|
230
|
+
root_id = str(session_id)
|
|
231
|
+
current_id = root_id
|
|
232
|
+
seen = {current_id}
|
|
233
|
+
for _ in range(len(rows_by_id) + 1):
|
|
234
|
+
current = rows_by_id.get(current_id)
|
|
235
|
+
parent_id = current.get('parent_session_id') if current else None
|
|
236
|
+
parent = rows_by_id.get(parent_id) if parent_id else None
|
|
237
|
+
if not parent or not _is_continuation_session(parent, current):
|
|
238
|
+
return root_id
|
|
239
|
+
if parent_id in seen:
|
|
240
|
+
return root_id
|
|
241
|
+
root_id = str(parent_id)
|
|
242
|
+
current_id = str(parent_id)
|
|
243
|
+
seen.add(current_id)
|
|
244
|
+
return root_id
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
|
248
|
+
"""Collapse compression chains into one logical sidebar row.
|
|
249
|
+
|
|
250
|
+
The visible conversation should still look like the original chain head
|
|
251
|
+
(title and timestamps), while importing should use the latest importable
|
|
252
|
+
segment so the user continues from the current compressed state.
|
|
253
|
+
"""
|
|
254
|
+
rows_by_id = {row['id']: row for row in rows}
|
|
255
|
+
children_by_parent: dict[str, list[dict]] = {}
|
|
256
|
+
continuation_child_ids = set()
|
|
257
|
+
|
|
258
|
+
for row in rows:
|
|
259
|
+
parent_id = row.get('parent_session_id')
|
|
260
|
+
if not parent_id:
|
|
261
|
+
continue
|
|
262
|
+
children_by_parent.setdefault(parent_id, []).append(row)
|
|
263
|
+
parent = rows_by_id.get(parent_id)
|
|
264
|
+
if _is_continuation_session(parent, row):
|
|
265
|
+
continuation_child_ids.add(row['id'])
|
|
266
|
+
else:
|
|
267
|
+
row['relationship_type'] = 'child_session'
|
|
268
|
+
row['parent_title'] = parent.get('title') if parent else None
|
|
269
|
+
row['parent_source'] = parent.get('source') if parent else None
|
|
270
|
+
parent_root = _continuation_root_id(rows_by_id, parent_id)
|
|
271
|
+
if parent_root:
|
|
272
|
+
row['_parent_lineage_root_id'] = parent_root
|
|
273
|
+
|
|
274
|
+
for children in children_by_parent.values():
|
|
275
|
+
children.sort(key=lambda row: row.get('started_at') or 0, reverse=True)
|
|
276
|
+
|
|
277
|
+
def compression_tip(row: dict) -> tuple[dict | None, int]:
|
|
278
|
+
current = row
|
|
279
|
+
seen = {row['id']}
|
|
280
|
+
latest_importable = row if (row.get('actual_message_count') or 0) > 0 else None
|
|
281
|
+
segment_count = 1
|
|
282
|
+
for _ in range(len(rows_by_id) + 1):
|
|
283
|
+
candidates = [
|
|
284
|
+
child for child in children_by_parent.get(current['id'], [])
|
|
285
|
+
if child['id'] not in seen and _is_continuation_session(current, child)
|
|
286
|
+
]
|
|
287
|
+
if not candidates:
|
|
288
|
+
return latest_importable, segment_count
|
|
289
|
+
current = candidates[0]
|
|
290
|
+
seen.add(current['id'])
|
|
291
|
+
segment_count += 1
|
|
292
|
+
if (current.get('actual_message_count') or 0) > 0:
|
|
293
|
+
latest_importable = current
|
|
294
|
+
return latest_importable, segment_count
|
|
295
|
+
|
|
296
|
+
projected = []
|
|
297
|
+
for row in rows:
|
|
298
|
+
if row['id'] in continuation_child_ids:
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
segment_count = 1
|
|
302
|
+
tip = row
|
|
303
|
+
if row.get('end_reason') in {'compression', 'cli_close'}:
|
|
304
|
+
tip, segment_count = compression_tip(row)
|
|
305
|
+
if not tip or (tip.get('actual_message_count') or 0) <= 0:
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
if tip is row:
|
|
309
|
+
projected.append(dict(row))
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
merged = dict(row)
|
|
313
|
+
# Keep the chain head's visible identity (title, started_at), but
|
|
314
|
+
# point the row at the latest importable segment for navigation AND
|
|
315
|
+
# surface the tip's recency so an actively-used chain bubbles to the
|
|
316
|
+
# top of the sidebar by its true last activity. Without overriding
|
|
317
|
+
# last_activity, a long-lived chain whose tip is being edited NOW
|
|
318
|
+
# would sort by the root's old timestamp and fall below recently
|
|
319
|
+
# touched standalone sessions — exactly the inverse of what a user
|
|
320
|
+
# expects from "Show agent sessions" sorted by activity.
|
|
321
|
+
for key in (
|
|
322
|
+
'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count',
|
|
323
|
+
'ended_at', 'end_reason', 'last_activity',
|
|
324
|
+
):
|
|
325
|
+
if key in tip:
|
|
326
|
+
merged[key] = tip[key]
|
|
327
|
+
if not merged.get('title'):
|
|
328
|
+
merged['title'] = tip.get('title')
|
|
329
|
+
if not merged.get('source'):
|
|
330
|
+
merged['source'] = tip.get('source')
|
|
331
|
+
merged['_lineage_root_id'] = row['id']
|
|
332
|
+
merged['_lineage_tip_id'] = tip['id']
|
|
333
|
+
merged['_compression_segment_count'] = segment_count
|
|
334
|
+
projected.append(merged)
|
|
335
|
+
|
|
336
|
+
projected.sort(
|
|
337
|
+
key=lambda row: row.get('last_activity') or row.get('started_at') or 0,
|
|
338
|
+
reverse=True,
|
|
339
|
+
)
|
|
340
|
+
return projected
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def read_importable_agent_session_rows(
|
|
344
|
+
db_path: Path,
|
|
345
|
+
limit: int = 200,
|
|
346
|
+
log=None,
|
|
347
|
+
exclude_sources: tuple[str, ...] | None = ("cron", "webui"),
|
|
348
|
+
) -> list[dict]:
|
|
349
|
+
"""Return agent sessions projected as importable conversations.
|
|
350
|
+
|
|
351
|
+
Hermes Agent can create rows in ``state.db.sessions`` before a session has
|
|
352
|
+
any messages, and long conversations can be split into compression-linked
|
|
353
|
+
rows. WebUI cannot import empty rows and should not show compression
|
|
354
|
+
segments as separate conversations, so both the regular ``/api/sessions``
|
|
355
|
+
path and the gateway SSE watcher use this shared projection.
|
|
356
|
+
|
|
357
|
+
By default, omit background/internal sources such as ``cron`` from the WebUI
|
|
358
|
+
sidebar. This mirrors Hermes Agent CLI's session-list behaviour: interactive
|
|
359
|
+
views should stay focused on user-facing conversations, while callers that
|
|
360
|
+
need a source-specific diagnostic view can opt out by passing
|
|
361
|
+
``exclude_sources=None``.
|
|
362
|
+
"""
|
|
363
|
+
db_path = Path(db_path)
|
|
364
|
+
if not db_path.exists():
|
|
365
|
+
return []
|
|
366
|
+
|
|
367
|
+
log = log or logger
|
|
368
|
+
with closing(sqlite3.connect(str(db_path))) as conn:
|
|
369
|
+
conn.row_factory = sqlite3.Row
|
|
370
|
+
cur = conn.cursor()
|
|
371
|
+
|
|
372
|
+
# Older Hermes Agent versions may not have source tracking. Without a
|
|
373
|
+
# source column we cannot safely distinguish WebUI rows from agent rows.
|
|
374
|
+
cur.execute("PRAGMA table_info(sessions)")
|
|
375
|
+
session_cols = {row[1] for row in cur.fetchall()}
|
|
376
|
+
cur.execute("PRAGMA table_info(messages)")
|
|
377
|
+
message_cols = {row[1] for row in cur.fetchall()}
|
|
378
|
+
if 'source' not in session_cols:
|
|
379
|
+
log.warning(
|
|
380
|
+
"agent session listing skipped: state.db at %s has no 'source' column "
|
|
381
|
+
"(older hermes-agent?). Agent sessions unavailable. "
|
|
382
|
+
"Upgrade hermes-agent to fix this.",
|
|
383
|
+
db_path,
|
|
384
|
+
)
|
|
385
|
+
return []
|
|
386
|
+
|
|
387
|
+
parent_expr = _optional_col('parent_session_id', session_cols)
|
|
388
|
+
session_source_expr = _optional_col('session_source', session_cols)
|
|
389
|
+
ended_expr = _optional_col('ended_at', session_cols)
|
|
390
|
+
end_reason_expr = _optional_col('end_reason', session_cols)
|
|
391
|
+
user_id_expr = _optional_col('user_id', session_cols)
|
|
392
|
+
chat_id_expr = _optional_col('chat_id', session_cols)
|
|
393
|
+
chat_type_expr = _optional_col('chat_type', session_cols)
|
|
394
|
+
thread_id_expr = _optional_col('thread_id', session_cols)
|
|
395
|
+
session_key_expr = _optional_col('session_key', session_cols)
|
|
396
|
+
origin_chat_id_expr = _optional_col('origin_chat_id', session_cols)
|
|
397
|
+
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
|
|
398
|
+
platform_expr = _optional_col('platform', session_cols)
|
|
399
|
+
user_message_count_expr = (
|
|
400
|
+
"COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END)"
|
|
401
|
+
if 'role' in message_cols
|
|
402
|
+
else "COUNT(m.id)"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
where_clauses = ["s.source IS NOT NULL"]
|
|
406
|
+
params: list[object] = []
|
|
407
|
+
if exclude_sources:
|
|
408
|
+
excluded = tuple(str(source) for source in exclude_sources if source)
|
|
409
|
+
if excluded:
|
|
410
|
+
placeholders = ", ".join("?" for _ in excluded)
|
|
411
|
+
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
|
412
|
+
params.extend(excluded)
|
|
413
|
+
|
|
414
|
+
select_sql = f"""
|
|
415
|
+
SELECT s.id, s.title, s.model, s.message_count,
|
|
416
|
+
s.started_at, s.source,
|
|
417
|
+
{session_source_expr},
|
|
418
|
+
{user_id_expr},
|
|
419
|
+
{chat_id_expr},
|
|
420
|
+
{chat_type_expr},
|
|
421
|
+
{thread_id_expr},
|
|
422
|
+
{session_key_expr},
|
|
423
|
+
{origin_chat_id_expr},
|
|
424
|
+
{origin_user_id_expr},
|
|
425
|
+
{platform_expr},
|
|
426
|
+
{parent_expr},
|
|
427
|
+
{ended_expr},
|
|
428
|
+
{end_reason_expr},
|
|
429
|
+
COUNT(m.id) AS actual_message_count,
|
|
430
|
+
{user_message_count_expr} AS actual_user_message_count,
|
|
431
|
+
MAX(m.timestamp) AS last_activity
|
|
432
|
+
"""
|
|
433
|
+
if limit is not None:
|
|
434
|
+
result_limit = max(0, int(limit))
|
|
435
|
+
if result_limit == 0:
|
|
436
|
+
return []
|
|
437
|
+
# The sidebar only needs a small visible window. Bound the expensive
|
|
438
|
+
# messages join to a recent-activity candidate set instead of
|
|
439
|
+
# aggregating every historical Hermes state.db session before
|
|
440
|
+
# slicing in Python. The candidate ordering must include the latest
|
|
441
|
+
# message timestamp, not only ``started_at``: long-lived CLI sessions
|
|
442
|
+
# can be resumed days later and should still surface at the top.
|
|
443
|
+
# Oversampling preserves room for hidden compression segments or
|
|
444
|
+
# other rows filtered after projection.
|
|
445
|
+
candidate_limit = max(result_limit * 8, result_limit)
|
|
446
|
+
cur.execute(
|
|
447
|
+
f"""
|
|
448
|
+
WITH candidates AS (
|
|
449
|
+
SELECT s.id
|
|
450
|
+
FROM sessions s
|
|
451
|
+
WHERE {' AND '.join(where_clauses)}
|
|
452
|
+
ORDER BY COALESCE(
|
|
453
|
+
(SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id),
|
|
454
|
+
s.started_at
|
|
455
|
+
) DESC,
|
|
456
|
+
s.started_at DESC
|
|
457
|
+
LIMIT ?
|
|
458
|
+
)
|
|
459
|
+
{select_sql}
|
|
460
|
+
FROM sessions s
|
|
461
|
+
JOIN candidates c ON c.id = s.id
|
|
462
|
+
LEFT JOIN messages m ON m.session_id = s.id
|
|
463
|
+
GROUP BY s.id
|
|
464
|
+
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
|
465
|
+
""",
|
|
466
|
+
[*params, candidate_limit],
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
cur.execute(
|
|
470
|
+
f"""
|
|
471
|
+
{select_sql}
|
|
472
|
+
FROM sessions s
|
|
473
|
+
LEFT JOIN messages m ON m.session_id = s.id
|
|
474
|
+
WHERE {' AND '.join(where_clauses)}
|
|
475
|
+
GROUP BY s.id
|
|
476
|
+
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
|
477
|
+
""",
|
|
478
|
+
params,
|
|
479
|
+
)
|
|
480
|
+
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
|
|
481
|
+
projected = [_with_normalized_source(row) for row in projected]
|
|
482
|
+
projected = [row for row in projected if is_cli_session_row_visible(row)]
|
|
483
|
+
if limit is None:
|
|
484
|
+
return projected
|
|
485
|
+
return projected[:max(0, int(limit))]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _lineage_report_row(row: dict, role: str) -> dict:
|
|
490
|
+
updated_at = row.get('ended_at') if row.get('ended_at') is not None else row.get('started_at')
|
|
491
|
+
return {
|
|
492
|
+
'session_id': row.get('id'),
|
|
493
|
+
'role': role,
|
|
494
|
+
'title': row.get('title'),
|
|
495
|
+
'source': row.get('source'),
|
|
496
|
+
'started_at': row.get('started_at'),
|
|
497
|
+
'updated_at': updated_at,
|
|
498
|
+
'end_reason': row.get('end_reason'),
|
|
499
|
+
'active': row.get('ended_at') is None,
|
|
500
|
+
'archived': False,
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _empty_lineage_report(session_id: str, *, found: bool = False) -> dict:
|
|
505
|
+
return {
|
|
506
|
+
'mutation': False,
|
|
507
|
+
'found': found,
|
|
508
|
+
'session_id': session_id,
|
|
509
|
+
'lineage_key': session_id,
|
|
510
|
+
'tip_session_id': session_id,
|
|
511
|
+
'total_segments': 0,
|
|
512
|
+
'materialized_segments': 0,
|
|
513
|
+
'segments': [],
|
|
514
|
+
'children': [],
|
|
515
|
+
'manual_review': False,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: int = 20) -> dict:
|
|
520
|
+
"""Return a bounded, read-only lifecycle report for a session lineage.
|
|
521
|
+
|
|
522
|
+
This helper intentionally reports only facts that can be derived from
|
|
523
|
+
``state.db.sessions`` without mutating WebUI JSON, archiving rows, or
|
|
524
|
+
deleting historical segments. It mirrors the sidebar continuation rules so
|
|
525
|
+
a future UI/PR can explain which rows are hidden compression/cli-close
|
|
526
|
+
segments and which child-session branches remain distinct.
|
|
527
|
+
"""
|
|
528
|
+
sid = str(session_id or '').strip()
|
|
529
|
+
if not sid:
|
|
530
|
+
return _empty_lineage_report('')
|
|
531
|
+
db_path = Path(db_path)
|
|
532
|
+
if not db_path.exists():
|
|
533
|
+
return _empty_lineage_report(sid)
|
|
534
|
+
|
|
535
|
+
try:
|
|
536
|
+
with closing(sqlite3.connect(str(db_path))) as conn:
|
|
537
|
+
conn.row_factory = sqlite3.Row
|
|
538
|
+
cur = conn.cursor()
|
|
539
|
+
cur.execute("PRAGMA table_info(sessions)")
|
|
540
|
+
session_cols = {row[1] for row in cur.fetchall()}
|
|
541
|
+
required = {'id', 'parent_session_id', 'end_reason'}
|
|
542
|
+
if not required.issubset(session_cols):
|
|
543
|
+
return _empty_lineage_report(sid)
|
|
544
|
+
|
|
545
|
+
source_expr = _optional_col('source', session_cols)
|
|
546
|
+
session_source_expr = _optional_col('session_source', session_cols)
|
|
547
|
+
title_expr = _optional_col('title', session_cols)
|
|
548
|
+
started_expr = _optional_col('started_at', session_cols, '0')
|
|
549
|
+
ended_expr = _optional_col('ended_at', session_cols)
|
|
550
|
+
end_reason_expr = _optional_col('end_reason', session_cols)
|
|
551
|
+
parent_expr = _optional_col('parent_session_id', session_cols)
|
|
552
|
+
|
|
553
|
+
def fetch_one(row_id: str | None) -> dict | None:
|
|
554
|
+
if not row_id:
|
|
555
|
+
return None
|
|
556
|
+
cur.execute(
|
|
557
|
+
f"""
|
|
558
|
+
SELECT s.id,
|
|
559
|
+
{source_expr},
|
|
560
|
+
{session_source_expr},
|
|
561
|
+
{title_expr},
|
|
562
|
+
{started_expr},
|
|
563
|
+
{parent_expr},
|
|
564
|
+
{ended_expr},
|
|
565
|
+
{end_reason_expr}
|
|
566
|
+
FROM sessions s
|
|
567
|
+
WHERE s.id = ?
|
|
568
|
+
""",
|
|
569
|
+
(row_id,),
|
|
570
|
+
)
|
|
571
|
+
row = cur.fetchone()
|
|
572
|
+
return dict(row) if row else None
|
|
573
|
+
|
|
574
|
+
target = fetch_one(sid)
|
|
575
|
+
if not target:
|
|
576
|
+
return _empty_lineage_report(sid)
|
|
577
|
+
|
|
578
|
+
segments = [target]
|
|
579
|
+
current = target
|
|
580
|
+
seen = {sid}
|
|
581
|
+
manual_review = False
|
|
582
|
+
for _hop in range(max(0, int(max_hops))):
|
|
583
|
+
parent_id = current.get('parent_session_id')
|
|
584
|
+
parent = fetch_one(parent_id)
|
|
585
|
+
if not parent or parent_id in seen:
|
|
586
|
+
manual_review = bool(parent_id and parent_id in seen)
|
|
587
|
+
break
|
|
588
|
+
if not _is_continuation_session(parent, current):
|
|
589
|
+
break
|
|
590
|
+
segments.append(parent)
|
|
591
|
+
seen.add(parent_id)
|
|
592
|
+
current = parent
|
|
593
|
+
else:
|
|
594
|
+
manual_review = True
|
|
595
|
+
|
|
596
|
+
segment_ids = {row['id'] for row in segments}
|
|
597
|
+
child_rows: list[dict] = []
|
|
598
|
+
for parent in segments:
|
|
599
|
+
cur.execute(
|
|
600
|
+
f"""
|
|
601
|
+
SELECT s.id,
|
|
602
|
+
{source_expr},
|
|
603
|
+
{session_source_expr},
|
|
604
|
+
{title_expr},
|
|
605
|
+
{started_expr},
|
|
606
|
+
{parent_expr},
|
|
607
|
+
{ended_expr},
|
|
608
|
+
{end_reason_expr}
|
|
609
|
+
FROM sessions s
|
|
610
|
+
WHERE s.parent_session_id = ?
|
|
611
|
+
ORDER BY s.started_at DESC
|
|
612
|
+
""",
|
|
613
|
+
(parent['id'],),
|
|
614
|
+
)
|
|
615
|
+
for child_row in cur.fetchall():
|
|
616
|
+
child = dict(child_row)
|
|
617
|
+
if child['id'] in segment_ids:
|
|
618
|
+
continue
|
|
619
|
+
if _is_continuation_session(parent, child):
|
|
620
|
+
# A continuation outside the selected path means the
|
|
621
|
+
# lineage is branched or the caller selected an older
|
|
622
|
+
# segment. Report manual review rather than proposing
|
|
623
|
+
# destructive cleanup candidates.
|
|
624
|
+
manual_review = True
|
|
625
|
+
continue
|
|
626
|
+
child_rows.append(child)
|
|
627
|
+
except Exception:
|
|
628
|
+
return _empty_lineage_report(sid)
|
|
629
|
+
|
|
630
|
+
root_id = segments[-1]['id'] if segments else sid
|
|
631
|
+
tip_id = segments[0]['id'] if segments else sid
|
|
632
|
+
return {
|
|
633
|
+
'mutation': False,
|
|
634
|
+
'found': True,
|
|
635
|
+
'session_id': sid,
|
|
636
|
+
'lineage_key': root_id,
|
|
637
|
+
'tip_session_id': tip_id,
|
|
638
|
+
'total_segments': len(segments),
|
|
639
|
+
'materialized_segments': len(segments),
|
|
640
|
+
'segments': [
|
|
641
|
+
_lineage_report_row(row, 'tip' if idx == 0 else 'hidden_segment')
|
|
642
|
+
for idx, row in enumerate(segments)
|
|
643
|
+
],
|
|
644
|
+
'children': [_lineage_report_row(row, 'child_session') for row in child_rows],
|
|
645
|
+
'manual_review': manual_review,
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[str]) -> dict[str, dict]:
|
|
650
|
+
"""Return compression-lineage metadata for known WebUI sidebar sessions.
|
|
651
|
+
|
|
652
|
+
WebUI sessions are persisted as JSON files, but Hermes Agent also mirrors
|
|
653
|
+
them into ``state.db.sessions`` for insights/session history. Compression
|
|
654
|
+
and cross-surface continuation create parent chains there. ``/api/sessions``
|
|
655
|
+
needs to surface that lineage to the sidebar so client-side collapse can
|
|
656
|
+
group logical continuations without mutating or deleting any session files.
|
|
657
|
+
|
|
658
|
+
Missing DBs, old schemas, or incomplete rows degrade to an empty mapping.
|
|
659
|
+
"""
|
|
660
|
+
wanted = {str(sid) for sid in (session_ids or []) if sid}
|
|
661
|
+
db_path = Path(db_path)
|
|
662
|
+
if not wanted or not db_path.exists():
|
|
663
|
+
return {}
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
with closing(sqlite3.connect(str(db_path))) as conn:
|
|
667
|
+
conn.row_factory = sqlite3.Row
|
|
668
|
+
cur = conn.cursor()
|
|
669
|
+
cur.execute("PRAGMA table_info(sessions)")
|
|
670
|
+
session_cols = {row[1] for row in cur.fetchall()}
|
|
671
|
+
if 'parent_session_id' not in session_cols or 'end_reason' not in session_cols:
|
|
672
|
+
return {}
|
|
673
|
+
session_source_expr = _optional_col('session_source', session_cols)
|
|
674
|
+
# Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a
|
|
675
|
+
# full table scan. The sessions table grows unbounded over time
|
|
676
|
+
# (1000+ rows is normal, 10000+ for power users), and this function
|
|
677
|
+
# runs on every sidebar refresh — a full SELECT was ~50x slower
|
|
678
|
+
# than the indexed lookup at 1000 rows and scales linearly.
|
|
679
|
+
#
|
|
680
|
+
# Fetch the wanted ids first, then chase parent_session_id chains
|
|
681
|
+
# in batches until no new ids appear. Each batch hits PRIMARY KEY
|
|
682
|
+
# so it's effectively O(N) lookups.
|
|
683
|
+
#
|
|
684
|
+
# IN-clause is chunked to 500 to stay under SQLITE_MAX_VARIABLE_NUMBER
|
|
685
|
+
# on older sqlite (Python 3.9 ships sqlite 3.31 which defaults to 999;
|
|
686
|
+
# newer Python ships sqlite 3.32+ at 32766). On a power user with
|
|
687
|
+
# 2000+ sessions in the sidebar, an unchunked first hop would raise
|
|
688
|
+
# `OperationalError: too many SQL variables`, get swallowed by the
|
|
689
|
+
# except below, and silently disable lineage collapse forever.
|
|
690
|
+
# (Opus pre-release review of v0.50.251, SHOULD-FIX 2.)
|
|
691
|
+
IN_CHUNK = 500
|
|
692
|
+
rows: dict[str, dict] = {}
|
|
693
|
+
to_fetch = set(wanted)
|
|
694
|
+
# Cap walk depth to bound worst-case query count. Real lineage
|
|
695
|
+
# chains seen in production are <10 segments; anything longer is
|
|
696
|
+
# almost certainly pathological data and not worth chasing.
|
|
697
|
+
for _hop in range(20):
|
|
698
|
+
if not to_fetch:
|
|
699
|
+
break
|
|
700
|
+
fetch_list = list(to_fetch)
|
|
701
|
+
to_fetch = set()
|
|
702
|
+
for i in range(0, len(fetch_list), IN_CHUNK):
|
|
703
|
+
chunk = fetch_list[i:i + IN_CHUNK]
|
|
704
|
+
placeholders = ','.join('?' * len(chunk))
|
|
705
|
+
cur.execute(
|
|
706
|
+
f"""
|
|
707
|
+
SELECT s.id, s.source, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason
|
|
708
|
+
FROM sessions s
|
|
709
|
+
WHERE s.id IN ({placeholders})
|
|
710
|
+
""",
|
|
711
|
+
chunk,
|
|
712
|
+
)
|
|
713
|
+
for row in cur.fetchall():
|
|
714
|
+
rows[row['id']] = dict(row)
|
|
715
|
+
# Queue up parents we haven't fetched yet.
|
|
716
|
+
for sid in fetch_list:
|
|
717
|
+
parent_id = rows.get(sid, {}).get('parent_session_id')
|
|
718
|
+
if parent_id and parent_id not in rows and parent_id not in to_fetch:
|
|
719
|
+
to_fetch.add(parent_id)
|
|
720
|
+
except Exception:
|
|
721
|
+
return {}
|
|
722
|
+
|
|
723
|
+
metadata: dict[str, dict] = {}
|
|
724
|
+
for sid in wanted:
|
|
725
|
+
row = rows.get(sid)
|
|
726
|
+
if not row:
|
|
727
|
+
continue
|
|
728
|
+
|
|
729
|
+
state_title = str(row.get('title') or '').strip()
|
|
730
|
+
if state_title:
|
|
731
|
+
metadata.setdefault(sid, {})['_state_db_title'] = state_title
|
|
732
|
+
state_source = str(row.get('source') or '').strip().lower()
|
|
733
|
+
if state_source:
|
|
734
|
+
entry = metadata.setdefault(sid, {})
|
|
735
|
+
entry['_state_db_source'] = state_source
|
|
736
|
+
source_meta = normalize_agent_session_source(state_source)
|
|
737
|
+
entry['_state_db_source_tag'] = state_source
|
|
738
|
+
entry['_state_db_raw_source'] = source_meta.get('raw_source')
|
|
739
|
+
entry['_state_db_session_source'] = source_meta.get('session_source')
|
|
740
|
+
entry['_state_db_source_label'] = source_meta.get('source_label')
|
|
741
|
+
|
|
742
|
+
parent_id = row.get('parent_session_id')
|
|
743
|
+
parent_row = rows.get(parent_id) if parent_id else None
|
|
744
|
+
if parent_id and parent_row:
|
|
745
|
+
entry = metadata.setdefault(sid, {})
|
|
746
|
+
entry['parent_session_id'] = parent_id
|
|
747
|
+
if not _is_continuation_session(parent_row, row):
|
|
748
|
+
entry['relationship_type'] = 'child_session'
|
|
749
|
+
entry['parent_title'] = parent_row.get('title')
|
|
750
|
+
entry['parent_source'] = parent_row.get('source')
|
|
751
|
+
parent_source = str(parent_row.get('source') or '').strip().lower()
|
|
752
|
+
child_source = str(row.get('source') or '').strip().lower()
|
|
753
|
+
if parent_source and child_source and parent_source != child_source:
|
|
754
|
+
entry['_cross_surface_child_session'] = True
|
|
755
|
+
parent_root = _continuation_root_id(rows, parent_id)
|
|
756
|
+
if parent_root:
|
|
757
|
+
entry['_parent_lineage_root_id'] = parent_root
|
|
758
|
+
continue
|
|
759
|
+
|
|
760
|
+
root_id = sid
|
|
761
|
+
current_id = sid
|
|
762
|
+
segment_count = 1
|
|
763
|
+
seen = {sid}
|
|
764
|
+
while True:
|
|
765
|
+
current = rows.get(current_id)
|
|
766
|
+
parent_id = current.get('parent_session_id') if current else None
|
|
767
|
+
parent = rows.get(parent_id) if parent_id else None
|
|
768
|
+
if not parent or parent_id in seen:
|
|
769
|
+
break
|
|
770
|
+
if not _is_continuation_session(parent, current):
|
|
771
|
+
break
|
|
772
|
+
root_id = parent_id
|
|
773
|
+
current_id = parent_id
|
|
774
|
+
seen.add(parent_id)
|
|
775
|
+
segment_count += 1
|
|
776
|
+
|
|
777
|
+
if root_id != sid:
|
|
778
|
+
entry = metadata.setdefault(sid, {})
|
|
779
|
+
entry['_lineage_root_id'] = root_id
|
|
780
|
+
entry['_compression_segment_count'] = segment_count
|
|
781
|
+
|
|
782
|
+
return metadata
|