Skip to content

Add interactive chat REPL with persistent sessions#16

Closed
rejojer wants to merge 14 commits intodevfrom
feat/chat-repl
Closed

Add interactive chat REPL with persistent sessions#16
rejojer wants to merge 14 commits intodevfrom
feat/chat-repl

Conversation

@rejojer
Copy link
Copy Markdown
Member

@rejojer rejojer commented Apr 11, 2026

Summary

Introduces openkb chat, a multi-turn conversation REPL for the knowledge base. Conversations are automatically persisted to .openkb/chats/<id>.json and can be resumed across invocations. Also bundles a handful of adjacent cleanups that were surfaced while building it.

Main feature

  • New openkb chat CLI command with --resume [ID] (latest or by id / unique prefix), --list, --delete ID, and --no-color.
  • Built on prompt_toolkit: bottom toolbar, styled prompt, Ctrl-C aborts current response while Ctrl-D / /exit quit cleanly.
  • In-REPL slash commands: /exit, /clear (start a fresh session, previous is kept on disk), /save [name] (export markdown transcript to wiki/explorations/), /help.
  • Streams the agent response with the same visual language as openkb query (dim tool-call lines flush against the preceding text, blank line separating a tool batch from the following response paragraph).
  • Header mirrors the Claude Code layout: brand-colored title with version, then <kb dir> · <model> · session <id>, then a short key hint.
  • Resume view shows prior turns compactly (older turns collapsed to ... N earlier turns omitted).
  • Reuses the existing build_query_agent so tool behavior is identical to single-shot query.

Prompt / agent polish bundled in

  • Fix a copy-paste bug where the Q&A and lint agents were told to "Write all wiki content in X language" — they don't write wiki content. Switched to "Answer in X" and "Write the lint report in X".
  • Give all three agents an "OpenKB" identity so the model introduces itself consistently.
  • Finish the Q&A search-strategy step on summaries (tell the model to follow full_text when a summary is too thin), move the get_image "when to call" guidance into the tool docstring, and reword step 5 to refer to the tool by name.

Refactor bundled in

  • Normalize get_page_content naming: rename the helper in openkb.agent.tools to get_wiki_page_content (matching the *_wiki_* sibling convention), rename the agent-side wrapper from get_page_content_tool to get_page_content so the tool name the model sees matches what the instructions have always said, drop the lazy-import workaround, and fix a stale test assertion.
  • Derive openkb.__version__ from installed package metadata (importlib.metadata) so pyproject.toml is the single source of truth for the version string.

Test plan

  • openkb chat from a kb root starts a new session, header renders, prompt works
  • Single-turn question streams a response, tool-call lines render dim, blank-line spacing is correct
  • Multi-turn: follow-up questions ("what about that?") use conversation context
  • /save writes a readable transcript to wiki/explorations/
  • /clear starts a fresh session; previous shows up in openkb chat --list
  • Ctrl-C during streaming aborts the current response but keeps the REPL alive
  • Ctrl-D / /exit quit with the friendly goodbye message
  • openkb chat --resume picks up the latest session; --resume <prefix> resolves a unique prefix
  • openkb chat --list and --delete <id> work
  • Existing openkb query still works (shared agent)
  • pytest tests/test_agent_tools.py tests/test_query.py passes (31/31 locally)

@rejojer
Copy link
Copy Markdown
Member Author

rejojer commented Apr 11, 2026

Code review

Found 2 issues:

  1. openkb chat --resume (and --list) sorts by creation time rather than activity — list_sessions uses key=lambda x: x.name to sort by filename (the creation-timestamp id), but resolve_session_id's docstring promises __latest__ returns "the most recently updated session id". record_turn does update updated_at, but sorting ignores it. After a user resumes an older session and keeps chatting, openkb chat --resume (flag value __latest__) still picks the most recently created session, not the most recently active one.

