diff --git a/cli.py b/cli.py index bb4286b34..e6ce2a95b 100644 --- a/cli.py +++ b/cli.py @@ -2916,7 +2916,7 @@ class HermesCLI: try: self._session_db.create_session( session_id=self.session_id, - source="cli", + source=os.environ.get("HERMES_SESSION_SOURCE", "cli"), model=self.model, model_config={ "max_iterations": self.max_turns, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fb71a74ee..d19b99b60 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -513,6 +513,10 @@ def cmd_chat(args): if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" + # --source: tag session source for filtering (e.g. 'tool' for third-party integrations) + if getattr(args, "source", None): + os.environ["HERMES_SESSION_SOURCE"] = args.source + # Import and run the CLI from cli import main as cli_main @@ -3170,6 +3174,11 @@ For more help on a command: default=False, help="Include the session ID in the agent's system prompt" ) + chat_parser.add_argument( + "--source", + default=None, + help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists." + ) chat_parser.set_defaults(func=cmd_chat) # ========================================================================= @@ -3868,8 +3877,12 @@ For more help on a command: action = args.sessions_action + # Hide third-party tool sessions by default, but honour explicit --source + _source = getattr(args, "source", None) + _exclude = None if _source else ["tool"] + if action == "list": - sessions = db.list_sessions_rich(source=args.source, limit=args.limit) + sessions = db.list_sessions_rich(source=args.source, exclude_sources=_exclude, limit=args.limit) if not sessions: print("No sessions found.") return @@ -3952,7 +3965,8 @@ For more help on a command: elif action == "browse": limit = getattr(args, "limit", 50) or 50 source = getattr(args, "source", None) - sessions = db.list_sessions_rich(source=source, limit=limit) + _browse_exclude = None if source else ["tool"] + sessions = db.list_sessions_rich(source=source, exclude_sources=_browse_exclude, limit=limit) db.close() if not sessions: print("No sessions found.") diff --git a/hermes_state.py b/hermes_state.py index 3c06f1010..d3088fce6 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -572,6 +572,7 @@ class SessionDB: def list_sessions_rich( self, source: str = None, + exclude_sources: List[str] = None, limit: int = 20, offset: int = 0, ) -> List[Dict[str, Any]]: @@ -583,7 +584,18 @@ class SessionDB: Uses a single query with correlated subqueries instead of N+2 queries. """ - source_clause = "WHERE s.source = ?" if source else "" + where_clauses = [] + params = [] + + if source: + where_clauses.append("s.source = ?") + params.append(source) + if exclude_sources: + placeholders = ",".join("?" for _ in exclude_sources) + where_clauses.append(f"s.source NOT IN ({placeholders})") + params.extend(exclude_sources) + + where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" query = f""" SELECT s.*, COALESCE( @@ -598,11 +610,11 @@ class SessionDB: s.started_at ) AS last_active FROM sessions s - {source_clause} + {where_sql} ORDER BY s.started_at DESC LIMIT ? OFFSET ? """ - params = (source, limit, offset) if source else (limit, offset) + params.extend([limit, offset]) with self._lock: cursor = self._conn.execute(query, params) rows = cursor.fetchall() @@ -818,6 +830,7 @@ class SessionDB: self, query: str, source_filter: List[str] = None, + exclude_sources: List[str] = None, role_filter: List[str] = None, limit: int = 20, offset: int = 0, @@ -850,6 +863,11 @@ class SessionDB: where_clauses.append(f"s.source IN ({source_placeholders})") params.extend(source_filter) + if exclude_sources is not None: + exclude_placeholders = ",".join("?" for _ in exclude_sources) + where_clauses.append(f"s.source NOT IN ({exclude_placeholders})") + params.extend(exclude_sources) + if role_filter: role_placeholders = ",".join("?" for _ in role_filter) where_clauses.append(f"m.role IN ({role_placeholders})") diff --git a/run_agent.py b/run_agent.py index 49fe64adf..7ccf15620 100644 --- a/run_agent.py +++ b/run_agent.py @@ -883,7 +883,7 @@ class AIAgent: try: self._session_db.create_session( session_id=self.session_id, - source=self.platform or "cli", + source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), model=self.model, model_config={ "max_iterations": self.max_iterations, @@ -4859,7 +4859,7 @@ class AIAgent: self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" self._session_db.create_session( session_id=self.session_id, - source=self.platform or "cli", + source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), model=self.model, parent_session_id=old_session_id, ) diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index d4712450b..e79c7f4fe 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1102,6 +1102,89 @@ class TestListSessionsRich: assert "Line one Line two" in sessions[0]["preview"] +# ========================================================================= +# Session source exclusion (--source flag for third-party isolation) +# ========================================================================= + +class TestExcludeSources: + """Tests for exclude_sources on list_sessions_rich and search_messages.""" + + def test_list_sessions_rich_excludes_tool_source(self, db): + db.create_session("s1", "cli") + db.create_session("s2", "tool") + db.create_session("s3", "telegram") + sessions = db.list_sessions_rich(exclude_sources=["tool"]) + ids = [s["id"] for s in sessions] + assert "s1" in ids + assert "s3" in ids + assert "s2" not in ids + + def test_list_sessions_rich_no_exclusion_returns_all(self, db): + db.create_session("s1", "cli") + db.create_session("s2", "tool") + sessions = db.list_sessions_rich() + ids = [s["id"] for s in sessions] + assert "s1" in ids + assert "s2" in ids + + def test_list_sessions_rich_source_and_exclude_combined(self, db): + """When source= is explicit, exclude_sources should not conflict.""" + db.create_session("s1", "cli") + db.create_session("s2", "tool") + db.create_session("s3", "telegram") + # Explicit source filter: only tool sessions, no exclusion + sessions = db.list_sessions_rich(source="tool") + ids = [s["id"] for s in sessions] + assert ids == ["s2"] + + def test_list_sessions_rich_exclude_multiple_sources(self, db): + db.create_session("s1", "cli") + db.create_session("s2", "tool") + db.create_session("s3", "cron") + db.create_session("s4", "telegram") + sessions = db.list_sessions_rich(exclude_sources=["tool", "cron"]) + ids = [s["id"] for s in sessions] + assert "s1" in ids + assert "s4" in ids + assert "s2" not in ids + assert "s3" not in ids + + def test_search_messages_excludes_tool_source(self, db): + db.create_session("s1", "cli") + db.append_message("s1", "user", "Python deployment question") + db.create_session("s2", "tool") + db.append_message("s2", "user", "Python automated question") + results = db.search_messages("Python", exclude_sources=["tool"]) + sources = [r["source"] for r in results] + assert "cli" in sources + assert "tool" not in sources + + def test_search_messages_no_exclusion_returns_all_sources(self, db): + db.create_session("s1", "cli") + db.append_message("s1", "user", "Rust deployment question") + db.create_session("s2", "tool") + db.append_message("s2", "user", "Rust automated question") + results = db.search_messages("Rust") + sources = [r["source"] for r in results] + assert "cli" in sources + assert "tool" in sources + + def test_search_messages_source_include_and_exclude(self, db): + """source_filter (include) and exclude_sources can coexist.""" + db.create_session("s1", "cli") + db.append_message("s1", "user", "Golang test") + db.create_session("s2", "telegram") + db.append_message("s2", "user", "Golang test") + db.create_session("s3", "tool") + db.append_message("s3", "user", "Golang test") + # Include cli+tool, but exclude tool → should only return cli + results = db.search_messages( + "Golang", source_filter=["cli", "tool"], exclude_sources=["tool"] + ) + sources = [r["source"] for r in results] + assert sources == ["cli"] + + class TestResolveSessionByNameOrId: """Tests for the main.py helper that resolves names or IDs.""" diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index e998a58b8..acb64d62f 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -8,6 +8,7 @@ from tools.session_search_tool import ( _format_timestamp, _format_conversation, _truncate_around_matches, + _HIDDEN_SESSION_SOURCES, MAX_SESSION_CHARS, SESSION_SEARCH_SCHEMA, ) @@ -17,6 +18,17 @@ from tools.session_search_tool import ( # Tool schema guidance # ========================================================================= +class TestHiddenSessionSources: + """Verify the _HIDDEN_SESSION_SOURCES constant used for third-party isolation.""" + + def test_tool_source_is_hidden(self): + assert "tool" in _HIDDEN_SESSION_SOURCES + + def test_standard_sources_not_hidden(self): + for src in ("cli", "telegram", "discord", "slack", "cron"): + assert src not in _HIDDEN_SESSION_SOURCES + + class TestSessionSearchSchema: def test_keeps_cross_session_recall_guidance_without_current_session_nudge(self): description = SESSION_SEARCH_SCHEMA["description"] diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index d7b693185..235585270 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -178,10 +178,16 @@ async def _summarize_session( return None +# Sources that are excluded from session browsing/searching by default. +# Third-party integrations (Paperclip agents, etc.) tag their sessions with +# HERMES_SESSION_SOURCE=tool so they don't clutter the user's session history. +_HIDDEN_SESSION_SOURCES = ("tool",) + + def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str: """Return metadata for the most recent sessions (no LLM calls).""" try: - sessions = db.list_sessions_rich(limit=limit + 5) # fetch extra to skip current + sessions = db.list_sessions_rich(limit=limit + 5, exclude_sources=list(_HIDDEN_SESSION_SOURCES)) # fetch extra to skip current # Resolve current session lineage to exclude it current_root = None @@ -265,6 +271,7 @@ def session_search( raw_results = db.search_messages( query=query, role_filter=role_list, + exclude_sources=list(_HIDDEN_SESSION_SOURCES), limit=50, # Get more matches to find unique sessions offset=0, )