TinyDB Session Manager

This commit is contained in:
Dita Aji Pratama 2026-06-17 16:00:26 +07:00
parent 017dcd7da0
commit b954f5a6cb
8 changed files with 512 additions and 37 deletions

View File

@ -114,6 +114,12 @@ TELEGRAM_ALLOWED_GROUP_IDS = os.getenv("TELEGRAM_ALLOWED_GROUP_IDS", default=_ya
TELEGRAM_SELECTIVE_RESPONSE = os.getenv("TELEGRAM_SELECTIVE_RESPONSE", default=str(_yaml_get("telegram", "selective_response", default="true"))).strip().lower() in ("true", "1", "yes")
# ─── Session (TinyDB) ────────────────────────────────────────────────────────────
SESSION_DB_PATH = os.path.expanduser(
os.getenv("SESSION_DB_PATH", default=_yaml_get("session", "db_path", default="~/.config/hendrik/sessions.json"))
)
# ─── RAG (YAML) ─────────────────────────────────────────────────────────────────
RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default=_yaml_get("rag", "persist_dir", default="chroma_db"))

View File

@ -36,6 +36,9 @@ llm:
rag:
persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local)
session:
db_path: "~/.config/hendrik/sessions.json"
xmpp:
enabled: false
muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server"

View File

@ -4,3 +4,4 @@ chromadb>=0.5.0
openpyxl>=3.1.0
slixmpp
python-telegram-bot>=20.0
tinydb>=4.8.0

View File

@ -0,0 +1,133 @@
from __future__ import annotations
import os
import re
import uuid
from datetime import datetime, timezone
from dataclasses import dataclass, field
from typing import Optional
from tinydb import TinyDB, Query
import config
DB_DIR = os.path.dirname(config.SESSION_DB_PATH)
os.makedirs(DB_DIR, exist_ok=True)
@dataclass
class NeoSession:
session_id: str
name: str
created_at: str
updated_at: str
model_info: dict
messages: list = field(default_factory=list)
doc_id: Optional[int] = None
class NeoSessionManager:
def __init__(self, db_path: str = config.SESSION_DB_PATH):
self._db = TinyDB(db_path)
self._table = self._db.table("sessions")
def create(self, name: str, model_info: dict) -> NeoSession:
now = datetime.now(timezone.utc).isoformat()
doc = {
"session_id": str(uuid.uuid4()),
"name": name,
"created_at": now,
"updated_at": now,
"model_info": model_info,
"messages": [],
}
doc_id = self._table.insert(doc)
return self._doc_to_session(self._table.get(doc_id=doc_id))
def list(self) -> list[dict]:
results = []
for doc in self._table.all():
results.append({
"doc_id": doc.doc_id,
"session_id": doc["session_id"],
"name": doc["name"],
"created_at": doc["created_at"],
"updated_at": doc["updated_at"],
"model_info": doc["model_info"],
"message_count": len(doc["messages"]),
})
return sorted(results, key=lambda x: x["updated_at"], reverse=True)
def get(self, doc_id: int) -> Optional[NeoSession]:
doc = self._table.get(doc_id=doc_id)
if doc is None:
return None
return self._doc_to_session(doc)
def rename(self, doc_id: int, new_name: str) -> bool:
if not new_name.strip():
return False
return self._table.update({"name": new_name.strip()}, doc_ids=[doc_id])
def delete(self, doc_id: int) -> bool:
return len(self._table.remove(doc_ids=[doc_id])) > 0
def search(self, query: str) -> list[dict]:
q = Query()
results = []
for doc in self._table.search(q.name.search(query, flags=re.IGNORECASE)):
results.append({
"doc_id": doc.doc_id,
"session_id": doc["session_id"],
"name": doc["name"],
"created_at": doc["created_at"],
"updated_at": doc["updated_at"],
"model_info": doc["model_info"],
"message_count": len(doc["messages"]),
})
return sorted(results, key=lambda x: x["updated_at"], reverse=True)
def search_messages(self, query: str) -> list[dict]:
q = Query()
results = []
for doc in self._table.all():
for msg in doc["messages"]:
content = msg.get("content", "")
if query.lower() in content.lower():
results.append({
"doc_id": doc.doc_id,
"session_name": doc["name"],
"role": msg.get("role"),
"content": content,
"session_id": doc["session_id"],
})
break
return results
def add_message(self, doc_id: int, role: str, content: str, **kwargs) -> bool:
doc = self._table.get(doc_id=doc_id)
if doc is None:
return False
msg = {"role": role, "content": content}
msg.update(kwargs)
messages = doc["messages"]
messages.append(msg)
now = datetime.now(timezone.utc).isoformat()
return self._table.update(
{"messages": messages, "updated_at": now},
doc_ids=[doc_id],
)
def update_model_info(self, doc_id: int, model_info: dict) -> bool:
return self._table.update({"model_info": model_info}, doc_ids=[doc_id])
@staticmethod
def _doc_to_session(doc) -> NeoSession:
return NeoSession(
doc_id=doc.doc_id,
session_id=doc["session_id"],
name=doc["name"],
created_at=doc["created_at"],
updated_at=doc["updated_at"],
model_info=doc["model_info"],
messages=doc.get("messages", []),
)

