Coverage for src/lilbee/sessions/store.py: 100%

199 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Append-only JSONL store for chat sessions. 

2 

3Each session is one ``<id>.jsonl`` file under ``cfg.data_dir/sessions``. The file 

4is a strictly append-only event log: one JSON object per line, appended and 

5fsynced, never rewritten. Event types are ``meta`` (first line), ``title`` 

6(newest wins, so rename appends rather than rewrites), ``message``, and 

7``summary`` (newest wins; compaction's condensed view of the turns that no 

8longer fit the prompt). The only corruption an append log can suffer is a torn 

9final line, which the reader skips. 

10""" 

11 

12from __future__ import annotations 

13 

14import json 

15import os 

16from collections.abc import Callable, Iterator 

17from dataclasses import dataclass 

18from datetime import UTC, datetime 

19from enum import StrEnum 

20from pathlib import Path 

21from typing import Any 

22from uuid import uuid4 

23 

24from filelock import FileLock 

25 

26from lilbee.core.config import cfg 

27 

28SESSIONS_DIRNAME = "sessions" 

29SESSIONS_DISABLED_HINT = ( 

30 "Sessions are off. Turn them on with /set sessions_enabled true in the TUI, " 

31 "settings_set via MCP, or sessions_enabled = true in config.toml." 

32) 

33AGENT_SESSIONS_DISABLED_HINT = ( 

34 "Agent sessions are off. Turn them on with settings_set mcp_sessions_enabled " 

35 "true, /set mcp_sessions_enabled true in the TUI, or mcp_sessions_enabled = " 

36 "true in config.toml." 

37) 

38# Bounds a wedged lock holder; a healthy append holds the lock for milliseconds. 

39_APPEND_LOCK_TIMEOUT_S = 10 

40UNTITLED_SESSION_TITLE = "Untitled chat" 

41TITLE_MAX_LEN = 60 

42TITLE_ELLIPSIS = "…" 

43 

44 

45def sessions_enabled() -> bool: 

46 """True when session persistence is on for the human surfaces (default on).""" 

47 return cfg.sessions_enabled 

48 

49 

50def agent_sessions_enabled() -> bool: 

51 """True when agent (MCP) sessions are on (default off). 

52 

53 Independent of ``sessions_enabled``: the two govern separate domains, so an 

54 agent's working state can be off while a human's conversations are saved. 

55 """ 

56 return cfg.mcp_sessions_enabled 

57 

58 

59class SessionEventType(StrEnum): 

60 """The tag on every line of a session log.""" 

61 

62 META = "meta" 

63 TITLE = "title" 

64 MESSAGE = "message" 

65 SUMMARY = "summary" 

66 ORIGIN = "origin" 

67 

68 

69class SessionOrigin(StrEnum): 

70 """The surface a session belongs to: whoever created it, or was last 

71 explicitly transferred to. Appends from any other surface are refused, so 

72 an agent cannot splice its turns into a conversation a human owns.""" 

73 

74 TUI = "tui" 

75 MCP = "mcp" 

76 HTTP = "http" 

77 CLI = "cli" 

78 

79 

80# The surfaces a human drives directly. Their sessions are one conversation 

81# space (start in Obsidian, continue in the TUI); agent sessions are working 

82# state and stay out of it unless asked for. 

83HUMAN_ORIGINS: frozenset[SessionOrigin] = frozenset( 

84 {SessionOrigin.TUI, SessionOrigin.HTTP, SessionOrigin.CLI} 

85) 

86 

87 

88class MessageRole(StrEnum): 

89 """Author of a chat message.""" 

90 

91 USER = "user" 

92 ASSISTANT = "assistant" 

93 

94 

95class TitleSource(StrEnum): 

96 """Where a session title came from.""" 

97 

98 AUTO = "auto" 

99 CUSTOM = "custom" 

100 

101 

102@dataclass(frozen=True) 

103class SessionMessage: 

104 """One turn in a session. ``ts`` is stamped by the store on write.""" 

105 

106 role: MessageRole 

107 content: str 

108 sources: tuple[str, ...] = () 

109 ts: str = "" 

110 

111 

112@dataclass(frozen=True) 

113class SessionMeta: 

114 """Session metadata, reconstructed from the log without its message bodies.""" 

115 

116 id: str 

117 title: str 

118 created_at: str 

119 updated_at: str 

120 model_ref: str 

121 scope: str 

122 message_count: int 

123 origin: SessionOrigin = SessionOrigin.TUI 

124 """Owning surface. Files written before ownership existed carry no origin; 

