Files
plyngent/CLAUDE.md
T
NCBM 0dc5c876ee core/process+cli: PTY hardening and config/log polish
Mark PTY master FD non-inheritable; run read_pty/close_pty via to_thread;
close_all PTY sessions on chat exit. Add --log-level; clearer invalid
TOML errors. Keep export/status free of provider secrets.
2026-07-15 13:48:58 +08:00

10 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). Early development: protocol clients, config, async memory, and a runtime client factory exist. Agent, CLI, web, tools, and router are still planned/stubbed.

Commands

pdm install          # first-time dependency setup
pdm sync             # sync after pulling changes

pdm run basedpyright .   # type checking (basedpyright, "recommended" strictness)
pdm run ruff check .     # linting
pdm run ruff format .    # formatting
pdm run pytest           # tests (pytest-asyncio auto mode)

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/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.pyBaseOpenAIClient / OpenAIClient via niquests async + SSE.
  • openai_compatible/config.pyOpenAIConfig (token + base URL).
  • DeepSeekDeepseekOpenAIClient; models add reasoning_content, prefix, ThinkingOptions.

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 / openai-compatible / deepseek(openai convention) supported; 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), messages stored as msgspec chat message JSON.

Agent (agent/)

  • ChatClient Protocol for chat_completions.
  • @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; optional on_limit.
  • ChatAgent: optional MemoryStore (user message persisted immediately; assistant/tools on success); stream; system prompt; retry() when history ends with a user message (failed/cancelled turn or orphan after resume).
  • /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 usage from completions (stream with include_usage); char≈token fallback (~4 chars/token) when omitted; context size = last request prompt_tokens (API preferred); last_turn_usage / session_usage are 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 root; path substring denylist; command basename denylist; clearer deny messages.
  • file: read_file, write_file, listdir, tree, glob_paths, grep_files (regex, skip VCS/binary), edit_replace, edit_lineno (1-based range), copy_path / move_path / delete_path.
  • process: run_command (argv, no shell, timeout, optional stdin/env); PTY open_pty / read_pty / write_pty / close_pty (Unix only: pty+fork).
  • PTY: structured status (alive/exit_code/data); read_pty(..., until=); session limit/idle TTL/output budget; close SIGTERM→SIGKILL.
  • 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; config confirm_destructive / path_denylist.
  • vcs: read-only VCS tools (vcs_kind / vcs_status / vcs_diff / vcs_log / vcs_branch) via VcsBackend protocol; git implemented; detectors are pluggable for other systems.
  • chat: human prompts as tools — ask_user, choose_user, form_user (shared prompting core).
  • DEFAULT_TOOLS: file + process + vcs + chat tool list for a ToolRegistry.

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 selection (flags or interactive), SQLite sessions via config [database] (file DB under user data if unset/:memory:), sessions bound to workspace dir; resumes most recently updated session for cwd/--workspace by default (--new / --session).
  • Slash commands: Click group in cli/slash.py (params/help/UsageError); dispatch via awaitlet.async_def + awaitlet() for async work. Completer reads slash.list_commands.
  • Explicit /resume or --session from another workspace prompts: keep session path, update binding to current, or abort.
  • Failed/cancelled turns: user message kept in DB; partial assistant/tool rolled back; Ctrl+C cancels in-flight turn; TTY confirms off-loop; auto-retry 10s/20s/30s then /retry (no duplicate user message).
  • plyngent providers: list config providers.
  • plyngent config path|edit: print or open config in $EDITOR (shlex-split, e.g. codium --wait).
  • If no providers and $EDITOR is set, chat/providers prompt to edit config then reload.
  • Tools default on (--tools / --no-tools); workspace defaults to cwd; --max-rounds default 32.
  • Readline/editline: Tab completion; input history file under platformdirs user data (repl_history).

Composition utility: Forward descriptor

utils/components.pyForward[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_lineno over 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/choose_user/form_user)
    • G1: ReasoningDeltaEvent, /stream, /verbose
    • G2: /rename, /delete (confirm), /export md|json
    • G2.5: Click slash registry (cli/slash.py) + awaitlet for sync Click / async memory; auto /help; completer from slash.list_commands
    • G3: multiline """""" input (cli/input_text.py); /edit via $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_pty via to_thread; PtyManager.close_all() on chat exit; --log-level; clearer invalid TOML errors; export/status stay secret-free

    Planned

    • G6 — Docs: real README, example TOML, CLAUDE/help accuracy

    Milestone order: G6.

    PTY / process model (decision)

    Today PtyManager (tools/process/pty_session.py) is in-process: pty.openpty + os.fork → child execvp, parent holds master FD; tools are sync and run on the asyncio loop thread (blocking select/read/waitpid). 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.
    What does belong in G5 (1) mark master (and ideally app) FDs non-inheritable before/after fork; (2) avoid unbounded loop block — to_thread or add_reader for long read_pty/close; (3) CLI exit / finally: PtyManager.close_all(); (4) keep os.fork on the loop/main thread — do not move open to a thread pool without redesign.
    Phase H Optional PTY helper process (JSON/Unix socket), or subprocess+PTY with asyncio reaping; sandboxed tools, multi-session isolation.
  • 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.