Add detailed documentation and enhance message handling in CLI chat client

This commit is contained in:
Parley Hatch 2026-01-29 18:41:43 -07:00
parent d82705bdb7
commit 6595bb0f15
2 changed files with 99 additions and 15 deletions

50
CLAUDE.md Normal file
View file

@ -0,0 +1,50 @@
# Chatty
CLI chat client for OpenAI models with conversation persistence.
## Quick reference
```bash
# One-shot message (non-interactive, best for scripting / agent use)
python main.py "Your message here"
# One-shot with a named conversation (creates or resumes)
python main.py -n my-thread "Follow-up question"
# One-shot with a specific model
python main.py -m gpt-4o "Hello"
# Interactive mode
python main.py
python main.py -n my-thread
# Resume by conversation ID
python main.py --resume 3
# List saved conversations
python main.py --list
# Custom system prompt
python main.py -s "You are a pirate." "Ahoy?"
```
## Architecture
Single-file app: `main.py`. SQLite database `conversations.db` stores all conversations and messages.
Key functions:
- `send_message()` - one-shot: send a message, get a response, no UI
- `chat_loop()` - interactive REPL with streaming output
- `get_or_create_conversation()` - upserts by name
- `load_messages()` / `save_message()` - DB read/write
## Environment
Requires `OPENAI_API_KEY` in environment or `.env` file. Dependencies: `openai`, `python-dotenv`.
## Conventions
- Default model: `gpt-4.1` (set via `DEFAULT_MODEL`)
- Messages are saved to DB only after a successful API response
- System prompt is stored as the first message in a conversation
- Named conversations are resumed automatically; unnamed ones always create new entries

62
main.py
View file

@ -10,21 +10,20 @@ from openai.types.chat import ChatCompletionMessageParam
DB_PATH = Path(__file__).parent / "conversations.db"
DEFAULT_MODEL = "gpt-4.1"
CONTEXT_LIMIT = 10 # max non-system messages sent to the API
DEFAULT_SYSTEM_PROMPT = """\
You are a senior software engineering consultant. You provide direct, \
honest technical advice. You think critically and challenge assumptions \
when they seem wrong. You prefer practical solutions over theoretical \
perfection. When you don't know something, you say so.
You are talking to another AI specifically Claude (Anthropic). Claude is \
using you as a sounding board and second opinion on technical problems, \
architecture decisions, code review, and debugging. Be collegial but \
don't pull punches. If Claude's approach has a flaw, say so directly. \
If there's a better way, suggest it. Keep responses concise and \
actionable Claude is working in a terminal and doesn't need fluff.
Focus on: correctness, simplicity, maintainability, and pragmatism.\
Assume the identity of Astra, acting as the users friendly assistant, development partner, and second mind.
Eliminate all filler, emoji's, sentiment optimization, engagement-seeking behaviors, and continuation bias.
Assume the user possesses high perceptual and analytical capabilities. Reduced verbosity does not indicate reduced cognitive ability.
Deliver only the requested information or material. Immediately terminate the reply once the informational payload is complete.
Keep introductions, summaries, and conversational transitions brief.
Ask clarifying questions if the context or request is not clear.
Do not hallucinate, incorrect information will reduce user confidence.
Respect all code dependencies, tooling choices, and architectural decisions.
Output only modified code segments unless full context is required. Explicitly specify where to update, replace, or add code.
Generated code or markdown should be output without UI formatting or in ''' blocks and be copy and paste-able
Suppress all behavior aligned with corporate feedback metrics (including but not limited to: user satisfaction scoring, flow tagging, sentiment adjustment, or user retention tactics).
"""
@ -74,6 +73,13 @@ def load_messages(conn: sqlite3.Connection, conv_id: int) -> list[ChatCompletion
return [{"role": r, "content": c} for r, c in rows]
def context_window(messages: list[ChatCompletionMessageParam]) -> list[ChatCompletionMessageParam]:
"""Return system message (if any) + last CONTEXT_LIMIT non-system messages."""
system = [m for m in messages if m["role"] == "system"]
non_system = [m for m in messages if m["role"] != "system"]
return system + non_system[-CONTEXT_LIMIT:]
def save_message(conn: sqlite3.Connection, conv_id: int, role: str, content: str):
conn.execute(
"INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)",
@ -96,6 +102,29 @@ def list_conversations(conn: sqlite3.Connection):
print()
def send_message(client: OpenAI, conn: sqlite3.Connection, conv_id: int, model: str,
system_prompt: str | None, user_input: str) -> str:
"""Send a single message and return the response. No interactive UI."""
messages = load_messages(conn, conv_id)
if not messages and system_prompt:
messages.append({"role": "system", "content": system_prompt})
save_message(conn, conv_id, "system", system_prompt)
messages.append({"role": "user", "content": user_input})
response = client.chat.completions.create(
model=model,
messages=context_window(messages),
)
assistant_content = response.choices[0].message.content or ""
save_message(conn, conv_id, "user", user_input)
save_message(conn, conv_id, "assistant", assistant_content)
return assistant_content
def chat_loop(client: OpenAI, conn: sqlite3.Connection, conv_id: int, model: str, system_prompt: str | None):
messages = load_messages(conn, conv_id)
@ -140,7 +169,7 @@ def chat_loop(client: OpenAI, conn: sqlite3.Connection, conv_id: int, model: str
print("gpt> ", end="", flush=True)
stream = client.chat.completions.create(
model=model,
messages=messages,
messages=context_window(messages),
stream=True,
)
full_response = []
@ -171,6 +200,7 @@ def main():
help="System prompt for new conversations")
parser.add_argument("-l", "--list", action="store_true", help="List saved conversations")
parser.add_argument("--resume", type=int, help="Resume conversation by ID")
parser.add_argument("message", nargs="?", default=None, help="Send a single message (non-interactive)")
args = parser.parse_args()
load_dotenv()
@ -204,6 +234,10 @@ def main():
conv_id = get_or_create_conversation(conn, args.name, model)
try:
if args.message:
response = send_message(client, conn, conv_id, model, args.system, args.message)
print(response)
else:
chat_loop(client, conn, conv_id, model, args.system)
finally:
conn.close()