125 the only writer then was the TUI, so that is the fallback.""" 

126 

127 

128@dataclass(frozen=True) 

129class Session: 

130 """A session's metadata plus its full transcript.""" 

131 

132 meta: SessionMeta 

133 messages: tuple[SessionMessage, ...] 

134 # Rolling summary of the turns compaction has folded away, empty until the 

135 # conversation first outgrows the prompt budget. It lives here rather than on 

136 # the meta because only replaying a session needs it: listing does not, and 

137 # carrying a paragraph per session would bloat the drawer's hot path and 

138 # every HTTP/MCP list payload. 

139 summary: str = "" 

140 

141 

142class SessionNotFoundError(Exception): 

143 """Raised when a session id has no backing file.""" 

144 

145 def __init__(self, session_id: str) -> None: 

146 super().__init__(f"No session with id {session_id!r}") 

147 self.session_id = session_id 

148 

149 

150def _may_append(surface: SessionOrigin, owner: SessionOrigin) -> bool: 

151 """Whether *surface* may append to a session owned by *owner*. 

152 

153 The human surfaces are one conversation space (the same person in the TUI, 

154 Obsidian, or the shell), so they append to each other's sessions freely. 

155 Agent sessions are working state: only the agent surface appends to them, 

156 and it appends to nothing else without an explicit claim. 

157 """ 

158 if surface is owner: 

159 return True 

160 return surface in HUMAN_ORIGINS and owner in HUMAN_ORIGINS 

161 

162 

163class SessionOwnershipError(Exception): 

164 """Raised when a surface appends to a session another surface owns.""" 

165 

166 def __init__(self, session_id: str, owner: SessionOrigin, surface: SessionOrigin) -> None: 

167 super().__init__( 

168 f"Session {session_id!r} belongs to the {owner.value} surface; " 

169 f"claim it before appending from {surface.value}." 

170 ) 

171 self.session_id = session_id 

172 self.owner = owner 

173 self.surface = surface 

174 

175 

176def derive_title(text: str) -> str: 

177 """Title a session from its first user message: first line, truncated.""" 

178 stripped = text.strip() 

179 if not stripped: 

180 return UNTITLED_SESSION_TITLE 

181 first = stripped.splitlines()[0] 

182 if len(first) > TITLE_MAX_LEN: 

183 return first[:TITLE_MAX_LEN] + TITLE_ELLIPSIS 

184 return first 

185 

186 

187def _message_from_event(event: dict[str, Any], ts: str) -> SessionMessage: 

188 """Reconstruct one message from its ``message`` event line.""" 

189 return SessionMessage( 

190 role=MessageRole(event["role"]), 

191 content=event["content"], 

192 sources=tuple(event.get("sources", [])), 

193 ts=ts, 

194 ) 

195 

196 

197class SessionStore: 

198 """Reads and appends session logs under ``cfg.data_dir/sessions``. 

199 

200 The directory is resolved late-bound from ``cfg`` on every call, so the store 

201 follows a reconfigured data dir (and test isolation) without reconstruction. 

202 ``clock`` is injectable for deterministic tests. 

203 """ 

204 

205 def __init__(self, clock: Callable[[], datetime] | None = None) -> None: 

206 self._clock = clock or (lambda: datetime.now(UTC)) 

207 # path -> (size, mtime, meta) from the last fold of that file; see _meta_for. 

208 self._meta_cache: dict[Path, tuple[int, float, SessionMeta]] = {} 

209 

210 @property 

211 def _dir(self) -> Path: 

212 return cfg.data_dir / SESSIONS_DIRNAME 

213 

214 def _path(self, session_id: str) -> Path: 

215 return self._dir / f"{session_id}.jsonl" 

216 

217 def _now(self) -> str: 

218 return self._clock().isoformat() 

219 

220 def _require(self, session_id: str) -> Path: 

221 path = self._path(session_id) 

222 if not path.exists(): 

223 raise SessionNotFoundError(session_id) 

224 return path 

225 

226 @staticmethod 