View File

@ -4,6 +4,16 @@ from datetime import datetime
import config
from scripts import ntro
def _add_msg(app, role, content, **kwargs):
msg = {"role": role, "content": content}
msg.update(kwargs)
app.messages.append(msg)
if app.current_session:
app.session_mgr.add_message(
app.current_session.doc_id, role, content, **kwargs
)
WELCOME_ART = """
__ __ _______ __ _ ______ ______ ___ ___ _
| | | || || | | || | | _ | | | | | | |
@ -52,7 +62,7 @@ def submit(app, stdscr):
app.scroll = 999999
app.processing = True
app.messages.append({"role": "user", "content": query})
_add_msg(app, "user", query)
app.agent_done.clear()
app.agent_thread = threading.Thread(
@ -79,12 +89,7 @@ def _agent_loop(app):
log(app, "system", f" {response.warning}")
if response.tool_calls:
amsg = {
"role": "assistant",
"content": response.content,
"tool_calls": response.tool_calls,
}
app.messages.append(amsg)
_add_msg(app, "assistant", response.content, tool_calls=response.tool_calls)
if response.content and response.content.strip():
log(app, "ai", response.content)
app.scroll = 999999
@ -99,10 +104,7 @@ def _agent_loop(app):
execute_tool(app, tc)
else:
if response.content:
app.messages.append({
"role": "assistant",
"content": response.content,
})
_add_msg(app, "assistant", response.content)
log(app, "ai", response.content)
log(app, "sep", "")
ntro.end(stamp)
@ -111,8 +113,7 @@ def _agent_loop(app):
ntro.end(stamp_step)
log(app, "error", "Max iterations reached without final answer.")
app.messages.append({"role": "assistant",
"content": "Max iterations reached without final answer."})
_add_msg(app, "assistant", "Max iterations reached without final answer.")
ntro.end(stamp)
app.agent_done.set()
@ -139,8 +140,4 @@ def execute_tool(app, tool_call):
except Exception as e:
result = f"Error executing tool: {str(e)}"
app.messages.append({
"role": "tool",
"tool_call_id": tool_call["id"],
"content": str(result),
})
_add_msg(app, "tool", str(result), tool_call_id=tool_call["id"])

View File

@ -1,9 +1,11 @@
import curses
import threading
from datetime import datetime
import config
from .render import init_colors, draw
from .input import handle_key
from .agent import log, WELCOME_ART
from services.session_manager_neo import NeoSessionManager, NeoSession
class HendrikTUI:
@ -29,10 +31,55 @@ class HendrikTUI:
self.agent_thread: threading.Thread | None = None
self.agent_done = threading.Event()
self.session_mgr = NeoSessionManager()
self.current_session: NeoSession | None = None
def switch_model(self, item: dict):
self.llm.base_url = item["base_url"]
self.llm.model = item["model"]
self.llm.api_key = item["api_key"]
if self.current_session:
self.session_mgr.update_model_info(
self.current_session.doc_id, self._model_info()
)
def _model_info(self) -> dict:
return {
"provider": config.resolve_provider(self.llm.base_url, self.llm.model),
"base_url": self.llm.base_url,
"model": self.llm.model,
}
def new_session(self):
name = f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}"
self.current_session = self.session_mgr.create(name, self._model_info())
self.messages = [{"role": "system", "content": self.build_system_prompt(
tools_definition=self.tools_def,
character=config.AGENT_CHARACTER or None,
skills=config.AGENT_SKILLS.split(",") if config.AGENT_SKILLS else None,
)}]
self.log.clear()
self.scroll = 0
log(self, "welcome", WELCOME_ART)
def switch_session(self, doc_id: int):
session = self.session_mgr.get(doc_id)
if not session:
return
self.current_session = session
self.messages = list(session.messages)
self.log.clear()
self.scroll = 0
log(self, "welcome", WELCOME_ART)
for msg in session.messages:
if msg["role"] == "user":
log(self, "user", msg["content"])
elif msg["role"] == "assistant":
log(self, "ai", msg["content"])
elif msg["role"] == "system" and msg.get("content"):
if len(msg["content"]) > 100:
continue
log(self, "system", msg["content"])
def run(self):
try:
@ -46,12 +93,7 @@ class HendrikTUI:
stdscr.keypad(True)
stdscr.refresh()
self.messages = [{"role": "system", "content": self.build_system_prompt(
tools_definition=self.tools_def,
character=config.AGENT_CHARACTER or None,
skills=config.AGENT_SKILLS.split(",") if config.AGENT_SKILLS else None,
)}]
log(self, "welcome", WELCOME_ART)
self.new_session()
while self.running:
self.h, self.w = stdscr.getmaxyx()

View File

@ -60,6 +60,23 @@ def handle_key(app, stdscr, key):
if not processing:
model_selector_popup(app, stdscr)
# -- Session management shortcuts --
elif key == 14: # Ctrl+N → new session
if not processing:
new_session_popup(app, stdscr)
elif key == 15: # Ctrl+O → open session browser
if not processing:
session_browser_popup(app, stdscr)
elif key == 6: # Ctrl+F → search sessions
if not processing:
session_search_popup(app, stdscr)
elif key == 18: # Ctrl+R → rename current session
if not processing:
rename_popup(app, stdscr)
elif key == 24: # Ctrl+X → delete current session
if not processing:
delete_session_popup(app, stdscr)
# -- Enter: split logical line at cursor position --
elif key in (curses.KEY_ENTER, 10, 13):
line = app.input_buffer[app.input_line]
@ -263,3 +280,260 @@ def model_selector_popup(app, stdscr):
del win
stdscr.touchwin()
stdscr.refresh()
def new_session_popup(app, stdscr):
if not app.current_session:
app.new_session()
return
pw = min(50, app.w - 4)
ph = 3
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
win.addstr(0, 2, " Start new session?", curses.A_BOLD)
win.addstr(1, 2, " [Y]es [N]o ")
win.refresh()
while True:
key = win.getch()
if key in (ord("y"), ord("Y")):
app.new_session()
break
elif key in (ord("n"), ord("N"), 27):
break
del win
stdscr.touchwin()
stdscr.refresh()
def session_browser_popup(app, stdscr):
sessions = app.session_mgr.list()
if not sessions:
curses.flash()
return
items = []
for s in sessions:
dt = s["updated_at"][:16].replace("T", " ")
label = f"{s['name']} ({dt}, {s['message_count']} msg)"
items.append((s["doc_id"], label))
pw = min(60, app.w - 4)
ph = min(len(items) + 4, app.h - 4)
px = (app.w - pw) // 2
py = (app.h - ph) // 2
if ph < 6:
return
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
current_idx = 0
while True:
win.erase()
win.box()
win.addstr(0, 2, " Sessions (\u2191\u2193 nav \u21b5 select D delete esc close)",
curses.A_BOLD)
visible_h = ph - 2
total = len(items)
scroll = max(0, min(current_idx - visible_h // 2, total - visible_h))
for i in range(visible_h):
idx = scroll + i
if idx >= total:
break
doc_id, label = items[idx]
y = 1 + i
is_cur = (idx == current_idx)
is_active = (app.current_session and doc_id == app.current_session.doc_id)
if is_cur:
prefix = " \u25b6 " if is_active else " > "
elif is_active:
prefix = " \u2192 "
else:
prefix = " "
display = prefix + label
if len(display) > pw - 4:
display = display[:pw - 4]
attr = curses.A_REVERSE if is_cur else curses.A_NORMAL
win.addstr(y, 2, display.ljust(pw - 4), attr)
win.refresh()
key = win.getch()
if key in (27, ord("q"), ord("Q")):
break
elif key in (curses.KEY_ENTER, 10, 13):
doc_id, _ = items[current_idx]
if not app.current_session or doc_id != app.current_session.doc_id:
app.switch_session(doc_id)
break
elif key == curses.KEY_UP:
if current_idx > 0:
current_idx -= 1
elif key == curses.KEY_DOWN:
if current_idx < len(items) - 1:
current_idx += 1
elif key in (ord("d"), ord("D")):
doc_id, label = items[current_idx]
if app.current_session and doc_id == app.current_session.doc_id:
continue
app.session_mgr.delete(doc_id)
sessions = app.session_mgr.list()
items = [(s["doc_id"],
f"{s['name']} ({s['updated_at'][:16].replace('T', ' ')}, {s['message_count']} msg)")
for s in sessions]
current_idx = min(current_idx, len(items) - 1)
if not items:
break
del win
stdscr.touchwin()
stdscr.refresh()
def session_search_popup(app, stdscr):
pw = min(60, app.w - 4)
ph = 3
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
win.addstr(0, 2, " Search sessions: ")
win.addstr(1, 2, " " * (pw - 4))
curses.echo()
query = win.getstr(1, 2, pw - 5).decode("utf-8")
curses.noecho()
del win
stdscr.touchwin()
stdscr.refresh()
query = query.strip()
if not query:
return
results = app.session_mgr.search(query)
if not results:
log(app, "system", f"No sessions matching: {query}")
return
items = []
for s in results:
dt = s["updated_at"][:16].replace("T", " ")
label = f"{s['name']} ({dt}, {s['message_count']} msg)"
items.append((s["doc_id"], label))
pw = min(60, app.w - 4)
ph = min(len(items) + 4, app.h - 4)
px = (app.w - pw) // 2
py = (app.h - ph) // 2
if ph < 6:
return
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
current_idx = 0
while True:
win.erase()
win.box()
win.addstr(0, 2, f" Results for \"{query}\" (\u2191\u2193 nav \u21b5 select esc close)",
curses.A_BOLD)
visible_h = ph - 2
total = len(items)
scroll = max(0, min(current_idx - visible_h // 2, total - visible_h))
for i in range(visible_h):
idx = scroll + i
if idx >= total:
break
doc_id, label = items[idx]
y = 1 + i
is_cur = (idx == current_idx)
attr = curses.A_REVERSE if is_cur else curses.A_NORMAL
win.addstr(y, 2, f" {label}"[:pw - 4].ljust(pw - 4), attr)
win.refresh()
key = win.getch()
if key in (27, ord("q"), ord("Q")):
break
elif key in (curses.KEY_ENTER, 10, 13):
doc_id, _ = items[current_idx]
if not app.current_session or doc_id != app.current_session.doc_id:
app.switch_session(doc_id)
break
elif key == curses.KEY_UP:
if current_idx > 0:
current_idx -= 1
elif key == curses.KEY_DOWN:
if current_idx < len(items) - 1:
current_idx += 1
del win
stdscr.touchwin()
stdscr.refresh()
def rename_popup(app, stdscr):
if not app.current_session:
return
pw = min(60, app.w - 4)
ph = 3
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
win.addstr(0, 2, " Rename session: ")
win.addstr(1, 2, " " * (pw - 4))
curses.echo()
new_name = win.getstr(1, 2, pw - 5).decode("utf-8")
curses.noecho()
del win
stdscr.touchwin()
stdscr.refresh()
new_name = new_name.strip()
if new_name:
app.session_mgr.rename(app.current_session.doc_id, new_name)
app.current_session.name = new_name
def delete_session_popup(app, stdscr):
if not app.current_session:
return
pw = min(50, app.w - 4)
ph = 4
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
win.addstr(0, 2, " Delete current session?", curses.A_BOLD)
name = app.current_session.name
display = name if len(name) <= pw - 6 else name[:pw - 9] + "..."
win.addstr(1, 2, f" \"{display}\"")
win.addstr(2, 2, " [Y]es [N]o ")
win.refresh()
while True:
key = win.getch()
if key in (ord("y"), ord("Y")):
doc_id = app.current_session.doc_id
app.session_mgr.delete(doc_id)
app.new_session()
break
elif key in (ord("n"), ord("N"), 27):
break
del win
stdscr.touchwin()
stdscr.refresh()

View File

@ -333,27 +333,46 @@ def draw_input(app, stdscr):
def draw_status(app, stdscr):
# Status bar di baris h-9: workspace, mode (READY/PROCESSING), shortcut hints
# Status bar di baris h-9: [session] workspace, mode (READY/PROCESSING), shortcut hints
h, w = app.h, app.w
y = h - 9
ws = os.getcwd()
mode = " PROCESSING " if app.processing else " READY "
hints = " ^D:send ^E:model ^W:workspace ^C:exit "
max_ws = w - len(mode) - len(hints) - 4
if len(ws) > max_ws:
ws = ".." + ws[-(max_ws - 2):]
status = (f" {ws} \u2502{mode}\u2502{hints}").ljust(w)[:w]
session_tag = ""
if app.current_session:
sname = app.current_session.name
if len(sname) > 20:
sname = sname[:17] + "..."
session_tag = f"[{sname}] "
mode = " PROCESSING " if app.processing else " READY "
hints = " ^N:new ^O:open ^R:rename ^D:send ^E:model ^W:ws ^C:exit "
max_left = w - len(mode) - len(hints) - 4
left = session_tag + ws
if len(left) > max_left:
left = ".." + left[-(max_left - 2):]
status = (f" {left} \u2502{mode}\u2502{hints}").ljust(w)[:w]
stdscr.addstr(y, 0, status, curses.color_pair(C_STATUS_INFO))
# Highlight mode dengan warna berbeda
mode_start = len(f" {ws} \u2502")
mode_start = len(f" {left} \u2502")
mode_end = mode_start + len(mode)
mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC)
stdscr.addstr(y, mode_start, mode, mode_attr | curses.A_BOLD)
# ^D:send — abu-abu saat processing, bold putih saat idle
if app.processing:
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_HINT_DISABLED))
else:
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_STATUS_INFO) | curses.A_BOLD)
# Hint shortcuts di kanan — tombol tertentu abu-abu saat processing
x = mode_end + 2
hints_parts = [
("^N:new", True),
(" ^O:open", True),
(" ^R:rename", True),
(" ^D:send", not app.processing),
(" ^E:model", True),
(" ^W:ws", True),
(" ^C:exit", True),
]
for text, enabled in hints_parts:
attr = curses.color_pair(C_STATUS_INFO) | curses.A_BOLD if enabled else curses.color_pair(C_HINT_DISABLED)
stdscr.addstr(y, x, text, attr)
x += len(text)