def list_sessions(kb_dir: Path) -> list[dict[str, Any]]:
"""Return session metadata dicts, newest first (by id, which is timestamp-prefixed)."""
d = chats_dir(kb_dir)
if not d.exists():
return []
out: list[dict[str, Any]] = []
for p in sorted(d.glob("*.json"), key=lambda x: x.name, reverse=True):
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
out.append(
{
"id": data.get("id", p.stem),
"title": data.get("title", ""),
"turn_count": data.get("turn_count", 0),
"updated_at": data.get("updated_at", ""),
"model": data.get("model", ""),
}
)
return out
def resolve_session_id(kb_dir: Path, query: str) -> str | None:
"""Resolve a query to a full session id.
``query`` may be:
- ``"__latest__"`` — returns the most recently updated session id.
- A full session id — returned as-is if it exists.
- A unique prefix of a session id — expanded to the full id.
Returns ``None`` if no session matches. Raises ``ValueError`` when a
prefix is ambiguous.
"""
sessions = list_sessions(kb_dir)
if not sessions:
return None
if query == "__latest__":
return sessions[0]["id"]
for s in sessions:
if s["id"] == query:
return s["id"]
matches = [s["id"] for s in sessions if s["id"].startswith(query)]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ValueError(
f"Ambiguous session prefix '{query}' matches: {', '.join(matches)}"
)
return None

  1. Pressing Ctrl-C at the prompt exits the entire REPL — the header says "Ctrl-C to abort current response", but run_chat()'s main loop catches KeyboardInterrupt around prompt_async() and just returns. The practical effect: a single accidental Ctrl-C while typing the next message exits the whole chat. Only Ctrl-C inside _run_turn should count as "abort response"; Ctrl-C at the prompt should clear the current input line and continue the loop (Ctrl-D / /exit are the exit paths).

OpenKB/openkb/agent/chat.py

Lines 337 to 346 in e1207f4

while True:
try:
user_input = await prompt_session.prompt_async()
except KeyboardInterrupt:
_fmt(style, ("class:header", "\nBye. Thanks for using OpenKB.\n\n"))
return
except EOFError:
_fmt(style, ("class:header", "Bye. Thanks for using OpenKB.\n\n"))
return

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

rejojer added 14 commits April 11, 2026 22:36
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/.
prompt_toolkit's ANSI color table has no ansibrightwhite — the bright
white slot is named ansiwhite (and ansigray is the dim one). Use
ansiwhite for the toolbar session id; boldness still distinguishes it
from the surrounding toolbar text.
prompt_toolkit's default bottom-toolbar style is `reverse`, which flipped
our `bg:ansiblue fg:ansiwhite` into the gray-background / dark-text we
were seeing. Add `noreverse` to the toolbar classes so the intended blue
background with white text renders correctly.
Drops the blue background from the bottom toolbar and tones it down to
match the rest of the dim chrome. Replaces the terminal-dependent
ansibrightblack (which rendered near-black in dark themes) with a fixed
#8a8a8a so the dim gray stays readable across themes.

