Skip to content

Commit 28362dc

Browse files
Merge pull request #17 from offendingcommit/codex/sessiondb-state-operations
2 parents 9a6d178 + 1e39c33 commit 28362dc

7 files changed

Lines changed: 542 additions & 3 deletions

File tree

.github/workflows/test.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,6 @@ jobs:
3535
- name: Run hermes contract tests against upstream main
3636
env:
3737
HERMES_AGENT_PATH: ${{ github.workspace }}/.hermes-agent
38-
run: uv run python -m unittest tests.test_hermes_contract -v
38+
run: |
39+
git -C "$HERMES_AGENT_PATH" rev-parse HEAD
40+
uv run python -m unittest tests.test_hermes_contract -v

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,38 @@ A handler returns a `dict` (becomes the success `data`), or raises (becomes a to
379379
error), or returns a `str` as an escape hatch (treated as already-encoded JSON). It must
380380
accept `(args, **kwargs)` — runtime keys like `task_id`/`session_id` arrive as kwargs.
381381

382+
## Session state helpers
383+
384+
The kit can read sessions and messages and append transcript rows through
385+
Hermes' public `SessionDB` API. It imports Hermes only when a database is
386+
opened, so the package keeps its zero-dependency runtime contract:
387+
388+
```python
389+
from hermes_plugin_kit import (
390+
append_session_message,
391+
open_session_db,
392+
read_session,
393+
read_session_messages,
394+
)
395+
396+
with open_session_db() as db: # current Hermes profile's state.db
397+
session = read_session(db, session_id)
398+
messages = read_session_messages(db, session_id, limit=50, latest=True)
399+
row_id = append_session_message(db, session_id, "user", "Remember this")
400+
```
401+
402+
`open_session_db(db_path)` constructs a Hermes `SessionDB` for that path and
403+
closes it on exit. `open_session_db(db=existing_db)` borrows a caller-owned
404+
handle and leaves it open. Supplying both is an error. Prefer the injected form
405+
inside a running plugin when Hermes already owns the profile-scoped handle.
406+
407+
Opening a writable `SessionDB` can migrate its schema. Tests must therefore use
408+
a generated database or a copy under temporary storage; never a developer's
409+
live `~/.hermes/state.db`. Production helpers issue no raw SQL and delegate
410+
ordering, pagination, structured message encoding, locking, and migration to
411+
Hermes itself. An incompatible Hermes build raises
412+
`SessionDBCompatibilityError` naming the missing contract.
413+
382414
## Calling host-managed capabilities
383415

384416
Not every Hermes capability lives in `tools.registry`. In particular,

hermes_plugin_kit/__init__.py

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,11 @@ def register(ctx):
6161
import sys
6262
import threading
6363
import time
64+
from contextlib import contextmanager
6465
from dataclasses import dataclass
6566
from enum import Enum
6667
from pathlib import Path
67-
from typing import Any, Callable, Iterable
68+
from typing import Any, Callable, Iterable, Iterator, Protocol
6869

