Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1845 lines
74 KiB
Python

"""
SQLite-backed unified chat session store.
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
import json
import os
from pathlib import Path
import re
import sqlite3
import time
from typing import Any
import uuid
from deeptutor.services.path_service import get_path_service
def _json_dumps(value: Any) -> str:
# default=str: a single non-serializable object inside an event payload
# (e.g. a dataclass smuggled into tool args) must degrade to its repr,
# never kill message/event persistence for the whole turn.
return json.dumps(value, ensure_ascii=False, default=str)
# Sentinel so ``add_message`` can distinguish "caller wants the legacy
# auto-pick-latest-message default" from "caller explicitly wants the
# message attached at the session root (parent = NULL)". Both surface as
# ``None`` in the public ``parent_message_id`` arg, which is why we need
# a sentinel separate from None.
class _Unset:
pass
_PARENT_AUTO = _Unset()
def _json_loads(value: str | None, default: Any) -> Any:
if not value:
return default
try:
return json.loads(value)
except json.JSONDecodeError:
return default
# Imported conversations share the session tables with native chats but carry
# this id prefix as their discriminator (see ``SQLiteSessionStore._WHERE_*``).
_IMPORTED_ID_PREFIX = "imported_"
_ID_SAFE = re.compile(r"[^A-Za-z0-9_-]")
def make_imported_session_id(source: str, external_id: str) -> str:
"""Build a deterministic, dedup-friendly id for an imported conversation.
``source`` (e.g. ``claude_code``/``codex``) namespaces the original
session uuid so two tools that happen to reuse an id never collide; the
determinism is what makes re-importing the same folder idempotent.
"""
src = _ID_SAFE.sub("-", (source or "external").strip()) or "external"
ext = _ID_SAFE.sub("-", (external_id or "").strip()) or uuid.uuid4().hex
return f"{_IMPORTED_ID_PREFIX}{src}_{ext}"
@dataclass
class TurnRecord:
id: str
session_id: str
capability: str
status: str
error: str
created_at: float
updated_at: float
finished_at: float | None
last_seq: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"turn_id": self.id,
"session_id": self.session_id,
"capability": self.capability,
"status": self.status,
"error": self.error,
"created_at": self.created_at,
"updated_at": self.updated_at,
"finished_at": self.finished_at,
"last_seq": self.last_seq,
}
class SQLiteSessionStore:
"""Persist unified chat sessions and messages in a SQLite database."""
def __init__(self, db_path: Path | None = None) -> None:
path_service = get_path_service()
self.db_path = db_path or path_service.get_chat_history_db()
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._migrate_legacy_db(path_service)
self._lock = asyncio.Lock()
self._initialize()
def _migrate_legacy_db(self, path_service) -> None:
"""Move the legacy ``data/chat_history.db`` into ``data/user/`` once."""
legacy_path = path_service.project_root / "data" / "chat_history.db"
if self.db_path.exists() or not legacy_path.exists() or legacy_path == self.db_path:
return
try:
os.replace(legacy_path, self.db_path)
except OSError:
# Fall back to leaving the legacy DB in place if an OS-level move
# is not possible; the new DB path will be initialized empty.
pass
def _initialize(self) -> None:
with self._connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT 'New conversation',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
compressed_summary TEXT DEFAULT '',
summary_up_to_msg_id INTEGER DEFAULT 0,
preferences_json TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
capability TEXT DEFAULT '',
events_json TEXT DEFAULT '',
attachments_json TEXT DEFAULT '',
metadata_json TEXT DEFAULT '{}',
created_at REAL NOT NULL,
-- Edit-branching: NULL for the first message in a session;
-- otherwise the immediately preceding message on the path
-- this row continues. Siblings (same parent) are alternate
-- branches the user can switch between.
parent_message_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_messages_session_created
ON messages(session_id, created_at, id);
-- ``idx_messages_parent`` is created after the
-- parent_message_id migration runs (see below). Putting it
-- in this script would fail on legacy DBs where the column
-- gets added by ALTER TABLE further down.
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at
ON sessions(updated_at DESC);
CREATE TABLE IF NOT EXISTS turns (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
capability TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'running',
error TEXT DEFAULT '',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
finished_at REAL
);
CREATE INDEX IF NOT EXISTS idx_turns_session_updated
ON turns(session_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_turns_session_status
ON turns(session_id, status, updated_at DESC);
CREATE TABLE IF NOT EXISTS turn_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
source TEXT DEFAULT '',
stage TEXT DEFAULT '',
content TEXT DEFAULT '',
metadata_json TEXT DEFAULT '',
timestamp REAL NOT NULL,
created_at REAL NOT NULL,
UNIQUE(turn_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_turn_events_turn_seq
ON turn_events(turn_id, seq);
CREATE TABLE IF NOT EXISTS notebook_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
turn_id TEXT NOT NULL DEFAULT '',
question_id TEXT NOT NULL,
question TEXT NOT NULL,
question_type TEXT DEFAULT '',
options_json TEXT DEFAULT '{}',
correct_answer TEXT DEFAULT '',
explanation TEXT DEFAULT '',
difficulty TEXT DEFAULT '',
user_answer TEXT DEFAULT '',
user_answer_images_json TEXT DEFAULT '[]',
is_correct INTEGER DEFAULT 0,
bookmarked INTEGER DEFAULT 0,
followup_session_id TEXT DEFAULT '',
ai_judgment TEXT DEFAULT '',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
UNIQUE(session_id, turn_id, question_id)
);
CREATE INDEX IF NOT EXISTS idx_notebook_entries_session
ON notebook_entries(session_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notebook_entries_bookmarked
ON notebook_entries(bookmarked, created_at DESC);
CREATE TABLE IF NOT EXISTS notebook_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS notebook_entry_categories (
entry_id INTEGER NOT NULL REFERENCES notebook_entries(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES notebook_categories(id) ON DELETE CASCADE,
PRIMARY KEY (entry_id, category_id)
);
"""
)
columns = {row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()}
if "preferences_json" not in columns:
conn.execute("ALTER TABLE sessions ADD COLUMN preferences_json TEXT DEFAULT '{}'")
if "kind" in columns:
try:
conn.execute("ALTER TABLE sessions DROP COLUMN kind")
except sqlite3.OperationalError:
# Older SQLite builds may not support DROP COLUMN. The
# application no longer reads or writes this legacy field.
pass
message_columns = {
row[1] for row in conn.execute("PRAGMA table_info(messages)").fetchall()
}
if "metadata_json" not in message_columns:
conn.execute("ALTER TABLE messages ADD COLUMN metadata_json TEXT DEFAULT '{}'")
if "parent_message_id" not in message_columns:
conn.execute("ALTER TABLE messages ADD COLUMN parent_message_id INTEGER")
# Backfill: for every existing session, treat the message stream
# as a single linear path — each row's parent is the previous
# row (by id) in the same session. Rows with no predecessor stay
# NULL. We do this per session in pure Python to avoid relying
# on window functions, which older SQLite builds may not have.
sessions_rows = conn.execute("SELECT id FROM sessions").fetchall()
for srow in sessions_rows:
prev_id: int | None = None
msg_rows = conn.execute(
"SELECT id FROM messages WHERE session_id = ? ORDER BY id ASC",
(srow[0],),
).fetchall()
for mrow in msg_rows:
if prev_id is not None:
conn.execute(
"UPDATE messages SET parent_message_id = ? WHERE id = ?",
(prev_id, mrow[0]),
)
prev_id = mrow[0]
# Always ensure the parent-lookup index exists — covers both
# the legacy-migration case (just added the column) and the
# fresh-DB case (created above without the index inline).
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_parent "
"ON messages(session_id, parent_message_id)"
)
self._migrate_notebook_entries_add_turn_id(conn)
self._migrate_notebook_entries_add_user_answer_images(conn)
self._migrate_notebook_entries_add_ai_judgment(conn)
conn.commit()
@staticmethod
def _migrate_notebook_entries_add_turn_id(conn: sqlite3.Connection) -> None:
"""Add ``turn_id`` to legacy notebook_entries and re-scope the UNIQUE
constraint to ``(session_id, turn_id, question_id)``.
The old unique constraint conflated quizzes generated in the same chat
(issue #487): regenerating a quiz with the same positional
``question_id`` (e.g. ``q_1``) would collide with the previous quiz's
notebook entries and the UI hydrated stale answers. Scoping by
``turn_id`` keeps each quiz isolated.
"""
notebook_cols = {
row[1] for row in conn.execute("PRAGMA table_info(notebook_entries)").fetchall()
}
if not notebook_cols:
return
if "turn_id" not in notebook_cols:
conn.execute("ALTER TABLE notebook_entries ADD COLUMN turn_id TEXT NOT NULL DEFAULT ''")
# SQLite stores table-level UNIQUE constraints as auto-indexes whose
# names start with ``sqlite_autoindex_notebook_entries_``; the columns
# they cover live in PRAGMA index_info. Detect whether any existing
# auto-index still covers only (session_id, question_id) and, if so,
# rebuild the table to swap in the new scope.
needs_rebuild = False
for idx_row in conn.execute("PRAGMA index_list(notebook_entries)").fetchall():
idx_name = idx_row[1]
if not idx_name.startswith("sqlite_autoindex_notebook_entries_"):
continue
cols = [r[2] for r in conn.execute(f"PRAGMA index_info({idx_name})").fetchall()]
if cols == ["session_id", "question_id"]:
needs_rebuild = True
break
if not needs_rebuild:
return
conn.executescript(
"""
CREATE TABLE notebook_entries_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
turn_id TEXT NOT NULL DEFAULT '',
question_id TEXT NOT NULL,
question TEXT NOT NULL,
question_type TEXT DEFAULT '',
options_json TEXT DEFAULT '{}',
correct_answer TEXT DEFAULT '',
explanation TEXT DEFAULT '',
difficulty TEXT DEFAULT '',
user_answer TEXT DEFAULT '',
is_correct INTEGER DEFAULT 0,
bookmarked INTEGER DEFAULT 0,
followup_session_id TEXT DEFAULT '',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
UNIQUE(session_id, turn_id, question_id)
);
INSERT INTO notebook_entries_new (
id, session_id, turn_id, question_id, question, question_type,
options_json, correct_answer, explanation, difficulty,
user_answer, is_correct, bookmarked, followup_session_id,
created_at, updated_at
)
SELECT
id, session_id, COALESCE(turn_id, ''), question_id, question,
question_type, options_json, correct_answer, explanation,
difficulty, user_answer, is_correct, bookmarked,
followup_session_id, created_at, updated_at
FROM notebook_entries;
DROP TABLE notebook_entries;
ALTER TABLE notebook_entries_new RENAME TO notebook_entries;
CREATE INDEX IF NOT EXISTS idx_notebook_entries_session
ON notebook_entries(session_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notebook_entries_bookmarked
ON notebook_entries(bookmarked, created_at DESC);
"""
)
@staticmethod
def _migrate_notebook_entries_add_user_answer_images(
conn: sqlite3.Connection,
) -> None:
"""Back-fill ``user_answer_images_json`` on legacy DBs.
The column stores a JSON array of ``{id, url, filename, mime_type}``
records for image attachments uploaded as part of the learner's
answer. The bytes themselves live in the AttachmentStore; we only
keep references in the row so notebook_entries stays lean.
"""
cols = {row[1] for row in conn.execute("PRAGMA table_info(notebook_entries)").fetchall()}
if not cols:
return
if "user_answer_images_json" not in cols:
conn.execute(
"ALTER TABLE notebook_entries ADD COLUMN user_answer_images_json TEXT DEFAULT '[]'"
)
@staticmethod
def _migrate_notebook_entries_add_ai_judgment(
conn: sqlite3.Connection,
) -> None:
"""Back-fill ``ai_judgment`` on legacy DBs.
Stores the latest AI-judge text per entry as plain markdown. Empty
string means the learner has not run the AI judge for this entry
yet.
"""
cols = {row[1] for row in conn.execute("PRAGMA table_info(notebook_entries)").fetchall()}
if not cols:
return
if "ai_judgment" not in cols:
conn.execute("ALTER TABLE notebook_entries ADD COLUMN ai_judgment TEXT DEFAULT ''")
async def _run(self, fn, *args):
async with self._lock:
return await asyncio.to_thread(fn, *args)
@contextmanager
def _connect(self) -> Iterator[sqlite3.Connection]:
# sqlite3.Connection's own context manager commits/rolls back but does
# NOT close the connection — so naked `with sqlite3.connect(...)` leaks
# one FD per call until GC. Wrap it so each call site gets both
# transaction semantics and deterministic close. The inner `with conn`
# commits on clean exit and rolls back on exception, so call sites do
# NOT need an explicit conn.commit() (any remaining ones are no-ops).
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
try:
with conn:
yield conn
finally:
conn.close()
def _create_session_sync(
self,
title: str | None = None,
session_id: str | None = None,
) -> dict[str, Any]:
now = time.time()
resolved_id = session_id or f"unified_{int(now * 1000)}_{uuid.uuid4().hex[:8]}"
resolved_title = (title or "New conversation").strip() or "New conversation"
with self._connect() as conn:
conn.execute(
"""
INSERT INTO sessions (
id, title, created_at, updated_at,
compressed_summary, summary_up_to_msg_id
)
VALUES (?, ?, ?, ?, '', 0)
""",
(resolved_id, resolved_title[:100], now, now),
)
conn.commit()
return {
"id": resolved_id,
"session_id": resolved_id,
"title": resolved_title[:100],
"created_at": now,
"updated_at": now,
"compressed_summary": "",
"summary_up_to_msg_id": 0,
}
async def create_session(
self,
title: str | None = None,
session_id: str | None = None,
) -> dict[str, Any]:
return await self._run(self._create_session_sync, title, session_id)
def _get_session_sync(self, session_id: str) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT
s.id,
s.title,
s.created_at,
s.updated_at,
s.compressed_summary,
s.summary_up_to_msg_id,
s.preferences_json,
COALESCE(
(
SELECT t.status
FROM turns t
WHERE t.session_id = s.id
ORDER BY t.updated_at DESC
LIMIT 1
),
'idle'
) AS status,
COALESCE(
(
SELECT t.id
FROM turns t
WHERE t.session_id = s.id AND t.status = 'running'
ORDER BY t.updated_at DESC
LIMIT 1
),
''
) AS active_turn_id,
COALESCE(
(
SELECT t.capability
FROM turns t
WHERE t.session_id = s.id
ORDER BY t.updated_at DESC
LIMIT 1
),
''
) AS capability
FROM sessions
s
WHERE s.id = ?
""",
(session_id,),
).fetchone()
if not row:
return None
payload = dict(row)
payload["session_id"] = payload["id"]
payload["preferences"] = _json_loads(payload.pop("preferences_json", ""), {})
return payload
async def get_session(self, session_id: str) -> dict[str, Any] | None:
return await self._run(self._get_session_sync, session_id)
async def ensure_session(
self,
session_id: str | None = None,
) -> dict[str, Any]:
if session_id:
session = await self.get_session(session_id)
if session is not None:
return session
return await self.create_session()
@staticmethod
def _serialize_turn(row: sqlite3.Row) -> dict[str, Any]:
return TurnRecord(
id=row["id"],
session_id=row["session_id"],
capability=row["capability"] or "",
status=row["status"] or "running",
error=row["error"] or "",
created_at=row["created_at"],
updated_at=row["updated_at"],
finished_at=row["finished_at"],
last_seq=row["last_seq"] if "last_seq" in row.keys() else 0,
).to_dict()
def _create_turn_sync(self, session_id: str, capability: str = "") -> dict[str, Any]:
now = time.time()
turn_id = f"turn_{int(now * 1000)}_{uuid.uuid4().hex[:10]}"
with self._connect() as conn:
session = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
if session is None:
raise ValueError(f"Session not found: {session_id}")
active = conn.execute(
"""
SELECT id
FROM turns
WHERE session_id = ? AND status = 'running'
ORDER BY updated_at DESC
LIMIT 1
""",
(session_id,),
).fetchone()
if active is not None:
raise RuntimeError(f"Session already has an active turn: {active['id']}")
conn.execute(
"""
INSERT INTO turns (id, session_id, capability, status, error, created_at, updated_at, finished_at)
VALUES (?, ?, ?, 'running', '', ?, ?, NULL)
""",
(turn_id, session_id, capability or "", now, now),
)
conn.commit()
return {
"id": turn_id,
"turn_id": turn_id,
"session_id": session_id,
"capability": capability or "",
"status": "running",
"error": "",
"created_at": now,
"updated_at": now,
"finished_at": None,
"last_seq": 0,
}
async def create_turn(self, session_id: str, capability: str = "") -> dict[str, Any]:
return await self._run(self._create_turn_sync, session_id, capability)
def _get_turn_sync(self, turn_id: str) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT
t.*,
COALESCE((SELECT MAX(seq) FROM turn_events te WHERE te.turn_id = t.id), 0) AS last_seq
FROM turns t
WHERE t.id = ?
""",
(turn_id,),
).fetchone()
if row is None:
return None
return self._serialize_turn(row)
async def get_turn(self, turn_id: str) -> dict[str, Any] | None:
return await self._run(self._get_turn_sync, turn_id)
def _get_active_turn_sync(self, session_id: str) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT
t.*,
COALESCE((SELECT MAX(seq) FROM turn_events te WHERE te.turn_id = t.id), 0) AS last_seq
FROM turns t
WHERE t.session_id = ? AND t.status = 'running'
ORDER BY t.updated_at DESC
LIMIT 1
""",
(session_id,),
).fetchone()
if row is None:
return None
return self._serialize_turn(row)
async def get_active_turn(self, session_id: str) -> dict[str, Any] | None:
return await self._run(self._get_active_turn_sync, session_id)
def _list_active_turns_sync(self, session_id: str) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT
t.*,
COALESCE((SELECT MAX(seq) FROM turn_events te WHERE te.turn_id = t.id), 0) AS last_seq
FROM turns t
WHERE t.session_id = ? AND t.status = 'running'
ORDER BY t.updated_at DESC
""",
(session_id,),
).fetchall()
return [self._serialize_turn(row) for row in rows]
async def list_active_turns(self, session_id: str) -> list[dict[str, Any]]:
return await self._run(self._list_active_turns_sync, session_id)
def _update_turn_status_sync(self, turn_id: str, status: str, error: str = "") -> bool:
now = time.time()
finished_at = now if status in {"completed", "failed", "cancelled"} else None
with self._connect() as conn:
cur = conn.execute(
"""
UPDATE turns
SET status = ?, error = ?, updated_at = ?, finished_at = ?
WHERE id = ?
""",
(status, error or "", now, finished_at, turn_id),
)
conn.commit()
return cur.rowcount > 0
async def update_turn_status(self, turn_id: str, status: str, error: str = "") -> bool:
return await self._run(self._update_turn_status_sync, turn_id, status, error)
def _append_turn_event_sync(self, turn_id: str, event: dict[str, Any]) -> dict[str, Any]:
now = time.time()
with self._connect() as conn:
turn = conn.execute(
"SELECT id, session_id FROM turns WHERE id = ?", (turn_id,)
).fetchone()
if turn is None:
raise ValueError(f"Turn not found: {turn_id}")
provided_seq = int(event.get("seq") or 0)
if provided_seq > 0:
seq = provided_seq
else:
row = conn.execute(
"SELECT COALESCE(MAX(seq), 0) AS last_seq FROM turn_events WHERE turn_id = ?",
(turn_id,),
).fetchone()
seq = int(row["last_seq"]) + 1 if row else 1
payload = dict(event)
payload["seq"] = seq
payload["turn_id"] = payload.get("turn_id") or turn_id
payload["session_id"] = payload.get("session_id") or turn["session_id"]
conn.execute(
"""
INSERT OR REPLACE INTO turn_events (
turn_id, seq, type, source, stage, content, metadata_json, timestamp, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
turn_id,
seq,
payload.get("type", ""),
payload.get("source", ""),
payload.get("stage", ""),
payload.get("content", "") or "",
_json_dumps(payload.get("metadata", {})),
float(payload.get("timestamp") or now),
now,
),
)
conn.execute(
"UPDATE turns SET updated_at = ? WHERE id = ?",
(now, turn_id),
)
conn.commit()
return payload
async def append_turn_event(self, turn_id: str, event: dict[str, Any]) -> dict[str, Any]:
return await self._run(self._append_turn_event_sync, turn_id, event)
def _get_turn_events_sync(self, turn_id: str, after_seq: int = 0) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT turn_id, seq, type, source, stage, content, metadata_json, timestamp
FROM turn_events
WHERE turn_id = ? AND seq > ?
ORDER BY seq ASC
""",
(turn_id, max(0, int(after_seq))),
).fetchall()
turn = conn.execute("SELECT session_id FROM turns WHERE id = ?", (turn_id,)).fetchone()
session_id = turn["session_id"] if turn else ""
return [
{
"type": row["type"],
"source": row["source"] or "",
"stage": row["stage"] or "",
"content": row["content"] or "",
"metadata": _json_loads(row["metadata_json"], {}),
"session_id": session_id,
"turn_id": row["turn_id"],
"seq": row["seq"],
"timestamp": row["timestamp"],
}
for row in rows
]
async def get_turn_events(self, turn_id: str, after_seq: int = 0) -> list[dict[str, Any]]:
return await self._run(self._get_turn_events_sync, turn_id, after_seq)
def _update_session_title_sync(self, session_id: str, title: str) -> bool:
with self._connect() as conn:
cur = conn.execute(
"""
UPDATE sessions
SET title = ?, updated_at = ?
WHERE id = ?
""",
((title.strip() or "New conversation")[:100], time.time(), session_id),
)
conn.commit()
return cur.rowcount > 0
async def update_session_title(self, session_id: str, title: str) -> bool:
return await self._run(self._update_session_title_sync, session_id, title)
def _delete_session_sync(self, session_id: str) -> bool:
with self._connect() as conn:
cur = conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
conn.commit()
return cur.rowcount > 0
async def delete_session(self, session_id: str) -> bool:
return await self._run(self._delete_session_sync, session_id)
def _add_message_sync(
self,
session_id: str,
role: str,
content: str,
capability: str = "",
events: list[dict[str, Any]] | None = None,
attachments: list[dict[str, Any]] | None = None,
metadata: dict[str, Any] | None = None,
parent_message_id: int | None | _Unset = _PARENT_AUTO,
) -> int:
now = time.time()
with self._connect() as conn:
session = conn.execute(
"SELECT id, title FROM sessions WHERE id = ?", (session_id,)
).fetchone()
if session is None:
raise ValueError(f"Session not found: {session_id}")
resolved_parent_id: int | None
if isinstance(parent_message_id, _Unset):
# Legacy auto-append path: chain off the latest row in the
# session so the linear thread stays connected.
last_row = conn.execute(
"SELECT id FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1",
(session_id,),
).fetchone()
resolved_parent_id = int(last_row["id"]) if last_row is not None else None
else:
# Caller pinned a parent explicitly — including ``None``,
# which means "attach at the session root" (used by edits
# of the very first message in a session).
resolved_parent_id = (
int(parent_message_id) if parent_message_id is not None else None
)
cur = conn.execute(
"""
INSERT INTO messages (
session_id, role, content, capability, events_json,
attachments_json, metadata_json, created_at, parent_message_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
session_id,
role,
content or "",
capability or "",
_json_dumps(events or []),
_json_dumps(attachments or []),
_json_dumps(metadata or {}),
now,
resolved_parent_id,
),
)
# Title is no longer derived from the first user message — the
# turn runtime calls an LLM to generate a real summary title
# once the first user+assistant pair is complete. Until then
# the session keeps the default sentinel ``New conversation``
# which the frontend renders as a breathing "New chat" chip.
conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?",
(now, session_id),
)
conn.commit()
return int(cur.lastrowid)
async def add_message(
self,
session_id: str,
role: str,
content: str,
capability: str = "",
events: list[dict[str, Any]] | None = None,
attachments: list[dict[str, Any]] | None = None,
metadata: dict[str, Any] | None = None,
parent_message_id: int | None | _Unset = _PARENT_AUTO,
) -> int:
return await self._run(
self._add_message_sync,
session_id,
role,
content,
capability,
events,
attachments,
metadata,
parent_message_id,
)
@staticmethod
def _backfill_import_meta_sync(
conn: sqlite3.Connection,
session_id: str,
current_prefs_json: str | None,
incoming_prefs: dict[str, Any],
) -> bool:
"""Merge agent attribution from a re-import into an existing session's
``preferences.import`` block, leaving everything else untouched. Returns
whether anything changed (so the caller can skip a needless write)."""
incoming_import = (incoming_prefs or {}).get("import") or {}
if not incoming_import:
return False
prefs = _json_loads(current_prefs_json, {})
if not isinstance(prefs, dict):
prefs = {}
meta = dict(prefs.get("import") or {})
changed = False
# Only attribution fields propagate on re-import; source/external_id are
# part of the dedup identity and never change for a given session.
for key in ("agent_id", "agent_name", "source_cwd"):
value = incoming_import.get(key)
if value and meta.get(key) != value:
meta[key] = value
changed = True
if not changed:
return False
prefs["import"] = meta
conn.execute(
"UPDATE sessions SET preferences_json = ? WHERE id = ?",
(_json_dumps(prefs), session_id),
)
return True
def _import_session_sync(
self,
session_id: str,
title: str,
created_at: float,
updated_at: float,
preferences: dict[str, Any],
messages: list[dict[str, Any]],
) -> dict[str, Any]:
with self._connect() as conn:
existing = conn.execute(
"SELECT preferences_json FROM sessions WHERE id = ?", (session_id,)
).fetchone()
if existing is not None:
# Idempotent on content: a session imported before keeps its
# (possibly already-continued) state — re-importing the same
# folder never duplicates rows or clobbers the user's edits.
# We do, however, backfill agent attribution (agent_id /
# agent_name) so re-syncing re-tags conversations that were
# imported before the agent model existed, and an agent rename
# propagates. This only touches the ``import`` metadata block.
updated = self._backfill_import_meta_sync(
conn, session_id, existing["preferences_json"], preferences
)
if updated:
conn.commit()
return {
"session_id": session_id,
"imported": False,
"updated": updated,
"message_count": 0,
}
safe_title = (title or "").strip()[:100] or "Imported conversation"
conn.execute(
"""
INSERT INTO sessions (
id, title, created_at, updated_at,
compressed_summary, summary_up_to_msg_id, preferences_json
) VALUES (?, ?, ?, ?, '', 0, ?)
""",
(session_id, safe_title, created_at, updated_at, _json_dumps(preferences or {})),
)
prev_id: int | None = None
count = 0
for msg in messages:
cur = conn.execute(
"""
INSERT INTO messages (
session_id, role, content, capability, events_json,
attachments_json, metadata_json, created_at, parent_message_id
) VALUES (?, ?, ?, '', '[]', '[]', ?, ?, ?)
""",
(
session_id,
msg.get("role") or "user",
msg.get("content") or "",
_json_dumps(msg.get("metadata") or {}),
float(msg.get("created_at") or created_at),
prev_id,
),
)
prev_id = int(cur.lastrowid)
count += 1
conn.commit()
return {"session_id": session_id, "imported": True, "message_count": count}
async def import_session(
self,
session_id: str,
title: str,
created_at: float,
updated_at: float,
preferences: dict[str, Any] | None,
messages: list[dict[str, Any]],
) -> dict[str, Any]:
"""Persist a pre-existing conversation (imported from an external CLI
such as Claude Code or Codex) as a normal session, so the chat loop can
re-open and continue it. ``session_id`` must carry the ``imported_``
prefix (see :data:`_IMPORTED_ID_PREFIX`). Idempotent by id: a session
already present is left untouched.
"""
return await self._run(
self._import_session_sync,
session_id,
title,
created_at,
updated_at,
preferences or {},
messages,
)
def _delete_message_sync(self, message_id: int | str) -> bool:
with self._connect() as conn:
cur = conn.execute("DELETE FROM messages WHERE id = ?", (int(message_id),))
conn.commit()
return cur.rowcount > 0
async def delete_message(self, message_id: int | str) -> bool:
return await self._run(self._delete_message_sync, message_id)
def _delete_turn_by_message_sync(self, session_id: str, message_id: int) -> dict[str, Any]:
with self._connect() as conn:
msg = conn.execute(
"""
SELECT id, session_id, role, attachments_json, created_at
FROM messages
WHERE id = ?
""",
(int(message_id),),
).fetchone()
if msg is None or msg["session_id"] != session_id:
return {
"deleted": False,
"attachment_ids": [],
"turn_id": None,
"was_running": False,
}
role = msg["role"]
paired_msg = None
if role == "user":
paired_msg = conn.execute(
"""
SELECT id, session_id, role, attachments_json, created_at
FROM messages
WHERE session_id = ? AND role = 'assistant' AND id > ?
ORDER BY id ASC
LIMIT 1
""",
(session_id, int(message_id)),
).fetchone()
elif role == "assistant":
paired_msg = conn.execute(
"""
SELECT id, session_id, role, attachments_json, created_at
FROM messages
WHERE session_id = ? AND role = 'user' AND id < ?
ORDER BY id DESC
LIMIT 1
""",
(session_id, int(message_id)),
).fetchone()
user_msg = msg if role == "user" else paired_msg
turn_id = None
was_running = False
if user_msg is not None:
user_created_at = user_msg["created_at"]
turn_row = conn.execute(
"""
SELECT id, status
FROM turns
WHERE session_id = ? AND created_at >= ?
ORDER BY created_at ASC
LIMIT 1
""",
(session_id, user_created_at),
).fetchone()
if turn_row is not None:
turn_id = turn_row["id"]
was_running = turn_row["status"] == "running"
if was_running:
return {
"deleted": False,
"attachment_ids": [],
"turn_id": turn_id,
"was_running": True,
}
attachment_ids: list[str] = []
for m in [msg, paired_msg]:
if m is not None:
atts = _json_loads(m["attachments_json"], [])
for att in atts:
aid = att.get("id") or att.get("attachment_id")
if aid:
attachment_ids.append(aid)
if turn_id is not None:
conn.execute("DELETE FROM turn_events WHERE turn_id = ?", (turn_id,))
conn.execute("DELETE FROM turns WHERE id = ?", (turn_id,))
ids_to_delete = [int(message_id)]
if paired_msg is not None:
ids_to_delete.append(int(paired_msg["id"]))
conn.execute(
f"DELETE FROM messages WHERE id IN ({','.join('?' * len(ids_to_delete))})", # nosec B608
tuple(ids_to_delete),
)
session_row = conn.execute(
"SELECT summary_up_to_msg_id FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if session_row is not None:
summary_up_to = int(session_row["summary_up_to_msg_id"])
if any(mid <= summary_up_to for mid in ids_to_delete):
conn.execute(
"UPDATE sessions SET summary_up_to_msg_id = 0 WHERE id = ?",
(session_id,),
)
conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?",
(time.time(), session_id),
)
conn.commit()
return {
"deleted": True,
"attachment_ids": attachment_ids,
"turn_id": turn_id,
"was_running": was_running,
}
async def delete_turn_by_message(self, session_id: str, message_id: int) -> dict[str, Any]:
return await self._run(self._delete_turn_by_message_sync, session_id, message_id)
def _get_last_message_sync(
self, session_id: str, role: str | None = None
) -> dict[str, Any] | None:
with self._connect() as conn:
if role is None:
row = conn.execute(
"""
SELECT id, session_id, role, content, capability, events_json,
attachments_json, metadata_json, created_at, parent_message_id
FROM messages
WHERE session_id = ?
ORDER BY id DESC
LIMIT 1
""",
(session_id,),
).fetchone()
else:
row = conn.execute(
"""
SELECT id, session_id, role, content, capability, events_json,
attachments_json, metadata_json, created_at, parent_message_id
FROM messages
WHERE session_id = ? AND role = ?
ORDER BY id DESC
LIMIT 1
""",
(session_id, role),
).fetchone()
if row is None:
return None
return self._serialize_message(row)
async def get_last_message(
self, session_id: str, role: str | None = None
) -> dict[str, Any] | None:
return await self._run(self._get_last_message_sync, session_id, role)
def _serialize_message(self, row: sqlite3.Row) -> dict[str, Any]:
row_keys = row.keys()
parent_id = row["parent_message_id"] if "parent_message_id" in row_keys else None
return {
"id": row["id"],
"session_id": row["session_id"],
"role": row["role"],
"content": row["content"],
"capability": row["capability"] or "",
"events": _json_loads(row["events_json"], []),
"attachments": _json_loads(row["attachments_json"], []),
"metadata": _json_loads(row["metadata_json"], {}),
"created_at": row["created_at"],
"parent_message_id": int(parent_id) if parent_id is not None else None,
}
def _get_messages_sync(self, session_id: str) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT id, session_id, role, content, capability, events_json,
attachments_json, metadata_json, created_at, parent_message_id
FROM messages
WHERE session_id = ?
ORDER BY id ASC
""",
(session_id,),
).fetchall()
return [self._serialize_message(row) for row in rows]
def _get_message_path_sync(self, session_id: str, leaf_message_id: int) -> list[dict[str, Any]]:
"""Return the chain of messages from the session root down to
``leaf_message_id`` (inclusive), in chronological order.
Used by the turn runtime to build LLM context for a branched
re-run: only ancestors of the new user message are included, so
sibling branches at any depth are excluded.
"""
with self._connect() as conn:
chain: list[dict[str, Any]] = []
current: int | None = int(leaf_message_id)
# Bound the walk defensively in case of corrupted parent pointers.
safety = 10_000
while current is not None and safety > 0:
row = conn.execute(
"""
SELECT id, session_id, role, content, capability, events_json,
attachments_json, metadata_json, created_at, parent_message_id
FROM messages
WHERE id = ? AND session_id = ?
""",
(current, session_id),
).fetchone()
if row is None:
break
chain.append(self._serialize_message(row))
parent = row["parent_message_id"]
current = int(parent) if parent is not None else None
safety -= 1
chain.reverse()
return chain
async def get_message_path(self, session_id: str, leaf_message_id: int) -> list[dict[str, Any]]:
return await self._run(self._get_message_path_sync, session_id, int(leaf_message_id))
async def get_messages(self, session_id: str) -> list[dict[str, Any]]:
return await self._run(self._get_messages_sync, session_id)
def _get_messages_for_context_sync(
self, session_id: str, leaf_message_id: int | None = None
) -> list[dict[str, Any]]:
with self._connect() as conn:
if leaf_message_id is None:
rows = conn.execute(
"""
SELECT id, role, content
FROM messages
WHERE session_id = ?
AND role IN ('user', 'assistant', 'system')
ORDER BY id ASC
""",
(session_id,),
).fetchall()
return [
{
"id": row["id"],
"role": row["role"],
"content": row["content"] or "",
}
for row in rows
]
# Branch-aware path walk: include only ancestors (+ leaf) so
# sibling branches at any depth are excluded from LLM context.
chain: list[dict[str, Any]] = []
current: int | None = int(leaf_message_id)
safety = 10_000
while current is not None and safety > 0:
row = conn.execute(
"""
SELECT id, role, content, parent_message_id
FROM messages
WHERE id = ? AND session_id = ?
AND role IN ('user', 'assistant', 'system')
""",
(current, session_id),
).fetchone()
if row is None:
break
chain.append(
{
"id": row["id"],
"role": row["role"],
"content": row["content"] or "",
}
)
parent = row["parent_message_id"]
current = int(parent) if parent is not None else None
safety -= 1
chain.reverse()
return chain
async def get_messages_for_context(
self, session_id: str, leaf_message_id: int | None = None
) -> list[dict[str, Any]]:
return await self._run(self._get_messages_for_context_sync, session_id, leaf_message_id)
# Imported conversations live in the same tables as native chats (so the
# chat loop can re-open and continue them) but carry an ``imported_`` id
# prefix. That prefix is the discriminator — it travels with the primary
# key, so we filter on it instead of adding a column + migration.
_SESSION_SUMMARY_SQL = """
SELECT
s.id,
s.title,
s.created_at,
s.updated_at,
s.compressed_summary,
s.summary_up_to_msg_id,
s.preferences_json,
COUNT(m.id) AS message_count,
COALESCE(
(SELECT t.status FROM turns t WHERE t.session_id = s.id
ORDER BY t.updated_at DESC LIMIT 1),
'idle'
) AS status,
COALESCE(
(SELECT t.id FROM turns t WHERE t.session_id = s.id AND t.status = 'running'
ORDER BY t.updated_at DESC LIMIT 1),
''
) AS active_turn_id,
COALESCE(
(SELECT t.capability FROM turns t WHERE t.session_id = s.id
ORDER BY t.updated_at DESC LIMIT 1),
''
) AS capability,
COALESCE(
(SELECT m2.content FROM messages m2
WHERE m2.session_id = s.id AND TRIM(COALESCE(m2.content, '')) != ''
ORDER BY m2.id DESC LIMIT 1),
''
) AS last_message
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
{where}
GROUP BY s.id
ORDER BY s.updated_at DESC
LIMIT ? OFFSET ?
"""
# ``ESCAPE '\'`` makes the underscore in ``imported_`` literal rather than
# the LIKE single-char wildcard.
_WHERE_NATIVE = r"WHERE s.id NOT LIKE 'imported\_%' ESCAPE '\'"
_WHERE_IMPORTED = r"WHERE s.id LIKE 'imported\_%' ESCAPE '\'"
def _list_session_summaries_sync(
self, where_sql: str, limit: int, offset: int
) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
self._SESSION_SUMMARY_SQL.format(where=where_sql),
(limit, offset),
).fetchall()
sessions = []
for row in rows:
payload = dict(row)
payload["session_id"] = payload["id"]
payload["preferences"] = _json_loads(payload.pop("preferences_json", ""), {})
sessions.append(payload)
return sessions
def _list_sessions_sync(
self,
limit: int = 50,
offset: int = 0,
) -> list[dict[str, Any]]:
# Native chats only — imported histories surface under their own
# Space category, not the regular history list.
return self._list_session_summaries_sync(self._WHERE_NATIVE, limit, offset)
def _list_imported_sessions_sync(
self,
limit: int = 50,
offset: int = 0,
) -> list[dict[str, Any]]:
return self._list_session_summaries_sync(self._WHERE_IMPORTED, limit, offset)
async def list_sessions(
self,
limit: int = 50,
offset: int = 0,
) -> list[dict[str, Any]]:
return await self._run(self._list_sessions_sync, limit, offset)
async def list_imported_sessions(
self,
limit: int = 50,
offset: int = 0,
) -> list[dict[str, Any]]:
return await self._run(self._list_imported_sessions_sync, limit, offset)
def _update_summary_sync(self, session_id: str, summary: str, up_to_msg_id: int) -> bool:
with self._connect() as conn:
cur = conn.execute(
"""
UPDATE sessions
SET compressed_summary = ?, summary_up_to_msg_id = ?, updated_at = updated_at
WHERE id = ?
""",
(summary, max(0, int(up_to_msg_id)), session_id),
)
conn.commit()
return cur.rowcount > 0
async def update_summary(self, session_id: str, summary: str, up_to_msg_id: int) -> bool:
return await self._run(self._update_summary_sync, session_id, summary, up_to_msg_id)
def _update_session_preferences_sync(
self, session_id: str, preferences: dict[str, Any]
) -> bool:
with self._connect() as conn:
current = conn.execute(
"SELECT preferences_json FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if current is None:
return False
merged = {
**_json_loads(current["preferences_json"], {}),
**(preferences or {}),
}
cur = conn.execute(
"""
UPDATE sessions
SET preferences_json = ?, updated_at = ?
WHERE id = ?
""",
(_json_dumps(merged), time.time(), session_id),
)
conn.commit()
return cur.rowcount > 0
async def update_session_preferences(
self, session_id: str, preferences: dict[str, Any]
) -> bool:
return await self._run(self._update_session_preferences_sync, session_id, preferences)
async def get_session_with_messages(self, session_id: str) -> dict[str, Any] | None:
session = await self.get_session(session_id)
if session is None:
return None
session["messages"] = await self.get_messages(session_id)
session["active_turns"] = await self.list_active_turns(session_id)
return session
# ── Notebook entries ──────────────────────────────────────────────
def _upsert_notebook_entries_sync(self, session_id: str, items: list[dict[str, Any]]) -> int:
if not items:
return 0
now = time.time()
with self._connect() as conn:
if (
conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
is None
):
raise ValueError(f"Session not found: {session_id}")
upserted = 0
for item in items:
question = (item.get("question") or "").strip()
question_id = (item.get("question_id") or "").strip()
if not question or not question_id:
continue
turn_id = (item.get("turn_id") or "").strip()
# ``user_answer_images`` is an optional list of records
# ``[{id, url, filename, mime_type}, …]``. We serialise it
# here so callers that only know about text don't need to
# know JSON. ``None`` keeps the existing column value on
# UPDATE (avoid clobbering stored images on a partial
# upsert that only changes ``is_correct``).
images_value = item.get("user_answer_images")
images_json = _json_dumps(images_value) if isinstance(images_value, list) else None
if images_json is None:
conn.execute(
"""
INSERT INTO notebook_entries (
session_id, turn_id, question_id, question, question_type,
options_json, correct_answer, explanation, difficulty,
user_answer, user_answer_images_json, is_correct,
bookmarked, followup_session_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, 0, '', ?, ?)
ON CONFLICT(session_id, turn_id, question_id) DO UPDATE SET
user_answer = excluded.user_answer,
is_correct = excluded.is_correct,
updated_at = excluded.updated_at
""",
(
session_id,
turn_id,
question_id,
question,
item.get("question_type") or "",
_json_dumps(item.get("options") or {}),
item.get("correct_answer") or "",
item.get("explanation") or "",
item.get("difficulty") or "",
item.get("user_answer") or "",
1 if item.get("is_correct") else 0,
now,
now,
),
)
else:
conn.execute(
"""
INSERT INTO notebook_entries (
session_id, turn_id, question_id, question, question_type,
options_json, correct_answer, explanation, difficulty,
user_answer, user_answer_images_json, is_correct,
bookmarked, followup_session_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '', ?, ?)
ON CONFLICT(session_id, turn_id, question_id) DO UPDATE SET
user_answer = excluded.user_answer,
user_answer_images_json = excluded.user_answer_images_json,
is_correct = excluded.is_correct,
updated_at = excluded.updated_at
""",
(
session_id,
turn_id,
question_id,
question,
item.get("question_type") or "",
_json_dumps(item.get("options") or {}),
item.get("correct_answer") or "",
item.get("explanation") or "",
item.get("difficulty") or "",
item.get("user_answer") or "",
images_json,
1 if item.get("is_correct") else 0,
now,
now,
),
)
upserted += 1
conn.commit()
return upserted
async def upsert_notebook_entries(self, session_id: str, items: list[dict[str, Any]]) -> int:
return await self._run(self._upsert_notebook_entries_sync, session_id, items)
@staticmethod
def _serialize_notebook_entry(row: sqlite3.Row) -> dict[str, Any]:
keys = set(row.keys())
images: list[dict[str, Any]] = []
if "user_answer_images_json" in keys:
raw_images = _json_loads(row["user_answer_images_json"], [])
if isinstance(raw_images, list):
images = [r for r in raw_images if isinstance(r, dict)]
return {
"id": int(row["id"]),
"session_id": row["session_id"],
"session_title": row["session_title"] or "" if "session_title" in keys else "",
"turn_id": (row["turn_id"] or "") if "turn_id" in keys else "",
"question_id": row["question_id"] or "",
"question": row["question"],
"question_type": row["question_type"] or "",
"options": _json_loads(row["options_json"], {}),
"correct_answer": row["correct_answer"] or "",
"explanation": row["explanation"] or "",
"difficulty": row["difficulty"] or "",
"user_answer": row["user_answer"] or "",
"user_answer_images": images,
"is_correct": bool(row["is_correct"]),
"bookmarked": bool(row["bookmarked"]),
"followup_session_id": row["followup_session_id"] or "",
"ai_judgment": (row["ai_judgment"] or "") if "ai_judgment" in keys else "",
"created_at": float(row["created_at"]),
"updated_at": float(row["updated_at"]),
}
def _list_notebook_entries_sync(
self,
category_id: int | None,
bookmarked: bool | None,
is_correct: bool | None,
limit: int,
offset: int,
session_id: str | None = None,
) -> dict[str, Any]:
base = """
SELECT
n.id, n.session_id, COALESCE(s.title, '') AS session_title,
n.turn_id, n.question_id, n.question, n.question_type, n.options_json,
n.correct_answer, n.explanation, n.difficulty,
n.user_answer, n.user_answer_images_json, n.is_correct, n.bookmarked,
n.followup_session_id, n.ai_judgment, n.created_at, n.updated_at
FROM notebook_entries n
LEFT JOIN sessions s ON s.id = n.session_id
"""
count_base = "SELECT COUNT(*) AS cnt FROM notebook_entries n"
conditions: list[str] = []
params: list[Any] = []
if category_id is not None:
join = " INNER JOIN notebook_entry_categories ec ON ec.entry_id = n.id"
base += join
count_base += join
conditions.append("ec.category_id = ?")
params.append(category_id)
if bookmarked is not None:
conditions.append("n.bookmarked = ?")
params.append(1 if bookmarked else 0)
if is_correct is not None:
conditions.append("n.is_correct = ?")
params.append(1 if is_correct else 0)
if session_id is not None:
conditions.append("n.session_id = ?")
params.append(session_id)
where = (" WHERE " + " AND ".join(conditions)) if conditions else ""
with self._connect() as conn:
total_row = conn.execute(count_base + where, tuple(params)).fetchone()
total = int(total_row["cnt"]) if total_row else 0
rows = conn.execute(
base + where + " ORDER BY n.created_at DESC LIMIT ? OFFSET ?",
tuple(params) + (limit, offset),
).fetchall()
items = [self._serialize_notebook_entry(r) for r in rows]
return {"items": items, "total": total}
async def list_notebook_entries(
self,
category_id: int | None = None,
bookmarked: bool | None = None,
is_correct: bool | None = None,
limit: int = 50,
offset: int = 0,
*,
session_id: str | None = None,
) -> dict[str, Any]:
return await self._run(
self._list_notebook_entries_sync,
category_id,
bookmarked,
is_correct,
limit,
offset,
session_id,
)
def _get_notebook_entry_sync(self, entry_id: int) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT
n.*, COALESCE(s.title, '') AS session_title
FROM notebook_entries n
LEFT JOIN sessions s ON s.id = n.session_id
WHERE n.id = ?
""",
(entry_id,),
).fetchone()
if row is None:
return None
entry = self._serialize_notebook_entry(row)
cats = conn.execute(
"""
SELECT c.id, c.name
FROM notebook_categories c
INNER JOIN notebook_entry_categories ec ON ec.category_id = c.id
WHERE ec.entry_id = ?
ORDER BY c.name
""",
(entry_id,),
).fetchall()
entry["categories"] = [{"id": c["id"], "name": c["name"]} for c in cats]
return entry
async def get_notebook_entry(self, entry_id: int) -> dict[str, Any] | None:
return await self._run(self._get_notebook_entry_sync, entry_id)
def _find_notebook_entry_sync(
self,
session_id: str,
question_id: str,
turn_id: str | None = None,
) -> dict[str, Any] | None:
with self._connect() as conn:
if turn_id is not None:
row = conn.execute(
"""
SELECT n.*, COALESCE(s.title, '') AS session_title
FROM notebook_entries n
LEFT JOIN sessions s ON s.id = n.session_id
WHERE n.session_id = ?
AND n.turn_id = ?
AND n.question_id = ?
""",
(session_id, turn_id, question_id),
).fetchone()
else:
# Legacy lookup: return the most recent matching entry across
# turns. Two quizzes in the same session can share a question_id
# (positional IDs like ``q_1``), so we explicitly pick the
# newest one to keep behavior deterministic for callers that
# don't yet pass a turn_id.
row = conn.execute(
"""
SELECT n.*, COALESCE(s.title, '') AS session_title
FROM notebook_entries n
LEFT JOIN sessions s ON s.id = n.session_id
WHERE n.session_id = ? AND n.question_id = ?
ORDER BY n.updated_at DESC, n.id DESC
LIMIT 1
""",
(session_id, question_id),
).fetchone()
if row is None:
return None
return self._serialize_notebook_entry(row)
async def find_notebook_entry(
self,
session_id: str,
question_id: str,
turn_id: str | None = None,
) -> dict[str, Any] | None:
return await self._run(self._find_notebook_entry_sync, session_id, question_id, turn_id)
def _update_notebook_entry_sync(self, entry_id: int, updates: dict[str, Any]) -> bool:
allowed = {
"bookmarked",
"followup_session_id",
"user_answer",
"is_correct",
"ai_judgment",
}
fields = {k: v for k, v in updates.items() if k in allowed}
if not fields:
return False
fields["updated_at"] = time.time()
if "bookmarked" in fields:
fields["bookmarked"] = 1 if fields["bookmarked"] else 0
if "is_correct" in fields:
fields["is_correct"] = 1 if fields["is_correct"] else 0
set_clause = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [entry_id]
with self._connect() as conn:
cur = conn.execute(
f"UPDATE notebook_entries SET {set_clause} WHERE id = ?", # nosec B608
tuple(values),
)
conn.commit()
return cur.rowcount > 0
async def update_notebook_entry(self, entry_id: int, updates: dict[str, Any]) -> bool:
return await self._run(self._update_notebook_entry_sync, entry_id, updates)
def _delete_notebook_entry_sync(self, entry_id: int) -> bool:
with self._connect() as conn:
cur = conn.execute("DELETE FROM notebook_entries WHERE id = ?", (entry_id,))
conn.commit()
return cur.rowcount > 0
async def delete_notebook_entry(self, entry_id: int) -> bool:
return await self._run(self._delete_notebook_entry_sync, entry_id)
# ── Notebook categories ────────────────────────────────────────
def _create_category_sync(self, name: str) -> dict[str, Any]:
now = time.time()
with self._connect() as conn:
cur = conn.execute(
"INSERT INTO notebook_categories (name, created_at) VALUES (?, ?)",
(name.strip(), now),
)
conn.commit()
return {"id": int(cur.lastrowid), "name": name.strip(), "created_at": now}
async def create_category(self, name: str) -> dict[str, Any]:
return await self._run(self._create_category_sync, name)
def _list_categories_sync(self) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT c.id, c.name, c.created_at,
COUNT(ec.entry_id) AS entry_count
FROM notebook_categories c
LEFT JOIN notebook_entry_categories ec ON ec.category_id = c.id
GROUP BY c.id
ORDER BY c.name
""",
).fetchall()
return [
{
"id": r["id"],
"name": r["name"],
"created_at": float(r["created_at"]),
"entry_count": int(r["entry_count"]),
}
for r in rows
]
async def list_categories(self) -> list[dict[str, Any]]:
return await self._run(self._list_categories_sync)
def _rename_category_sync(self, category_id: int, name: str) -> bool:
with self._connect() as conn:
cur = conn.execute(
"UPDATE notebook_categories SET name = ? WHERE id = ?",
(name.strip(), category_id),
)
conn.commit()
return cur.rowcount > 0
async def rename_category(self, category_id: int, name: str) -> bool:
return await self._run(self._rename_category_sync, category_id, name)
def _delete_category_sync(self, category_id: int) -> bool:
with self._connect() as conn:
cur = conn.execute("DELETE FROM notebook_categories WHERE id = ?", (category_id,))
conn.commit()
return cur.rowcount > 0
async def delete_category(self, category_id: int) -> bool:
return await self._run(self._delete_category_sync, category_id)
def _add_entry_to_category_sync(self, entry_id: int, category_id: int) -> bool:
with self._connect() as conn:
try:
conn.execute(
"INSERT OR IGNORE INTO notebook_entry_categories (entry_id, category_id) VALUES (?, ?)",
(entry_id, category_id),
)
conn.commit()
except sqlite3.IntegrityError:
return False
return True
async def add_entry_to_category(self, entry_id: int, category_id: int) -> bool:
return await self._run(self._add_entry_to_category_sync, entry_id, category_id)
def _remove_entry_from_category_sync(self, entry_id: int, category_id: int) -> bool:
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM notebook_entry_categories WHERE entry_id = ? AND category_id = ?",
(entry_id, category_id),
)
conn.commit()
return cur.rowcount > 0
async def remove_entry_from_category(self, entry_id: int, category_id: int) -> bool:
return await self._run(self._remove_entry_from_category_sync, entry_id, category_id)
def _get_entry_categories_sync(self, entry_id: int) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT c.id, c.name FROM notebook_categories c
INNER JOIN notebook_entry_categories ec ON ec.category_id = c.id
WHERE ec.entry_id = ?
ORDER BY c.name
""",
(entry_id,),
).fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
async def get_entry_categories(self, entry_id: int) -> list[dict[str, Any]]:
return await self._run(self._get_entry_categories_sync, entry_id)
_instances: dict[str, SQLiteSessionStore] = {}
def get_sqlite_session_store() -> SQLiteSessionStore:
db_path = get_path_service().get_chat_history_db().resolve()
key = str(db_path)
if key not in _instances:
_instances[key] = SQLiteSessionStore(db_path=db_path)
return _instances[key]
__all__ = ["SQLiteSessionStore", "get_sqlite_session_store", "make_imported_session_id"]