227 def _write_event(path: Path, event: dict[str, Any]) -> None: 

228 # Per-session lock: two writers on one id (a second process, another 

229 # surface) serialize instead of interleaving lines. Appends take 

230 # milliseconds, so a blocked writer waits, never fails, under any 

231 # realistic contention; the timeout only bounds a wedged holder. 

232 with ( 

233 FileLock(str(path) + ".lock", timeout=_APPEND_LOCK_TIMEOUT_S), 

234 path.open("a", encoding="utf-8") as fh, 

235 ): 

236 fh.write(json.dumps(event) + "\n") 

237 fh.flush() 

238 os.fsync(fh.fileno()) 

239 

240 @staticmethod 

241 def _iter_events(path: Path) -> Iterator[dict[str, Any]]: 

242 # A torn multi-byte character decodes here, outside the try that skips 

243 # torn lines; replacing makes it a JSON failure that try can catch. 

244 with path.open(encoding="utf-8", errors="replace") as fh: 

245 for raw in fh: 

246 line = raw.strip() 

247 if not line: 

248 continue 

249 try: 

250 yield json.loads(line) 

251 except json.JSONDecodeError: 

252 continue # torn final line; skip it 

253 

254 def create(self, model_ref: str, scope: str, origin: SessionOrigin = SessionOrigin.TUI) -> str: 

255 """Start a new session owned by *origin* and return its id.""" 

256 session_id = uuid4().hex 

257 self._dir.mkdir(parents=True, exist_ok=True) 

258 now = self._now() 

259 self._write_event( 

260 self._path(session_id), 

261 { 

262 "type": SessionEventType.META, 

263 "id": session_id, 

264 "created_at": now, 

265 "model_ref": model_ref, 

266 "scope": scope, 

267 "origin": origin, 

268 "ts": now, 

269 }, 

270 ) 

271 return session_id 

272 

273 def add_message( 

274 self, session_id: str, message: SessionMessage, *, surface: SessionOrigin | None = None 

275 ) -> None: 

276 """Append one message event to an existing session. 

277 

278 With *surface* given, the append is refused unless that surface owns the 

279 session (see ``transfer``); without it the caller is a library embedder 

280 that manages its own store and ownership does not apply. 

281 """ 

282 path = self._require(session_id) 

283 if surface is not None: 

284 meta = self._meta_for(path) 

285 if meta is not None and not _may_append(surface, meta.origin): 

286 raise SessionOwnershipError(session_id, meta.origin, surface) 

287 self._write_event( 

288 path, 

289 { 

290 "type": SessionEventType.MESSAGE, 

291 "role": message.role, 

292 "content": message.content, 

293 "sources": list(message.sources), 

294 "ts": self._now(), 

295 }, 

296 ) 

297 

298 def transfer(self, session_id: str, origin: SessionOrigin) -> None: 

299 """Append an origin event handing the session to *origin*; newest wins. 

300 

301 This is the explicit bridge between the human and agent domains: an 

302 agent claims a session whose id the user handed it, and POST /claim 

303 brings one back. Never implicit in an append. 

304 """ 

305 self._write_event( 

306 self._require(session_id), 

307 {"type": SessionEventType.ORIGIN, "origin": origin, "ts": self._now()}, 

308 ) 

309 

310 def set_title(self, session_id: str, title: str, source: TitleSource) -> None: 

311 """Append a title event; the newest title wins on read.""" 

312 self._write_event( 

313 self._require(session_id), 

314 {"type": SessionEventType.TITLE, "title": title, "source": source, "ts": self._now()}, 

315 ) 

316 

317 def set_summary(self, session_id: str, summary: str) -> None: 

318 """Append a summary event; the newest summary wins on read. 

319 

320 Compaction folds the oldest turns into a summary once they no longer fit 

321 the prompt. The messages themselves stay in the log untouched: the 

322 transcript the user scrolls is always complete, and only what is fed to 

323 the model is condensed. 

324 """ 

325 self._write_event( 

326 self._require(session_id), 

327 {"type": SessionEventType.SUMMARY, "summary": summary, "ts": self._now()}, 

328 ) 

329 

330 def delete(self, session_id: str) -> None: 

331 """Remove a session's file.""" 

332 self._require(session_id).unlink() 

333 

334 def get(self, session_id: str) -> Session: 

