feat: chat sidebar and inbox, feat: saved chats, fix: wake jobs, fix: sandbox sends, ux: negotiation replies and draft box

This commit is contained in:
2026-05-06 22:53:19 -04:00
parent 58a57ddc6a
commit 3b6e3c34d5
18 changed files with 1797 additions and 105 deletions
+195 -16
View File
@@ -8,6 +8,9 @@ from typing import Any
from zoneinfo import ZoneInfo
DEFAULT_THREAD_ID = "default"
def utc_now() -> datetime:
return datetime.now(timezone.utc)
@@ -65,8 +68,16 @@ class MemoryStore:
with self._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS chat_threads (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
@@ -129,27 +140,152 @@ class MemoryStore:
);
"""
)
def add_conversation(self, role: str, content: str) -> None:
with self._connect() as db:
self._ensure_column(db, "conversations", "thread_id", "TEXT")
now = iso_now()
db.execute(
"INSERT INTO conversations(role, content, created_at) VALUES (?, ?, ?)",
(role, content, iso_now()),
"""
INSERT INTO chat_threads(id, title, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
""",
(DEFAULT_THREAD_ID, "New chat", now, now),
)
db.execute(
"UPDATE conversations SET thread_id = ? WHERE thread_id IS NULL OR thread_id = ''",
(DEFAULT_THREAD_ID,),
)
def last_interaction(self) -> dict[str, Any] | None:
@staticmethod
def _ensure_column(db: sqlite3.Connection, table: str, column: str, definition: str) -> None:
columns = {row["name"] for row in db.execute(f"PRAGMA table_info({table})").fetchall()}
if column not in columns:
db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def ensure_thread(self, thread_id: str | None = None, title: str | None = None) -> dict[str, Any]:
now = iso_now()
resolved_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
resolved_title = (title or "New chat").strip() or "New chat"
with self._connect() as db:
db.execute(
"""
INSERT INTO chat_threads(id, title, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at
""",
(resolved_id, resolved_title, now, now),
)
row = db.execute(
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
(resolved_id,),
).fetchone()
return dict(row)
def create_thread(self, title: str | None = None) -> dict[str, Any]:
import uuid
thread_id = f"chat-{uuid.uuid4()}"
return self.ensure_thread(thread_id, title or "New chat")
def list_threads(self) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"""
SELECT
t.id,
t.title,
t.created_at,
t.updated_at,
COUNT(c.id) AS message_count,
MAX(c.created_at) AS last_message_at
FROM chat_threads t
LEFT JOIN conversations c ON c.thread_id = t.id
GROUP BY t.id
ORDER BY COALESCE(MAX(c.created_at), t.updated_at) DESC
"""
).fetchall()
return [dict(row) for row in rows]
def delete_thread(self, thread_id: str) -> bool:
with self._connect() as db:
db.execute("DELETE FROM conversations WHERE thread_id = ?", (thread_id,))
cursor = db.execute("DELETE FROM chat_threads WHERE id = ?", (thread_id,))
return cursor.rowcount > 0
def rename_thread(self, thread_id: str, title: str) -> dict[str, Any] | None:
clean_title = self._clean_thread_title(title)
if not clean_title:
return None
now = iso_now()
with self._connect() as db:
db.execute(
"UPDATE chat_threads SET title = ?, updated_at = ? WHERE id = ?",
(clean_title, now, thread_id),
)
row = db.execute(
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
(thread_id,),
).fetchone()
return dict(row) if row else None
def recent_conversation(self, limit: int = 8) -> list[dict[str, Any]]:
def get_thread(self, thread_id: str) -> dict[str, Any] | None:
with self._connect() as db:
rows = db.execute(
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
row = db.execute(
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
(thread_id,),
).fetchone()
return dict(row) if row else None
def add_conversation(self, role: str, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> None:
resolved_thread_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
self.ensure_thread(resolved_thread_id)
now = iso_now()
with self._connect() as db:
db.execute(
"INSERT INTO conversations(thread_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(resolved_thread_id, role, content, now),
)
db.execute(
"UPDATE chat_threads SET updated_at = ? WHERE id = ?",
(now, resolved_thread_id),
)
def last_interaction(self, thread_id: str | None = None) -> dict[str, Any] | None:
with self._connect() as db:
if thread_id:
row = db.execute(
"""
SELECT thread_id, role, content, created_at
FROM conversations
WHERE thread_id = ?
ORDER BY id DESC
LIMIT 1
""",
(thread_id,),
).fetchone()
else:
row = db.execute(
"SELECT thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def recent_conversation(self, limit: int = 8, thread_id: str | None = None) -> list[dict[str, Any]]:
with self._connect() as db:
if thread_id:
rows = db.execute(
"""
SELECT id, thread_id, role, content, created_at
FROM conversations
WHERE thread_id = ?
ORDER BY id DESC
LIMIT ?
""",
(thread_id, limit),
).fetchall()
else:
rows = db.execute(
"SELECT id, thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in reversed(rows)]
def remember(self, kind: str, content: str, importance: int = 3, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -222,13 +358,22 @@ class MemoryStore:
).fetchall()
conversations = db.execute(
"""
SELECT id, role, content, created_at
SELECT id, thread_id, role, content, created_at
FROM conversations
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
threads = db.execute(
"""
SELECT id, title, created_at, updated_at
FROM chat_threads
ORDER BY updated_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
profile_rows = db.execute(
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
).fetchall()
@@ -252,6 +397,7 @@ class MemoryStore:
return {
"path": str(self.path),
"memories": [self._memory_row(row) for row in memories],
"chat_threads": [dict(row) for row in threads],
"conversations": [dict(row) for row in conversations],
"profile": profile,
"scheduled_jobs": [dict(row) for row in jobs],
@@ -339,17 +485,38 @@ class MemoryStore:
).fetchall()
return [dict(row) for row in rows]
def mark_job_run(self, job_id: str, next_run_at: str | None = None) -> None:
def mark_job_run(self, job_id: str, next_run_at: str | None = None, enabled: bool = True) -> None:
with self._connect() as db:
db.execute(
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?",
(iso_now(), next_run_at, job_id),
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?",
(iso_now(), next_run_at, 1 if enabled else 0, job_id),
)
def add_outbox(self, content: str) -> None:
with self._connect() as db:
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
def list_outbox(self, limit: int = 100) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def get_outbox(self, inbox_id: int) -> dict[str, Any] | None:
with self._connect() as db:
row = db.execute(
"SELECT id, content, created_at, delivered_at FROM outbox WHERE id = ?",
(inbox_id,),
).fetchone()
return dict(row) if row else None
def delete_outbox(self, inbox_id: int) -> bool:
with self._connect() as db:
cursor = db.execute("DELETE FROM outbox WHERE id = ?", (inbox_id,))
return cursor.rowcount > 0
def undelivered_outbox(self) -> list[dict[str, Any]]:
now = iso_now()
with self._connect() as db:
@@ -362,6 +529,18 @@ class MemoryStore:
)
return [dict(row) for row in rows]
@staticmethod
def _thread_title(content: str) -> str:
text = " ".join(content.strip().split())
if not text:
return "New chat"
return text[:42] + ("..." if len(text) > 42 else "")
@staticmethod
def _clean_thread_title(title: str) -> str:
text = " ".join(title.strip().strip('"').strip("'").split())
return text[:64]
@staticmethod
def _fts_query(query: str) -> str:
tokens = [token.replace('"', "") for token in query.split() if token.strip()]