feat(state): sessions carry read/unread state

Adds a last_read_at watermark to the sessions table so surfaces (CLI,
TUI, desktop) can badge unread conversations. Read state derives from
the watermark vs latest activity, so new messages flip a conversation
back to unread with zero writes on the message path. NULL means never
tracked, so shipping the column doesn't badge pre-existing history.

set_session_read() stamps the whole compression lineage, matching the
archive/pin semantics; list_sessions_rich() rows carry a derived
`unread` key. DB layer only — no surface exposes it yet.
This commit is contained in:
Brooklyn Nicholson 2026-08-04 12:32:27 -06:00
parent 9712b8f0cc
commit ec0c8d9c20
3 changed files with 187 additions and 0 deletions

View File

@ -5478,6 +5478,80 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
rowcount = self._execute_write(_do)
return rowcount > 0
def set_session_read(self, session_id: str, read: bool = True) -> bool:
"""Mark a session read or unread (and its whole compression lineage).
Read state is a watermark, not a flag: ``last_read_at`` records when
the conversation was last read, and it counts as unread when activity
postdates that watermark (the derived ``unread`` key on
:meth:`list_sessions_rich` rows). New messages therefore flip a read
conversation back to unread without any write on the message path.
Three states:
* NULL never tracked (every pre-feature row): treated as read, so
shipping the column doesn't badge a user's entire history at once.
* 0 explicitly marked unread: any activity postdates it.
* timestamp read up to that moment.
Like :meth:`set_session_archived` / :meth:`set_session_pinned`, the
whole compression chain is stamped as a unit, so reading the surfaced
tip clears the root (and vice-versa) no matter which id the caller
holds. Returns True when at least one row changed.
"""
def _do(conn):
cursor = conn.execute(
"""
WITH RECURSIVE
ancestors(id) AS (
SELECT ?
UNION
SELECT parent.id
FROM ancestors a
JOIN sessions child ON child.id = a.id
JOIN sessions parent ON parent.id = child.parent_session_id
WHERE parent.end_reason = 'compression'
),
descendants(id) AS (
SELECT ?
UNION
SELECT child.id
FROM descendants d
JOIN sessions parent ON parent.id = d.id
JOIN sessions child ON child.parent_session_id = parent.id
WHERE parent.end_reason = 'compression'
),
lineage(id) AS (
SELECT id FROM ancestors
UNION
SELECT id FROM descendants
)
UPDATE sessions
SET last_read_at = ?
WHERE id IN (SELECT id FROM lineage)
""",
(session_id, session_id, time.time() if read else 0.0),
)
rowcount = cursor.rowcount
if rowcount is None or rowcount < 0:
rowcount = conn.execute("SELECT changes()").fetchone()[0]
return rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
@staticmethod
def session_unread(session_row: Dict[str, Any]) -> bool:
"""Derive unread from a session row's watermark and activity.
Shared by ``list_sessions_rich`` and any future surface that holds a
row (or projected row) with ``last_read_at`` and ``last_active``.
NULL watermark = never tracked = read.
"""
last_read = session_row.get("last_read_at")
if last_read is None:
return False
last_active = session_row.get("last_active") or session_row.get("started_at")
return float(last_active or 0) > float(last_read)
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
with self._read_ctx() as conn:
@ -5994,6 +6068,13 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
projected.append(merged)
sessions = projected
# Derive read state per surfaced conversation. ``last_read_at`` is
# lineage-stamped by set_session_read, so a projected row's root
# watermark and its tip's are the same value — comparing it against
# the tip's last_active is correct either way.
for s in sessions:
s["unread"] = self.session_unread(s)
return sessions
# =========================================================================

View File

@ -245,6 +245,7 @@ CREATE TABLE IF NOT EXISTS sessions (
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
last_read_at REAL,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id),
FOREIGN KEY (system_prompt_hash) REFERENCES system_prompts(hash)
);

View File

@ -0,0 +1,105 @@
import time
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
database = SessionDB(tmp_path / "state.db")
try:
yield database
finally:
database.close()
def _last_read(db, sid):
row = db._conn.execute(
"SELECT last_read_at FROM sessions WHERE id = ?", (sid,)
).fetchone()
return row["last_read_at"] if row is not None else None
def _row(db, sid):
rows = db.list_sessions_rich(include_archived=True)
return next(s for s in rows if s["id"] == sid)
def test_untracked_sessions_are_read(db):
"""NULL watermark = never tracked = read, so shipping the column doesn't
badge a user's entire pre-feature history at once."""
db.create_session(session_id="s1", source="cli")
db.append_message(session_id="s1", role="user", content="hi")
assert _last_read(db, "s1") is None
assert _row(db, "s1")["unread"] is False
def test_mark_read_then_new_activity_flips_back_to_unread(db):
db.create_session(session_id="s1", source="cli")
db.append_message(session_id="s1", role="user", content="hi")
assert db.set_session_read("s1") is True
assert _row(db, "s1")["unread"] is False
# New activity postdating the watermark makes it unread again without
# any write on the message path.
time.sleep(0.01)
db.append_message(session_id="s1", role="assistant", content="reply")
assert _row(db, "s1")["unread"] is True
def test_mark_unread_explicitly(db):
db.create_session(session_id="s1", source="cli")
db.append_message(session_id="s1", role="user", content="hi")
db.set_session_read("s1")
assert db.set_session_read("s1", read=False) is True
assert _last_read(db, "s1") == 0.0
assert _row(db, "s1")["unread"] is True
def test_missing_session_returns_false(db):
assert db.set_session_read("nope") is False
def _compression_pair(db: SessionDB):
base = time.time() - 100
db.create_session("root", source="cli")
db.create_session("tip", source="cli", parent_session_id="root")
db._conn.execute(
"UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'",
(base, base + 10),
)
db._conn.execute(
"UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'",
(base + 20,),
)
db._conn.commit()
def test_reading_compression_tip_stamps_whole_lineage(db):
_compression_pair(db)
assert db.set_session_read("tip") is True
root_read = _last_read(db, "root")
assert root_read is not None and root_read > 0
assert root_read == _last_read(db, "tip")
# The projected conversation row (root surfaced as tip) derives read.
rows = db.list_sessions_rich(order_by_last_active=True)
assert [s["id"] for s in rows] == ["tip"]
assert rows[0]["unread"] is False
def test_marking_root_unread_marks_projected_conversation(db):
_compression_pair(db)
db.set_session_read("tip")
assert db.set_session_read("root", read=False) is True
rows = db.list_sessions_rich(order_by_last_active=True)
assert [s["id"] for s in rows] == ["tip"]
assert rows[0]["unread"] is True