335 """Replay a session's log into its reconstructed view.""" 

336 meta, messages, summary = self._replay( 

337 session_id, self._require(session_id), collect_messages=True 

338 ) 

339 return Session(meta=meta, messages=messages, summary=summary) 

340 

341 def list(self, origins: frozenset[SessionOrigin] | None = None) -> list[SessionMeta]: 

342 """Sessions' metadata, newest first; *origins* narrows to those surfaces. 

343 

344 Listing replays every event of every session, so it is the one hot path 

345 here: the drawer runs it on open. Messages are not materialised (only 

346 counted), and each file's meta is memoised against its size and mtime so 

347 reopening a vault that has not changed costs one stat() per session. 

348 """ 

349 if not self._dir.exists(): 

350 return [] 

351 paths = list(self._dir.glob("*.jsonl")) 

352 metas = [meta for meta in (self._meta_for(path) for path in paths) if meta is not None] 

353 if origins is not None: 

354 metas = [meta for meta in metas if meta.origin in origins] 

355 # Drop cache entries for sessions that no longer exist, so a long-lived 

356 # store does not pin the meta of every session ever deleted. 

357 live = {path for path in paths} 

358 self._meta_cache = {p: v for p, v in self._meta_cache.items() if p in live} 

359 return sorted(metas, key=lambda meta: (meta.updated_at, meta.id), reverse=True) 

360 

361 def _meta_for(self, path: Path) -> SessionMeta | None: 

362 """Meta for one session file, reusing the last fold when it is unchanged. 

363 

364 The log is append-only, so any new event grows the file: size plus mtime 

365 is enough to notice a change. A file that grows between the stat and the 

366 read is simply re-folded on the next list(), never served stale. 

367 

368 Returns None when the file goes away underneath us, which is routine: the 

369 CLI or another surface can delete a session while the drawer is listing. 

370 Reading it instead would raise straight out of list(). 

371 """ 

372 try: 

373 stat = path.stat() 

374 except OSError: 

375 return None 

376 cached = self._meta_cache.get(path) 

377 if cached is not None and cached[0] == stat.st_size and cached[1] == stat.st_mtime: 

378 return cached[2] 

379 try: 

380 meta = self._replay(path.stem, path, collect_messages=False)[0] 

381 except OSError: 

382 return None 

383 self._meta_cache[path] = (stat.st_size, stat.st_mtime, meta) 

384 return meta 

385 

386 def _replay( 

387 self, session_id: str, path: Path, *, collect_messages: bool 

388 ) -> tuple[SessionMeta, tuple[SessionMessage, ...], str]: 

389 """Fold a session's event log into its meta, messages and summary. 

390 

391 ``collect_messages=False`` is for listing, which needs only the count: 

392 building a SessionMessage per message across a whole vault is pure waste. 

393 """ 

394 created_at = "" 

395 model_ref = "" 

396 scope = "" 

397 title = UNTITLED_SESSION_TITLE 

398 updated_at = "" 

399 summary = "" 

400 origin = SessionOrigin.TUI 

401 message_count = 0 

402 messages: list[SessionMessage] = [] 

403 for event in self._iter_events(path): 

404 ts = event.get("ts", "") 

405 updated_at = ts 

406 event_type = event.get("type") 

407 if event_type == SessionEventType.META: 

408 created_at = event["created_at"] 

409 model_ref = event["model_ref"] 

410 scope = event["scope"] 

411 origin = SessionOrigin(event.get("origin", SessionOrigin.TUI)) 

412 elif event_type == SessionEventType.ORIGIN: 

413 origin = SessionOrigin(event["origin"]) 

414 elif event_type == SessionEventType.TITLE: 

415 title = event["title"] 

416 elif event_type == SessionEventType.SUMMARY: 

417 summary = event["summary"] 

418 elif event_type == SessionEventType.MESSAGE: 

419 message_count += 1 

420 if collect_messages: 

421 messages.append(_message_from_event(event, ts)) 

422 meta = SessionMeta( 

423 id=session_id, 

424 title=title, 

425 created_at=created_at, 

426 updated_at=updated_at, 

427 model_ref=model_ref, 

428 scope=scope, 

429 message_count=message_count, 

430 origin=origin, 

431 ) 

432 return meta, tuple(messages), summary