6970
__all__ = [
7071
"tool",
@@ -75,6 +76,13 @@ def register(ctx):
7576
"register_plugin",
7677
"log_registration_summary",
7778
"invoke_host_tool",
79+
"open_session_db",
80+
"read_session",
81+
"list_sessions",
82+
"read_session_messages",
83+
"append_session_message",
84+
"SessionDBLike",
85+
"SessionDBCompatibilityError",
7886
"deliver_media",
7987
"resolve_delivery_target",
8088
"transform_media_delivery_output",
@@ -126,6 +134,183 @@ def register(ctx):
126134
_TELEGRAM_SPOILER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
127135

128136

137+
class SessionDBCompatibilityError(RuntimeError):
138+
"""Hermes does not expose the SessionDB contract required by the kit."""
139+
140+
141+
class SessionDBLike(Protocol):
142+
"""Public SessionDB methods used by the kit's state helpers."""
143+
144+
def get_session(self, session_id: str) -> dict[str, Any] | None: ...
145+
146+
def list_sessions_rich(self, **kwargs: Any) -> list[dict[str, Any]]: ...
147+
148+
def get_messages(self, session_id: str, **kwargs: Any) -> list[dict[str, Any]]: ...
149+
150+
def append_message(
151+
self, session_id: str, role: str, content: Any = None, **kwargs: Any
152+
) -> int: ...
153+
154+
def close(self) -> None: ...
155+
156+
157+
def _session_db_method(db: Any, method_name: str) -> Callable[..., Any]:
158+
method = getattr(db, method_name, None)
159+
if not callable(method):
160+
raise SessionDBCompatibilityError(
161+
"hermes-agent SessionDB is incompatible: required public method "
162+
f"{method_name}() is unavailable"
163+
)
164+
return method
165+
166+
167+
def _call_session_db(
168+
db: Any, method_name: str, *args: Any, **kwargs: Any
169+
) -> Any:
170+
method = _session_db_method(db, method_name)
171+
try:
172+
inspect.signature(method).bind(*args, **kwargs)
173+
except (TypeError, ValueError) as exc:
174+
raise SessionDBCompatibilityError(
175+
"hermes-agent SessionDB is incompatible: public method "
176+
f"{method_name}() does not accept the required arguments: {exc}"
177+
) from exc
178+
return method(*args, **kwargs)
179+
180+
181+
@contextmanager
182+
def open_session_db(
183+
db_path: str | Path | None = None,
184+
*,
185+
db: SessionDBLike | None = None,
186+
) -> Iterator[SessionDBLike]:
187+
"""Yield an injected or lazily opened Hermes ``SessionDB``.
188+
189+
An injected handle remains caller-owned and is never closed here. When the
190+
kit constructs the handle, it closes it on context exit. Constructing a
191+
real ``SessionDB`` may migrate the selected database, so tests should
192+
always provide a path in temporary storage.
193+
"""
194+
if db is not None and db_path is not None:
195+
raise ValueError("db and db_path are mutually exclusive")
196+
if db is not None:
197+
yield db
198+
return
199+
try:
200+
from hermes_state import SessionDB
201+
except (ImportError, AttributeError) as exc:
202+
raise SessionDBCompatibilityError(
203+
"hermes-agent SessionDB is unavailable; run inside a compatible "
204+
"Hermes runtime or inject a SessionDB-compatible handle"
205+
) from exc
206+
207+
if db_path is None:
208+
owned_db = SessionDB()
209+
else:
210+
try:
211+
inspect.signature(SessionDB).bind(db_path=Path(db_path))
212+
except (TypeError, ValueError) as exc:
213+
raise SessionDBCompatibilityError(
214+
"hermes-agent SessionDB does not support the required db_path contract"
215+
) from exc
216+
owned_db = SessionDB(db_path=Path(db_path))
217+
try:
218+
yield owned_db
219+
finally:
220+
_session_db_method(owned_db, "close")()
221+
222+
223+
def read_session(db: SessionDBLike, session_id: str) -> dict[str, Any] | None:
224+
"""Read one session through Hermes' public SessionDB API."""
225+
return _call_session_db(db, "get_session", session_id)
226+
227+
228+
def list_sessions(
229+
db: SessionDBLike,
230+
*,
231+
source: str | None = None,
232+
sources: list[str] | None = None,
233+
exclude_sources: list[str] | None = None,
234+
cwd_prefix: str | None = None,
235+
limit: int = 20,
236+
offset: int = 0,
237+
include_children: bool = False,
238+
min_message_count: int = 0,
239+
project_compression_tips: bool = True,
240+
order_by_last_active: bool = False,
241+
include_archived: bool = False,
242+
archived_only: bool = False,
243+
id_query: str | None = None,
244+
search_query: str | None = None,
245+
compact_rows: bool = False,
246+
include_pinned: bool = False,
247+
session_key: str | None = None,
248+
) -> list[dict[str, Any]]:
249+
"""List rich session rows using Hermes-supported filters and pagination."""
250+
return _call_session_db(
251+
db,
252+
"list_sessions_rich",
253+
source=source,
254+
sources=sources,
255+
exclude_sources=exclude_sources,
256+
cwd_prefix=cwd_prefix,
257+
limit=limit,
258+
offset=offset,
259+
include_children=include_children,
260+
min_message_count=min_message_count,
261+
project_compression_tips=project_compression_tips,
262+
order_by_last_active=order_by_last_active,
263+
include_archived=include_archived,
264+
archived_only=archived_only,
265+
id_query=id_query,
266+
search_query=search_query,
267+
compact_rows=compact_rows,
268+
include_pinned=include_pinned,
269+
session_key=session_key,
270+
)
271+
272+
273+
def read_session_messages(
274+
db: SessionDBLike,
275+
session_id: str,
276+
*,
277+
include_inactive: bool = False,
278+
limit: int | None = None,
279+
offset: int = 0,
280+
latest: bool = False,
281+
after_id: int | None = None,
282+
) -> list[dict[str, Any]]:
283+
"""Read a session transcript using Hermes' ordering and paging rules."""
284+
return _call_session_db(
285+
db,
286+
"get_messages",
287+
session_id,
288+
include_inactive=include_inactive,
289+
limit=limit,
290+
offset=offset,
291+
latest=latest,
292+
after_id=after_id,
293+
)
294+
295+
296+
def append_session_message(
297+
db: SessionDBLike,
298+
session_id: str,
299+
role: str,
300+
content: Any = None,
301+
**message_fields: Any,
302+
) -> int:
303+
"""Append a message, forwarding structured fields to Hermes unchanged."""
304+
return _call_session_db(
305+
db,
306+
"append_message",
307+
session_id,
308+
role,
309+
content,
310+
**message_fields,
311+
)
312+
313+
129314
@dataclass(frozen=True)
130315
class PluginSkill:
131316
"""Validated declaration for a plugin-owned, read-only Hermes skill."""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Repository = "https://github.com/offendingcommit/hermes-plugin-kit"
2323
# Runtime stays dependency-free. The hermes contract tests import current
2424
# upstream source, whose plugin and gateway seams transitively need these.
2525
[dependency-groups]
26-
dev = ["pyyaml", "requests==2.33.0"]
26+
dev = ["httpx[socks]==0.28.1", "pyyaml", "requests==2.33.0"]
2727

2828
[tool.setuptools]
2929
packages = ["hermes_plugin_kit"]

0 commit comments

Comments
 (0)