developer/user keep OPEN WORK prose; synthetic_tool injects forged todo_list call with stack.render() only so the result matches a real tool output.
14 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project overview
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, and REPL/one-shot chat. Multi-tenant router/ / web remain Phase H.
Commands
pdm install # first-time dependency setup
pdm sync # sync after pulling changes
pdm run ruff check . # linting
pdm run ruff format . # apply formatting
pdm run ruff format --check . # CI: fail if unformatted (do not skip)
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
pdm run pytest # tests (pytest-asyncio auto mode)
# Commit gateway (prek — https://prek.j178.dev/): ruff check/format + basedpyright
uv tool install prek # once
prek install # wire git pre-commit hook (once per clone)
prek run --all-files # run all hooks on demand
GitHub Actions runs ruff check, ruff format --check, basedpyright, then pytest. Local commits run the same checks via prek.toml so ruff format is not forgotten.
Architecture
Data modeling: msgspec.Struct
All protocol models use msgspec.Struct — not dataclasses, not Pydantic. Optional fields use msgspec.field(default=UNSET) / default=UNSET with type T | Unset.
typedef.py: Unset = UnsetType (plain assignment so msgspec recognizes it; do not use PEP 695 type Unset = ...). Multi-struct unions must be tagged via tag_field / tag (e.g. role, type) for decode. JSONSchema is dict[str, Any].
Protocol layering (lmproto/)
lmproto/openai_compatible/ ← Base: model, config, client
lmproto/openai/ ← OpenAI platform: Responses + chat (extends base)
lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields
openai_compatible/model.py— tagged chat messages (SystemChatMessage,UserChatMessage, …), tools, request/response, streaming chunks.openai_compatible/client.py—BaseOpenAIClient/OpenAICompatibleClientvianiquestsasync + SSE;chat_completions,modelsonly (OpenAIClientis a compat alias).openai_compatible/config.py—OpenAIConfig(token + base URL).openai/model.py— OpenAI Responses API (ResponsesCreateParam,Response, function_call items, stream events).openai/client.py— platformOpenAIClient: chat completions +responses/get_response/delete_response.- DeepSeek —
DeepseekOpenAIClient; models addreasoning_content,prefix,ThinkingOptions. Config default model ids:deepseek-v4-flash,deepseek-v4-pro.
Config (config/)
TOML load/store (ConfigStore): [providers] tagged union presets, [database] section. Default path via platformdirs.
Runtime (runtime/)
create_client(provider) maps config Provider → protocol client. OpenAI → lmproto.openai.OpenAIClient (Responses-capable); openai-compatible / deepseek(openai convention) → chat-completions clients; anthropic and deepseek anthropic convention raise ProviderNotSupportedError.
Memory (memory/)
Async SQLAlchemy + aiosqlite. MemoryStore: schema init (+ lightweight SQLite ALTER for new columns), default local user, sessions (bound to workspace path; optional provider_name/model), messages stored as msgspec chat message JSON.
Agent (agent/)
ChatClientProtocol forchat_completions(agent history stays chat-shaped).- OpenAI Responses integration:
ResponsesChatClientadapts platformOpenAIClient.responsestoChatClient; selected automatically forOpenAIProviderincreate_client. Compat/DeepSeek stay on chat completions. - Provider-side tools:
OpenAIProvider.provider_tools(list of dicts; default[{type="web_search"}]when omitted;[]disables) merged into Responsestoolsalongside local function tools; never executed byToolRegistry. @tool/ToolRegistry: decorator infers JSON Schema from type hints; execute tools by name.run_chat_loop: multi-round tool loop; default streaming text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (API-calibrated after first usage when available); cooperative cancel points; optionalon_limit.ChatAgent: optionalMemoryStore(user message persisted immediately; completed tool batches checkpointed mid-turn; unfinished assistant suffix rolled back on failure);stream; system prompt;retry()continues incomplete turns (user-only or after committed tools — does not re-run those tools)./compact: soft-compact tool dumps → model summary (no tools) → new session seeded with summary message.- Events: text_delta, reasoning_delta, assistant_message, tool_call/result, max_rounds, error (
retryable/source), cancelled (reason), usage (TokenUsage). - Usage: API
usagefrom completions (stream withinclude_usage); char≈token fallback (~4 chars/token) when omitted; context size = last requestprompt_tokens(API preferred);last_turn_usage/session_usageare billed sums (tool rounds re-send history); CLI end-of-turn +/status. - Config
[agent]:system_prompt,max_tool_result_chars,parallel_tools,confirm_destructive,path_denylist,max_context_tokens(default 200k est. tokens).
Tools (tools/)
Module-level @tool handlers. Call set_workspace_root() before use.
workspace: path resolve under primary root or temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; not skipped by YOLO); clearer deny messages.file:read_file(with_lineno),write_file,listdir,tree(VCS + default noise dirs + optionalskip_dirs/ denylist walk),glob_paths,grep_files(regex, skip VCS/binary),edit_replace(max_replaces, reports remaining matches),edit_lineno(1-based range),copy_path/move_path/delete_path,new_temporary_workspace(system temp allowlist; cleanup on chat exit; no session rebind).process:run_command(argv, no shell, timeout, optional stdin/env);run_command_batch(serial steps,pipe_outon provider,mix_stderr,stop_on_error); PTYopen_pty/read_pty/write_pty(literal) /write_pty_keys(escapes) /ask_into_pty(human→PTY only;secretno-echo; answer never in tool result) /close_pty(POSIX:pty+fork; Windows: ConPTY viapywinptyenv marker dep).- PTY: backend in
pty_backend.py; structured status (alive/exit_code/data);read_pty(..., until=)(CSI sanitized so host TTY is never reset); keys viawrite_pty_keysonly (\xHH,ctrl+x,key=esc); secrets viaask_into_pty(notwrite_ptydata); session limit/idle TTL/output budget; close terminate→kill. - CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
- Destructive confirms:
classify_danger+ToolRegistry(on_confirm=…); CLI default deny; configconfirm_destructive/path_denylist. Soft confirm also for shells/REPLs andpython|bash -cone-liners (argv + code preview); deny may include a user comment for the model. Session YOLO:/yolo on|off|onceand--yes(skip soft confirms; hard denylists unchanged;onceexpires after the next user turn). vcs: read-only VCS tools (vcs_kind/vcs_status/vcs_diff/vcs_log/vcs_branch) viaVcsBackendprotocol; git implemented; detectors are pluggable for other systems.chat: human prompts as tools —ask_user_line/ask_user_choice/ask_user_form(sharedpromptingcore).todo: LIFO stack of task groups (not a queue of tasks) —todo_pushcreates one group of siblings;todo_popremoves the whole top group;todo_updateby id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work ([TODO OPEN WORK]); all-terminal non-empty = hygiene ([TODO HYGIENE]); nag channel via[agent] todo_nag_strategy(developer/user= prose nag;synthetic_tool= forgedtodo_listcall + realstack.render()result + ToolCall/Result events;none).DEFAULT_TOOLS: file + process + vcs + chat + todo tool list for aToolRegistry.
Prompting (prompting.py)
Shared interactive I/O: ask / choose / form / confirm with pluggable backend; non-TTY uses defaults or errors. CLI limit/confirm hooks and chat tools both use this. Async helpers serialize prompts (run_prompt_async).
CLI (cli/)
Click app + readline REPL. Entry: plyngent / python -m plyngent.
plyngent chat: provider/model (flags or interactive; Tab via readline inprompting); sessions storeprovider_name/modeland restore on resume; SQLite via[database](file DB under user data when url unset; expliciturl = ":memory:"kept + warn); workspace-bound; resume latest for cwd/--workspaceby default (--new/--session). One-shot:-p/--promptand non-TTY stdin; exit codes 0/1/2/3;--yes(YOLO on),--stream/--no-stream,--quiet. Root--log-level.- Slash: Click group in
cli/slash.py+awaitletfor async work; Tab completer from registry + ParamTypeshell_complete. Multiline"""…""";/editvia$VISUAL/$EDITOR(blocking only)./yolo on|off|oncefor soft destructive confirms./model --persist//models --persistwrite model catalog entries into TOML./todosfor human show/push/pop/clear of the todo stack. - Explicit
/resumeor--sessionfrom another workspace prompts: keep / update / abort. - Failed/cancelled turns: user kept; committed tool rounds kept (side effects not re-run on
/retry); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then/retry. plyngent providers,config path|edit. Config open:$VISUAL/$EDITOR(wait), else system default (xdg-open/open/startfile, non-blocking)./editstays blocking-only. No providers → optional edit then reload when waited.- Tools default on; workspace defaults to cwd;
--max-roundsdefault 32. Readline history under platformdirs (repl_history). PTY:close_allon chat exit.
Composition utility: Forward descriptor
utils/components.py — Forward[T] / forward() for attribute forwarding on composed objects.
Type annotations are mandatory
Basedpyright recommended. Ruff includes ANN (private return types ANN202 ignored). Prefer PEP 695 aliases except where msgspec requires plain assignment (Unset).
Roadmap notes (single-user → platform)
-
Phase D (context quality): soft char budget, request compact,
/compact, richer errors/cancel, workspace sessions. Context size is char estimate plus optional API usage when reported. -
No local tokenizer stage for now.
-
Phase E: tooling depth (grep/glob, VCS backends; prefer
edit_replace/edit_linenoover model-generated patches). -
Phase F (providers + usage v2): API
usage+ char-based estimate fallback; session/turn totals;/status+ end-of-turn. Optional later: cost, real tokenizer. -
Phase G (CLI polish + hardening) — single-user only; multi-tenant stays Phase H.
Done
- G0:
prompting(ask/choose/form/confirm) + chat tools (ask_user_line/ask_user_choice/ask_user_form) - G1:
ReasoningDeltaEvent,/stream,/verbose - G2:
/rename,/delete(confirm),/export md|json - G2.5: Click slash registry (
cli/slash.py) +awaitletfor sync Click / async memory; auto/help; completer fromslash.list_commands - G3: multiline
"""…"""input (cli/input_text.py);/editvia$EDITOR(edit_text_in_editor) - G4:
plyngent chat -p/--prompt(+ non-TTY stdin); exit codes 0/1/2/3;--yes/ non-interactive confirm deny;--stream/--no-stream,--quiet - G5: PTY master FD non-inheritable;
read_pty/close_ptyviato_thread;PtyManager.close_all()on chat exit;--log-level; clearer invalid TOML errors; export/status stay secret-free - G6: README,
doc/plyngent.example.toml, CLAUDE overview/CLI notes
Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
PTY / process model (decision)
Today
PtyManager(tools/process/pty_session.py) is in-process:pty.openpty+os.fork→ childexecvp, parent holds master FD;read_pty/close_ptyrun viato_thread. Session registry is process-global. Safe enough for single-user CLI if the child path stays fork-then-exec only.Question Answer Separate PTY supervisor process so the main app is safer with threads/greenlets? Not for Phase G. Defer to Phase H (sandbox / multi-tenant) or if we hit real FD-leak / freeze bugs. Why not now? Fork-then-exec is already the right shape; rewrite cost (IPC, lifecycle, tests) dwarfs single-user CLI risk. awaitlet greenlets are slash-only; PTY is not forked from a worker thread today. G5 (done) Master FD non-inheritable; read_pty/close_ptyviato_thread; chat exitclose_all; fork stays on loop/main thread.Phase H Optional PTY helper process (JSON/Unix socket), or subprocess+PTY with asyncio reaping; sandboxed tools, multi-session isolation. - G0:
-
Phase H: multi-tenant platform (
router/, auth, sandboxed tools, web). Optional out-of-process PTY host if isolation is required.
Commit messages
Scoped commit messages, not conventional commits:
<scope>: <brief description>
Examples: deps: add fastapi, core/mq: fix incorrect message sending, router: add routing service for xxx, test/webserver: change test client, ci/lint: run ruff style check.
Check git log for the full convention.