Rebuilds the header to mirror the Claude Code style: a bold title with
version suffix, followed by "<kb path> · <model> · session <id>" and
the short help hint. Adds `_openkb_version` and `_display_kb_dir`
helpers so the path collapses to `~/...` when under $HOME.
Use print_formatted_text with end="" so the `\n` in our own strings
isn't doubled by the function's default newline, which was putting a
blank line between every header row. Add explicit blank lines before
and after the header block, and emit two newlines after each turn so
the next `>>>` has breathing room instead of sitting flush against
the response.
Switch the three chat accent styles (title, prompt symbol, resume turn
numbers) to the brand blue #5fa0e0 so the REPL reads as an OpenKB
surface rather than a generic cyan terminal prompt. Tighten the
streaming cadence so tool call lines sit flush against the preceding
text (they're part of the same "thought") while a blank line always
separates a tool batch from the response text that follows.
Three related touch-ups to the three agent prompts:

- Fix a copy-paste bug where the Q&A and lint agents were told to
  "Write all wiki content in X language" — the Q&A agent doesn't write
  wiki content, and the lint agent writes reports. Switch them to
  "Answer in X" and "Write the lint report in X" respectively. The
  compiler agent keeps its original wording since it actually writes
  wiki content.

- Give all three agents an OpenKB identity in their opening line so
  the model introduces itself consistently when asked who it is.

- In the Q&A search strategy, finish the thought on summaries (tell
  the model to follow the `full_text` path when a summary is too
  thin), trim step 5 so the get_image tool's "when to call" guidance
  lives in the tool docstring instead of the instructions template,
  and reword step 5 to refer to the tool by name with "the ... tool".
The Q&A agent had an odd naming wart: the helper in openkb.agent.tools
was called get_page_content (no wiki_ prefix like its siblings
read_wiki_file, list_wiki_files, read_wiki_image, write_wiki_file), so
the @function_tool wrapper had to be named get_page_content_tool and
do a lazy local import to avoid a name collision. The instructions
template meanwhile referred to the tool as get_page_content — a third
name — leaving three spellings for one concept.

Rename the helper to get_wiki_page_content so it matches the wiki_
convention, rename the wrapper to get_page_content so the tool name
the model sees matches what the instructions have always said, and
drop the lazy-import workaround. Update the test imports, call sites,
class name, and the one assertion in test_query that was still
checking for the old wrapper name (that assertion was already broken
by earlier work).
Previously openkb/__init__.py had a hand-written __version__ = "0.1.0"
that drifted out of sync with pyproject.toml's version = "0.1.0.dev0",
and the chat REPL had a three-level try/except fallback to paper over
which string it would actually read. Make pyproject.toml the single
source of truth by having __init__.py pull its __version__ from the
installed package metadata via importlib.metadata, and simplify
_openkb_version in chat.py to just import __version__.
Change the three exit paths (/exit command, Ctrl-C at empty prompt,
Ctrl-D) from a curt "Bye." to "Bye. Thanks for using OpenKB." with a
trailing blank line so the shell prompt isn't flush against the
goodbye.
Bump the tool and tool.name styles from #8a8a8a to #a8a8a8 so
streaming tool-call lines sit visually between the static dim chrome
(header, slash help, resume previews — still #8a8a8a) and the
default-colored response text. Gives the "something is happening"
lines a touch more presence without making them compete with the
answer.
A single stray Ctrl-C at the prompt now prints "(Press Ctrl-C again
to exit)" and returns to the prompt. A second Ctrl-C within 2s (or
Ctrl-D / /exit) actually quits. Matches the bash/zsh safety dance and
avoids losing an open session to a fat-fingered interrupt. Typing
anything at the prompt resets the window, so the warning only
persists across consecutive lone interrupts.
list_sessions was sorting by filename (which is the session id, a
timestamp of when the session was created). That means a session you
resumed recently but created days ago would sink below sessions you
created today but haven't touched since — the opposite of what you
want in a "pick a session to resume" UI. Sort by updated_at instead
with the session id as a tiebreaker so the most recently active
session is always at the top of `openkb chat --list`.
Add a dedicated "Interactive chat" subsection under Usage that covers
what chat is, how it differs from one-off query, the session
management flags, and where to find the slash commands. Add the
`openkb chat` row to the Commands table, add a chat step in the
Quick start (replacing the lint step, which was already covered by
its own bullet), and surface chat as its own feature bullet.

While there, polish a few of the existing feature bullets: rename
"Any format" to "Broad format support" to avoid overclaiming,
tighten the "Auto wiki" bullet into "Compiled Wiki" with a single
sentence that ends on the "kept in sync" value prop, and tag the
"Query" bullet as one-off so it reads in contrast to chat.
@rejojer
Copy link
Copy Markdown
Member Author

rejojer commented Apr 11, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@rejojer
Copy link
Copy Markdown
Member Author

rejojer commented Apr 11, 2026

Superseded by #18 (same content, rebased into logical commits, plus a new commit sanitizing base64 image payloads from persisted chat history).

@rejojer rejojer closed this Apr 11, 2026
@rejojer rejojer deleted the feat/chat-repl branch April 11, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant