Skip to content

Commit b723ae9

Browse files
committed
Add interactive chat REPL with persistent sessions
Introduces `openkb chat`, a multi-turn conversation REPL that stores each session under `.openkb/chats/<id>.json` so conversations survive across invocations and can be resumed by id or prefix. Built on prompt_toolkit for input editing and a bottom toolbar, and reuses the existing query agent so tool calls and streaming behavior match `openkb query`. Supports `--resume`, `--list`, `--delete`, and `--no-color`, plus in-REPL slash commands (/exit, /clear, /save, /help) where /save exports a human-readable transcript to wiki/explorations/.
1 parent 8658d1c commit b723ae9

4 files changed

Lines changed: 696 additions & 0 deletions

File tree

openkb/agent/chat.py

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
"""Interactive multi-turn chat REPL for the OpenKB knowledge base.
2+
3+
Builds on the single-shot Q&A agent in ``openkb.agent.query`` and keeps
4+
conversation state in ``ChatSession``. Uses prompt_toolkit for the input
5+
line (history, editing, bottom toolbar) and streams responses directly to
6+
stdout to preserve the existing ``query`` visual.
7+
"""
8+
from __future__ import annotations
9+
10+
import os
11+
import re
12+
import sys
13+
import time
14+
from pathlib import Path
15+
from typing import Any
16+
17+
from prompt_toolkit import PromptSession
18+
from prompt_toolkit.formatted_text import FormattedText
19+
from prompt_toolkit.shortcuts import print_formatted_text
20+
from prompt_toolkit.styles import Style
21+
22+
from openkb.agent.chat_session import ChatSession
23+
from openkb.agent.query import MAX_TURNS, build_query_agent
24+
from openkb.log import append_log
25+
26+
27+
_STYLE_DICT: dict[str, str] = {
28+
"prompt": "bold #5fa0e0",
29+
"bottom-toolbar": "noreverse nobold #8a8a8a bg:default",
30+
"toolbar": "noreverse nobold #8a8a8a bg:default",
31+
"toolbar.session": "noreverse #8a8a8a bg:default bold",
32+
"header": "#8a8a8a",
33+
"header.title": "bold #5fa0e0",
34+
"tool": "#a8a8a8",
35+
"tool.name": "#a8a8a8 bold",
36+
"slash.ok": "ansigreen",
37+
"slash.help": "#8a8a8a",
38+
"error": "ansired bold",
39+
"resume.turn": "#5fa0e0",
40+
"resume.user": "bold",
41+
"resume.assistant": "#8a8a8a",
42+
}
43+
44+
_HELP_TEXT = (
45+
"Commands:\n"
46+
" /exit Exit (Ctrl-D also works)\n"
47+
" /clear Start a fresh session (current one is kept on disk)\n"
48+
" /save [name] Export transcript to wiki/explorations/\n"
49+
" /help Show this"
50+
)
51+
52+
_SIGINT_EXIT_WINDOW = 2.0
53+
54+
55+
def _use_color(force_off: bool) -> bool:
56+
if force_off:
57+
return False
58+
if os.environ.get("NO_COLOR", ""):
59+
return False
60+
if not sys.stdout.isatty():
61+
return False
62+
return True
63+
64+
65+
def _build_style(use_color: bool) -> Style:
66+
return Style.from_dict(_STYLE_DICT if use_color else {})
67+
68+
69+
def _fmt(style: Style, *fragments: tuple[str, str]) -> None:
70+
print_formatted_text(FormattedText(list(fragments)), style=style, end="")
71+
72+
73+
def _format_tool_line(name: str, args: str, width: int = 78) -> str:
74+
args = args or ""
75+
args = args.replace("\n", " ")
76+
base = f" \u00b7 {name}({args})"
77+
if len(base) > width:
78+
base = base[: width - 1] + "\u2026"
79+
return base
80+
81+
82+
def _extract_preview(text: str, limit: int = 150) -> str:
83+
text = " ".join((text or "").strip().split())
84+
if len(text) <= limit:
85+
return text
86+
return text[: limit - 1] + "\u2026"
87+
88+
89+
def _openkb_version() -> str:
90+
try:
91+
from importlib.metadata import version
92+
return version("openkb")
93+
except Exception:
94+
try:
95+
from openkb import __version__
96+
return __version__
97+
except Exception:
98+
return ""
99+
100+
101+
def _display_kb_dir(kb_dir: Path) -> str:
102+
home = str(Path.home())
103+
s = str(kb_dir)
104+
if s == home:
105+
return "~"
106+
if s.startswith(home + "/"):
107+
return "~" + s[len(home):]
108+
return s
109+
110+
111+
def _print_header(session: ChatSession, kb_dir: Path, style: Style) -> None:
112+
disp_dir = _display_kb_dir(kb_dir)
113+
version = _openkb_version()
114+
version_suffix = f" v{version}\n" if version else "\n"
115+
print()
116+
_fmt(
117+
style,
118+
("class:header.title", "OpenKB Chat"),
119+
("class:header", version_suffix),
120+
)
121+
_fmt(
122+
style,
123+
(
124+
"class:header",
125+
f"{disp_dir} \u00b7 {session.model} \u00b7 session {session.id}\n",
126+
),
127+
)
128+
_fmt(
129+
style,
130+
(
131+
"class:header",
132+
"Type /help for commands, Ctrl-D to exit, "
133+
"Ctrl-C to abort current response.\n",
134+
),
135+
)
136+
print()
137+
138+
139+
def _print_resume_view(session: ChatSession, style: Style) -> None:
140+
turns = list(zip(session.user_turns, session.assistant_texts))
141+
if not turns:
142+
return
143+
total = len(turns)
144+
if total > 5:
145+
omitted = total - 5
146+
_fmt(
147+
style,
148+
("class:header", f"... {omitted} earlier turn(s) omitted\n"),
149+
)
150+
turns = turns[-5:]
151+
start = omitted + 1
152+
else:
153+
start = 1
154+
155+
_fmt(
156+
style,
157+
("class:header", f"Resumed session {total} turn(s)\n"),
158+
)
159+
for i, (u, a) in enumerate(turns, start):
160+
_fmt(
161+
style,
162+
("class:resume.turn", f"[{i}] "),
163+
("class:resume.user", f">>> {u}\n"),
164+
)
165+
if a:
166+
preview = _extract_preview(a, 180)
167+
extra = ""
168+
if len(a) > len(preview):
169+
extra = f" ({len(a)} chars)"
170+
_fmt(
171+
style,
172+
("class:resume.turn", f"[{i}] "),
173+
("class:resume.assistant", f" {preview}{extra}\n"),
174+
)
175+
print()
176+
177+
178+
def _bottom_toolbar(session: ChatSession) -> FormattedText:
179+
return FormattedText(
180+
[
181+
("class:toolbar", " session "),
182+
("class:toolbar.session", session.id),
183+
(
184+
"class:toolbar",
185+
f" {session.turn_count} turn(s) {session.model} ",
186+
),
187+
]
188+
)
189+
190+
191+
def _make_prompt_session(session: ChatSession, style: Style, use_color: bool) -> PromptSession:
192+
return PromptSession(
193+
message=FormattedText([("class:prompt", ">>> ")]),
194+
style=style,
195+
bottom_toolbar=(lambda: _bottom_toolbar(session)) if use_color else None,
196+
)
197+
198+
199+
async def _run_turn(agent: Any, session: ChatSession, user_input: str, style: Style) -> None:
200+
"""Run one agent turn with streaming output and persist the new history."""
201+
from agents import (
202+
RawResponsesStreamEvent,
203+
RunItemStreamEvent,
204+
Runner,
205+
)
206+
from openai.types.responses import ResponseTextDeltaEvent
207+
208+
new_input = session.history + [{"role": "user", "content": user_input}]
209+
210+
result = Runner.run_streamed(agent, new_input, max_turns=MAX_TURNS)
211+
212+
sys.stdout.write("\n")
213+
sys.stdout.flush()
214+
collected: list[str] = []
215+
last_was_text = False
216+
need_blank_before_text = False
217+
try:
218+
async for event in result.stream_events():
219+
if isinstance(event, RawResponsesStreamEvent):
220+
if isinstance(event.data, ResponseTextDeltaEvent):
221+
text = event.data.delta
222+
if text:
223+
if need_blank_before_text:
224+
sys.stdout.write("\n")
225+
need_blank_before_text = False
226+
sys.stdout.write(text)
227+
sys.stdout.flush()
228+
collected.append(text)
229+
last_was_text = True
230+
elif isinstance(event, RunItemStreamEvent):
231+
item = event.item
232+
if item.type == "tool_call_item":
233+
if last_was_text:
234+
sys.stdout.write("\n")
235+
sys.stdout.flush()
236+
last_was_text = False
237+
raw = item.raw_item
238+
name = getattr(raw, "name", "?")
239+
args = getattr(raw, "arguments", "") or ""
240+
_fmt(style, ("class:tool", _format_tool_line(name, args) + "\n"))
241+
need_blank_before_text = True
242+
finally:
243+
sys.stdout.write("\n\n")
244+
sys.stdout.flush()
245+
246+
answer = "".join(collected).strip()
247+
if not answer:
248+
answer = (result.final_output or "").strip()
249+
session.record_turn(user_input, answer, result.to_input_list())
250+
251+
252+
def _save_transcript(kb_dir: Path, session: ChatSession, name: str | None) -> Path:
253+
explore_dir = kb_dir / "wiki" / "explorations"
254+
explore_dir.mkdir(parents=True, exist_ok=True)
255+
256+
base = name or session.title or (session.user_turns[0] if session.user_turns else session.id)
257+
slug = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-")[:60] or session.id
258+
date = session.created_at[:10].replace("-", "")
259+
path = explore_dir / f"{slug}-{date}.md"
260+
261+
lines: list[str] = [
262+
"---",
263+
f'session: "{session.id}"',
264+
f'model: "{session.model}"',
265+
f'created: "{session.created_at}"',
266+
"---",
267+
"",
268+
f"# Chat transcript {session.title or session.id}",
269+
"",
270+
]
271+
for i, (u, a) in enumerate(zip(session.user_turns, session.assistant_texts), 1):
272+
lines.append(f"## [{i}] {u}")
273+
lines.append("")
274+
lines.append(a or "_(no response recorded)_")
275+
lines.append("")
276+
277+
path.write_text("\n".join(lines), encoding="utf-8")
278+
return path
279+
280+
281+
async def _handle_slash(
282+
cmd: str,
283+
kb_dir: Path,
284+
session: ChatSession,
285+
style: Style,
286+
) -> str | None:
287+
"""Return ``"exit"`` to end the REPL, ``"new_session"`` to swap sessions,
288+
or ``None`` to continue with the current session."""
289+
parts = cmd.split(maxsplit=1)
290+
head = parts[0].lower()
291+
arg = parts[1].strip() if len(parts) > 1 else ""
292+
293+
if head in ("/exit", "/quit"):
294+
_fmt(style, ("class:header", "Bye. Thanks for using OpenKB.\n\n"))
295+
return "exit"
296+
297+
if head == "/help":
298+
_fmt(style, ("class:slash.help", _HELP_TEXT + "\n"))
299+
return None
300+
301+
if head == "/clear":
302+
old_id = session.id
303+
_fmt(
304+
style,
305+
("class:slash.ok", f"Started new session (previous: {old_id})\n"),
306+
)
307+
return "new_session"
308+
309+
if head == "/save":
310+
if not session.user_turns:
311+
_fmt(style, ("class:error", "Nothing to save yet.\n"))
312+
return None
313+
path = _save_transcript(kb_dir, session, arg or None)
314+
_fmt(style, ("class:slash.ok", f"Saved to {path}\n"))
315+
return None
316+
317+
_fmt(
318+
style,
319+
("class:error", f"Unknown command: {head}. Try /help.\n"),
320+
)
321+
return None
322+
323+
324+
async def run_chat(
325+
kb_dir: Path,
326+
session: ChatSession,
327+
*,
328+
no_color: bool = False,
329+
) -> None:
330+
"""Run the chat REPL against ``session`` until the user exits."""
331+
from openkb.config import load_config
332+
333+
use_color = _use_color(force_off=no_color)
334+
style = _build_style(use_color)
335+
336+
config = load_config(kb_dir / ".openkb" / "config.yaml")
337+
language = session.language or config.get("language", "en")
338+
wiki_root = str(kb_dir / "wiki")
339+
agent = build_query_agent(wiki_root, session.model, language=language)
340+
341+
_print_header(session, kb_dir, style)
342+
if session.turn_count > 0:
343+
_print_resume_view(session, style)
344+
345+
prompt_session = _make_prompt_session(session, style, use_color)
346+
347+
last_sigint = 0.0
348+
349+
while True:
350+
try:
351+
user_input = await prompt_session.prompt_async()
352+
last_sigint = 0.0
353+
except KeyboardInterrupt:
354+
now = time.monotonic()
355+
if last_sigint and (now - last_sigint) < _SIGINT_EXIT_WINDOW:
356+
_fmt(style, ("class:header", "\nBye. Thanks for using OpenKB.\n\n"))
357+
return
358+
last_sigint = now
359+
_fmt(style, ("class:header", "\n(Press Ctrl-C again to exit)\n"))
360+
continue
361+
except EOFError:
362+
_fmt(style, ("class:header", "Bye. Thanks for using OpenKB.\n\n"))
363+
return
364+
365+
user_input = (user_input or "").strip()
366+
if not user_input:
367+
continue
368+
369+
if user_input.startswith("/"):
370+
action = await _handle_slash(user_input, kb_dir, session, style)
371+
if action == "exit":
372+
return
373+
if action == "new_session":
374+
session = ChatSession.new(kb_dir, session.model, session.language)
375+
agent = build_query_agent(wiki_root, session.model, language=language)
376+
prompt_session = _make_prompt_session(session, style, use_color)
377+
continue
378+
379+
append_log(kb_dir / "wiki", "query", user_input)
380+
try:
381+
await _run_turn(agent, session, user_input, style)
382+
except KeyboardInterrupt:
383+
_fmt(style, ("class:error", "\n[aborted]\n"))
384+
except Exception as exc:
385+
_fmt(style, ("class:error", f"[ERROR] {exc}\n"))

0 commit comments

Comments
 (0)