diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e6eb9d16 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install ruff + - run: ruff check turnstone/ tests/ + - run: ruff format --check turnstone/ tests/ + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install mypy types-redis + - run: pip install -e ".[mq]" + - run: mypy turnstone/ + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[test,mq]" + - run: pytest tests/ -m "not live" -q diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..da783dba --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,21 @@ +name: Publish to PyPI + +on: + push: + tags: ["v*"] + +permissions: + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install build + - run: python -m build + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index f48a316d..7ed8048d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Turnstone +[![CI](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml/badge.svg)](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/turnstone)](https://pypi.org/project/turnstone/) +[![Python](https://img.shields.io/pypi/pyversions/turnstone)](https://pypi.org/project/turnstone/) +[![License](https://img.shields.io/badge/license-BSL--1.1-blue)](LICENSE) + Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers, driven by message queues or interactive interfaces. Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) — a bird that flips rocks to expose what's hiding underneath. @@ -141,9 +146,30 @@ docs/ ├── docker.md # Docker Compose deployment and configuration ├── simulator.md # Cluster simulator usage and scenarios ├── tools.md # Tool schemas, execution pipeline, approval flow -└── eval.md # Evaluation harness internals +├── eval.md # Evaluation harness internals +└── diagrams/ # UML architecture diagrams (PlantUML sources + PNGs) + └── png/ # Pre-rendered diagram images ``` +### Architecture Diagrams + +Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/): + +| Diagram | Description | +|---------|-------------| +| [System Context](docs/diagrams/png/01-system-context.png) | Top-level components and external dependencies | +| [Package Structure](docs/diagrams/png/02-package-structure.png) | Python modules and dependency graph | +| [Core Engine Classes](docs/diagrams/png/03-core-engine-classes.png) | SessionUI protocol, ChatSession, WorkstreamManager | +| [Conversation Turn](docs/diagrams/png/04-conversation-turn.png) | Full message lifecycle through the engine | +| [Tool Pipeline](docs/diagrams/png/05-tool-pipeline.png) | Three-phase prepare/approve/execute | +| [MQ Protocol](docs/diagrams/png/06-mq-protocol.png) | 9 inbound + 19 outbound message types | +| [Message Routing](docs/diagrams/png/07-message-routing.png) | Multi-node routing scenarios | +| [Redis Key Schema](docs/diagrams/png/08-redis-key-schema.png) | All Redis keys, types, and TTLs | +| [Workstream States](docs/diagrams/png/09-workstream-states.png) | State machine transitions | +| [Simulator](docs/diagrams/png/10-simulator-architecture.png) | SimCluster, dispatchers, scenarios | +| [Console Data Flow](docs/diagrams/png/11-console-data-flow.png) | Dashboard data collection threads | +| [Deployment](docs/diagrams/png/12-deployment.png) | Docker Compose service topology | + ## Multi-node routing Each Turnstone server runs a bridge process. Bridges share a Redis instance for coordination: diff --git a/compose.yaml b/compose.yaml index bb6d330b..d8254aa1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -23,7 +23,7 @@ services: # Redis — message broker, pub/sub, node registry # ------------------------------------------------------------------- redis: - image: redis:7-alpine + image: redis:7.4-alpine command: - sh - -c diff --git a/docs/api-reference.md b/docs/api-reference.md index e8379dc4..42f7169d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2,6 +2,8 @@ ## Overview +> See also: [MQ Protocol diagram](diagrams/png/06-mq-protocol.png) | [Message Routing diagram](diagrams/png/07-message-routing.png) | [Redis Key Schema diagram](diagrams/png/08-redis-key-schema.png) + `turnstone-server` exposes a browser-based chat UI backed by a Python stdlib HTTP server (`socketserver.ThreadingMixIn` + `http.server.HTTPServer`). The server uses **Server-Sent Events (SSE)** for real-time streaming and **HTTP POST** for diff --git a/docs/architecture.md b/docs/architecture.md index 3decd973..f9036c7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,6 +65,8 @@ turnstone/ ## Core Loop +> See also: [Conversation Turn diagram](diagrams/png/04-conversation-turn.png) + A user message flows through the system as follows: ``` @@ -110,6 +112,8 @@ A user message flows through the system as follows: ### Tool Execution Pipeline +> See also: [Tool Pipeline diagram](diagrams/png/05-tool-pipeline.png) + Tool execution is a three-phase process: ``` @@ -168,6 +172,8 @@ The engine emits state changes via `_emit_state()` which calls ## SessionUI Protocol +> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png) + Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 13 methods. Every frontend must implement all of them. @@ -225,6 +231,8 @@ Workstreams are parallel, independent chat sessions. Each has its own ### WorkstreamState +> See also: [Workstream States diagram](diagrams/png/09-workstream-states.png) + Defined in `turnstone.core.workstream.WorkstreamState` (5 states): ``` @@ -418,8 +426,8 @@ independently, then returns the final content as the tool result. to `.plan-.md` — unique per `ChatSession` so concurrent workstreams don't collide. On repeat invocations the prior `plan` tool call and its result are forwarded from `self.messages` so the agent refines the existing plan rather - than starting over. Planning instructions are passed via `model_identity` in - `chat_template_kwargs` rather than as a developer message. + than starting over. Planning instructions are injected as a developer message + prepended to the agent's conversation. - **Turn limit**: controlled by `agent_max_turns` (default: `-1`, unlimited). When a limit is set and reached, the agent is forced to synthesize a final response without tools. When unlimited, the loop only exits when the model diff --git a/docs/console.md b/docs/console.md index a3324daa..33e40f5c 100644 --- a/docs/console.md +++ b/docs/console.md @@ -6,6 +6,8 @@ The console is read-only — it observes but does not own workstreams or drive L ## Architecture +> See also: [Console Data Flow diagram](diagrams/png/11-console-data-flow.png) + ``` turnstone-server ──→ turnstone-bridge ──→ Redis ──→ turnstone-console ──→ Browser (per node) (per node) (shared) (one instance) diff --git a/docs/diagrams/01-system-context.puml b/docs/diagrams/01-system-context.puml new file mode 100644 index 00000000..2c1a6210 --- /dev/null +++ b/docs/diagrams/01-system-context.puml @@ -0,0 +1,65 @@ +@startuml +!theme plain +title Turnstone — System Context + +' Actors/Users +actor "CLI User" as cli_user +actor "Browser User" as browser_user +actor "External Client\n(Python / CI)" as ext_client +actor "Eval Harness" as eval_user + +' External Systems +cloud "LLM Provider\n(OpenAI-compatible API)" as llm +database "Redis" as redis +database "SQLite\n(.turnstone.db)" as sqlite + +' Turnstone System Boundary +package "Turnstone Platform" { + component [turnstone\n(CLI)] as cli <> + component [turnstone-server\n(HTTP + SSE)] as server <> + component [turnstone-bridge\n(Queue ↔ HTTP)] as bridge <> + component [turnstone-console\n(Dashboard)] as console <> + component [turnstone-eval\n(Headless)] as eval <> + component [turnstone-sim\n(Simulator)] as sim <> +} + +' User connections +cli_user --> cli : stdin / stdout +browser_user --> server : HTTP + SSE\n(port 8080) +browser_user --> console : HTTP + SSE\n(port 8090) +ext_client --> redis : Redis LIST\n(push commands) +eval_user --> eval : Python API + +' Internal connections +cli --> llm : OpenAI Streaming API\n(HTTPS) +cli --> sqlite : SQLite + +server --> llm : OpenAI Streaming API\n(HTTPS) +server --> sqlite : SQLite + +eval --> llm : OpenAI API\n(non-streaming) +eval --> sqlite : SQLite + +bridge --> server : HTTP REST\n(POST /api/send, etc.) +bridge <-- server : SSE\n(GET /api/events) +bridge --> redis : Redis LIST + PUBSUB\n+ STRING (routing, heartbeats) + +console --> redis : Redis PUBSUB + STRING\n(cluster channel, heartbeats) +console --> server : HTTP polling\n(GET /api/dashboard) + +sim --> redis : Redis LIST + PUBSUB\n+ STRING (heartbeats) + +' Notes +note right of sim + Simulator replaces Server+Bridge + with lightweight SimNodes that + publish to the same Redis channels. +end note + +note right of redis + Shared message broker: + - LIST: command queues + - STRING: heartbeats, routing + - PUBSUB: event broadcast +end note +@enduml diff --git a/docs/diagrams/02-package-structure.puml b/docs/diagrams/02-package-structure.puml new file mode 100644 index 00000000..4e1bfe8c --- /dev/null +++ b/docs/diagrams/02-package-structure.puml @@ -0,0 +1,140 @@ +@startuml +!theme plain +title Turnstone — Package & Module Structure + +' Color definitions +skinparam component { + BackgroundColor<> #B8D4E3 + BackgroundColor<> #C8E6C9 + BackgroundColor<> #FFE0B2 + BackgroundColor<> #E1BEE7 + BackgroundColor<> #B2EBF2 + BackgroundColor<> #F0F4C3 + BackgroundColor<> #ECEFF1 +} + +' Entry points +package "Entry Points" <> { + component [cli.py\nturnstone] as cli <> + component [server.py\nturnstone-server] as server <> + component [eval.py\nturnstone-eval] as eval <> + component [chat.py\n(re-exports)] as chat <> +} + +' Core engine +package "turnstone/core/" <> { + component [session.py\nChatSession, SessionUI] as session <> + component [workstream.py\nWorkstreamManager] as workstream <> + component [tools.py\nTool loader] as tools <> + component [memory.py\nSQLite + FTS5] as memory <> + component [metrics.py\nPrometheus metrics] as metrics <> + component [config.py\nTOML config] as config <> + component [safety.py\nPath validation] as safety <> + component [sandbox.py\nCommand sandbox] as sandbox <> + component [edit.py\nFile editing] as edit <> + component [web.py\nWeb helpers] as web <> + component [auth.py\nAuthentication] as auth <> +} + +' MQ subsystem +package "turnstone/mq/" <> { + component [protocol.py\n28 message types] as protocol <> + component [broker.py\nMessageBroker, RedisBroker] as broker <> + component [bridge.py\nturnstone-bridge] as bridge <> + component [client.py\nTurnstoneClient] as client <> +} + +' Simulator +package "turnstone/sim/" <> { + component [cluster.py\nSimCluster] as simcluster <> + component [node.py\nSimNode, SimWorkstream] as simnode <> + component [engine.py\nSimEngine] as simengine <> + component [scenario.py\n5 scenarios] as scenario <> + component [sim/config.py\nSimConfig] as simconfig <> + component [sim/metrics.py\nSim metrics] as simmetrics <> + component [sim/cli.py\nturnstone-sim] as simcli <> +} + +' Console +package "turnstone/console/" <> { + component [collector.py\nClusterCollector] as collector <> + component [console/server.py\nDashboard HTTP+SSE] as consoleserver <> +} + +' UI +package "turnstone/ui/" <> { + component [colors.py\nANSI colors] as colors <> + component [markdown.py\nMD rendering] as markdown <> + component [spinner.py\nTerminal spinner] as spinner <> +} + +' Tool schemas +package "turnstone/tools/" <> { + component [*.json\n14 tool schemas] as schemas <> +} + +' Entry point dependencies +cli --> session +cli --> workstream +cli --> config +cli --> memory +cli --> colors +cli --> markdown +cli --> spinner +cli --> tools + +server --> session +server --> workstream +server --> config +server --> memory +server --> metrics +server --> auth +server --> tools + +eval --> session +eval --> memory +eval --> config +eval --> tools + +chat --> session + +' Core internal deps +session --> tools +session --> memory +session --> safety +session --> sandbox +session --> edit +session --> web +tools --> schemas + +' MQ dependencies +bridge --> protocol +bridge --> broker +bridge --> config +client --> protocol +client --> broker + +' Sim dependencies +simcli --> simcluster +simcli --> simconfig +simcli --> scenario +simcluster --> simnode +simcluster --> broker +simcluster --> simmetrics +simcluster --> simconfig +simnode --> simengine +simnode --> protocol +simnode --> simconfig +simnode --> simmetrics +scenario --> broker +scenario --> protocol +scenario --> simconfig +scenario --> simmetrics + +' Console dependencies +consoleserver --> collector +consoleserver --> config +consoleserver --> auth +collector --> broker + +@enduml diff --git a/docs/diagrams/03-core-engine-classes.puml b/docs/diagrams/03-core-engine-classes.puml new file mode 100644 index 00000000..88718319 --- /dev/null +++ b/docs/diagrams/03-core-engine-classes.puml @@ -0,0 +1,167 @@ +@startuml +!theme plain +title Turnstone — Core Engine Classes + +skinparam classAttributeIconSize 0 + +' SessionUI Protocol +interface "SessionUI" as SessionUI <> { + + on_thinking_start() + + on_thinking_stop() + + on_reasoning_token(text: str) + + on_content_token(text: str) + + on_stream_end() + + approve_tools(items: list) → (bool, str|None) + + on_tool_result(name: str, output: str) + + on_status(usage: dict, ctx_window: int, effort: str) + + on_plan_review(content: str) → str + + on_info(message: str) + + on_error(message: str) + + on_state_change(state: str) + + on_rename(name: str) +} + +' Implementations +class "TerminalUI" as TerminalUI { + Writes to stdout with ANSI colors + Prompts for approval via input() + -- + cli.py +} + +class "WorkstreamTerminalUI" as WsTermUI { + - _output_buffer: list[tuple] + - ws_id: str + - manager: WorkstreamManager + + flush_buffer() + -- + Buffers output when workstream + is not foregrounded +} + +class "WebUI" as WebUI { + - _event_queue: Queue + - _approval_event: Event + - _plan_event: Event + - _ws_prompt_tokens: int + - _ws_tool_calls: dict + + resolve_approval(approved, feedback) + + resolve_plan(feedback) + -- + Enqueues JSON events for SSE. + Blocks on threading.Event for + approval/plan review. + -- + server.py +} + +class "NullUI" as NullUI { + approve_tools() → (True, None) + All other methods: no-op + -- + eval.py +} + +' ChatSession +class "ChatSession" as ChatSession { + - client: OpenAI + - model: str + - ui: SessionUI + - messages: list[dict] + - _msg_tokens: list[int] + - _session_id: str + - _read_files: set[str] + - system_messages: list[dict] + -- + + send(user_input: str) + + handle_command(command: str) + + resume_session(session_id: str) + - _stream_response(stream) → dict + - _create_stream_with_retry(msgs) → Stream + - _execute_tools(tool_calls) → (results, feedback) + - _prepare_tool(tc) → item dict + - _run_agent(messages, tools, ...) → str + - _compact_messages(auto: bool) + - _full_messages() → list[dict] + - _update_token_table(msg) + - _emit_state(state: str) + - _generate_title() +} + +' HeadlessSession +class "HeadlessSession" as HeadlessSession { + + tool_call_log: list[dict] + + auto_approve: bool = True + + send_headless(input, max_turns, ...) + - _override_system_prompt(content) + -- + eval.py: non-streaming, + records all tool calls +} + +' WorkstreamManager +class "WorkstreamManager" as WsMgr { + - _session_factory: Callable[[SessionUI], ChatSession] + - _workstreams: dict[str, Workstream] + - _order: list[str] + - _active_id: str + - _on_state_change: Callable + -- + + create(name, ui_factory) → Workstream + + close(ws_id) + + get(ws_id) → Workstream + + get_active() → Workstream + + switch(ws_id) + + set_state(ws_id, state) + + close_idle(max_age_seconds) + -- + MAX_WORKSTREAMS = 10 +} + +' Workstream +class "Workstream" as Ws <> { + + id: str + + name: str + + state: WorkstreamState + + session: ChatSession + + ui: SessionUI + + worker_thread: Thread + + error_message: str + + last_active: float + - _lock: Lock +} + +' WorkstreamState +enum "WorkstreamState" as WsState { + IDLE + THINKING + RUNNING + ATTENTION + ERROR +} + +' Relationships +SessionUI <|.. TerminalUI +TerminalUI <|-- WsTermUI +SessionUI <|.. WebUI +SessionUI <|.. NullUI + +ChatSession --> SessionUI : uses +ChatSession <|-- HeadlessSession + +WsMgr --> "*" Ws : manages +Ws --> "1" ChatSession : wraps +Ws --> "1" SessionUI : wraps +Ws --> "1" WsState : has + +WsMgr ..> ChatSession : creates via\nsession_factory(ui) + +note bottom of ChatSession + Central engine: multi-turn LLM loop + with tool dispatch, agent sub-sessions, + context compaction, and memory persistence. + + core/session.py (~2700 lines) +end note + +@enduml diff --git a/docs/diagrams/04-conversation-turn.puml b/docs/diagrams/04-conversation-turn.puml new file mode 100644 index 00000000..375bb80c --- /dev/null +++ b/docs/diagrams/04-conversation-turn.puml @@ -0,0 +1,147 @@ +@startuml +!theme plain +title Turnstone — Conversation Turn Lifecycle + +skinparam sequenceArrowThickness 1.5 +skinparam sequenceLifeLineBackgroundColor #F5F5F5 + +participant "User /\nHTTP Client" as User +participant "ChatSession" as CS +participant "SessionUI" as UI +participant "OpenAI API\n(LLM)" as LLM +participant "Tool Executor\n(ThreadPool)" as TP +database "SQLite" as DB + +== User Input == + +User -> CS : send(user_input) +activate CS + +CS -> CS : messages.append({role: "user", content: input}) +CS -> DB : save_message(session_id, "user", input) + +== LLM Call Loop == + +group loop [while tool_calls present] + + CS -> UI : on_state_change("thinking") + CS -> UI : on_thinking_start() + + CS -> LLM : client.chat.completions.create(\n model, messages, tools,\n stream=True, stream_options={include_usage}) + activate LLM + + note right of CS + Retry up to 3× on transient errors: + RateLimitError, APITimeoutError, + APIConnectionError, InternalServerError, + ServiceUnavailableError, APIError + Backoff: 1s, 2s, 4s + end note + + == Streaming Response == + + loop for each chunk in stream + LLM --> CS : delta + note right of CS + on_thinking_stop() called on first + delta token via _stop_spinner_once() + end note + alt reasoning_content present + CS -> UI : on_reasoning_token(text) + else content present + CS -> UI : on_content_token(text) + else tool_call delta + CS -> CS : accumulate in tool_calls_acc + end + end + + LLM --> CS : stream complete (usage stats) + deactivate LLM + + CS -> UI : on_thinking_stop() (no-op guard: already called by _stop_spinner_once) + CS -> UI : on_stream_end() + + CS -> CS : _update_token_table()\ncalibrate chars_per_token ratio + CS -> CS : messages.append(assistant_msg) + CS -> DB : save_message(session_id, "assistant", content) + CS -> DB : save_message(session_id, "tool_call", ...) ×N + + == Tool Dispatch (if tool_calls) == + + alt no tool_calls + CS -> UI : on_status(usage, context_window, effort) + + opt prompt_tokens > context_window × auto_compact_pct + CS -> CS : _compact_messages(auto=True) + CS -> LLM : Non-streaming summarization call + CS -> CS : Replace messages with [summary] + end + + opt first exchange & no title + CS -> CS : Background thread: _generate_title() + end + + CS -> UI : on_state_change("idle") + CS --> User : return + + else has tool_calls + CS -> UI : on_state_change("running") + + == Phase 1: Prepare == + CS -> CS : [_prepare_tool(tc) for tc in tool_calls]\nParse JSON args, validate,\nbuild preview + header + + == Phase 2: Approve == + CS -> UI : on_state_change("attention") + CS -> UI : approve_tools(items) + activate UI + note right of UI + TerminalUI: input() prompt + WebUI: _approval_event.wait() + NullUI: returns (True, None) + end note + UI --> CS : (approved: bool, feedback: str?) + deactivate UI + CS -> UI : on_state_change("running") + + == Phase 3: Execute == + CS -> TP : ThreadPoolExecutor(max_workers=4)\nrun_one(item) for each tool + activate TP + + note right of TP + Parallel execution: + bash → subprocess.run() + read_file → open().read() + search → grep subprocess + edit_file → string replace + task/plan → _run_agent() sub-loop + math → sandboxed subprocess + web_fetch → httpx + LLM summarize + web_search → Tavily API + remember/recall/forget → SQLite + end note + + note right of TP + on_tool_result() called + inside each _exec_* handler + end note + + TP --> CS : [(call_id, output), ...] + deactivate TP + + loop for each result + CS -> CS : messages.append({role: "tool", ...}) + CS -> DB : save_message(session_id, "tool_result", ...) + end + + opt user_feedback from approval + CS -> CS : messages.append({role: "user", content: feedback}) + end + + note right of CS : Loop back for next LLM call + end + +end + +deactivate CS + +@enduml diff --git a/docs/diagrams/05-tool-pipeline.puml b/docs/diagrams/05-tool-pipeline.puml new file mode 100644 index 00000000..f4730013 --- /dev/null +++ b/docs/diagrams/05-tool-pipeline.puml @@ -0,0 +1,130 @@ +@startuml +!theme plain +title Turnstone — Tool Execution Pipeline (Three Phases) + +start + +partition "Phase 1: Prepare" #E8F5E9 { + :Receive tool_calls list from LLM response; + + while (more tool_calls?) is (yes) + :Extract call_id, func_name, raw_args; + + if (json.loads(raw_args) succeeds?) then (yes) + :parsed_args = JSON dict; + else (no) + :Fallback 1: regex extraction; + if (regex found keys?) then (yes) + :parsed_args = extracted dict; + else (no) + :Fallback 2: bare string →\nPRIMARY_KEY_MAP[func_name]; + endif + endif + + :Dispatch to _prepare_{func_name}(); + + note right + **Dispatch table (14 tools):** + ┌─────────────┬──────────────────┐ + │ Tool │ Needs Approval? │ + ├─────────────┼──────────────────┤ + │ bash │ ✓ Yes │ + │ read_file │ ✗ Auto-approve │ + │ write_file │ ✓ Yes │ + │ edit_file │ ✓ Yes │ + │ search │ ✗ Auto-approve │ + │ math │ ✓ Yes │ + │ man │ ✗ Auto-approve │ + │ web_fetch │ ✓ Yes │ + │ web_search │ ✓ Yes │ + │ task │ ✓ Yes │ + │ plan │ ✓ Yes │ + │ remember │ ✗ Auto-approve │ + │ recall │ ✗ Auto-approve │ + │ forget │ ✗ Auto-approve │ + └─────────────┴──────────────────┘ + end note + + :Build item dict: + {call_id, func_name, header, + preview, needs_approval, + approval_label, execute: Callable}; + endwhile (no) +} + +partition "Phase 2: Approve" #FFF3E0 { + if (any items need approval?) then (yes) + :_emit_state("attention"); + :ui.approve_tools(items); + + note right + **auto_approve check is handled + internally by ui.approve_tools()** + + **TerminalUI**: Print headers/previews, + prompt [y/n/a, optional message] + If user chose "always": + Set ui.auto_approve = True + (auto-approve all future tools in this session) + **WebUI**: Enqueue approve_request, + block on _approval_event.wait() + **NullUI**: Return (True, None) + end note + + if (user approved?) then (yes) + :_emit_state("running"); + else (denied) + :Mark all pending items as denied; + :denial_msg = "Denied by user"; + :_emit_state("running"); + endif + else (all auto-approved) + :ui enqueues tool_info event\n(no blocking); + endif +} + +partition "Phase 3: Execute" #E3F2FD { + if (single tool call?) then (yes) + :Execute sequentially:\nrun_one(items[0]); + else (multiple) + :Execute in parallel:\nThreadPoolExecutor(max_workers=4)\npool.map(run_one, items); + endif + + note right + **run_one(item):** + if item.error → return error string + if item.denied → return denial message + else → item["execute"](item) + ├─ _exec_bash: subprocess.run(["bash", script.sh]) + ├─ _exec_read_file: open().readlines() + ├─ _exec_write_file: makedirs + write + ├─ _exec_edit_file: find_occurrences + replace + ├─ _exec_search: grep subprocess + ├─ _exec_math: sandboxed subprocess + ├─ _exec_man: man/info subprocess + ├─ _exec_web_fetch: httpx.get + LLM summary + ├─ _exec_web_search: Tavily API POST + ├─ _exec_task: _run_agent(TASK_AGENT_TOOLS) + ├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only) + ├─ _exec_remember: SQLite INSERT OR REPLACE + ├─ _exec_recall: SQLite FTS5/LIKE search + └─ _exec_forget: SQLite DELETE + end note + + :Collect results: [(call_id, output), ...]; + + :_truncate_output() on each result\n(max context_window × chars_per_token × 0.5 chars\ndefault: ~context_window × 2 chars); + + :ui.on_tool_result(name, output) for each; + + if (plan tool was executed?) then (yes) + :ui.on_plan_review(output); + :Block for user review/feedback; + endif +} + +:Return (results, user_feedback); + +stop + +@enduml diff --git a/docs/diagrams/06-mq-protocol.puml b/docs/diagrams/06-mq-protocol.puml new file mode 100644 index 00000000..c78267ba --- /dev/null +++ b/docs/diagrams/06-mq-protocol.puml @@ -0,0 +1,242 @@ +@startuml +!theme plain +title Turnstone — Message Queue Protocol Types +skinparam classAttributeIconSize 0 +skinparam packageStyle rectangle + +package "Inbound Messages (Client → Bridge)" #FFF3E0 { + + abstract class "InboundMessage" as IM { + + type: str + + correlation_id: str {auto: uuid4().hex[:12]} + + timestamp: float {auto: time.time()} + -- + + to_json() → str + + {static} from_json(raw) → InboundMessage + } + + class SendMessage { + type = "send" + -- + + ws_id: str + + message: str + + auto_approve: bool = False + + auto_approve_tools: list[str] = [] + + name: str = "" + + target_node: str = "" + } + + class ApproveMessage { + type = "approve" + -- + + ws_id: str + + request_id: str + + approved: bool = True + + feedback: str | None + + always: bool = False + } + + class PlanFeedbackMessage { + type = "plan_feedback" + -- + + ws_id: str + + request_id: str + + feedback: str + } + + class CommandMessage { + type = "command" + -- + + ws_id: str + + command: str + } + + class CreateWorkstreamMessage { + type = "create_workstream" + -- + + name: str = "" + + auto_approve: bool = False + + auto_approve_tools: list[str] = [] + + target_node: str = "" + } + + class CloseWorkstreamMessage { + type = "close_workstream" + -- + + ws_id: str + } + + class ListWorkstreamsMessage { + type = "list_workstreams" + } + + class HealthMessage { + type = "health" + } + + class ListNodesMessage { + type = "list_nodes" + } + + IM <|-- SendMessage + IM <|-- ApproveMessage + IM <|-- PlanFeedbackMessage + IM <|-- CommandMessage + IM <|-- CreateWorkstreamMessage + IM <|-- CloseWorkstreamMessage + IM <|-- ListWorkstreamsMessage + IM <|-- HealthMessage + IM <|-- ListNodesMessage +} + +package "Outbound Events (Bridge → Client)" #E3F2FD { + + abstract class "OutboundEvent" as OE { + + type: str + + ws_id: str + + correlation_id: str + + timestamp: float + -- + + to_json() → str + + {static} from_json(raw) → OutboundEvent + } + + package "Streaming" #BBDEFB { + class ContentEvent { + type = "content" + + text: str + } + class ReasoningEvent { + type = "reasoning" + + text: str + } + class StreamEndEvent { + type = "stream_end" + } + } + + package "Tools" #C8E6C9 { + class ToolInfoEvent { + type = "tool_info" + + items: list + } + class ApprovalRequestEvent { + type = "approval_request" + + items: list + .. + correlation_id = request_id + } + class ToolResultEvent { + type = "tool_result" + + name: str + + output: str + } + class PlanReviewEvent { + type = "plan_review" + + content: str + } + } + + package "Status" #FFF9C4 { + class AckEvent { + type = "ack" + + status: str + + detail: str + } + class StatusEvent { + type = "status" + + prompt_tokens: int + + completion_tokens: int + + total_tokens: int + + context_window: int + + pct: float + + effort: str + } + class StateChangeEvent { + type = "state_change" + + state: str + } + class TurnCompleteEvent { + type = "turn_complete" + } + } + + package "Lifecycle" #F8BBD0 { + class WorkstreamCreatedEvent { + type = "ws_created" + + name: str + } + class WorkstreamClosedEvent { + type = "ws_closed" + } + class WorkstreamListEvent { + type = "ws_list" + + workstreams: list + } + class WorkstreamRenameEvent { + type = "ws_rename" + + name: str + } + } + + package "System" #E0E0E0 { + class HealthResponseEvent { + type = "health_response" + + data: dict + } + class ErrorEvent { + type = "error" + + message: str + } + class InfoEvent { + type = "info" + + message: str + } + class NodeListEvent { + type = "node_list" + + nodes: list + } + class ClusterStateEvent { + type = "cluster_state" + + state: str + + node_id: str + + tokens: int + + context_ratio: float + + activity: str + + activity_state: str + } + } + + OE <|-- ContentEvent + OE <|-- ReasoningEvent + OE <|-- StreamEndEvent + OE <|-- ToolInfoEvent + OE <|-- ApprovalRequestEvent + OE <|-- ToolResultEvent + OE <|-- PlanReviewEvent + OE <|-- AckEvent + OE <|-- StatusEvent + OE <|-- StateChangeEvent + OE <|-- TurnCompleteEvent + OE <|-- WorkstreamCreatedEvent + OE <|-- WorkstreamClosedEvent + OE <|-- WorkstreamListEvent + OE <|-- WorkstreamRenameEvent + OE <|-- HealthResponseEvent + OE <|-- ErrorEvent + OE <|-- InfoEvent + OE <|-- NodeListEvent + OE <|-- ClusterStateEvent +} + +note bottom of IM + **Deserialization**: Strict type-dispatch via _INBOUND_REGISTRY. + Unknown type raises ValueError. +end note + +note bottom of OE + **Deserialization**: Lenient type-dispatch via _OUTBOUND_REGISTRY. + Unknown type falls back to base OutboundEvent. +end note + +@enduml diff --git a/docs/diagrams/07-message-routing.puml b/docs/diagrams/07-message-routing.puml new file mode 100644 index 00000000..e52bb7bb --- /dev/null +++ b/docs/diagrams/07-message-routing.puml @@ -0,0 +1,105 @@ +@startuml +!theme plain +title Turnstone — Multi-Node Message Routing + +skinparam sequenceArrowThickness 1.5 + +participant "TurnstoneClient" as Client +collections "Redis" as Redis +participant "Bridge-A\n(node_id: nodeA)" as BridgeA +participant "Bridge-B\n(node_id: nodeB)" as BridgeB +participant "Server-A" as ServerA + +== Scenario A: New Message — No Workstream Affinity == + +Client -> Redis : RPUSH turnstone:inbound\n{type:"send", message:"...", ws_id:""} +note right of Redis : Shared queue — any bridge can pick up + +BridgeA -> Redis : BLPOP [turnstone:inbound:nodeA,\n turnstone:inbound] +Redis --> BridgeA : SendMessage (from shared queue) + +BridgeA -> ServerA : POST /api/workstreams/new\n{name:"", auto_approve:false} +ServerA --> BridgeA : {ws_id:"abc12345", name:"ws-abc1"} + +BridgeA -> Redis : SET turnstone:ws:abc12345 "nodeA" +note right : Register workstream ownership + +BridgeA -> ServerA : GET /api/events?ws_id=abc12345 +note right : Start per-WS SSE thread + +BridgeA -> Redis : PUBLISH turnstone:events:global\nWorkstreamCreatedEvent + +BridgeA -> Redis : PUBLISH turnstone:events:cluster\nClusterStateEvent(ws_id, state:"idle", node_id:"nodeA") + +BridgeA -> ServerA : POST /api/send\n{message:"...", ws_id:"abc12345"} +ServerA --> BridgeA : {status:"ok"} + +BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nAckEvent(status:"ok") + +... SSE events flow: content, tool_result, status, state_change ... + +BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nContentEvent, ToolResultEvent, ... +BridgeA -> Redis : PUBLISH turnstone:events:global\nStateChangeEvent(state:"idle") +BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nTurnCompleteEvent + +== Scenario B: Directed Message to Specific Node == + +Client -> Redis : RPUSH turnstone:inbound:nodeB\n{type:"send", target_node:"nodeB", ...} +note right : Per-node queue — only nodeB picks up + +BridgeB -> Redis : BLPOP [turnstone:inbound:nodeB,\n turnstone:inbound] +Redis --> BridgeB : SendMessage (from per-node queue, priority) + +note right of BridgeB : Process locally on nodeB + +== Scenario C: Re-routing (Lands on Wrong Node) == + +Client -> Redis : RPUSH turnstone:inbound\n{type:"send", ws_id:"abc12345"} + +BridgeB -> Redis : BLPOP [..., turnstone:inbound] +Redis --> BridgeB : SendMessage (ws_id: abc12345) + +BridgeB -> Redis : GET turnstone:ws:abc12345 +Redis --> BridgeB : "nodeA" + +note right of BridgeB : Owner is nodeA, not me — re-route + +BridgeB -> Redis : RPUSH turnstone:inbound:nodeA\n(re-routed message) + +BridgeA -> Redis : BLPOP [turnstone:inbound:nodeA, ...] +Redis --> BridgeA : SendMessage (from per-node queue) +note right of BridgeA : Process locally — I own this workstream + +== Scenario D: Approval via Response Queue == + +BridgeA <- ServerA : SSE: {type:"approve_request", items:[...]} + +note right of BridgeA + Bridge checks auto-approve: + 1. _ws_auto_approve[ws_id]? → auto + 2. All tools in safe set? → auto + (read_file, search, man, + remember, recall, forget) + 3. Otherwise → manual approval +end note + +BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nApprovalRequestEvent(correlation_id: req_xyz) + +Client <- Redis : (subscribed) ApprovalRequestEvent + +Client -> Redis : RPUSH turnstone:resp:req_xyz\nApproveMessage(approved:true) +note right : Response queue — bypasses inbound queue + +BridgeA -> Redis : BLPOP turnstone:resp:req_xyz\n(spawned approval thread, timeout 300s) +Redis --> BridgeA : ApproveMessage + +BridgeA -> ServerA : POST /api/approve\n{approved:true, ws_id:"abc12345"} + +== Heartbeat (continuous) == + +BridgeA -> Redis : SET turnstone:node:nodeA\n{server_url, started} EX 60 +note right : Every 30s — TTL 60s + +BridgeB -> Redis : SET turnstone:node:nodeB\n{server_url, started} EX 60 + +@enduml diff --git a/docs/diagrams/08-redis-key-schema.puml b/docs/diagrams/08-redis-key-schema.puml new file mode 100644 index 00000000..69aa6bcb --- /dev/null +++ b/docs/diagrams/08-redis-key-schema.puml @@ -0,0 +1,98 @@ +@startuml +!theme plain +title Turnstone — Redis Key Schema + +skinparam component { + BackgroundColor<> #BBDEFB + BackgroundColor<> #C8E6C9 + BackgroundColor<> #FFE0B2 +} + +skinparam note { + BackgroundColor #FAFAFA +} + +package "Queues (Redis LIST)" #E3F2FD { + component [**turnstone:inbound**\n\nShared command queue.\nAny bridge can consume.\n\nOps: RPUSH (write), BLPOP (read)] as inbound <> + + component [**turnstone:inbound:{node_id}**\n\nPer-node directed queue.\nPriority over shared queue.\n\nOps: RPUSH (write), BLPOP (read)] as inbound_node <> + + component [**turnstone:resp:{request_id}**\n\nPer-request response queue.\nFor approval / plan feedback.\nTTL: 600s\n\nOps: RPUSH + EXPIRE (write), BLPOP (read)] as resp <> +} + +package "Routing (Redis STRING)" #E8F5E9 { + component [**turnstone:ws:{ws_id}**\n\nWorkstream → node ownership.\nValue: node_id string.\nNo TTL.\n\nOps: SET, GET, DEL] as ws_owner <> + + component [**turnstone:node:{node_id}**\n\nNode heartbeat + metadata.\nValue: JSON {server_url, started, ...}\nTTL: 60s (refreshed every 30s)\n\nOps: SET with EX, GET, SCAN] as node_hb <> +} + +package "Event Channels (Redis PUBSUB)" #FFF3E0 { + component [**turnstone:events:global**\n\nGlobal event broadcast.\nAll state changes, ws lifecycle.\n\nOps: PUBLISH, SUBSCRIBE] as evt_global <> + + component [**turnstone:events:{ws_id}**\n\nPer-workstream events.\nContent, tools, status.\n\nOps: PUBLISH, SUBSCRIBE] as evt_ws <> + + component [**turnstone:events:cluster**\n\nCluster-wide state changes.\nUsed by Console dashboard.\n\nOps: PUBLISH, SUBSCRIBE] as evt_cluster <> +} + +' Readers / Writers + +actor "TurnstoneClient" as client +actor "Bridge" as bridge +actor "SimNode" as sim +actor "Console\nCollector" as console +actor "Scenario\n(injector)" as scenario + +' Queue interactions +client --> inbound : RPUSH\n(send commands) +client --> inbound_node : RPUSH\n(directed) +scenario --> inbound : RPUSH\n(inject load) +scenario --> inbound_node : RPUSH\n(directed scenario) +bridge --> inbound : BLPOP\n(consume) +bridge --> inbound_node : BLPOP\n(priority) +bridge --> inbound_node : RPUSH\n(re-route) +sim --> inbound_node : BLPOP\n(via dispatcher) + +client --> resp : RPUSH\n(approval response) +bridge --> resp : BLPOP\n(wait for approval) + +' Routing interactions +bridge --> ws_owner : SET / GET / DEL +client --> ws_owner : GET\n(route lookup) +sim --> ws_owner : SET / DEL + +bridge --> node_hb : SET with EX\n(heartbeat) +sim --> node_hb : SET with EX\n(heartbeat) +console --> node_hb : SCAN + GET\n(discovery) +client --> node_hb : SCAN + GET\n(list_nodes) + +' Pub/sub interactions +bridge --> evt_global : PUBLISH +bridge --> evt_ws : PUBLISH +bridge --> evt_cluster : PUBLISH +client --> evt_global : SUBSCRIBE +client --> evt_ws : SUBSCRIBE +sim --> evt_global : PUBLISH +sim --> evt_ws : PUBLISH +sim --> evt_cluster : PUBLISH +console --> evt_cluster : SUBSCRIBE + +note bottom of inbound + **BLPOP priority**: Bridges call + BLPOP [per-node, shared] so the + per-node queue is always checked first. +end note + +note bottom of resp + **Bypasses inbound queue**: Approval + responses go directly to the response + queue, not through the inbound queue. + Auto-cleaned after 600s TTL. +end note + +note bottom of evt_cluster + **ClusterStateEvent** includes node_id, + tokens, and context_ratio — enriched + data not available on the global channel. +end note + +@enduml diff --git a/docs/diagrams/09-workstream-states.puml b/docs/diagrams/09-workstream-states.puml new file mode 100644 index 00000000..c3434ca5 --- /dev/null +++ b/docs/diagrams/09-workstream-states.puml @@ -0,0 +1,81 @@ +@startuml +!theme plain +title Turnstone — Workstream State Machine + +skinparam state { + BackgroundColor<> #E8F5E9 + BackgroundColor<> #E3F2FD + BackgroundColor<> #FFF3E0 + BackgroundColor<> #FCE4EC + BackgroundColor<> #FFCDD2 +} + +state "IDLE" as idle <> : Waiting for user input.\nNo active LLM call or tool execution. +state "THINKING" as thinking <> : LLM streaming response.\nTokens flowing (reasoning + content). +state "RUNNING" as running <> : Tools executing.\nThreadPoolExecutor active. +state "ATTENTION" as attention <> : Blocked on user action.\nTool approval or plan review needed. +state "ERROR" as error <> : Exception occurred.\nRecoverable on next send(). + +[*] --> idle : Session created + +idle --> thinking : send() called\n_emit_state("thinking") + +thinking --> running : Tool calls detected\nin LLM response\n_emit_state("running") + +thinking --> idle : No tool calls\n(final answer)\n_emit_state("idle") + +thinking --> error : Exception during\nstreaming + +running --> attention : approve_tools() called\n_emit_state("attention") + +attention --> running : User approves\n(tools execute)\n_emit_state("running") + +attention --> running : User denies\n(denial recorded)\n_emit_state("running") + +running --> thinking : Tool results appended,\nnext LLM call\n_emit_state("thinking") + +running --> attention : Plan tool complete,\non_plan_review()\n_emit_state("attention") + +running --> error : Exception during\ntool execution + +error --> thinking : New send() call\n_emit_state("thinking") + +note right of thinking + **Emitted via:** + session._emit_state(state) + → ui.on_state_change(state) + + **Propagation:** + • WebUI → global SSE queue (ws_state) + • Bridge → PUBLISH to global + cluster channels + • CLI → WorkstreamManager.set_state() +end note + +note left of attention + **Blocking mechanisms:** + • TerminalUI: input() prompt + • WebUI: threading.Event.wait() + • Bridge: BLPOP on response queue + • NullUI: auto-approve (never reaches) +end note + +state "SimWorkstream (simplified)" as sim_group { + state "sim_idle" as si <> + state "sim_thinking" as st <> + state "sim_running" as sr <> + state "sim_error" as se <> + + [*] --> si + si --> st : process_turn() called + st --> sr : Tool calls generated + sr --> st : More rounds + st --> si : No tools / max rounds + st --> se : Uncaught exception +} + +note right of sim_group + SimWorkstream has no ATTENTION state — + tool approval is not simulated. +end note + +@enduml diff --git a/docs/diagrams/10-simulator-architecture.puml b/docs/diagrams/10-simulator-architecture.puml new file mode 100644 index 00000000..ed3c61ad --- /dev/null +++ b/docs/diagrams/10-simulator-architecture.puml @@ -0,0 +1,113 @@ +@startuml +!theme plain +title Turnstone — Simulator Architecture + +skinparam component { + BackgroundColor<> #E1BEE7 + BackgroundColor<> #CE93D8 + BackgroundColor<> #F3E5F5 + BackgroundColor<> #FFF3E0 + BackgroundColor<> #E8F5E9 + BackgroundColor<> #FFCDD2 +} + +package "SimCluster" as cluster <> { + + component [**ThreadPoolExecutor**\nmax_workers=64\n(blocking Redis ops)] as executor <> + + component [**redis.ConnectionPool**\nmax_connections=64\ndecode_responses=True\n(shared across all nodes)] as pool <> + + package "InboundDispatchers" { + component [**Dispatcher 0**\nnodes 0-49] as d0 + component [**Dispatcher 1**\nnodes 50-99] as d1 + component [**...**\n(ceil(N/50) total)] as dn + + note bottom of d0 + Each dispatcher calls BLPOP on a single Redis + connection for up to 50 node queues + shared queue. + Keys: [prefix:inbound:sim-0000, ..., prefix:inbound] + Per-node keys have BLPOP priority over shared. + end note + } + + package "SimNodes (N instances)" { + component [**SimNode sim-0000**] as n0 <> + component [**SimNode sim-0001**] as n1 <> + component [**...**] as nn <> + + component [**SimEngine**\n(per node, seeded RNG)\n\nLLM simulation:\n gaussian(μ=2s, σ=0.5s) latency\n gaussian(μ=200, σ=50) tokens\n random word content\n P(tool_calls) = 0.6/0.3\n\nTool simulation:\n gaussian(μ=0.5s, σ=0.2s) latency\n P(failure) = 0.02] as engine <> + + component [**SimWorkstream**\n(0..max_ws per node)\n\nState: idle→thinking→running→idle\nToken accounting: word_count × 3\nContent: 8-chunk streaming] as ws <> + } + + component [**MetricsCollector**\n(thread-safe, shared)\n\nTracks: turn latencies,\nthroughput, utilization,\nerrors, node kills] as metrics <> +} + +package "Scenarios (5 workload patterns)" <> { + component [**SteadyState**\nConstant rate:\n1/mps interval\nfor duration secs] as steady <> + + component [**Burst**\nburst_size messages\nas fast as possible\nthen wait] as burst <> + + component [**NodeFailure**\nSteadyState + periodic\nnode kills (up to N/2)] as failure <> + + component [**Directed**\nMessages targeted to\nspecific nodes via\ntarget_node field] as directed <> + + component [**Lifecycle**\n3 phases:\n1. Create workstreams\n2. Send messages\n3. Close half] as lifecycle <> +} + +database "Redis" as redis <> + +' Scenario -> Redis +steady --> redis : RPUSH prefix:inbound\n(SendMessage) +burst --> redis : RPUSH prefix:inbound\n(burst) +failure --> redis : RPUSH prefix:inbound +directed --> redis : RPUSH prefix:inbound:{node}\n(directed) +lifecycle --> redis : RPUSH prefix:inbound\n(Create/Send/Close) + +' Dispatchers -> Redis -> Nodes +d0 --> redis : BLPOP [per-node..., shared] +d1 --> redis : BLPOP [per-node..., shared] +d0 --> n0 : handle_message(raw) +d0 --> n1 : handle_message(raw) + +' Nodes internal +n0 --> engine : simulate_llm_response()\nsimulate_tool_execution() +n0 --> ws : process_turn() + +' Nodes -> Redis (events) +n0 --> redis : PUBLISH prefix:events:global\n(StateChangeEvent) +n0 --> redis : PUBLISH prefix:events:{ws_id}\n(ContentEvent, ToolResultEvent, ...) +n0 --> redis : PUBLISH prefix:events:cluster\n(ClusterStateEvent) +n0 --> redis : SET prefix:node:sim-0000\nEX 60 (heartbeat) +n0 --> redis : SET prefix:ws:{ws_id}\n(ownership) + +' Shared pool +n0 ..> pool : PooledBroker\n(shared connection) +n1 ..> pool : PooledBroker +d0 ..> pool +d0 ..> executor : asyncio.to_thread() + +' Metrics +ws --> metrics : record_turn(ws_id, node_id, latency) +steady --> metrics : record_inject() +burst --> metrics : record_inject() +directed --> metrics : record_inject() +lifecycle --> metrics : record_inject() +cluster --> metrics : record_node_kill(node_id) +cluster --> metrics : snapshot_utilization()\n(every metrics_interval) + +note bottom of cluster + **SimConfig** controls all simulation parameters: + num_nodes, max_ws_per_node, redis settings, + llm_latency_mean/stddev, tool_failure_rate, + scenario, duration, messages_per_second, seed +end note + +note right of redis + Simulator uses **real Redis** — + not a mock. Console dashboard + can monitor a running simulation + via the same cluster channel. +end note + +@enduml diff --git a/docs/diagrams/11-console-data-flow.puml b/docs/diagrams/11-console-data-flow.puml new file mode 100644 index 00000000..ffc3bef6 --- /dev/null +++ b/docs/diagrams/11-console-data-flow.puml @@ -0,0 +1,124 @@ +@startuml +!theme plain +title Turnstone — Console Dashboard Data Collection + +skinparam sequenceArrowThickness 1.5 + +participant "Browser" as Browser +participant "Console\nHTTP Server" as Server +participant "ClusterCollector" as CC +collections "Redis" as Redis +participant "Node-A\n(real server)" as NodeA +participant "Node-B\n(sim node)" as NodeB + +== Thread 1: Cluster Event Subscriber (real-time) == + +CC -> Redis : SUBSCRIBE turnstone:events:cluster +activate CC #E1BEE7 + +Redis --> CC : ClusterStateEvent\n{ws_id, state:"thinking",\nnode_id:"nodeA", tokens:500,\ncontext_ratio:0.05} +CC -> CC : Update NodeSnapshot["nodeA"]\n.workstreams["ws123"].state = "thinking" +CC -> CC : _fanout(event) → all SSE listeners + +Redis --> CC : {"type":"ws_created",\nws_id:"ws456", name:"task-1",\nnode_id:"sim-0003"} +CC -> CC : Add workstream to\nNodeSnapshot["sim-0003"] +CC -> CC : _fanout(event) + +Redis --> CC : ClusterStateEvent\n{ws_id:"ws456", state:"idle"} +CC -> CC : Update workstream state + +note right of CC + Handles: cluster_state, + ws_created, ws_closed, ws_rename + + Thread runs continuously. + All updates are thread-safe + via threading.Lock. +end note + +deactivate CC + +== Thread 2: Node Discovery (every 15s) == + +CC -> Redis : SCAN 0 MATCH turnstone:node:* +activate CC #B2EBF2 +Redis --> CC : [turnstone:node:nodeA, turnstone:node:sim-0003, ...] + +loop for each discovered key + CC -> Redis : GET turnstone:node:{id} + Redis --> CC : JSON: {server_url, started, max_ws, sim:true/false} +end + +CC -> CC : Create new NodeSnapshot\nfor newly discovered nodes +CC -> CC : Remove NodeSnapshot\nfor disappeared nodes + +CC -> CC : _fanout({type: "node_joined", ...})\n_fanout({type: "node_lost", ...}) + +deactivate CC + +== Thread 3: HTTP Polling (every 10s, real nodes only) == + +CC -> CC : Filter nodes where\nserver_url.startswith("http") +activate CC #C8E6C9 + +note right of CC + sim:// nodes are SKIPPED. + Their data comes exclusively + from the cluster event channel. +end note + +CC -> NodeA : GET /api/dashboard +activate NodeA +NodeA --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}} +deactivate NodeA + +CC -> NodeA : GET /health +activate NodeA +NodeA --> CC : {status:"ok", model:"...",\nworkstreams:{total, idle, ...}} +deactivate NodeA + +CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate + +CC -x NodeB : (SKIPPED: sim:// URL) + +deactivate CC + +== Browser SSE Stream == + +Browser -> Server : GET /api/cluster/events +activate Server + +Server -> CC : register_listener(queue) +note right : Per-client queue.Queue(maxsize=500) + +loop continuous + CC -> Server : event via listener queue\n(from any of the 3 threads) + Server -> Browser : data: {"type":"cluster_state",...}\n\n +end + +alt timeout (5s no events) + Server -> Browser : : keepalive\n\n +end + +Browser -> Server : connection closed +Server -> CC : unregister_listener(queue) +deactivate Server + +== Browser REST Requests == + +Browser -> Server : GET /api/cluster/overview +Server -> CC : get_overview() +CC --> Server : {nodes: 10, workstreams: 47,\nstates: {running:5, thinking:3, ...},\naggregate: {total_tokens: 50000}} +Server --> Browser : JSON response + +Browser -> Server : GET /api/cluster/nodes?sort=activity +Server -> CC : get_nodes(sort_by="activity") +CC --> Server : {nodes: [...], total: 10} +Server --> Browser : JSON response + +Browser -> Server : GET /api/cluster/workstreams\n?state=running&node=sim-0003 +Server -> CC : get_workstreams(state="running",\nnode="sim-0003") +CC --> Server : {workstreams: [...], total: 5,\npage: 1, per_page: 50, pages: 1} +Server --> Browser : JSON response + +@enduml diff --git a/docs/diagrams/12-deployment.puml b/docs/diagrams/12-deployment.puml new file mode 100644 index 00000000..cad75f13 --- /dev/null +++ b/docs/diagrams/12-deployment.puml @@ -0,0 +1,111 @@ +@startuml +!theme plain +title Turnstone — Docker Compose Deployment + +skinparam node { + BackgroundColor #F5F5F5 +} + +cloud "LLM Provider" as llm { + component [vLLM / OpenAI API\nport 8000] as llm_api +} + +node "Docker Host" as host { + + frame "turnstone-net (bridge network)" as net { + + node "redis" <> as redis_node { + component [Redis Server\nport 6379] as redis + note bottom of redis + Healthcheck: redis-cli ping + Volume: redis-data + end note + } + + node "server" <> as server_node { + component [turnstone-server\nport 8080] as server + note bottom of server + Command: turnstone-server + --host 0.0.0.0 + --port 8080 + Depends: redis (healthy) + Volume: turnstone-data + (/data) + end note + } + + node "bridge ×N" <> as bridge_node { + component [turnstone-bridge] as bridge + note bottom of bridge + Command: turnstone-bridge + --server-url http://server:8080 + --redis-host redis + Depends: server + redis + Scalable: --scale bridge=N + node_id: auto from hostname + end note + } + + node "console" <> as console_node { + component [turnstone-console\nport 8090] as console + note bottom of console + Command: turnstone-console + --redis-host redis + --port 8090 + Depends: redis + end note + } + + node "sim (profile: sim)" <> as sim_node { + component [turnstone-sim] as sim + note bottom of sim + Command: turnstone-sim + --redis-host redis + --nodes 100 + --scenario steady + Depends: redis + Optional: only with + --profile sim + end note + } + } +} + +actor "Browser\nUser" as browser +actor "MQ Client" as mqclient + +' External connections +browser --> server : HTTP + SSE\nport 8080 +browser --> console : HTTP + SSE\nport 8090 +mqclient --> redis : Redis protocol\nport 6379 + +' Internal connections +server --> redis : Redis protocol\n(6379) +server --> llm_api : OpenAI API\n(HTTPS/HTTP) + +bridge --> server : HTTP REST\n(POST /api/send, etc.) +bridge <-- server : SSE\n(GET /api/events) +bridge --> redis : Redis protocol\n(queues + pubsub) + +console --> redis : Redis PUBSUB\n(cluster channel) +console --> server : HTTP polling\n(GET /api/dashboard) + +sim --> redis : Redis protocol\n(queues + pubsub + keys) + +' Environment variables +note right of host + **Environment Variables:** + • LLM_BASE_URL — LLM endpoint + • OPENAI_API_KEY — API key + • REDIS_PASSWORD — Redis auth + • TURNSTONE_AUTH_TOKEN — API auth +end note + +' Volumes +database "redis-data" as rv +database "turnstone-data" as tv + +redis_node --> rv +server_node --> tv + +@enduml diff --git a/docs/diagrams/png/01-system-context.png b/docs/diagrams/png/01-system-context.png new file mode 100644 index 00000000..f841b372 --- /dev/null +++ b/docs/diagrams/png/01-system-context.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:341a8ab1483b1e0146878bd384a11d56bc78d29262de8262d06ef924317e2762 +size 139969 diff --git a/docs/diagrams/png/02-package-structure.png b/docs/diagrams/png/02-package-structure.png new file mode 100644 index 00000000..4c9a2ee7 --- /dev/null +++ b/docs/diagrams/png/02-package-structure.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4b1db039e9edbed8b2b7246328b49ae87afbe58c1b0fca0812712678faee366 +size 252572 diff --git a/docs/diagrams/png/03-core-engine-classes.png b/docs/diagrams/png/03-core-engine-classes.png new file mode 100644 index 00000000..4c0929dd --- /dev/null +++ b/docs/diagrams/png/03-core-engine-classes.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30deb9eec4cb61d9865611f3a6b10c696638f540f93683654ea8d903b2e2ac0b +size 227161 diff --git a/docs/diagrams/png/04-conversation-turn.png b/docs/diagrams/png/04-conversation-turn.png new file mode 100644 index 00000000..d2e031de --- /dev/null +++ b/docs/diagrams/png/04-conversation-turn.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bd265f9e3ecb55e93b88039f363cfd7953fd29b4a68e8830d924a34b431fa29 +size 264506 diff --git a/docs/diagrams/png/05-tool-pipeline.png b/docs/diagrams/png/05-tool-pipeline.png new file mode 100644 index 00000000..c1c331a2 --- /dev/null +++ b/docs/diagrams/png/05-tool-pipeline.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32bb7e8aa409d872a3e519a878601649b569f4af8e1ee77da940b785e834effe +size 232985 diff --git a/docs/diagrams/png/06-mq-protocol.png b/docs/diagrams/png/06-mq-protocol.png new file mode 100644 index 00000000..92823911 --- /dev/null +++ b/docs/diagrams/png/06-mq-protocol.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3846bb799587c6b1ff8335f10eeeef874248500b36c6be0a54ee532c4772c459 +size 190947 diff --git a/docs/diagrams/png/07-message-routing.png b/docs/diagrams/png/07-message-routing.png new file mode 100644 index 00000000..e251ef65 --- /dev/null +++ b/docs/diagrams/png/07-message-routing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d77472902935937f04420b35375b3a869a4cb51f8ac08dab3c1d097d549de2d +size 221103 diff --git a/docs/diagrams/png/08-redis-key-schema.png b/docs/diagrams/png/08-redis-key-schema.png new file mode 100644 index 00000000..dd4f80ba --- /dev/null +++ b/docs/diagrams/png/08-redis-key-schema.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d33b9b3affcdb07086b5aebca8a3b9c2b009cdfc6f360950a0e72e65fbcb8f17 +size 201602 diff --git a/docs/diagrams/png/09-workstream-states.png b/docs/diagrams/png/09-workstream-states.png new file mode 100644 index 00000000..df49b5ee --- /dev/null +++ b/docs/diagrams/png/09-workstream-states.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:adac93a0bb062d7199b819a600a0983ff011a75d16928fb80322cbb41f9284ea +size 158866 diff --git a/docs/diagrams/png/10-simulator-architecture.png b/docs/diagrams/png/10-simulator-architecture.png new file mode 100644 index 00000000..2300ff67 --- /dev/null +++ b/docs/diagrams/png/10-simulator-architecture.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69f201cff948cb0a19810b7c4ad26d346f869ee2dd3141eba4f353332efa2e21 +size 373649 diff --git a/docs/diagrams/png/11-console-data-flow.png b/docs/diagrams/png/11-console-data-flow.png new file mode 100644 index 00000000..7d0f574b --- /dev/null +++ b/docs/diagrams/png/11-console-data-flow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:793d7c2b28a751c6f467f2de788fcd462d3b8fd9cd5cb7adb5b32d78fb185394 +size 236004 diff --git a/docs/diagrams/png/12-deployment.png b/docs/diagrams/png/12-deployment.png new file mode 100644 index 00000000..2ec76a17 --- /dev/null +++ b/docs/diagrams/png/12-deployment.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e6c1dfaef840d5228645aaad3637c973b2f71c372595814f3b743a991f5c6fc +size 239128 diff --git a/docs/docker.md b/docs/docker.md index cd774685..25c48092 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -17,6 +17,8 @@ docker compose --profile sim up redis console sim Console dashboard: http://localhost:8090 +> See also: [Deployment diagram](diagrams/png/12-deployment.png) + ## Services | Service | Port | Profile | Description | diff --git a/docs/simulator.md b/docs/simulator.md index 7164ed27..ef49b450 100644 --- a/docs/simulator.md +++ b/docs/simulator.md @@ -164,6 +164,8 @@ Open http://localhost:8090 to see simulated nodes, workstream states, token coun ## Architecture +> See also: [Simulator Architecture diagram](diagrams/png/10-simulator-architecture.png) + ``` turnstone/sim/ ├── __init__.py # Public API: SimCluster, SimConfig diff --git a/docs/tools.md b/docs/tools.md index bac98a1a..e6d3082f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -55,6 +55,8 @@ schema plus turnstone-specific metadata keys: ## Execution Pipeline +> See also: [Tool Pipeline diagram](diagrams/png/05-tool-pipeline.png) + Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools()`: ### Phase 1: Prepare diff --git a/pyproject.toml b/pyproject.toml index ed88ef87..cd16fbf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,39 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.29"] build-backend = "hatchling.build" [project] name = "turnstone" -version = "0.2.0" -description = "AI chat client with tool use, agent tools, and persistent memory." +version = "0.2.1" +description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation." readme = "README.md" license = "BUSL-1.1" requires-python = ">=3.11" -dependencies = ["openai>=1.0", "httpx>=0.24"] +authors = [{name = "Patrick Buckley", email = "buckleypm@gmail.com"}] +keywords = ["ai", "chat", "llm", "agent", "tools", "openai"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = ["openai>=2.24", "httpx>=0.28"] + +[project.urls] +Homepage = "https://github.com/turnstonelabs/turnstone" +Repository = "https://github.com/turnstonelabs/turnstone" +Issues = "https://github.com/turnstonelabs/turnstone/issues" [project.optional-dependencies] -test = ["pytest>=7.0"] -mq = ["redis>=5.0"] -console = ["redis>=5.0"] -sim = ["redis>=5.0"] +test = ["pytest>=9.0"] +dev = ["ruff>=0.9", "mypy>=1.14", "types-redis>=4.6"] +mq = ["redis>=7.2"] +console = ["redis>=7.2"] +sim = ["redis>=7.2"] [project.scripts] turnstone = "turnstone.cli:main" @@ -39,3 +57,33 @@ include = [ [tool.pytest.ini_options] testpaths = ["tests"] +markers = ["live: requires a running LLM backend"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"] +ignore = ["E501"] + +[tool.ruff.format] +quote-style = "double" + +[tool.mypy] +python_version = "3.11" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true + +[[tool.mypy.overrides]] +module = ["sympy", "sympy.*", "numpy", "numpy.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false diff --git a/tests.json b/tests.json index c813cbf1..0f0bd9b4 100644 --- a/tests.json +++ b/tests.json @@ -1,5 +1,5 @@ { - "description": "pcode behavior tests — tool selection, sequencing, and multi-step reasoning", + "description": "turnstone behavior tests — tool selection, sequencing, and multi-step reasoning", "defaults": { "n_runs": 5, "max_turns": 15 diff --git a/tests/conftest.py b/tests/conftest.py index 68c3ea11..1413884a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ -import pytest from unittest.mock import MagicMock +import pytest + @pytest.fixture def tmp_db(tmp_path, monkeypatch): diff --git a/tests/test_auth.py b/tests/test_auth.py index f7103c95..13c5452d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,8 +6,8 @@ from unittest.mock import patch import pytest from turnstone.core.auth import ( - AuthConfig, WRITE_PATHS, + AuthConfig, _extract_bearer, _extract_cookie, check_request, @@ -18,7 +18,6 @@ from turnstone.core.auth import ( required_role, ) - # --------------------------------------------------------------------------- # TestIsPublicPath # --------------------------------------------------------------------------- @@ -186,9 +185,7 @@ class TestExtractCookie: assert _extract_cookie("", "turnstone_auth") is None def test_spaces_around_value(self): - assert ( - _extract_cookie("turnstone_auth = tok_abc ", "turnstone_auth") == "tok_abc" - ) + assert _extract_cookie("turnstone_auth = tok_abc ", "turnstone_auth") == "tok_abc" def test_no_equals(self): assert _extract_cookie("malformed", "turnstone_auth") is None @@ -288,44 +285,32 @@ class TestCheckRequest: assert status == 401 def test_api_read_token_ok(self, enabled): - allowed, status, msg = check_request( - enabled, "GET", "/api/workstreams", "Bearer tok_read" - ) + allowed, status, msg = check_request(enabled, "GET", "/api/workstreams", "Bearer tok_read") assert allowed is True assert status == 200 def test_api_full_token_ok(self, enabled): - allowed, status, msg = check_request( - enabled, "GET", "/api/workstreams", "Bearer tok_full" - ) + allowed, status, msg = check_request(enabled, "GET", "/api/workstreams", "Bearer tok_full") assert allowed is True def test_write_read_token_403(self, enabled): - allowed, status, msg = check_request( - enabled, "POST", "/api/send", "Bearer tok_read" - ) + allowed, status, msg = check_request(enabled, "POST", "/api/send", "Bearer tok_read") assert allowed is False assert status == 403 assert "Forbidden" in msg def test_write_full_token_ok(self, enabled): - allowed, status, msg = check_request( - enabled, "POST", "/api/send", "Bearer tok_full" - ) + allowed, status, msg = check_request(enabled, "POST", "/api/send", "Bearer tok_full") assert allowed is True assert status == 200 def test_approve_read_token_403(self, enabled): - allowed, status, msg = check_request( - enabled, "POST", "/api/approve", "Bearer tok_read" - ) + allowed, status, msg = check_request(enabled, "POST", "/api/approve", "Bearer tok_read") assert allowed is False assert status == 403 def test_approve_full_token_ok(self, enabled): - allowed, status, msg = check_request( - enabled, "POST", "/api/approve", "Bearer tok_full" - ) + allowed, status, msg = check_request(enabled, "POST", "/api/approve", "Bearer tok_full") assert allowed is True def test_no_auth_header_string(self, enabled): @@ -576,9 +561,7 @@ class TestServerAuth: mock_mgr = MagicMock() mock_mgr.list_all.return_value = [mock_ws] - cls.server = srv_mod.ThreadedHTTPServer( - ("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler - ) + cls.server = srv_mod.ThreadedHTTPServer(("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler) cls.server.workstreams = mock_mgr cls.server.skip_permissions = False cls.server.global_listeners = [] @@ -835,9 +818,7 @@ class TestServerLogin: mock_mgr = MagicMock() mock_mgr.list_all.return_value = [mock_ws] - cls.server = srv_mod.ThreadedHTTPServer( - ("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler - ) + cls.server = srv_mod.ThreadedHTTPServer(("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler) cls.server.workstreams = mock_mgr cls.server.skip_permissions = False cls.server.global_listeners = [] diff --git a/tests/test_console.py b/tests/test_console.py index ca14e03b..0b2d955d 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -3,10 +3,7 @@ import json import queue import threading -import time -from http.server import HTTPServer, BaseHTTPRequestHandler -from socketserver import ThreadingMixIn -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import httpx import pytest @@ -14,12 +11,8 @@ import pytest from turnstone.console.collector import ClusterCollector, NodeSnapshot from turnstone.mq.protocol import ( ClusterStateEvent, - WorkstreamClosedEvent, - WorkstreamCreatedEvent, - WorkstreamRenameEvent, ) - # --------------------------------------------------------------------------- # Mock broker for collector tests # --------------------------------------------------------------------------- @@ -128,7 +121,7 @@ class TestCollectorDiscovery: def test_discover_emits_node_joined_event(self): broker = MockBroker() c = _make_collector(broker) - events = [] + _events = [] q = queue.Queue() c.register_listener(q) @@ -218,9 +211,7 @@ class TestCollectorEvents: c._nodes["node-a"] = NodeSnapshot( node_id="node-a", server_url="http://a:8080", - workstreams={ - "ws1": {"id": "ws1", "name": "test", "state": "idle", "node": "node-a"} - }, + workstreams={"ws1": {"id": "ws1", "name": "test", "state": "idle", "node": "node-a"}}, ) event = ClusterStateEvent( @@ -276,9 +267,7 @@ class TestCollectorEvents: workstreams={"ws1": {"id": "ws1", "name": "old-name", "state": "idle"}}, ) - event_json = json.dumps( - {"type": "ws_rename", "ws_id": "ws1", "name": "new-name"} - ) + event_json = json.dumps({"type": "ws_rename", "ws_id": "ws1", "name": "new-name"}) c._on_cluster_event(event_json) assert c._nodes["node-a"].workstreams["ws1"]["name"] == "new-name" @@ -440,9 +429,7 @@ class TestCollectorQueries: ws, _ = populated_collector.get_workstreams(sort_by="state") states = [w["state"] for w in ws] # running before attention before idle - assert ( - states.index("running") < states.index("attention") < states.index("idle") - ) + assert states.index("running") < states.index("attention") < states.index("idle") def test_get_workstreams_combined_filters(self, populated_collector): ws, total = populated_collector.get_workstreams(state="idle", node="node-a") @@ -588,15 +575,11 @@ class TestConsoleHTTPEndpoints: mock_collector.get_overview.assert_called_once() def test_get_nodes(self, server, mock_collector): - status, data = self._get( - server, "/api/cluster/nodes?sort=activity&limit=10&offset=0" - ) + status, data = self._get(server, "/api/cluster/nodes?sort=activity&limit=10&offset=0") assert status == 200 assert len(data["nodes"]) == 1 assert data["total"] == 1 - mock_collector.get_nodes.assert_called_once_with( - sort_by="activity", limit=10, offset=0 - ) + mock_collector.get_nodes.assert_called_once_with(sort_by="activity", limit=10, offset=0) def test_get_workstreams(self, server, mock_collector): status, data = self._get( diff --git a/tests/test_db.py b/tests/test_db.py index 0caea4ba..788cd0fb 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,12 +1,11 @@ """Tests for turnstone.core.memory — database operations.""" -import turnstone.core.memory as memory from turnstone.core.memory import ( + normalize_key, open_db, save_message, search_history, search_history_recent, - normalize_key, ) diff --git a/tests/test_fts5.py b/tests/test_fts5.py index 7f0cc4f0..eed2f4a5 100644 --- a/tests/test_fts5.py +++ b/tests/test_fts5.py @@ -1,6 +1,6 @@ """Tests for turnstone.core.memory — fts5_query and escape_like.""" -from turnstone.core.memory import fts5_query, escape_like +from turnstone.core.memory import escape_like, fts5_query class TestFts5Query: diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 407cd609..3536c9ef 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -1,7 +1,7 @@ """Tests for turnstone.ui.markdown — MarkdownRenderer.""" +from turnstone.ui.colors import BOLD, CYAN, DIM, ITALIC, MAGENTA from turnstone.ui.markdown import MarkdownRenderer -from turnstone.ui.colors import BOLD, MAGENTA, CYAN, DIM, ITALIC, RESET class TestMarkdownRenderer: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index fe88b7f9..74ce8f27 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -37,7 +37,6 @@ from turnstone.mq.protocol import ( WorkstreamRenameEvent, ) - # --------------------------------------------------------------------------- # Inbound message round-trip tests # --------------------------------------------------------------------------- diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 68389b36..948b5a02 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -1,6 +1,6 @@ """Tests for turnstone.core.sandbox — validate_math_code and auto_print_wrap.""" -from turnstone.core.sandbox import validate_math_code, auto_print_wrap +from turnstone.core.sandbox import auto_print_wrap, validate_math_code class TestValidateMathCode: diff --git a/tests/test_scoring.py b/tests/test_scoring.py index abfeb174..e08c91d3 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -1,6 +1,6 @@ """Tests for turnstone.eval — score_run and _match_action.""" -from turnstone.eval import score_run, _match_action +from turnstone.eval import _match_action, score_run class TestMatchAction: diff --git a/tests/test_server_live.py b/tests/test_server_live.py index 046a2a73..f0e0cddc 100644 --- a/tests/test_server_live.py +++ b/tests/test_server_live.py @@ -1,15 +1,21 @@ -"""Integration tests against a live llama.cpp backend on port 8000. +"""Tests for turnstone ChatSession and server endpoints. -These tests use turnstone's HeadlessSession to run actual LLM inference -and tool execution against the backend. They verify end-to-end behavior: -model connectivity, tool calling, response quality, and session mechanics. +Mock-based tests verify streaming, tool calling, multi-turn conversation, +and session configuration WITHOUT a running LLM backend. The mocks replace +only the OpenAI streaming layer -- tool execution (bash, math, read_file) +still runs real subprocesses. -Requires: llama-server (or compatible OpenAI API) running on localhost:8000. +The TestBackendConnectivity class is marked @pytest.mark.live and requires a +running llama-server (or compatible OpenAI API) on localhost:8000. -Run with: pytest tests/test_server_live.py -v --timeout=120 +The TestServerHealthMetrics class spins up an in-process HTTP server and +needs no LLM backend at all. -The TestServerHealthMetrics class does NOT require a live LLM and can be run -independently: pytest tests/test_server_live.py::TestServerHealthMetrics -v +Run all non-live tests: + pytest tests/test_server_live.py -v -m "not live" + +Run everything (needs backend): + pytest tests/test_server_live.py -v --timeout=120 """ import json @@ -17,15 +23,15 @@ import os import queue import tempfile import threading -import time +from types import SimpleNamespace +from unittest.mock import MagicMock + import httpx import pytest from openai import OpenAI -from turnstone.core.session import ChatSession -from turnstone.core.tools import TOOLS import turnstone.core.memory as _memory_module - +from turnstone.core.session import ChatSession # --------------------------------------------------------------------------- # Fixtures @@ -35,8 +41,8 @@ BASE_URL = os.environ.get("TURNSTONE_TEST_BASE_URL", "http://localhost:8000/v1") @pytest.fixture(scope="module") -def client(): - """Create an OpenAI client pointed at the local backend.""" +def live_client(): + """Create an OpenAI client pointed at the local backend (live tests only).""" return OpenAI( base_url=BASE_URL, api_key=os.environ.get("TURNSTONE_TEST_API_KEY", "not-needed"), @@ -44,9 +50,9 @@ def client(): @pytest.fixture(scope="module") -def model_id(client): - """Auto-detect the model name from the backend.""" - models = client.models.list() +def live_model_id(live_client): + """Auto-detect the model name from the backend (live tests only).""" + models = live_client.models.list() ids = [m.id for m in models.data] assert len(ids) > 0, "No models found on the backend" return ids[0] @@ -125,16 +131,13 @@ def tmp_db(): os.unlink(path) -def _make_session( - client, model_id, tmp_db, **kwargs -) -> tuple[ChatSession, RecordingUI]: +def _make_session(client, model_id, tmp_db, **kwargs) -> tuple[ChatSession, RecordingUI]: """Create a ChatSession with RecordingUI and sensible test defaults.""" ui = RecordingUI() defaults = dict( client=client, model=model_id, ui=ui, - persona=None, instructions=None, temperature=0.3, max_tokens=2048, @@ -148,174 +151,339 @@ def _make_session( # --------------------------------------------------------------------------- -# Tests — Backend connectivity +# Mock streaming helpers # --------------------------------------------------------------------------- +def _make_chunk( + *, + content=None, + reasoning_content=None, + tool_calls=None, + finish_reason=None, + usage=None, +): + """Build a single mock streaming chunk matching the OpenAI format. + + The chunk structure mirrors openai.types.chat.ChatCompletionChunk: + chunk.choices[0].delta.content + chunk.choices[0].delta.reasoning_content + chunk.choices[0].delta.tool_calls + chunk.choices[0].finish_reason + chunk.usage + """ + delta = SimpleNamespace( + content=content, + reasoning_content=reasoning_content, + reasoning=None, + tool_calls=tool_calls, + role=None, + model_extra=None, + ) + choice = SimpleNamespace(delta=delta, finish_reason=finish_reason) + chunk = SimpleNamespace(choices=[choice], usage=usage) + return chunk + + +def _make_tool_call_deltas(call_id, name, arguments): + """Build a list of tool_call delta objects for a single tool call. + + Returns a list with one element (single tool call at index 0). + """ + fn = SimpleNamespace(name=name, arguments=arguments) + return [SimpleNamespace(index=0, id=call_id, function=fn)] + + +def _usage(prompt=100, completion=50, total=None): + """Build a mock usage object.""" + return SimpleNamespace( + prompt_tokens=prompt, + completion_tokens=completion, + total_tokens=total or (prompt + completion), + ) + + +def make_mock_stream( + content_tokens=None, + reasoning_tokens=None, + tool_calls=None, + finish_reason="stop", + usage=None, +): + """Create an iterable of mock chunks simulating an OpenAI streaming response. + + Parameters + ---------- + content_tokens : list[str] | None + Content token strings, each emitted as a separate chunk. + reasoning_tokens : list[str] | None + Reasoning token strings, emitted before content. + tool_calls : list[tuple[str, str, str]] | None + Each entry is (call_id, function_name, arguments_json). + When provided, finish_reason defaults to "tool_calls". + finish_reason : str + Finish reason on the last content/tool chunk. + usage : SimpleNamespace | None + Usage object for the final chunk. Defaults to a sensible value. + """ + chunks = [] + + if reasoning_tokens: + for token in reasoning_tokens: + chunks.append(_make_chunk(reasoning_content=token)) + + if content_tokens: + for i, token in enumerate(content_tokens): + is_last = (i == len(content_tokens) - 1) and not tool_calls + chunks.append( + _make_chunk( + content=token, + finish_reason=finish_reason if is_last else None, + ) + ) + + if tool_calls: + for i, (call_id, name, arguments) in enumerate(tool_calls): + is_last = i == len(tool_calls) - 1 + tc_deltas = _make_tool_call_deltas(call_id, name, arguments) + chunks.append( + _make_chunk( + tool_calls=tc_deltas, + finish_reason="tool_calls" if is_last else None, + ) + ) + + # Final usage-only chunk (no choices) + if usage is None: + usage = _usage() + chunks.append(SimpleNamespace(choices=[], usage=usage)) + + return iter(chunks) + + +def _mock_client(): + """Create a mock OpenAI client with a patchable chat.completions.create.""" + client = MagicMock(spec=OpenAI) + client.chat = MagicMock() + client.chat.completions = MagicMock() + client.chat.completions.create = MagicMock() + return client + + +# --------------------------------------------------------------------------- +# Tests -- Backend connectivity (live, requires running LLM) +# --------------------------------------------------------------------------- + + +@pytest.mark.live class TestBackendConnectivity: """Verify the LLM backend is reachable and returns valid responses.""" - def test_models_endpoint(self, client): - models = client.models.list() + def test_models_endpoint(self, live_client): + models = live_client.models.list() assert len(models.data) > 0 - def test_model_id_detected(self, model_id): - assert isinstance(model_id, str) - assert len(model_id) > 0 + def test_model_id_detected(self, live_model_id): + assert isinstance(live_model_id, str) + assert len(live_model_id) > 0 - def test_basic_completion(self, client, model_id): - """Raw API call — no turnstone involved.""" - resp = client.chat.completions.create( - model=model_id, + def test_basic_completion(self, live_client, live_model_id): + """Raw API call -- no turnstone involved.""" + resp = live_client.chat.completions.create( + model=live_model_id, messages=[{"role": "user", "content": "Say 'hello'"}], max_completion_tokens=200, temperature=0.0, stream=False, ) - assert ( - resp.choices[0].message.content or resp.choices[0].message.reasoning_content - ) + assert resp.choices[0].message.content or resp.choices[0].message.reasoning_content assert resp.usage.total_tokens > 0 # --------------------------------------------------------------------------- -# Tests — Streaming session +# Tests -- Streaming session (mocked) # --------------------------------------------------------------------------- class TestStreamingSession: - """Test ChatSession.send() with streaming against the live backend.""" + """Test ChatSession.send() with mocked streaming responses.""" - def test_simple_response(self, client, model_id, tmp_db): - """Model responds to a basic prompt via streaming.""" - session, ui = _make_session(client, model_id, tmp_db) - session.send("Reply with exactly: PONG") + def test_simple_response(self, tmp_db): + """Mock returns content tokens; verify RecordingUI captures them.""" + client = _mock_client() + client.chat.completions.create.return_value = make_mock_stream( + content_tokens=["Hello", " ", "world"], + ) - # Should have gotten some content or reasoning - total = ui.full_content + ui.full_reasoning - assert len(total) > 0, "No output from model" + session, ui = _make_session(client, "mock-model", tmp_db) + session._title_generated = True # prevent background title generation + + session.send("Say hello") + + assert "Hello world" in ui.full_content + + def test_reasoning_tokens_appear(self, tmp_db): + """Mock returns reasoning tokens then content; verify both captured.""" + client = _mock_client() + client.chat.completions.create.return_value = make_mock_stream( + reasoning_tokens=["Let me", " think..."], + content_tokens=["The answer", " is 56"], + ) + + session, ui = _make_session(client, "mock-model", tmp_db) + session._title_generated = True - def test_reasoning_tokens_appear(self, client, model_id, tmp_db): - """Model produces reasoning tokens (extended thinking).""" - session, ui = _make_session(client, model_id, tmp_db) session.send("What is 7 * 8?") - # This model uses reasoning_content, so we expect reasoning tokens - assert len(ui.reasoning_tokens) > 0, "No reasoning tokens received" + assert len(ui.reasoning_tokens) > 0 + assert "think" in ui.full_reasoning.lower() + assert "56" in ui.full_content - def test_stream_end_event(self, client, model_id, tmp_db): + def test_stream_end_event(self, tmp_db): """stream_end event is emitted after response.""" - session, ui = _make_session(client, model_id, tmp_db) + client = _mock_client() + client.chat.completions.create.return_value = make_mock_stream( + content_tokens=["Hi"], + ) + + session, ui = _make_session(client, "mock-model", tmp_db) + session._title_generated = True + session.send("Say hi") event_types = [e[0] for e in ui.events] assert "stream_end" in event_types - def test_thinking_lifecycle(self, client, model_id, tmp_db): + def test_thinking_lifecycle(self, tmp_db): """thinking_start and thinking_stop bracket the response.""" - session, ui = _make_session(client, model_id, tmp_db) + client = _mock_client() + client.chat.completions.create.return_value = make_mock_stream( + content_tokens=["Hi", " there"], + ) + + session, ui = _make_session(client, "mock-model", tmp_db) + session._title_generated = True + session.send("Say hi") event_types = [e[0] for e in ui.events] assert "thinking_start" in event_types assert "thinking_stop" in event_types - # thinking_start should come before thinking_stop start_idx = event_types.index("thinking_start") stop_idx = event_types.index("thinking_stop") assert start_idx < stop_idx # --------------------------------------------------------------------------- -# Tests — Tool calling +# Tests -- Tool calling (mocked LLM, real tool execution) # --------------------------------------------------------------------------- class TestToolCalling: - """Test that the model can invoke tools and turnstone executes them.""" + """Test that mocked tool_calls trigger real tool execution.""" - def test_math_tool(self, client, model_id, tmp_db): - """Model uses the math tool for computation.""" - session, ui = _make_session( - client, - model_id, - tmp_db, - instructions="You have tools. Use the math tool to compute results. Always use tools when asked to calculate.", + def test_math_tool(self, tmp_db): + """First call returns tool_call for math(code='2+2'), second returns content.""" + client = _mock_client() + + # First create() call: model requests math tool + stream1 = make_mock_stream( + tool_calls=[("call_math_1", "math", json.dumps({"code": "2+2"}))], ) - session.send("Use the math tool to calculate: 17 * 23. Report the result.") + # Second create() call: model produces final answer + stream2 = make_mock_stream( + content_tokens=["The result is ", "4"], + ) + client.chat.completions.create.side_effect = [stream1, stream2] - # Check if math tool was invoked + session, ui = _make_session(client, "mock-model", tmp_db) + session._title_generated = True + + session.send("Calculate 2+2") + + # math tool was invoked and returned a result math_results = [r for r in ui.tool_results if r[0] == "math"] - if math_results: - # Verify the result contains 391 - assert "391" in math_results[0][1], ( - f"Expected 391, got: {math_results[0][1]}" - ) - else: - # Model may have answered directly — check content - total = ui.full_content + ui.full_reasoning - assert "391" in total, f"Expected 391 somewhere in output" + assert len(math_results) > 0 + assert "4" in math_results[0][1] - def test_bash_tool(self, client, model_id, tmp_db): - """Model uses bash to answer a system question.""" - session, ui = _make_session( - client, - model_id, - tmp_db, - instructions="You have tools. Use the bash tool to run commands. Always use bash when asked about system info.", + # Final content contains the answer + assert "4" in ui.full_content + + def test_bash_tool(self, tmp_db): + """First call returns tool_call for bash, second returns content.""" + client = _mock_client() + + stream1 = make_mock_stream( + tool_calls=[("call_bash_1", "bash", json.dumps({"command": "echo hello"}))], ) - session.send( - "Use the bash tool to run 'echo hello_from_test' and report what it prints." + stream2 = make_mock_stream( + content_tokens=["The command printed: ", "hello"], ) + client.chat.completions.create.side_effect = [stream1, stream2] + + session, ui = _make_session(client, "mock-model", tmp_db) + session._title_generated = True + + session.send("Run echo hello") bash_results = [r for r in ui.tool_results if r[0] == "bash"] - if bash_results: - assert "hello_from_test" in bash_results[0][1] - else: - total = ui.full_content + ui.full_reasoning - assert "hello_from_test" in total, "Expected bash output in response" + assert len(bash_results) > 0 + assert "hello" in bash_results[0][1] - def test_read_file_tool(self, client, model_id, tmp_db): - """Model uses read_file to read a known file.""" - # Create a temp file for the model to read + def test_read_file_tool(self, tmp_db): + """First call returns tool_call for read_file, second returns content.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: f.write("SECRET_CONTENT_42\n") path = f.name try: - session, ui = _make_session( - client, - model_id, - tmp_db, - instructions="You have tools. Use the read_file tool to read files. Always use read_file when asked to read a file.", - ) - session.send( - f"Use the read_file tool to read {path} and tell me what it says." - ) + client = _mock_client() + + stream1 = make_mock_stream( + tool_calls=[("call_read_1", "read_file", json.dumps({"path": path}))], + ) + stream2 = make_mock_stream( + content_tokens=["The file says: SECRET_CONTENT_42"], + ) + client.chat.completions.create.side_effect = [stream1, stream2] + + session, ui = _make_session(client, "mock-model", tmp_db) + session._title_generated = True + + session.send(f"Read {path}") - # read_file was invoked (UI gets a summary like "1 lines") read_results = [r for r in ui.tool_results if r[0] == "read_file"] - assert len(read_results) > 0, "read_file tool was not called" + assert len(read_results) > 0 - # The model sees the actual file content and should relay it - total = ui.full_content + ui.full_reasoning - assert "SECRET_CONTENT_42" in total, ( - f"Model didn't relay file content. Got: {total[:500]}" - ) + # Model relays the content + assert "SECRET_CONTENT_42" in ui.full_content finally: os.unlink(path) # --------------------------------------------------------------------------- -# Tests — Multi-turn conversation +# Tests -- Multi-turn conversation (mocked) # --------------------------------------------------------------------------- class TestMultiTurn: - """Test multi-turn conversation state.""" + """Test multi-turn conversation state with mocked responses.""" + + def test_context_retained(self, tmp_db): + """Second send references context from the first.""" + client = _mock_client() + + stream1 = make_mock_stream( + content_tokens=["I'll remember ", "Zephyr"], + ) + stream2 = make_mock_stream( + content_tokens=["Your name is ", "Zephyr"], + ) + client.chat.completions.create.side_effect = [stream1, stream2] + + session, ui = _make_session(client, "mock-model", tmp_db, max_tokens=1024) + session._title_generated = True - def test_context_retained(self, client, model_id, tmp_db): - """Second message can reference the first.""" - session, ui = _make_session(client, model_id, tmp_db, max_tokens=1024) session.send("My name is Zephyr. Remember it.") # Reset UI tracking for second turn @@ -324,58 +492,86 @@ class TestMultiTurn: session.send("What is my name?") - total = ui.full_content + ui.full_reasoning - assert "zephyr" in total.lower(), f"Model forgot the name. Got: {total[:300]}" + assert "zephyr" in ui.full_content.lower() - def test_message_list_grows(self, client, model_id, tmp_db): + def test_message_list_grows(self, tmp_db): """Each send adds user + assistant messages.""" - session, ui = _make_session(client, model_id, tmp_db, max_tokens=512) + client = _mock_client() + + stream1 = make_mock_stream(content_tokens=["Hello"]) + stream2 = make_mock_stream(content_tokens=["World"]) + client.chat.completions.create.side_effect = [stream1, stream2] + + session, ui = _make_session(client, "mock-model", tmp_db, max_tokens=512) + session._title_generated = True initial_count = len(session.messages) session.send("Hello") + after_first = len(session.messages) + assert after_first >= initial_count + 2 - # Should have at least user + assistant - assert len(session.messages) >= initial_count + 2 + session.send("World") + after_second = len(session.messages) + assert after_second >= after_first + 2 # --------------------------------------------------------------------------- -# Tests — Session configuration +# Tests -- Session configuration (mocked) # --------------------------------------------------------------------------- class TestSessionConfig: - """Test session construction and configuration.""" + """Test session construction and configuration with mocked responses.""" - def test_creative_mode_no_tools(self, client, model_id, tmp_db): - """In creative mode, tools are not sent to the API.""" - session, ui = _make_session(client, model_id, tmp_db, max_tokens=256) + def test_creative_mode_no_tools(self, tmp_db): + """In creative mode, create() is called WITHOUT tools kwarg.""" + client = _mock_client() + client.chat.completions.create.return_value = make_mock_stream( + content_tokens=["A haiku about code"], + ) + + session, ui = _make_session(client, "mock-model", tmp_db, max_tokens=256) + session._title_generated = True session.creative_mode = True + # Re-init system messages so creative_mode takes effect + session._init_system_messages() + session.send("Write a haiku about code.") + # Verify create() was called without 'tools' in kwargs + call_kwargs = client.chat.completions.create.call_args + assert "tools" not in call_kwargs.kwargs, "tools should not be passed in creative mode" + # Should get content back without tool calls - total = ui.full_content + ui.full_reasoning - assert len(total) > 0 + assert len(ui.full_content) > 0 assert len(ui.tool_results) == 0 - def test_custom_instructions(self, client, model_id, tmp_db): - """Custom instructions are included in the session.""" + def test_custom_instructions(self, tmp_db): + """Custom instructions appear in system messages.""" + client = _mock_client() + client.chat.completions.create.return_value = make_mock_stream( + content_tokens=["Hello. ENDMARKER"], + ) + session, ui = _make_session( client, - model_id, + "mock-model", tmp_db, instructions="Always end your response with ENDMARKER.", max_tokens=512, ) - session.send("Say hello briefly.") + session._title_generated = True - total = ui.full_content - # We can't strictly guarantee the model follows instructions, - # but we verify the session didn't error out + # Verify custom instructions appear in system messages + dev_msg = session.system_messages[0] + assert "ENDMARKER" in dev_msg["content"] + + session.send("Say hello briefly.") assert len(ui.errors) == 0 # --------------------------------------------------------------------------- -# Tests — /health and /metrics endpoints (no live LLM required) +# Tests -- /health and /metrics endpoints (no live LLM required) # --------------------------------------------------------------------------- @@ -391,23 +587,40 @@ class TestServerHealthMetrics: @classmethod def setup_class(cls): from unittest.mock import MagicMock + import turnstone.server as srv_mod - from turnstone.core.workstream import WorkstreamState # Reset module-level metrics so each test run starts fresh - srv_mod._metrics = srv_mod.MetricsCollector() + from turnstone.core.metrics import MetricsCollector + from turnstone.core.workstream import WorkstreamState + + srv_mod._metrics = MetricsCollector() srv_mod._metrics.model = "test-model" # Mock WorkstreamManager.list_all() to return one idle workstream + mock_ui = MagicMock() + mock_ui._ws_lock = threading.Lock() + mock_ui._ws_prompt_tokens = 0 + mock_ui._ws_completion_tokens = 0 + mock_ui._ws_messages = 0 + mock_ui._ws_tool_calls = {} + mock_ui._ws_context_ratio = 0.0 + + mock_session = MagicMock() + mock_session.session_id = "test-session-id" + mock_ws = MagicMock() + mock_ws.id = "test-ws" + mock_ws.name = "test" mock_ws.state = WorkstreamState.IDLE + mock_ws.ui = mock_ui + mock_ws.session = mock_session + mock_mgr = MagicMock() mock_mgr.list_all.return_value = [mock_ws] - # Start a server on a random port (port 0 → OS assigns free port) - cls.server = srv_mod.ThreadedHTTPServer( - ("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler - ) + # Start a server on a random port (port 0 -> OS assigns free port) + cls.server = srv_mod.ThreadedHTTPServer(("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler) from turnstone.core.auth import AuthConfig cls.server.workstreams = mock_mgr diff --git a/tests/test_session.py b/tests/test_session.py index 0689840c..edce20ee 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,7 +1,6 @@ """Tests for turnstone.core.session — ChatSession construction.""" import json -import os from unittest.mock import MagicMock, patch from turnstone.core.session import ChatSession @@ -46,10 +45,12 @@ class NullUI: def on_state_change(self, state): pass + def on_rename(self, name): + pass + def _make_session( mock_openai_client=None, - persona=None, instructions=None, **kwargs, ): @@ -59,7 +60,6 @@ def _make_session( client=client, model="test-model", ui=NullUI(), - persona=persona, instructions=instructions, temperature=0.5, max_tokens=4096, @@ -77,15 +77,6 @@ class TestChatSessionConstruction: roles = [m["role"] for m in session.system_messages] assert "developer" in roles - def test_persona_injected_into_chat_template_kwargs(self, tmp_db): - session = _make_session(persona="Helpful assistant") - assert "model_identity" in session._chat_template_kwargs - assert "Helpful assistant" in session._chat_template_kwargs["model_identity"] - - def test_no_persona_no_model_identity(self, tmp_db): - session = _make_session(persona=None) - assert "model_identity" not in session._chat_template_kwargs - def test_instructions_appended_to_developer_message(self, tmp_db): session = _make_session(instructions="Always be concise.") dev_msgs = [m for m in session.system_messages if m["role"] == "developer"] @@ -268,8 +259,6 @@ class TestPlanExec: monkeypatch.chdir(tmp_path) session = _make_session() agent_output = "## Goal\n\nBuild it." - call_id, content, _ = self._run_plan( - session, "do stuff", agent_return=agent_output - ) + call_id, content, _ = self._run_plan(session, "do stuff", agent_return=agent_output) assert call_id == "test-call-1" assert content == agent_output diff --git a/tests/test_sessions.py b/tests/test_sessions.py index c9fd7b96..d5271787 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1,22 +1,20 @@ """Tests for session persistence and resume functionality.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -import turnstone.core.memory as memory from turnstone.core.memory import ( - register_session, - update_session_title, - set_session_alias, - resolve_session, + delete_session, list_sessions, load_session_messages, - delete_session, - save_message, open_db, + register_session, + resolve_session, + save_message, + set_session_alias, + update_session_title, ) from turnstone.core.session import ChatSession - # ── Session registration ────────────────────────────────────────────── @@ -154,12 +152,8 @@ class TestLoadSessionMessages: def test_tool_calls_with_ids(self, tmp_db): save_message("s1", "user", "run ls") save_message("s1", "assistant", "Let me check.") - save_message( - "s1", "tool_call", None, "bash", '{"command":"ls"}', tool_call_id="call_abc" - ) - save_message( - "s1", "tool_result", "file1.txt\nfile2.txt", "bash", tool_call_id="call_abc" - ) + save_message("s1", "tool_call", None, "bash", '{"command":"ls"}', tool_call_id="call_abc") + save_message("s1", "tool_result", "file1.txt\nfile2.txt", "bash", tool_call_id="call_abc") msgs = load_session_messages("s1") assert len(msgs) == 3 # user, assistant+tool_calls, tool # Assistant should have content merged with tool_calls @@ -186,12 +180,8 @@ class TestLoadSessionMessages: def test_parallel_tool_calls(self, tmp_db): save_message("s1", "user", "search two things") - save_message( - "s1", "tool_call", None, "search", '{"query":"a"}', tool_call_id="call_1" - ) - save_message( - "s1", "tool_call", None, "search", '{"query":"b"}', tool_call_id="call_2" - ) + save_message("s1", "tool_call", None, "search", '{"query":"a"}', tool_call_id="call_1") + save_message("s1", "tool_call", None, "search", '{"query":"b"}', tool_call_id="call_2") save_message("s1", "tool_result", "result a", "search", tool_call_id="call_1") save_message("s1", "tool_result", "result b", "search", tool_call_id="call_2") msgs = load_session_messages("s1") @@ -231,9 +221,7 @@ class TestDeleteSession: class TestSaveMessageToolCallId: def test_tool_call_id_stored(self, tmp_db): - save_message( - "s1", "tool_call", None, "bash", '{"cmd":"ls"}', tool_call_id="call_xyz" - ) + save_message("s1", "tool_call", None, "bash", '{"cmd":"ls"}', tool_call_id="call_xyz") conn = open_db() try: row = conn.execute( @@ -293,7 +281,6 @@ class TestResumeSession: client=mock_openai_client, model="test-model", ui=MagicMock(), - persona=None, instructions=None, temperature=0.5, max_tokens=1000, @@ -314,7 +301,6 @@ class TestResumeSession: client=mock_openai_client, model="test-model", ui=MagicMock(), - persona=None, instructions=None, temperature=0.5, max_tokens=1000, @@ -327,7 +313,6 @@ class TestResumeSession: client=mock_openai_client, model="test-model", ui=MagicMock(), - persona=None, instructions=None, temperature=0.5, max_tokens=1000, @@ -347,7 +332,7 @@ class TestSaveMessageUpdatesSession: register_session("s1") save_message("s1", "user", "first") rows = list_sessions() - original_updated = rows[0][4] + _original_updated = rows[0][4] import time diff --git a/tests/test_sim.py b/tests/test_sim.py index 418b2198..0c047060 100644 --- a/tests/test_sim.py +++ b/tests/test_sim.py @@ -4,18 +4,14 @@ from __future__ import annotations import asyncio import random -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock import pytest from turnstone.mq.protocol import ( - ContentEvent, - InboundMessage, OutboundEvent, SendMessage, StateChangeEvent, - TurnCompleteEvent, - WorkstreamCreatedEvent, ) from turnstone.sim.config import SimConfig from turnstone.sim.engine import SimEngine, ToolSimulationError diff --git a/tests/test_tools_schema.py b/tests/test_tools_schema.py index 429bbb06..c500e414 100644 --- a/tests/test_tools_schema.py +++ b/tests/test_tools_schema.py @@ -1,13 +1,13 @@ """Tests for turnstone.core.tools — JSON auto-loading and schema validation.""" from turnstone.core.tools import ( - TOOLS, - AGENT_TOOLS, - TASK_AGENT_TOOLS, - AGENT_AUTO_TOOLS, - TASK_AUTO_TOOLS, - PRIMARY_KEY_MAP, _META, + AGENT_AUTO_TOOLS, + AGENT_TOOLS, + PRIMARY_KEY_MAP, + TASK_AGENT_TOOLS, + TASK_AUTO_TOOLS, + TOOLS, ) @@ -23,9 +23,7 @@ class TestToolsSchema: def test_all_tools_have_description(self): for tool in TOOLS: - assert "description" in tool["function"], ( - f"Tool missing description: {tool}" - ) + assert "description" in tool["function"], f"Tool missing description: {tool}" assert len(tool["function"]["description"]) > 0 def test_all_tools_have_parameters(self): @@ -84,8 +82,8 @@ class TestToolsMetadata: def test_auto_approve_sets_match(self): expected = {"read_file", "search", "math", "man", "web_fetch", "web_search"} - assert AGENT_AUTO_TOOLS == expected - assert TASK_AUTO_TOOLS == expected + assert expected == AGENT_AUTO_TOOLS + assert expected == TASK_AUTO_TOOLS def test_primary_key_map(self): expected = { @@ -104,7 +102,7 @@ class TestToolsMetadata: "recall": "query", "forget": "key", } - assert PRIMARY_KEY_MAP == expected + assert expected == PRIMARY_KEY_MAP def test_no_metadata_in_function_dicts(self): """Ensure turnstone metadata keys are stripped from the OpenAI schema.""" @@ -112,9 +110,7 @@ class TestToolsMetadata: for tool in TOOLS: func = tool["function"] leaked = meta_keys & set(func) - assert not leaked, ( - f"Tool '{func['name']}' leaks metadata into function dict: {leaked}" - ) + assert not leaked, f"Tool '{func['name']}' leaks metadata into function dict: {leaked}" def test_meta_has_all_tools(self): tool_names = {t["function"]["name"] for t in TOOLS} diff --git a/tests/test_workstream.py b/tests/test_workstream.py index 060993c6..e47ac9e1 100644 --- a/tests/test_workstream.py +++ b/tests/test_workstream.py @@ -4,10 +4,8 @@ import threading import time import pytest -from unittest.mock import MagicMock - -from turnstone.core.workstream import WorkstreamManager, WorkstreamState, Workstream +from turnstone.core.workstream import Workstream, WorkstreamManager, WorkstreamState # --------------------------------------------------------------------------- # Helpers @@ -130,7 +128,7 @@ class TestManagerCreation: def test_create_second_does_not_change_active(self): mgr = WorkstreamManager(_fake_factory) ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) - ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + _ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) assert mgr.active_id == ws1.id def test_create_assigns_session(self): @@ -176,9 +174,9 @@ class TestManagerLookup: def test_list_all_creation_order(self): mgr = WorkstreamManager(_fake_factory) - ws1 = mgr.create(name="a", ui_factory=lambda wid: FakeUI(wid)) - ws2 = mgr.create(name="b", ui_factory=lambda wid: FakeUI(wid)) - ws3 = mgr.create(name="c", ui_factory=lambda wid: FakeUI(wid)) + _ws1 = mgr.create(name="a", ui_factory=lambda wid: FakeUI(wid)) + _ws2 = mgr.create(name="b", ui_factory=lambda wid: FakeUI(wid)) + _ws3 = mgr.create(name="c", ui_factory=lambda wid: FakeUI(wid)) result = mgr.list_all() assert [w.name for w in result] == ["a", "b", "c"] @@ -222,7 +220,7 @@ class TestManagerSwitching: def test_switch_by_index(self): mgr = WorkstreamManager(_fake_factory) - ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + _ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) result = mgr.switch_by_index(2) @@ -244,7 +242,7 @@ class TestManagerSwitching: class TestManagerClose: def test_close_removes_workstream(self): mgr = WorkstreamManager(_fake_factory) - ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + _ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) assert mgr.close(ws2.id) is True @@ -274,9 +272,9 @@ class TestManagerClose: def test_close_updates_order(self): mgr = WorkstreamManager(_fake_factory) - ws1 = mgr.create(name="a", ui_factory=lambda wid: FakeUI(wid)) + _ws1 = mgr.create(name="a", ui_factory=lambda wid: FakeUI(wid)) ws2 = mgr.create(name="b", ui_factory=lambda wid: FakeUI(wid)) - ws3 = mgr.create(name="c", ui_factory=lambda wid: FakeUI(wid)) + _ws3 = mgr.create(name="c", ui_factory=lambda wid: FakeUI(wid)) mgr.close(ws2.id) names = [w.name for w in mgr.list_all()] @@ -285,7 +283,7 @@ class TestManagerClose: def test_close_unblocks_approval_event(self): """Closing a workstream whose UI has a pending approval should unblock it.""" mgr = WorkstreamManager(_fake_factory) - ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + _ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) # Create a workstream with a WebUI-like approval mechanism from turnstone.server import WebUI @@ -300,7 +298,7 @@ class TestManagerClose: def test_close_unblocks_plan_event(self): """Closing a workstream with pending plan review should unblock it.""" mgr = WorkstreamManager(_fake_factory) - ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + _ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) from turnstone.server import WebUI @@ -551,6 +549,7 @@ class TestWebUI: def test_on_state_change_broadcasts(self): """on_state_change should put an event on the global queue.""" import queue + from turnstone.server import WebUI gq = queue.Queue() @@ -742,6 +741,7 @@ class TestNoColor: def test_no_color_env_disables_ansi(self): import importlib import os + import turnstone.ui.colors as colors_mod old_env = os.environ.get("NO_COLOR") diff --git a/turnstone/__init__.py b/turnstone/__init__.py index cbe83c0f..14cce82a 100644 --- a/turnstone/__init__.py +++ b/turnstone/__init__.py @@ -1,3 +1,3 @@ -"""turnstone - Single-file AI chat client with tool use.""" +"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation.""" -__version__ = "0.1.0" +__version__ = "0.2.1" diff --git a/turnstone/chat.py b/turnstone/chat.py index 60abdf91..ab5cdf3e 100755 --- a/turnstone/chat.py +++ b/turnstone/chat.py @@ -15,37 +15,31 @@ All functionality has been moved to submodules: """ # Re-export public API for backward compatibility -from turnstone.core.session import ChatSession, SessionUI # noqa: F401 -from turnstone.core.tools import TOOLS, AGENT_TOOLS, TASK_AGENT_TOOLS # noqa: F401 -from turnstone.core.edit import ( - find_occurrences as _find_occurrences, - pick_nearest as _pick_nearest, -) # noqa: F401 -from turnstone.core.sandbox import ( - validate_math_code as _validate_math_code, - auto_print_wrap as _auto_print_wrap, - execute_math_sandboxed as _execute_math_sandboxed, -) # noqa: F401 -from turnstone.core.safety import ( - is_command_blocked, - sanitize_command as _sanitize_command, - BLOCKED_PATTERNS, -) # noqa: F401 -from turnstone.core.web import strip_html as _strip_html # noqa: F401 +from turnstone.cli import detect_model, main # noqa: F401 from turnstone.core.memory import ( # noqa: F401 open_db as _open_db, - load_memories as _load_memories, - save_message as _save_message, - normalize_key as _normalize_key, - search_history as _search_history, - search_history_recent as _search_history_recent, - escape_like as _escape_like, - fts5_query as _fts5_query, - get_tavily_key as _get_tavily_key, - db_override as _db_override, - db_initialized as _db_initialized, ) -from turnstone.ui.colors import * # noqa: F401, F403 +from turnstone.core.session import ChatSession, SessionUI # noqa: F401 +from turnstone.core.tools import AGENT_TOOLS, TASK_AGENT_TOOLS, TOOLS # noqa: F401 +from turnstone.core.web import strip_html as _strip_html # noqa: F401 +from turnstone.ui.colors import ( # noqa: F401 + BLUE, + BOLD, + CYAN, + DIM, + GRAY, + GREEN, + ITALIC, + MAGENTA, + RED, + RESET, + YELLOW, + bold, + cyan, + dim, + green, + red, + yellow, +) from turnstone.ui.markdown import MarkdownRenderer # noqa: F401 from turnstone.ui.spinner import Spinner # noqa: F401 -from turnstone.cli import main, detect_model # noqa: F401 diff --git a/turnstone/cli.py b/turnstone/cli.py index ec859a70..de9163b7 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -4,24 +4,23 @@ Provides TerminalUI (implementing the SessionUI protocol), readline setup, model auto-detection, workstream management, and the main() REPL entry point. """ +from __future__ import annotations + import argparse import os import readline import sys import textwrap import threading +from typing import TYPE_CHECKING, Any from openai import OpenAI from turnstone.core.session import ChatSession, SessionUI -from turnstone.core.tools import TOOLS -from turnstone.core.workstream import WorkstreamManager, WorkstreamState +from turnstone.core.workstream import Workstream, WorkstreamManager, WorkstreamState from turnstone.ui.colors import ( BOLD, - CYAN, DIM, - GRAY, - GREEN, RED, RESET, YELLOW, @@ -35,11 +34,12 @@ from turnstone.ui.colors import ( from turnstone.ui.markdown import MarkdownRenderer from turnstone.ui.spinner import Spinner +if TYPE_CHECKING: + from collections.abc import Callable # ─── Readline ───────────────────────────────────────────────────────────── SLASH_COMMANDS = [ - "/persona", "/instructions", "/clear", "/new", @@ -63,18 +63,15 @@ SLASH_COMMANDS = [ ] -def _completer(text, state): +def _completer(text: str, state: int) -> str | None: """Tab-complete slash commands.""" - if text.startswith("/"): - matches = [c for c in SLASH_COMMANDS if c.startswith(text)] - else: - matches = [] + matches = [c for c in SLASH_COMMANDS if c.startswith(text)] if text.startswith("/") else [] if state < len(matches): return matches[state] + " " return None -def setup_readline(): +def setup_readline() -> None: """Set up readline with tab completion.""" readline.set_history_length(1000) readline.set_completer(_completer) @@ -88,32 +85,32 @@ def setup_readline(): class TerminalUI(SessionUI): """Terminal-based UI using ANSI colors, MarkdownRenderer, and Spinner.""" - def __init__(self): + def __init__(self) -> None: self.md = MarkdownRenderer() - self.spinner = None + self.spinner: Spinner | None = None self._print_lock = threading.Lock() self.auto_approve = False - def on_thinking_start(self): + def on_thinking_start(self) -> None: self.spinner = Spinner("Thinking") self.spinner.start() - def on_thinking_stop(self): + def on_thinking_stop(self) -> None: if self.spinner: self.spinner.stop() self.spinner = None - def on_reasoning_token(self, text): + def on_reasoning_token(self, text: str) -> None: sys.stdout.write(f"{DIM}{text}{RESET}") sys.stdout.flush() - def on_content_token(self, text): + def on_content_token(self, text: str) -> None: rendered = self.md.feed(text) if rendered: sys.stdout.write(rendered) sys.stdout.flush() - def on_stream_end(self): + def on_stream_end(self) -> None: remainder = self.md.flush() if remainder: sys.stdout.write(remainder) @@ -121,14 +118,12 @@ class TerminalUI(SessionUI): sys.stdout.write("\n") sys.stdout.flush() - def approve_tools(self, items): + def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]: """Display tool previews and prompt for batch approval. Returns (approved: bool, feedback: str | None). """ - pending = [ - it for it in items if it.get("needs_approval") and not it.get("error") - ] + pending = [it for it in items if it.get("needs_approval") and not it.get("error")] with self._print_lock: # Print all headers and previews @@ -153,9 +148,7 @@ class TerminalUI(SessionUI): f"\001{DIM}\002[y/n/a(lways), optional message]\001{RESET}\002 " ) else: - labels = ", ".join( - it.get("approval_label", it["func_name"]) for it in pending - ) + labels = ", ".join(it.get("approval_label", it["func_name"]) for it in pending) prompt_text = ( f" \001{BOLD}\002Allow {len(pending)} tools ({labels})?\001{RESET}\002 " f"\001{DIM}\002[y/n/a(lways), optional message]\001{RESET}\002 " @@ -188,10 +181,10 @@ class TerminalUI(SessionUI): item["denial_msg"] = denial_msg return False, None - def on_tool_result(self, name, output): + def on_tool_result(self, name: str, output: str) -> None: pass # Optional: display summary - def on_status(self, usage, context_window, effort): + def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: total_tok = usage["prompt_tokens"] + usage["completion_tokens"] pct = total_tok / context_window * 100 if context_window > 0 else 0 parts = [f"{total_tok:,} / {context_window:,} tokens ({pct:.0f}%)"] @@ -200,7 +193,7 @@ class TerminalUI(SessionUI): sys.stdout.write(f"\n {DIM}[{' · '.join(parts)}]{RESET}\n") sys.stdout.flush() - def on_plan_review(self, content): + def on_plan_review(self, content: str) -> str: sys.stdout.write(f"\n{DIM}{'─' * 60}{RESET}\n") for line in content.splitlines(): sys.stdout.write(f" {line}\n") @@ -218,17 +211,17 @@ class TerminalUI(SessionUI): resp = "reject" return resp - def on_info(self, message): + def on_info(self, message: str) -> None: print(message) - def on_error(self, message): + def on_error(self, message: str) -> None: sys.stdout.write(f"{RED}{message}{RESET}\n") sys.stdout.flush() - def on_state_change(self, state): + def on_state_change(self, state: str) -> None: pass # base TerminalUI ignores state changes - def on_rename(self, name: str): + def on_rename(self, name: str) -> None: pass # base TerminalUI ignores renames @@ -236,7 +229,7 @@ class TerminalUI(SessionUI): # State display config: (symbol, color_fn, label) -_STATE_DISPLAY = { +_STATE_DISPLAY: dict[WorkstreamState, tuple[str, Callable[[str], str], str]] = { WorkstreamState.IDLE: ("·", dim, "idle"), WorkstreamState.THINKING: ("◌", cyan, "thinking"), WorkstreamState.RUNNING: ("▸", green, "running"), @@ -249,7 +242,7 @@ class WorkstreamTerminalUI(TerminalUI): """TerminalUI with workstream awareness: buffers output when in background, blocks on approval until foregrounded.""" - def __init__(self, ws_id: str, manager: WorkstreamManager): + def __init__(self, ws_id: str, manager: WorkstreamManager) -> None: super().__init__() self.ws_id = ws_id self.manager = manager @@ -261,13 +254,13 @@ class WorkstreamTerminalUI(TerminalUI): def is_foreground(self) -> bool: return self.manager.active_id == self.ws_id - def set_foreground(self, fg: bool): + def set_foreground(self, fg: bool) -> None: if fg: self._fg_event.set() else: self._fg_event.clear() - def on_state_change(self, state: str): + def on_state_change(self, state: str) -> None: try: ws_state = WorkstreamState(state) except ValueError: @@ -276,61 +269,61 @@ class WorkstreamTerminalUI(TerminalUI): # -- output buffering when in background -------------------------------- - def on_thinking_start(self): + def on_thinking_start(self) -> None: if self.is_foreground: super().on_thinking_start() - def on_thinking_stop(self): + def on_thinking_stop(self) -> None: if self.is_foreground: super().on_thinking_stop() elif self.spinner: self.spinner.stop() self.spinner = None - def _buffer(self, event_type: str, text: str): + def _buffer(self, event_type: str, text: str) -> None: with self._print_lock: self._output_buffer.append((event_type, text)) - def on_reasoning_token(self, text): + def on_reasoning_token(self, text: str) -> None: if self.is_foreground: super().on_reasoning_token(text) else: self._buffer("reasoning", text) - def on_content_token(self, text): + def on_content_token(self, text: str) -> None: if self.is_foreground: super().on_content_token(text) else: self._buffer("content", text) - def on_stream_end(self): + def on_stream_end(self) -> None: if self.is_foreground: super().on_stream_end() else: self._buffer("stream_end", "") - def on_status(self, usage, context_window, effort): + def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: if self.is_foreground: super().on_status(usage, context_window, effort) # silently drop status for background streams - def on_info(self, message): + def on_info(self, message: str) -> None: if self.is_foreground: super().on_info(message) else: self._buffer("info", message) - def on_error(self, message): + def on_error(self, message: str) -> None: if self.is_foreground: super().on_error(message) else: self._buffer("error", message) - def on_tool_result(self, name, output): + def on_tool_result(self, name: str, output: str) -> None: if self.is_foreground: super().on_tool_result(name, output) - def on_plan_review(self, content): + def on_plan_review(self, content: str) -> str: # Must wait until foregrounded to show plan review if not self.is_foreground: self._buffer( @@ -340,7 +333,7 @@ class WorkstreamTerminalUI(TerminalUI): self._fg_event.wait() return super().on_plan_review(content) - def approve_tools(self, items): + def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]: """Block until foregrounded if in background, then show approval prompt.""" if not self.is_foreground: tool_names = ", ".join( @@ -349,22 +342,18 @@ class WorkstreamTerminalUI(TerminalUI): if it.get("needs_approval") and not it.get("error") ) if tool_names: - self._buffer( - "info", f"{YELLOW}Waiting for approval: {tool_names}{RESET}" - ) + self._buffer("info", f"{YELLOW}Waiting for approval: {tool_names}{RESET}") self._fg_event.wait() return super().approve_tools(items) - def flush_buffer(self): + def flush_buffer(self) -> None: """Replay buffered output when switching to foreground.""" with self._print_lock: if not self._output_buffer: return buf = list(self._output_buffer) self._output_buffer.clear() - sys.stdout.write( - f"\n {DIM}--- buffered output ({len(buf)} events) ---{RESET}\n" - ) + sys.stdout.write(f"\n {DIM}--- buffered output ({len(buf)} events) ---{RESET}\n") replay_md = MarkdownRenderer() for event_type, text in buf: if event_type == "reasoning": @@ -390,7 +379,7 @@ class WorkstreamTerminalUI(TerminalUI): # ─── Workstream commands ────────────────────────────────────────────────── -def _print_ws_status_line(manager: WorkstreamManager): +def _print_ws_status_line(manager: WorkstreamManager) -> None: """Print a one-line status of background workstreams that are active.""" active_id = manager.active_id parts = [] @@ -411,7 +400,7 @@ def _handle_ws_command( manager: WorkstreamManager, cmd_line: str, skip_permissions: bool, -): +) -> bool: """Handle /ws subcommands. Returns (switched: bool).""" parts = cmd_line.strip().split() sub = parts[1] if len(parts) > 1 else "list" @@ -438,27 +427,27 @@ def _handle_ws_command( except RuntimeError as e: print(red(str(e))) return False - if skip_permissions: + if skip_permissions and isinstance(ws.ui, TerminalUI): ws.ui.auto_approve = True # Mark old active as background old = manager.get_active() - if old and old.ui and hasattr(old.ui, "set_foreground"): + if old and isinstance(old.ui, WorkstreamTerminalUI): old.ui.set_foreground(False) manager.switch(ws.id) - ws.ui.set_foreground(True) + if isinstance(ws.ui, WorkstreamTerminalUI): + ws.ui.set_foreground(True) print(f"Created workstream {cyan(ws.name)} (#{manager.index_of(ws.id)})") return True elif sub.isdigit(): idx = int(sub) old = manager.get_active() - ws = manager.switch_by_index(idx) + ws: Workstream | None = manager.switch_by_index(idx) # type: ignore[no-redef] if ws: - if old and old.ui and hasattr(old.ui, "set_foreground"): + if old and isinstance(old.ui, WorkstreamTerminalUI): old.ui.set_foreground(False) - if hasattr(ws.ui, "set_foreground"): + if isinstance(ws.ui, WorkstreamTerminalUI): ws.ui.set_foreground(True) - if hasattr(ws.ui, "flush_buffer"): ws.ui.flush_buffer() print(f"Switched to {cyan(ws.name)}") return True @@ -468,6 +457,7 @@ def _handle_ws_command( elif sub == "close": target_idx = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else None + ws_id: str | None = None if target_idx is not None: all_ws = manager.list_all() if 1 <= target_idx <= len(all_ws): @@ -477,13 +467,17 @@ def _handle_ws_command( return False else: ws_id = manager.active_id + if ws_id is None: + return False - ws_name = manager.get(ws_id).name if manager.get(ws_id) else "?" + assert ws_id is not None + ws_obj = manager.get(ws_id) + ws_name = ws_obj.name if ws_obj else "?" if manager.close(ws_id): print(f"Closed workstream {ws_name}") # Ensure new active is foregrounded new_active = manager.get_active() - if new_active and hasattr(new_active.ui, "set_foreground"): + if new_active and isinstance(new_active.ui, WorkstreamTerminalUI): new_active.ui.set_foreground(True) return True else: @@ -495,34 +489,28 @@ def _handle_ws_command( if not new_name: print(red("Usage: /ws rename ")) return False - ws = manager.get_active() - if ws: - old_name = ws.name - ws.name = new_name + ws_active: Workstream | None = manager.get_active() + if ws_active: + old_name = ws_active.name + ws_active.name = new_name print(f"Renamed {old_name} -> {cyan(new_name)}") return False else: print(f"Unknown /ws subcommand: {sub}") - print(f"Usage: /ws [list|new [name]||close [N]|rename ]") + print("Usage: /ws [list|new [name]||close [N]|rename ]") return False # ─── Cluster commands ───────────────────────────────────────────────────── -def _handle_cluster_command( - cmd_line: str, console_url: str | None, auth_token: str = "" -): +def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token: str = "") -> None: """Handle /cluster subcommands querying the turnstone-console API.""" import httpx if not console_url: - print( - red( - "No console URL configured. Use --console-url or set [console] url in config." - ) - ) + print(red("No console URL configured. Use --console-url or set [console] url in config.")) return headers: dict[str, str] = {} @@ -534,9 +522,7 @@ def _handle_cluster_command( try: if sub == "status": - resp = httpx.get( - f"{console_url}/api/cluster/overview", timeout=5, headers=headers - ) + resp = httpx.get(f"{console_url}/api/cluster/overview", timeout=5, headers=headers) data = resp.json() states = data.get("states", {}) agg = data.get("aggregate", {}) @@ -584,9 +570,7 @@ def _handle_cluster_command( print( f"\n {'NODE'.ljust(max_name)} {'WS':>4} {'RUN':>4} {'ATTN':>4} {'TOKENS':>8}" ) - print( - f" {'-' * max_name} {'----':>4} {'----':>4} {'----':>4} {'--------':>8}" - ) + print(f" {'-' * max_name} {'----':>4} {'----':>4} {'----':>4} {'--------':>8}") for n in nodes: name = n["node_id"].ljust(max_name) ws = str(n.get("ws_total", 0)) @@ -596,9 +580,7 @@ def _handle_cluster_command( tok_str = f"{tok / 1000:.1f}k" if tok >= 1000 else str(tok) run_str = green(str(run)) if run else dim("0") attn_str = yellow(str(attn)) if attn else dim("0") - print( - f" {cyan(name)} {ws:>4} {run_str:>4} {attn_str:>4} {dim(tok_str):>8}" - ) + print(f" {cyan(name)} {ws:>4} {run_str:>4} {attn_str:>4} {dim(tok_str):>8}") if total > len(nodes): print(dim(f"\n Showing {len(nodes)} of {total} nodes")) print() @@ -723,14 +705,13 @@ def detect_model(client: OpenAI) -> str: # ─── Main ────────────────────────────────────────────────────────────────── -def main(): +def main() -> None: parser = argparse.ArgumentParser( description="Interactive CLI for vLLM models with tool calling.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent("""\ Examples: python3 chat.py # auto-detect model - python3 chat.py --persona lawful_evil # with persona python3 chat.py --model kappa_20b_131k # explicit model python3 chat.py --temperature 0.7 # lower temperature """), @@ -745,11 +726,6 @@ def main(): default=None, help="Model name (default: auto-detect from server)", ) - parser.add_argument( - "--persona", - default=None, - help="Persona name injected as system message", - ) parser.add_argument( "--instructions", default=None, @@ -863,18 +839,15 @@ def main(): ) # Detect or use provided model - if args.model: - model = args.model - else: - model = detect_model(client) + model = args.model or detect_model(client) # Session factory — captures shared config for creating workstream sessions - def session_factory(ui): + def session_factory(ui: SessionUI | None) -> ChatSession: + assert ui is not None, "session_factory requires a non-None UI" return ChatSession( client=client, model=model, ui=ui, - persona=args.persona, instructions=args.instructions, temperature=args.temperature, max_tokens=args.max_tokens, @@ -892,7 +865,7 @@ def main(): ws = manager.create( ui_factory=lambda wid: WorkstreamTerminalUI(wid, manager), ) - if args.skip_permissions: + if args.skip_permissions and isinstance(ws.ui, TerminalUI): ws.ui.auto_approve = True # Handle --resume @@ -903,15 +876,16 @@ def main(): if not target_id: print(red(f"Session not found: {args.resume}")) sys.exit(1) + if ws.session is None: + print(red("No session available.")) + sys.exit(1) if not ws.session.resume_session(target_id): print(red(f"Session '{args.resume}' has no messages.")) sys.exit(1) - print( - f"Resumed session {bold(target_id)} ({len(ws.session.messages)} messages)" - ) + print(f"Resumed session {bold(target_id)} ({len(ws.session.messages)} messages)") # Background attention notification — write to stderr while user types - def _bg_attention_notify(ws_id, state): + def _bg_attention_notify(ws_id: str, state: WorkstreamState) -> None: if state == WorkstreamState.ATTENTION and ws_id != manager.active_id: bg_ws = manager.get(ws_id) if bg_ws: @@ -927,9 +901,7 @@ def main(): # Print banner print(f"\n{bold('Chat')} with {cyan(model)}") - if args.persona: - print(f"Persona: {cyan(args.persona)}") - print(f"Type /help for commands, /ws for workstreams, /exit or Ctrl+D to quit.\n") + print("Type /help for commands, /ws for workstreams, /exit or Ctrl+D to quit.\n") # Prompt string -- use a short display name display_name = model.split("/")[-1] # strip path prefixes if any @@ -945,7 +917,7 @@ def main(): # Build prompt with workstream info active = manager.get_active() - if manager.count > 1: + if manager.count > 1 and active is not None: idx = manager.index_of(active.id) prompt_str = f"\001{BOLD}\002{idx}:{active.name}\001{RESET}\002 > " else: @@ -968,6 +940,8 @@ def main(): continue active = manager.get_active() + if active is None or active.session is None: + continue if user_input.startswith("/"): should_exit = active.session.handle_command(user_input) if should_exit: diff --git a/turnstone/console/collector.py b/turnstone/console/collector.py index a5e38f8e..a3fea20e 100644 --- a/turnstone/console/collector.py +++ b/turnstone/console/collector.py @@ -7,6 +7,7 @@ for real-time state changes. from __future__ import annotations +import contextlib import json import logging import queue @@ -14,10 +15,12 @@ import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any import httpx -from turnstone.mq.broker import RedisBroker +if TYPE_CHECKING: + from turnstone.mq.broker import RedisBroker log = logging.getLogger("turnstone.console.collector") @@ -31,9 +34,9 @@ class NodeSnapshot: started: float = 0.0 last_seen: float = 0.0 # monotonic time of last successful poll max_ws: int = 10 # max workstreams (capacity) - workstreams: dict[str, dict] = field(default_factory=dict) - health: dict = field(default_factory=dict) - aggregate: dict = field(default_factory=dict) + workstreams: dict[str, dict[str, Any]] = field(default_factory=dict) + health: dict[str, Any] = field(default_factory=dict) + aggregate: dict[str, Any] = field(default_factory=dict) reachable: bool = True @@ -74,7 +77,7 @@ class ClusterCollector: self._http_client = httpx.Client(timeout=http_timeout, headers=headers) # SSE fan-out to browser clients - self._listeners: list[queue.Queue] = [] + self._listeners: list[queue.Queue[dict[str, Any]]] = [] self._listeners_lock = threading.Lock() # -- lifecycle ----------------------------------------------------------- @@ -165,14 +168,12 @@ class ClusterCollector: # Fan out to SSE listeners self._fanout(data) - def _fanout(self, event: dict) -> None: + def _fanout(self, event: dict[str, Any]) -> None: """Copy an event to all registered SSE listener queues.""" with self._listeners_lock: for q in self._listeners: - try: + with contextlib.suppress(queue.Full): q.put_nowait(event) - except queue.Full: - pass # -- node discovery ------------------------------------------------------ @@ -242,10 +243,7 @@ class ClusterCollector: if not targets: return - futures = { - self._poll_pool.submit(self._fetch_node, nid, url): nid - for nid, url in targets - } + futures = {self._poll_pool.submit(self._fetch_node, nid, url): nid for nid, url in targets} for future in as_completed(futures): nid = futures[future] try: @@ -257,19 +255,19 @@ class ClusterCollector: if nid in self._nodes: self._nodes[nid].reachable = False - def _fetch_node(self, node_id: str, server_url: str) -> tuple[dict, dict]: + def _fetch_node(self, node_id: str, server_url: str) -> tuple[dict[str, Any], dict[str, Any]]: """Fetch /api/dashboard and /health from a single node.""" base = server_url.rstrip("/") dash_resp = self._http_client.get(f"{base}/api/dashboard") - dash_data = dash_resp.json() + dash_data: dict[str, Any] = dash_resp.json() try: health_resp = self._http_client.get(f"{base}/health") - health_data = health_resp.json() + health_data: dict[str, Any] = health_resp.json() except Exception: health_data = {} return dash_data, health_data - def _apply_poll(self, node_id: str, dashboard: dict, health: dict) -> None: + def _apply_poll(self, node_id: str, dashboard: dict[str, Any], health: dict[str, Any]) -> None: """Apply polled data to the in-memory node snapshot.""" ws_list = dashboard.get("workstreams", []) aggregate = dashboard.get("aggregate", {}) @@ -289,7 +287,7 @@ class ClusterCollector: # -- query methods (thread-safe) ----------------------------------------- - def get_overview(self) -> dict: + def get_overview(self) -> dict[str, Any]: """Return cluster overview: state counts, totals, aggregate stats.""" states = {"running": 0, "thinking": 0, "attention": 0, "idle": 0, "error": 0} total_tokens = 0 @@ -316,7 +314,7 @@ class ClusterCollector: def get_nodes( self, sort_by: str = "activity", limit: int = 100, offset: int = 0 - ) -> tuple[list[dict], int]: + ) -> tuple[list[dict[str, Any]], int]: """Return sorted, paginated node list with per-node counts.""" with self._lock: items = [] @@ -334,9 +332,7 @@ class ClusterCollector: # Use aggregate tokens if available, else sum from workstreams agg_tokens = node.aggregate.get("total_tokens", 0) if not agg_tokens: - agg_tokens = sum( - ws.get("tokens", 0) for ws in node.workstreams.values() - ) + agg_tokens = sum(ws.get("tokens", 0) for ws in node.workstreams.values()) items.append( { "node_id": node.node_id, @@ -376,7 +372,7 @@ class ClusterCollector: sort_by: str = "state", page: int = 1, per_page: int = 50, - ) -> tuple[list[dict], int]: + ) -> tuple[list[dict[str, Any]], int]: """Return filtered, sorted, paginated workstreams + total count.""" with self._lock: all_ws = [] @@ -419,7 +415,7 @@ class ClusterCollector: page_ws = all_ws[start : start + per_page] return page_ws, total - def get_node_detail(self, node_id: str) -> dict | None: + def get_node_detail(self, node_id: str) -> dict[str, Any] | None: """Return a single node's workstreams and health.""" with self._lock: node = self._nodes.get(node_id) @@ -436,12 +432,12 @@ class ClusterCollector: # -- SSE listener management --------------------------------------------- - def register_listener(self, q: queue.Queue) -> None: + def register_listener(self, q: queue.Queue[dict[str, Any]]) -> None: """Register a queue for SSE event fan-out.""" with self._listeners_lock: self._listeners.append(q) - def unregister_listener(self, q: queue.Queue) -> None: + def unregister_listener(self, q: queue.Queue[dict[str, Any]]) -> None: """Unregister a queue from SSE event fan-out.""" with self._listeners_lock: if q in self._listeners: diff --git a/turnstone/console/server.py b/turnstone/console/server.py index bac3df33..b3f1f57d 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -4,6 +4,8 @@ Serves the cluster-level dashboard UI and provides REST/SSE APIs backed by the ClusterCollector. """ +from __future__ import annotations + import argparse import json import logging @@ -11,12 +13,11 @@ import math import os import queue import textwrap -import threading -import time -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from socketserver import ThreadingMixIn -from urllib.parse import urlparse, parse_qs +from typing import Any +from urllib.parse import ParseResult, parse_qs, urlparse from turnstone.console.collector import ClusterCollector from turnstone.mq.broker import RedisBroker @@ -48,17 +49,17 @@ def _load_static() -> None: class ConsoleHTTPHandler(BaseHTTPRequestHandler): """HTTP handler for the cluster dashboard.""" - def log_message(self, format, *args): + def log_message(self, fmt: str, *args: object) -> None: # noqa: N802 pass # suppress default logging - def _set_headers(self, status=200, content_type="application/json"): + def _set_headers(self, status: int = 200, content_type: str = "application/json") -> None: self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Cache-Control", "no-cache") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() - def _send_json(self, data: dict, status=200): + def _send_json(self, data: dict[str, Any], status: int = 200) -> None: self._set_headers(status, "application/json") self.wfile.write(json.dumps(data).encode("utf-8")) @@ -69,24 +70,22 @@ class ConsoleHTTPHandler(BaseHTTPRequestHandler): auth_config = self.server.auth_config # type: ignore[attr-defined] auth_header = self.headers.get("Authorization") cookie_header = self.headers.get("Cookie") - allowed, status, msg = check_request( - auth_config, method, path, auth_header, cookie_header - ) + allowed, status, msg = check_request(auth_config, method, path, auth_header, cookie_header) if not allowed: self._send_json({"error": msg}, status) return allowed - def _read_body(self) -> dict: + def _read_body(self) -> dict[str, Any]: length = int(self.headers.get("Content-Length", 0)) if length == 0: return {} raw = self.rfile.read(length) try: - return json.loads(raw.decode("utf-8")) + return json.loads(raw.decode("utf-8")) # type: ignore[no-any-return] except (json.JSONDecodeError, UnicodeDecodeError, ValueError): return {} - def do_POST(self): + def do_POST(self) -> None: # Login/logout pass through _check_auth because they are in PUBLIC_PATHS. if not self._check_auth("POST", self.path): return @@ -103,9 +102,7 @@ class ConsoleHTTPHandler(BaseHTTPRequestHandler): self.send_header("Set-Cookie", make_set_cookie(token)) self.send_header("Cache-Control", "no-cache") self.end_headers() - self.wfile.write( - json.dumps({"status": "ok", "role": role}).encode("utf-8") - ) + self.wfile.write(json.dumps({"status": "ok", "role": role}).encode("utf-8")) else: self._send_json({"error": "Invalid token"}, 401) @@ -122,7 +119,7 @@ class ConsoleHTTPHandler(BaseHTTPRequestHandler): else: self._send_json({"error": "Not found"}, 404) - def do_GET(self): + def do_GET(self) -> None: parsed = urlparse(self.path) try: if not self._check_auth("GET", parsed.path): @@ -134,7 +131,7 @@ class ConsoleHTTPHandler(BaseHTTPRequestHandler): @staticmethod def _parse_int( - qs: dict, name: str, default: int, minimum: int = 0, maximum: int = 10000 + qs: dict[str, list[str]], name: str, default: int, minimum: int = 0, maximum: int = 10000 ) -> int: try: val = int(qs.get(name, [str(default)])[0]) @@ -142,7 +139,7 @@ class ConsoleHTTPHandler(BaseHTTPRequestHandler): val = default return max(minimum, min(val, maximum)) - def _do_GET(self, parsed): + def _do_GET(self, parsed: ParseResult) -> None: # noqa: N802 collector: ClusterCollector = self.server.collector # type: ignore[attr-defined] if parsed.path == "/": @@ -171,9 +168,7 @@ class ConsoleHTTPHandler(BaseHTTPRequestHandler): sort_by = qs.get("sort", ["activity"])[0] limit = self._parse_int(qs, "limit", 100, minimum=1, maximum=1000) offset = self._parse_int(qs, "offset", 0) - nodes, total = collector.get_nodes( - sort_by=sort_by, limit=limit, offset=offset - ) + nodes, total = collector.get_nodes(sort_by=sort_by, limit=limit, offset=offset) self._send_json({"nodes": nodes, "total": total}) elif parsed.path == "/api/cluster/workstreams": @@ -232,7 +227,7 @@ class ConsoleHTTPHandler(BaseHTTPRequestHandler): self._set_headers(404, "text/plain") self.wfile.write(b"Not found") - def _handle_sse(self, collector: ClusterCollector): + def _handle_sse(self, collector: ClusterCollector) -> None: """Server-Sent Events stream for cluster updates.""" self.send_response(200) self.send_header("Content-Type", "text/event-stream") @@ -241,14 +236,14 @@ class ConsoleHTTPHandler(BaseHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() - client_queue: queue.Queue = queue.Queue(maxsize=500) + client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=500) collector.register_listener(client_queue) try: while True: try: event = client_queue.get(timeout=5) data = json.dumps(event) - self.wfile.write(f"data: {data}\n\n".encode("utf-8")) + self.wfile.write(f"data: {data}\n\n".encode()) self.wfile.flush() except queue.Empty: self.wfile.write(b": keepalive\n\n") @@ -258,7 +253,7 @@ class ConsoleHTTPHandler(BaseHTTPRequestHandler): finally: collector.unregister_listener(client_queue) - def do_OPTIONS(self): + def do_OPTIONS(self) -> None: """Handle CORS preflight.""" self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") @@ -281,7 +276,7 @@ class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): # --------------------------------------------------------------------------- -def main(): +def main() -> None: parser = argparse.ArgumentParser( description="turnstone console — cluster dashboard service.", formatter_class=argparse.RawDescriptionHelpFormatter, diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index 87dc3fe9..60f3711c 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -38,6 +38,9 @@ function authFetch(url, opts) { var currentView = "overview"; // "overview" | "node" | "filtered" var currentNodeId = null; var currentFilter = { state: null, node: null, page: 1, per_page: 50 }; +var expandedGroups = {}; +var _lastOverviewJson = ""; +var _lastNodesJson = ""; var evtSource = null; var retryDelay = 1000; @@ -95,6 +98,8 @@ function connectSSE() { retryDelay = 1000; statusBar.classList.remove("disconnected"); statusBar.textContent = ""; + var csb = document.getElementById("cluster-status-bar"); + if (csb) csb.classList.remove("stale"); try { var data = JSON.parse(e.data); handleClusterEvent(data); @@ -107,6 +112,8 @@ function connectSSE() { evtSource = null; statusBar.textContent = "Reconnecting\u2026"; statusBar.classList.add("disconnected"); + var csb = document.getElementById("cluster-status-bar"); + if (csb) csb.classList.add("stale"); // Raw fetch (not authFetch) — need to inspect status before throwing fetch("/api/cluster/overview") .then(function (r) { @@ -167,16 +174,15 @@ function loadOverview() { var overviewP = authFetch("/api/cluster/overview").then(function (r) { return r.json(); }); - var nodesP = authFetch("/api/cluster/nodes?sort=activity&limit=50").then( + var nodesP = authFetch("/api/cluster/nodes?sort=activity&limit=1000").then( function (r) { return r.json(); }, ); Promise.all([overviewP, nodesP]) .then(function (res) { - renderStateCards(res[0].states); - renderAggregateBar(res[0]); - renderNodeTable(res[1].nodes, res[1].total); + renderStatusBar(res[0]); + renderNodeGroups(res[1].nodes, res[1].total); document.getElementById("cluster-summary").textContent = res[0].nodes + " nodes \u00b7 " + @@ -189,87 +195,307 @@ function loadOverview() { }); } -function renderStateCards(states) { - var container = document.getElementById("state-cards"); - container.innerHTML = ""; +// --- Status Bar --- +function renderStatusBar(overview) { + var cacheKey = + JSON.stringify(overview) + + "|" + + currentView + + "|" + + (currentFilter.state || ""); + if (cacheKey === _lastOverviewJson) return; + _lastOverviewJson = cacheKey; + + var states = overview.states || {}; + var agg = overview.aggregate || {}; + + var statesContainer = document.getElementById("csb-states"); + statesContainer.innerHTML = ""; STATE_ORDER.forEach(function (state) { var count = states[state] || 0; var sd = STATE_DISPLAY[state] || STATE_DISPLAY.idle; - var card = document.createElement("div"); - card.className = "state-card"; - card.dataset.state = state; - card.setAttribute("role", "button"); - card.setAttribute("tabindex", "0"); - card.setAttribute("aria-label", sd.label + ": " + count + " workstreams"); - card.innerHTML = - '
' + + var pill = document.createElement("button"); + pill.className = "csb-state"; + if (currentView === "filtered" && currentFilter.state === state) { + pill.classList.add("active"); + } + pill.setAttribute("aria-label", sd.label + ": " + count + " workstreams"); + pill.innerHTML = + '' + + '' + formatCount(count) + - "
" + - '
' + - sd.symbol + - " " + + "" + + '' + sd.label + - "
"; - card.onclick = function () { + ""; + pill.onclick = function () { drillDownByState(state); }; - card.onkeydown = function (e) { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - drillDownByState(state); - } - }; - container.appendChild(card); + statesContainer.appendChild(pill); + }); + + var metricsContainer = document.getElementById("csb-metrics"); + metricsContainer.innerHTML = ""; + var metrics = [ + { value: overview.nodes || 0, label: "nodes", format: formatCount }, + { value: overview.workstreams || 0, label: "ws", format: formatCount }, + { value: agg.total_tokens || 0, label: "tokens", format: formatTokens }, + { value: agg.total_tool_calls || 0, label: "calls", format: formatCount }, + ]; + metrics.forEach(function (m) { + if (m.value === 0 && m.label !== "nodes" && m.label !== "ws") return; + var el = document.createElement("span"); + el.className = "csb-metric"; + var valSpan = document.createElement("span"); + valSpan.className = "csb-metric-value"; + valSpan.textContent = m.format(m.value); + var labelSpan = document.createElement("span"); + labelSpan.className = "csb-metric-label"; + labelSpan.textContent = m.label; + el.appendChild(valSpan); + el.appendChild(labelSpan); + metricsContainer.appendChild(el); }); } -function renderAggregateBar(overview) { - var agg = overview.aggregate || {}; - var parts = []; - if (agg.total_tokens) parts.push(formatTokens(agg.total_tokens) + " tokens"); - if (agg.total_tool_calls) - parts.push(formatCount(agg.total_tool_calls) + " tool calls"); - document.getElementById("aggregate-bar").textContent = parts.join(" \u00b7 "); +// --- Node Grouping --- +function extractNodePrefix(nodeId) { + var stripped = nodeId.replace(/[-_][a-z0-9]*\d[a-z0-9]*$/i, ""); + if (!stripped || stripped === nodeId) { + stripped = nodeId.replace(/[-_]?\d+$/, ""); + } + // Clean trailing separators (e.g., FQDN-style "node.prod.01" → "node.prod") + stripped = stripped.replace(/[-_.]$/, ""); + return stripped || nodeId; } -function renderNodeTable(nodes, total) { +function groupNodes(nodes) { + var groupMap = {}; + var groupOrder = []; + nodes.forEach(function (node) { + var prefix = extractNodePrefix(node.node_id); + if (!groupMap[prefix]) { + groupMap[prefix] = { + prefix: prefix, + nodes: [], + ws_total: 0, + ws_running: 0, + ws_thinking: 0, + ws_attention: 0, + ws_error: 0, + ws_idle: 0, + total_tokens: 0, + all_reachable: true, + }; + groupOrder.push(prefix); + } + var g = groupMap[prefix]; + g.nodes.push(node); + g.ws_total += node.ws_total || 0; + g.ws_running += node.ws_running || 0; + g.ws_thinking += node.ws_thinking || 0; + g.ws_attention += node.ws_attention || 0; + g.ws_error += node.ws_error || 0; + g.ws_idle += node.ws_idle || 0; + g.total_tokens += node.total_tokens || 0; + if (!node.reachable) g.all_reachable = false; + }); + groupOrder.forEach(function (prefix) { + groupMap[prefix].nodes.sort(function (a, b) { + return b.ws_running + b.ws_attention - (a.ws_running + a.ws_attention); + }); + }); + var groups = groupOrder.map(function (p) { + return groupMap[p]; + }); + groups.sort(function (a, b) { + var aAct = a.ws_running + a.ws_attention; + var bAct = b.ws_running + b.ws_attention; + if (bAct !== aAct) return bAct - aAct; + return a.prefix.localeCompare(b.prefix); + }); + return groups; +} + +function buildNodeRow(node) { + var row = document.createElement("div"); + row.className = "node-row"; + if (node.ws_attention > 0) row.classList.add("has-attention"); + else if (node.ws_running > 0) row.classList.add("has-running"); + else if (node.ws_thinking > 0) row.classList.add("has-thinking"); + else if (node.ws_error > 0) row.classList.add("has-error"); + row.setAttribute("role", "button"); + row.setAttribute("tabindex", "0"); + row.setAttribute( + "aria-label", + node.node_id + + ": " + + node.ws_total + + " workstreams, " + + node.ws_running + + " running, " + + node.ws_attention + + " attention, " + + formatTokens(node.total_tokens) + + " tokens", + ); + + var dotClass = node.reachable ? "node-dot" : "node-dot unreachable"; + var displayTokens = node.total_tokens || node.ws_tokens || 0; + var maxWs = node.max_ws || 10; + var healthPct = + maxWs > 0 ? Math.min(Math.round((node.ws_total / maxWs) * 100), 100) : 0; + var healthFillClass = + healthPct < 50 ? "low" : healthPct < 80 ? "mid" : "high"; + var healthFillHtml = + healthPct > 0 + ? '' + : ""; + + row.innerHTML = + '' + + escapeHtml(node.node_id) + + "" + + '' + + node.ws_total + + "" + + '' + + node.ws_running + + "" + + '' + + node.ws_attention + + "" + + '' + + formatTokens(displayTokens) + + "" + + '' + + healthFillHtml + + " " + + healthPct + + "%"; + + row.onclick = function () { + drillDownToNode(node.node_id, node.server_url); + }; + row.onkeydown = function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + drillDownToNode(node.node_id, node.server_url); + } + }; + return row; +} + +function toggleGroup(prefix) { + expandedGroups[prefix] = !expandedGroups[prefix]; + var body = document.querySelector( + '.node-group-body[data-prefix="' + prefix.replace(/"/g, '\\"') + '"]', + ); + if (!body) return; + var isExpanded = expandedGroups[prefix]; + if (isExpanded) body.classList.remove("collapsed"); + else body.classList.add("collapsed"); + var groupEl = body.parentElement; + if (groupEl) groupEl.setAttribute("aria-expanded", String(isExpanded)); + var chevron = groupEl ? groupEl.querySelector(".node-group-chevron") : null; + if (chevron) { + if (isExpanded) chevron.classList.add("expanded"); + else chevron.classList.remove("expanded"); + } +} + +function renderNodeGroups(nodes, total) { + var json = JSON.stringify(nodes); + if (json === _lastNodesJson) return; + _lastNodesJson = json; + var table = document.getElementById("node-table"); table.innerHTML = ""; if (!nodes.length) { table.innerHTML = '
No nodes discovered
'; return; } - nodes.forEach(function (node) { - var row = document.createElement("div"); - row.className = "node-row"; - if (node.ws_attention > 0) row.classList.add("has-attention"); - else if (node.ws_running > 0) row.classList.add("has-running"); - else if (node.ws_thinking > 0) row.classList.add("has-thinking"); - else if (node.ws_error > 0) row.classList.add("has-error"); - row.setAttribute("role", "button"); - row.setAttribute("tabindex", "0"); - row.setAttribute( + + var topHeaders = document.createElement("div"); + topHeaders.className = "node-colheaders"; + topHeaders.setAttribute("aria-hidden", "true"); + topHeaders.innerHTML = + 'NODE' + + 'WS' + + 'RUN' + + 'ATTN' + + 'TOKENS' + + 'LOAD'; + table.appendChild(topHeaders); + + var groups = groupNodes(nodes); + + groups.forEach(function (group) { + // Single-node group — render as plain row + if (group.nodes.length === 1) { + var wrapper = document.createElement("div"); + wrapper.className = "node-group node-group-single"; + wrapper.appendChild(buildNodeRow(group.nodes[0])); + table.appendChild(wrapper); + return; + } + + var groupEl = document.createElement("div"); + groupEl.className = "node-group"; + var isExpanded = !!expandedGroups[group.prefix]; + groupEl.setAttribute("role", "listitem"); + groupEl.setAttribute("aria-expanded", String(isExpanded)); + + // Group header + var header = document.createElement("div"); + header.className = "node-group-header"; + if (group.ws_attention > 0) header.classList.add("has-attention"); + else if (group.ws_running > 0) header.classList.add("has-running"); + else if (group.ws_thinking > 0) header.classList.add("has-thinking"); + else if (group.ws_error > 0) header.classList.add("has-error"); + header.setAttribute("role", "button"); + header.setAttribute("tabindex", "0"); + header.setAttribute( "aria-label", - node.node_id + - ": " + - node.ws_total + + group.prefix + + " group: " + + group.nodes.length + + " nodes, " + + group.ws_total + " workstreams, " + - node.ws_running + + group.ws_running + " running, " + - node.ws_attention + + group.ws_attention + " attention, " + - formatTokens(node.total_tokens) + + formatTokens(group.total_tokens) + " tokens", ); - var dotClass = node.reachable ? "node-dot" : "node-dot unreachable"; - - // Use aggregate tokens, fall back to summed workstream tokens - var displayTokens = node.total_tokens || node.ws_tokens || 0; - - // Load = workstream count / max capacity - var maxWs = node.max_ws || 10; - var healthPct = Math.round((node.ws_total / maxWs) * 100); + var chevronClass = "node-group-chevron" + (isExpanded ? " expanded" : ""); + var totalMaxWs = 0; + group.nodes.forEach(function (n) { + totalMaxWs += n.max_ws || 10; + }); + var healthPct = + totalMaxWs > 0 + ? Math.min(Math.round((group.ws_total / totalMaxWs) * 100), 100) + : 0; var healthFillClass = healthPct < 50 ? "low" : healthPct < 80 ? "mid" : "high"; var healthFillHtml = @@ -281,57 +507,76 @@ function renderNodeTable(nodes, total) { '%">' : ""; - row.innerHTML = - '' + - escapeHtml(node.node_id) + + header.innerHTML = + '' + + '' + + escapeHtml(group.prefix) + + '' + + group.nodes.length + + " nodes" + "" + - '' + - node.ws_total + + group.ws_total + "" + - '' + - node.ws_running + + group.ws_running + "" + - '' + - node.ws_attention + + group.ws_attention + "" + - '' + - formatTokens(displayTokens) + + '' + + formatTokens(group.total_tokens) + "" + - '' + - '' + + '' + healthFillHtml + - "" + - " " + + " " + healthPct + - "%" + - ""; + "%"; - row.onclick = function () { - drillDownToNode(node.node_id, node.server_url); + var prefix = group.prefix; + header.onclick = function () { + toggleGroup(prefix); }; - row.onkeydown = function (e) { + header.onkeydown = function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - drillDownToNode(node.node_id, node.server_url); + toggleGroup(prefix); } }; - table.appendChild(row); - }); + groupEl.appendChild(header); - // Pagination hint - var pag = document.getElementById("node-pagination"); - pag.innerHTML = ""; - if (total > nodes.length) { - pag.textContent = "Showing " + nodes.length + " of " + total + " nodes"; - } + // Group body + var body = document.createElement("div"); + body.className = "node-group-body" + (isExpanded ? "" : " collapsed"); + body.dataset.prefix = group.prefix; + + var colHeaders = document.createElement("div"); + colHeaders.className = "node-colheaders"; + colHeaders.setAttribute("aria-hidden", "true"); + colHeaders.innerHTML = + 'NODE' + + 'WS' + + 'RUN' + + 'ATTN' + + 'TOKENS' + + 'LOAD'; + body.appendChild(colHeaders); + + group.nodes.forEach(function (node) { + body.appendChild(buildNodeRow(node)); + }); + + groupEl.appendChild(body); + table.appendChild(groupEl); + }); } // --- Drill-down: Node --- @@ -349,30 +594,38 @@ function drillDownToNode(nodeId, serverUrl) { link.style.display = ""; } document.getElementById("main").scrollTop = 0; + document.getElementById("node-ws-table").innerHTML = + '
Loading workstreams...
'; loadNodeDetail(nodeId); document.getElementById("breadcrumb-home").focus(); history.pushState({ view: "node", nodeId: nodeId, serverUrl: serverUrl }, ""); } function loadNodeDetail(nodeId) { - authFetch("/api/cluster/node/" + encodeURIComponent(nodeId)) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - if (data.error) { - document.getElementById("node-ws-table").innerHTML = - '
' + escapeHtml(data.error) + "
"; - return; - } - var ws = data.workstreams || []; - var active = ws.filter(function (w) { - return w.state !== "idle"; - }).length; - document.getElementById("node-ws-summary").textContent = - active + " active \u00b7 " + ws.length + " total"; - renderWsTable(document.getElementById("node-ws-table"), ws); - }); + var detailP = authFetch( + "/api/cluster/node/" + encodeURIComponent(nodeId), + ).then(function (r) { + return r.json(); + }); + var overviewP = authFetch("/api/cluster/overview").then(function (r) { + return r.json(); + }); + Promise.all([detailP, overviewP]).then(function (res) { + var data = res[0]; + renderStatusBar(res[1]); + if (data.error) { + document.getElementById("node-ws-table").innerHTML = + '
' + escapeHtml(data.error) + "
"; + return; + } + var ws = data.workstreams || []; + var active = ws.filter(function (w) { + return w.state !== "idle"; + }).length; + document.getElementById("node-ws-summary").textContent = + active + " active \u00b7 " + ws.length + " total"; + renderWsTable(document.getElementById("node-ws-table"), ws); + }); } // --- Drill-down: Filtered --- @@ -417,11 +670,16 @@ function loadFilteredWorkstreams() { params += "&state=" + encodeURIComponent(currentFilter.state); if (currentFilter.node) params += "&node=" + encodeURIComponent(currentFilter.node); - authFetch("/api/cluster/workstreams?" + params) - .then(function (r) { - return r.json(); - }) - .then(function (data) { + var wsP = authFetch("/api/cluster/workstreams?" + params).then(function (r) { + return r.json(); + }); + var overviewP = authFetch("/api/cluster/overview").then(function (r) { + return r.json(); + }); + Promise.all([wsP, overviewP]) + .then(function (res) { + var data = res[0]; + renderStatusBar(res[1]); document.getElementById("main").scrollTop = 0; document.getElementById("filtered-summary").textContent = "Page " + diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 24d906af..b0ae7be1 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -4,41 +4,33 @@ turnstone console + + +
-
-
NODES
- -
+
Loading cluster data...
-
@@ -78,6 +70,12 @@
+
+
Loading...
+ +
+
+ diff --git a/turnstone/console/static/style.css b/turnstone/console/static/style.css index 25c4408b..724cb501 100644 --- a/turnstone/console/static/style.css +++ b/turnstone/console/static/style.css @@ -1,205 +1,874 @@ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -:root { - --bg: #1a1b26; --bg-surface: #24283b; --bg-highlight: #292e42; - --fg: #c8d1f5; --fg-dim: #828db5; --fg-bright: #a9b1d6; - --accent: #7aa2f7; --green: #9ece6a; --red: #f7768e; - --yellow: #e0af68; --cyan: #7dcfff; --magenta: #bb9af7; - --border: #3b4261; --code-bg: #1f2335; - --radius: 8px; - --dash-grid: 72px 120px 100px 1fr 60px 48px; -} -[data-theme="light"] { - --bg: #f5f5f5; --bg-surface: #ffffff; --bg-highlight: #e8e8ec; - --fg: #1a1a2e; --fg-dim: #4b5563; --fg-bright: #374151; - --accent: #1d4ed8; --green: #15803d; --red: #b91c1c; - --yellow: #92400e; --cyan: #0e7490; --magenta: #7e22ce; - --border: #d1d5db; --code-bg: #eaeaef; -} -html, body { height: 100%; background: var(--bg); color: var(--fg); font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 14px; } -body { display: flex; flex-direction: column; } +/* ========================================================================== + turnstone console — "Instrument Panel" aesthetic + Deep charcoal surfaces, warm amber indicators, precision typography + ========================================================================== */ -/* Header */ -#header { padding: 8px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; flex-shrink: 0; } -#header h1 { font-size: 16px; color: var(--accent); font-weight: 600; } -#cluster-summary { font-size: 12px; color: var(--fg-dim); } -#status-bar { font-size: 12px; color: var(--fg-dim); margin-left: auto; } +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + /* Surface palette — deep charcoal with blue undertone */ + --bg: #0b0f19; + --bg-surface: #111827; + --bg-highlight: #1c2333; + --bg-elevated: #1f2a3d; + + /* Text hierarchy */ + --fg: #d1d5e4; + --fg-dim: #8a93ad; + --fg-bright: #e8ecf4; + + /* Accent — warm amber (the signature color) */ + --accent: #e5a042; + --accent-dim: rgba(229, 160, 66, 0.15); + --accent-glow: rgba(229, 160, 66, 0.08); + + /* Semantic indicators */ + --green: #34d399; + --red: #f87171; + --yellow: #fbbf24; + --cyan: #67e8f9; + --magenta: #c084fc; + + /* Glow variants for LED effects */ + --green-glow: rgba(52, 211, 153, 0.25); + --red-glow: rgba(248, 113, 113, 0.25); + --yellow-glow: rgba(251, 191, 36, 0.25); + --accent-glow-strong: rgba(229, 160, 66, 0.3); + --cyan-glow: rgba(103, 232, 249, 0.2); + + /* Structure */ + --border: rgba(255, 255, 255, 0.06); + --border-strong: rgba(255, 255, 255, 0.1); + --code-bg: #0d1117; + --radius: 6px; + --radius-sm: 3px; + --dash-grid: 72px 120px 100px 1fr 60px 48px; + + /* Typography */ + --font-mono: 'IBM Plex Mono', 'SF Mono', 'Cascadia Code', monospace; + --font-display: 'Outfit', 'Segoe UI', system-ui, sans-serif; +} + +[data-theme="light"] { + --bg: #f3f4f6; + --bg-surface: #ffffff; + --bg-highlight: #e9ecf0; + --bg-elevated: #f9fafb; + --fg: #1e293b; + --fg-dim: #576275; + --fg-bright: #0f172a; + --accent: #8c5e1b; + --accent-dim: rgba(140, 94, 27, 0.1); + --accent-glow: rgba(140, 94, 27, 0.05); + --green: #047857; + --red: #dc2626; + --yellow: #b45309; + --cyan: #0e7490; + --magenta: #7c3aed; + --green-glow: rgba(4, 120, 87, 0.25); + --red-glow: rgba(220, 38, 38, 0.25); + --yellow-glow: rgba(180, 83, 9, 0.25); + --accent-glow-strong: rgba(140, 94, 27, 0.15); + --cyan-glow: rgba(14, 116, 144, 0.2); + --border: rgba(0, 0, 0, 0.08); + --border-strong: rgba(0, 0, 0, 0.12); + --code-bg: #f0f1f5; +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + display: flex; + flex-direction: column; + /* Subtle noise texture for depth */ + background-image: + radial-gradient(ellipse at 20% 0%, rgba(229, 160, 66, 0.03) 0%, transparent 50%), + radial-gradient(ellipse at 80% 100%, rgba(103, 232, 249, 0.02) 0%, transparent 50%); +} + +/* ========================================================================== + Header — thin instrument bar + ========================================================================== */ +#header { + padding: 10px 20px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-strong); + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; + position: relative; +} +#header::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-dim), transparent); +} +#header h1 { + font-family: var(--font-display); + font-size: 15px; + font-weight: 700; + color: var(--accent); + letter-spacing: 0.02em; +} +.header-dim { + color: var(--fg-dim); + font-weight: 400; +} +#cluster-summary { + font-size: 11px; + color: var(--fg-dim); + letter-spacing: 0.03em; +} +#status-bar { + font-size: 11px; + color: var(--fg-dim); + margin-left: auto; +} #status-bar.disconnected { color: var(--red); } -/* Main content */ -#main { flex: 1; overflow-y: auto; padding: 16px; max-width: 1100px; margin: 0 auto; width: 100%; } +.header-btn { + background: none; + border: 1px solid var(--border-strong); + color: var(--fg-dim); + border-radius: var(--radius-sm); + padding: 3px 10px; + cursor: pointer; + font: inherit; + font-size: 11px; + transition: background 0.15s, border-color 0.15s, color 0.15s; + letter-spacing: 0.02em; +} +.header-btn:hover { + background: var(--bg-highlight); + color: var(--fg-bright); + border-color: var(--accent-dim); +} +#theme-toggle { color: var(--fg); } -/* Breadcrumb */ -.breadcrumb { padding: 8px 16px; font-size: 12px; color: var(--fg-dim); background: var(--bg-surface); border-bottom: 1px solid var(--border); } -.breadcrumb a { color: var(--accent); text-decoration: none; } +/* ========================================================================== + Main content + ========================================================================== */ +#main { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + padding-bottom: 60px; + max-width: 1140px; + margin: 0 auto; + width: 100%; +} + +/* ========================================================================== + Breadcrumb + ========================================================================== */ +.breadcrumb { + padding: 8px 20px; + font-size: 11px; + color: var(--fg-dim); + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + font-family: var(--font-display); + letter-spacing: 0.02em; +} +.breadcrumb a { + color: var(--accent); + text-decoration: none; + font-weight: 500; +} .breadcrumb a:hover { text-decoration: underline; } -.breadcrumb-sep { margin: 0 6px; color: var(--fg-dim); } +.breadcrumb-sep { margin: 0 8px; color: var(--fg-dim); opacity: 0.5; } -/* State cards */ -.state-cards { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } -.state-card { flex: 1; min-width: 100px; background: var(--bg-surface); border: 1px solid var(--border); border-top: 3px solid var(--fg-dim); border-radius: var(--radius); padding: 12px 14px; cursor: pointer; transition: border-color 0.15s, background 0.15s; text-align: center; } -.state-card:hover { background: var(--bg-highlight); border-color: var(--accent); } -.state-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -.state-card[data-state="running"] { border-top-color: var(--green); } -.state-card[data-state="thinking"] { border-top-color: var(--accent); } -.state-card[data-state="attention"] { border-top-color: var(--yellow); } -.state-card[data-state="idle"] { border-top-color: var(--fg-dim); } -.state-card[data-state="error"] { border-top-color: var(--red); } -.state-card-count { font-size: 24px; font-weight: bold; color: var(--fg-bright); margin-bottom: 2px; } -.state-card-label { font-size: 11px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.05em; } +/* ========================================================================== + Cluster Status Bar — the instrument panel + Backlit, slightly raised, precision readouts + ========================================================================== */ +#cluster-status-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 44px; + background: var(--bg-surface); + border-top: 1px solid var(--border-strong); + display: flex; + align-items: center; + padding: 0 20px; + gap: 12px; + z-index: 100; + font-size: 12px; + /* Subtle top glow — like backlit instruments */ + box-shadow: + 0 -1px 0 var(--border), + 0 -8px 24px -4px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.03); +} +#cluster-status-bar::before { + content: ''; + position: absolute; + top: -1px; + left: 10%; + right: 10%; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-glow-strong), transparent); + pointer-events: none; +} -/* Aggregate bar */ -.aggregate-bar { font-size: 11px; color: var(--fg-dim); margin-bottom: 20px; } +.csb-states { display: flex; gap: 2px; align-items: center; } -/* Section header */ -.section-header { font-size: 12px; font-weight: bold; color: var(--accent); letter-spacing: 0.05em; margin-bottom: 8px; } +.csb-state { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + white-space: nowrap; + color: var(--fg-dim); + font-size: 12px; + background: none; + border: 1px solid transparent; + font: inherit; + font-family: var(--font-mono); +} +.csb-state:hover { + background: var(--bg-highlight); + border-color: var(--border-strong); + color: var(--fg-bright); +} +.csb-state:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.csb-state.active { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} +.csb-state.active .csb-state-count { color: var(--accent); } +.csb-state.active .csb-state-label { color: var(--accent); opacity: 0.8; } -/* Node table */ -.node-colheaders { display: grid; grid-template-columns: 1fr 50px 50px 50px 70px 140px; padding: 4px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); font-size: 11px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.03em; position: sticky; top: 0; z-index: 10; } +/* State indicator dots — LED effect with glow */ +.csb-state-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.csb-state-dot[data-state="running"] { background: var(--green); border-radius: 2px; box-shadow: 0 0 6px var(--green-glow); } +.csb-state-dot[data-state="thinking"] { background: var(--cyan); box-shadow: 0 0 6px var(--cyan-glow); } +.csb-state-dot[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); } +.csb-state-dot[data-state="idle"] { background: var(--fg-dim); opacity: 0.5; } +.csb-state-dot[data-state="error"] { background: var(--red); border-radius: 0; box-shadow: 0 0 6px var(--red-glow); } + +.csb-state-count { font-weight: 600; color: var(--fg-bright); font-variant-numeric: tabular-nums; } +.csb-state-count.zero { color: var(--fg-dim); font-weight: 400; opacity: 0.6; } + +.csb-state-label { + color: var(--fg-dim); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + font-family: var(--font-display); + font-weight: 500; +} + +.csb-divider { width: 1px; height: 22px; background: var(--border-strong); flex-shrink: 0; } + +.csb-metrics { + display: flex; + gap: 16px; + align-items: center; + color: var(--fg-dim); + font-size: 11px; + margin-left: auto; + white-space: nowrap; +} +.csb-metric { display: flex; align-items: center; gap: 5px; } +.csb-metric-value { color: var(--fg-bright); font-weight: 500; font-variant-numeric: tabular-nums; } +.csb-metric-label { + color: var(--fg-dim); + font-size: 10px; + font-family: var(--font-display); + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.7; +} + +.csb-loading { color: var(--fg-dim); font-size: 11px; font-style: italic; opacity: 0.8; } + +#cluster-status-bar.stale { border-top-color: var(--yellow); } +#cluster-status-bar.stale::after { + content: 'STALE'; + position: absolute; + top: -10px; + left: 50%; + transform: translateX(-50%); + font-size: 9px; + font-family: var(--font-display); + font-weight: 600; + color: var(--yellow); + letter-spacing: 0.1em; + background: var(--bg-surface); + padding: 0 8px; + line-height: 1; +} + +/* ========================================================================== + Section headers + ========================================================================== */ +.section-header { + font-family: var(--font-display); + font-size: 11px; + font-weight: 600; + color: var(--accent); + letter-spacing: 0.1em; + margin-bottom: 12px; + text-transform: uppercase; +} + +/* ========================================================================== + Node table — column headers + ========================================================================== */ +.node-colheaders { + display: grid; + grid-template-columns: 1fr 50px 50px 50px 70px 140px; + padding: 6px 16px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-strong); + font-size: 10px; + font-family: var(--font-display); + font-weight: 600; + color: var(--fg-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + position: sticky; + top: 0; + z-index: 10; +} .ncol-ws, .ncol-run, .ncol-attn, .ncol-tokens { text-align: right; } .ncol-health, .node-cell-health { padding-left: 10px; } -.node-row { display: grid; grid-template-columns: 1fr 50px 50px 50px 70px 140px; padding: 8px 16px; cursor: pointer; transition: background 0.15s; border-left: 3px solid transparent; } +/* ========================================================================== + Node rows — individual instrument readouts + ========================================================================== */ +.node-row { + display: grid; + grid-template-columns: 1fr 50px 50px 50px 70px 140px; + padding: 9px 16px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; + border-left: 3px solid transparent; + position: relative; +} .node-row:nth-child(odd) { background: var(--bg); } -.node-row:nth-child(even) { background: var(--bg-surface); } -.node-row:hover { background: var(--bg-highlight); box-shadow: inset 0 0 0 1px var(--border); } +.node-row:nth-child(even) { background: rgba(255, 255, 255, 0.01); } +.node-row:hover { + background: var(--bg-highlight); + box-shadow: inset 0 0 0 1px var(--border); +} .node-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } -.node-row.has-attention { border-left-color: var(--yellow); } -.node-row.has-running { border-left-color: var(--green); } -.node-row.has-thinking { border-left-color: var(--accent); } -.node-row.has-error { border-left-color: var(--red); } +.node-row.has-attention { border-left-color: var(--yellow); background: linear-gradient(90deg, var(--yellow-glow), transparent 30%); } +.node-row.has-running { border-left-color: var(--green); background: linear-gradient(90deg, var(--green-glow), transparent 30%); } +.node-row.has-thinking { border-left-color: var(--cyan); background: linear-gradient(90deg, var(--cyan-glow), transparent 30%); } +.node-row.has-error { border-left-color: var(--red); background: linear-gradient(90deg, var(--red-glow), transparent 30%); } +.node-row.has-attention:hover, .node-row.has-running:hover, +.node-row.has-thinking:hover, .node-row.has-error:hover { + background: var(--bg-highlight); +} .node-cell { font-size: 12px; display: flex; align-items: center; } -.node-cell-name { color: var(--fg-bright); font-weight: bold; gap: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.node-cell-name .node-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); flex-shrink: 0; } -.node-cell-name .node-dot.unreachable { background: var(--red); } -.node-cell-num { color: var(--fg-dim); font-size: 11px; justify-content: flex-end; } +.node-cell-name { + color: var(--fg-bright); + font-weight: 500; + gap: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.node-cell-name .node-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green); + flex-shrink: 0; + box-shadow: 0 0 4px var(--green-glow); +} +.node-cell-name .node-dot.unreachable { + background: var(--red); + box-shadow: 0 0 4px var(--red-glow); +} +.node-cell-num { + color: var(--fg-dim); + font-size: 11px; + justify-content: flex-end; + font-variant-numeric: tabular-nums; +} .node-cell-num.has-value { color: var(--fg-bright); } -.node-cell-health { gap: 6px; font-size: 11px; color: var(--fg-dim); } -.health-bar { width: 80px; height: 6px; background: var(--bg-highlight); border-radius: 3px; overflow: hidden; } -.health-bar-fill { display: block; height: 100%; min-width: 4px; border-radius: 3px; transition: width 0.3s; } -.health-bar-fill.low { background: var(--green); } -.health-bar-fill.mid { background: var(--yellow); } -.health-bar-fill.high { background: var(--red); } +.node-cell-health { gap: 8px; font-size: 11px; color: var(--fg-dim); font-variant-numeric: tabular-nums; } -/* Dashboard table (reused from per-node dashboard) */ -.dash-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: var(--code-bg); border-radius: var(--radius) var(--radius) 0 0; } -.dash-header-title { color: var(--accent); font-size: 12px; font-weight: bold; letter-spacing: 0.05em; } +/* Health bar — precision gauge */ +.health-bar { + width: 80px; + height: 4px; + background: var(--bg-highlight); + border-radius: 2px; + overflow: hidden; + position: relative; +} +.health-bar-fill { + display: block; + height: 100%; + min-width: 3px; + border-radius: 2px; + transition: width 0.3s ease; +} +.health-bar-fill.low { background: var(--green); box-shadow: 0 0 4px var(--green-glow); } +.health-bar-fill.mid { background: var(--yellow); box-shadow: 0 0 4px var(--yellow-glow); } +.health-bar-fill.high { background: var(--red); box-shadow: 0 0 4px var(--red-glow); } + +/* ========================================================================== + Node groups — rack panel sections + ========================================================================== */ +.node-group { margin-bottom: 1px; } + +.node-group-header { + display: grid; + grid-template-columns: 1fr 50px 50px 50px 70px 140px; + padding: 10px 16px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; + background: var(--bg-surface); + border-left: 3px solid transparent; + border-bottom: 1px solid var(--border); + position: relative; +} +.node-group-header::before { + content: ''; + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 0.15s; + pointer-events: none; +} +.node-group-header:hover { background: var(--bg-highlight); } +.node-group-header:hover::before { opacity: 1; } +.node-group-header:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.node-group-header.has-attention { border-left-color: var(--yellow); } +.node-group-header.has-running { border-left-color: var(--green); } +.node-group-header.has-thinking { border-left-color: var(--cyan); } +.node-group-header.has-error { border-left-color: var(--red); } + +.node-group-name { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--fg-bright); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.node-group-chevron { + font-size: 10px; + color: var(--fg); + transition: transform 0.2s ease; + flex-shrink: 0; + width: 14px; + display: inline-block; + text-align: center; +} +.node-group-chevron.expanded { transform: rotate(90deg); } +.node-group-badge { + font-size: 10px; + font-family: var(--font-display); + color: var(--fg-dim); + background: var(--bg-highlight); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 1px 6px; + font-weight: 500; + letter-spacing: 0.02em; +} +.node-group-cell { font-size: 12px; display: flex; align-items: center; } +.node-group-cell.num { color: var(--fg-dim); font-size: 11px; justify-content: flex-end; font-variant-numeric: tabular-nums; } +.node-group-cell.num.has-value { color: var(--fg-bright); } + +.node-group-body { overflow: hidden; } +.node-group-body.collapsed { display: none; } +.node-group-body .node-colheaders { position: static; z-index: auto; opacity: 0.85; } +.node-group-body .node-row { padding-left: 32px; } +.node-group-single .node-row { padding-left: 16px; } + +/* ========================================================================== + Dashboard table — workstream detail views + ========================================================================== */ +.dash-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + background: var(--code-bg); + border-radius: var(--radius) var(--radius) 0 0; + border: 1px solid var(--border); + border-bottom: none; +} +.dash-header-title { + font-family: var(--font-display); + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; +} .dash-header-summary { color: var(--fg-dim); font-size: 11px; } -.dash-colheaders { display: grid; grid-template-columns: var(--dash-grid); padding: 4px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); font-size: 11px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.03em; position: sticky; top: 0; z-index: 10; } +.dash-colheaders { + display: grid; + grid-template-columns: var(--dash-grid); + padding: 6px 16px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-strong); + font-size: 10px; + font-family: var(--font-display); + font-weight: 600; + color: var(--fg-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + position: sticky; + top: 0; + z-index: 10; +} .dash-col-tokens, .dash-col-ctx { text-align: right; } .dash-table { min-height: 40px; } -.dash-row { position: relative; border-left: 3px solid transparent; cursor: pointer; transition: background 0.15s; } + +.dash-row { + position: relative; + border-left: 3px solid transparent; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} .dash-row:nth-child(odd) { background: var(--bg); } -.dash-row:nth-child(even) { background: var(--bg-surface); } +.dash-row:nth-child(even) { background: rgba(255, 255, 255, 0.01); } .dash-row:hover { background: var(--bg-highlight); box-shadow: inset 0 0 0 1px var(--border); } .dash-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .dash-row[data-state="running"] { border-left-color: var(--green); } -.dash-row[data-state="thinking"] { border-left-color: var(--accent); } +.dash-row[data-state="thinking"] { border-left-color: var(--cyan); } .dash-row[data-state="attention"] { border-left-color: var(--yellow); } -.dash-row[data-state="idle"] { border-left-color: var(--fg-dim); opacity: 0.7; } +.dash-row[data-state="idle"] { border-left-color: var(--fg-dim); opacity: 0.6; } .dash-row[data-state="error"] { border-left-color: var(--red); } -.dash-row-main { display: grid; grid-template-columns: var(--dash-grid); padding: 8px 16px 2px; align-items: center; font-size: 12px; } + +.dash-row-main { display: grid; grid-template-columns: var(--dash-grid); padding: 9px 16px 3px; align-items: center; font-size: 12px; } .dash-row-sub { padding: 0 16px 8px 88px; font-size: 11px; color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .dash-row-sub.sub-attention { color: var(--yellow); } +/* State dots with LED glow */ .dash-cell-state { display: flex; align-items: center; gap: 6px; font-size: 11px; } .dash-state-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } -.dash-state-dot[data-state="running"] { background: var(--green); border-radius: 2px; animation: pulse 2s infinite; } -.dash-state-dot[data-state="thinking"] { background: var(--accent); animation: pulse 2.2s infinite; } -.dash-state-dot[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); animation: pulse 1.8s infinite; } -.dash-state-dot[data-state="idle"] { background: var(--fg-dim); } -.dash-state-dot[data-state="error"] { background: var(--red); border-radius: 0; } -@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } +.dash-state-dot[data-state="running"] { background: var(--green); border-radius: 2px; box-shadow: 0 0 6px var(--green-glow); animation: pulse 2s infinite; will-change: opacity; } +.dash-state-dot[data-state="thinking"] { background: var(--cyan); box-shadow: 0 0 6px var(--cyan-glow); animation: pulse 2.2s infinite; will-change: opacity; } +.dash-state-dot[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); animation: pulse 1.8s infinite; will-change: opacity; } +.dash-state-dot[data-state="idle"] { background: var(--fg-dim); opacity: 0.4; } +.dash-state-dot[data-state="error"] { background: var(--red); border-radius: 0; box-shadow: 0 0 6px var(--red-glow); } -.dash-state-label { white-space: nowrap; } +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.dash-state-label { white-space: nowrap; font-weight: 500; } .dash-state-label[data-state="running"] { color: var(--green); } -.dash-state-label[data-state="thinking"] { color: var(--accent); } +.dash-state-label[data-state="thinking"] { color: var(--cyan); } .dash-state-label[data-state="attention"] { color: var(--yellow); } .dash-state-label[data-state="idle"] { color: var(--fg-dim); } .dash-state-label[data-state="error"] { color: var(--red); } -.dash-cell-name { font-weight: bold; color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dash-cell-name { font-weight: 500; color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dash-row[data-state="idle"] .dash-cell-name { color: var(--fg-dim); } -.dash-cell-node { color: var(--accent); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } -.dash-cell-node:hover { text-decoration: underline; } +.dash-cell-node { + color: var(--accent); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + transition: color 0.1s; +} +.dash-cell-node:hover { text-decoration: underline; color: var(--fg-bright); } .dash-cell-task { color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dash-row[data-state="idle"] .dash-cell-task { color: var(--fg-dim); } -.dash-cell-tokens { text-align: right; color: var(--fg-dim); font-size: 11px; } -.dash-cell-ctx { text-align: right; font-size: 11px; } +.dash-cell-tokens { text-align: right; color: var(--fg-dim); font-size: 11px; font-variant-numeric: tabular-nums; } +.dash-cell-ctx { text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; } .dash-cell-ctx.ctx-low { color: var(--green); } .dash-cell-ctx.ctx-mid { color: var(--yellow); } .dash-cell-ctx.ctx-high { color: var(--red); } -.dash-cell-ctx.ctx-danger { color: var(--red); font-weight: bold; } +.dash-cell-ctx.ctx-danger { color: var(--red); font-weight: 600; } .dash-cell-ctx.ctx-idle { color: var(--fg-dim); } -/* Node link */ -.node-link { display: inline-block; margin-top: 12px; color: var(--accent); font-size: 12px; text-decoration: none; } -.node-link:hover { text-decoration: underline; } +/* ========================================================================== + Node link + ========================================================================== */ +.node-link { + display: inline-block; + margin-top: 16px; + color: var(--accent); + font-size: 11px; + text-decoration: none; + font-family: var(--font-display); + font-weight: 500; + letter-spacing: 0.02em; + padding: 4px 0; + border-bottom: 1px solid transparent; + transition: border-color 0.15s; +} +.node-link:hover { border-bottom-color: var(--accent); } -/* Pagination */ -.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 0; font-size: 12px; color: var(--fg-dim); } -.pagination button { background: var(--bg-surface); border: 1px solid var(--border); color: var(--fg-bright); border-radius: 4px; padding: 4px 10px; font: inherit; font-size: 12px; cursor: pointer; } -.pagination button:hover { background: var(--bg-highlight); } -.pagination button:disabled { opacity: 0.3; cursor: not-allowed; } +/* ========================================================================== + Pagination + ========================================================================== */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px 0; + font-size: 12px; + color: var(--fg-dim); +} +.pagination button { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + color: var(--fg-bright); + border-radius: var(--radius-sm); + padding: 5px 14px; + font: inherit; + font-size: 11px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + font-family: var(--font-display); + font-weight: 500; +} +.pagination button:hover { background: var(--bg-highlight); border-color: var(--accent-dim); } +.pagination button:disabled { opacity: 0.25; cursor: not-allowed; } -/* Empty state */ -.dashboard-empty { color: var(--fg-dim); font-size: 13px; padding: 16px 0; text-align: center; } +/* ========================================================================== + Empty state + ========================================================================== */ +.dashboard-empty { + color: var(--fg-dim); + font-size: 12px; + padding: 24px 0; + text-align: center; + font-family: var(--font-display); + font-style: italic; + opacity: 0.7; +} -/* Focus */ +/* ========================================================================== + Focus indicators + ========================================================================== */ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -/* Scrollbar */ -::-webkit-scrollbar { width: 8px; } -::-webkit-scrollbar-track { background: var(--bg); } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +/* ========================================================================== + Scrollbar — thin, minimal + ========================================================================== */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); } -/* Reduced motion */ +/* ========================================================================== + Reduced motion + ========================================================================== */ @media (prefers-reduced-motion: reduce) { .dash-state-dot[data-state="running"], .dash-state-dot[data-state="thinking"], .dash-state-dot[data-state="attention"] { animation: none; opacity: 1; } + .node-group-chevron { transition: none; } + .health-bar-fill { transition: none; } + .csb-state, .node-row, .node-group-header, .dash-row { transition: none; } + .header-btn, .node-link, .dash-cell-node, + #login-box input, #login-box button, + .pagination button { transition: none; } } -/* Responsive */ +/* ========================================================================== + Responsive + ========================================================================== */ @media (max-width: 700px) { :root { --dash-grid: 68px 110px 1fr 56px 44px; } .dash-col-node, .dash-cell-node { display: none; } .node-colheaders, .node-row { grid-template-columns: 1fr 40px 40px 40px 60px; } + .node-group-header { grid-template-columns: 1fr 40px 40px 40px 60px; } + .node-group-header .node-group-cell:last-child { display: none; } .ncol-health, .node-cell-health { display: none; } + #main { padding: 16px; padding-bottom: 60px; } } @media (max-width: 480px) { :root { --dash-grid: 50px 1fr 50px; } .dash-col-node, .dash-cell-node, .dash-col-task, .dash-cell-task, .dash-col-ctx, .dash-cell-ctx { display: none; } - .state-cards { flex-wrap: nowrap; overflow-x: auto; gap: 8px; } - .state-card { min-width: 70px; flex: 0 0 auto; padding: 8px 10px; } - .state-card-count { font-size: 18px; } - .state-card-label { font-size: 10px; } + #cluster-status-bar { height: auto; flex-wrap: wrap; padding: 8px 12px; gap: 6px; } + .csb-states { flex-wrap: wrap; gap: 2px; } + .csb-state { padding: 4px 6px; font-size: 11px; } + .csb-divider { display: none; } + .csb-metrics { width: 100%; justify-content: center; } + #main { padding-bottom: 80px; } + #header h1 { font-size: 13px; } } -/* Login overlay */ -#login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; } -#login-box { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; width: 320px; max-width: 90vw; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } -#login-box h2 { color: var(--accent); font-size: 16px; margin-bottom: 16px; } -#login-box input { width: 100%; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); font: inherit; font-size: 13px; margin-bottom: 12px; } -#login-box input:focus-visible { border-color: var(--accent); outline: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); } -#login-box input::placeholder { color: var(--fg-dim); } -#login-box button { width: 100%; padding: 12px; background: var(--accent); color: var(--bg); border: none; border-radius: 4px; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } -#login-box button:hover { opacity: 0.9; } +/* ========================================================================== + Login overlay — cinematic + ========================================================================== */ +#login-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +#login-box { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 36px; + width: 340px; + max-width: 90vw; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.03), + 0 24px 48px -12px rgba(0, 0, 0, 0.5), + 0 0 80px -20px var(--accent-dim); + position: relative; +} +#login-box::before { + content: ''; + position: absolute; + top: -1px; + left: 20%; + right: 20%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + border-radius: 1px; +} +#login-box h2 { + font-family: var(--font-display); + color: var(--accent); + font-size: 16px; + font-weight: 700; + margin-bottom: 20px; + letter-spacing: 0.02em; +} +#login-box input { + width: 100%; + padding: 11px 14px; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--fg); + font: inherit; + font-size: 13px; + margin-bottom: 14px; + transition: border-color 0.15s, box-shadow 0.15s; +} +#login-box input:focus-visible { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px var(--accent-dim); +} +#login-box input::placeholder { color: var(--fg-dim); opacity: 0.6; } +#login-box button { + width: 100%; + padding: 11px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius-sm); + font: inherit; + font-family: var(--font-display); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + letter-spacing: 0.02em; +} +#login-box button:hover { filter: brightness(1.1); } #login-box button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; } -#login-box button:disabled { opacity: 0.5; cursor: not-allowed; } +#login-box button:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } #login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; } -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -@media (max-width: 380px) { #login-box { padding: 24px 20px; } } -/* Keyboard shortcuts overlay */ -#kb-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 999; } -#kb-box { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px 28px; width: 360px; max-width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } -#kb-box h2 { color: var(--accent); font-size: 14px; margin-bottom: 14px; } -.kb-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; } -.kb-key { color: var(--fg-bright); background: var(--bg-highlight); border: 1px solid var(--border); border-radius: 3px; padding: 1px 6px; font-family: inherit; font-size: 11px; white-space: nowrap; } -.kb-desc { color: var(--fg-dim); } -.kb-section { color: var(--fg-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 12px; margin-bottom: 4px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +@media (max-width: 380px) { #login-box { padding: 28px 20px; } } + +/* ========================================================================== + Keyboard shortcuts overlay + ========================================================================== */ +#kb-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; +} +#kb-box { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 28px; + width: 360px; + max-width: 90vw; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5); +} +#kb-box h2 { + font-family: var(--font-display); + color: var(--accent); + font-size: 13px; + font-weight: 600; + margin-bottom: 16px; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.kb-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 12px; } +.kb-key { + color: var(--fg-bright); + background: var(--bg-highlight); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 2px 8px; + font-family: var(--font-mono); + font-size: 11px; + white-space: nowrap; +} +.kb-desc { color: var(--fg-dim); font-family: var(--font-display); } +.kb-section { + font-family: var(--font-display); + color: var(--fg-dim); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-top: 14px; + margin-bottom: 6px; +} .kb-section:first-child { margin-top: 0; } -#kb-box .kb-hint { color: var(--fg-dim); font-size: 11px; text-align: center; margin-top: 14px; } +#kb-box .kb-hint { color: var(--fg-dim); font-size: 11px; text-align: center; margin-top: 16px; font-family: var(--font-display); } diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index 211fc8c0..4814825d 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -109,9 +109,7 @@ def load_auth_config() -> AuthConfig: tokens[env_token] = "full" if enabled and not tokens: - log.warning( - "Auth enabled but no tokens configured — all API requests will be rejected" - ) + log.warning("Auth enabled but no tokens configured — all API requests will be rejected") return AuthConfig(enabled=enabled, tokens=tokens) @@ -125,10 +123,7 @@ def is_public_path(path: str) -> bool: """Return *True* if the path should be accessible without authentication.""" if path in PUBLIC_PATHS: return True - for prefix in PUBLIC_PREFIXES: - if path.startswith(prefix): - return True - return False + return any(path.startswith(prefix) for prefix in PUBLIC_PREFIXES) def required_role(method: str, path: str) -> str: diff --git a/turnstone/core/config.py b/turnstone/core/config.py index a02acaed..07f13d20 100644 --- a/turnstone/core/config.py +++ b/turnstone/core/config.py @@ -6,11 +6,13 @@ Precedence: CLI args > env vars > config file > hardcoded defaults. from __future__ import annotations -import argparse import logging -from pathlib import Path - import tomllib +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import argparse log = logging.getLogger(__name__) @@ -18,10 +20,10 @@ CONFIG_DIR = Path("~/.config/turnstone").expanduser() CONFIG_PATH = CONFIG_DIR / "config.toml" # Cache: None = not loaded yet, {} = loaded but empty/missing -_cache: dict | None = None +_cache: dict[str, Any] | None = None -def load_config(section: str | None = None) -> dict: +def load_config(section: str | None = None) -> dict[str, Any]: """Load config.toml and return the full dict or a specific section. Returns empty dict if file doesn't exist or can't be parsed. @@ -36,7 +38,8 @@ def load_config(section: str | None = None) -> dict: except Exception as exc: log.warning("Failed to parse %s: %s", CONFIG_PATH, exc) if section: - return _cache.get(section, {}) + result = _cache.get(section, {}) + return result if isinstance(result, dict) else {} return _cache @@ -57,7 +60,6 @@ _CONFIG_MAP: dict[str, dict[str, str]] = { "context_window": "context_window", }, "session": { - "persona": "persona", "instructions": "instructions", "retention_days": "session_retention_days", "compact_max_tokens": "compact_max_tokens", diff --git a/turnstone/core/memory.py b/turnstone/core/memory.py index 9a83e884..8977ca7e 100644 --- a/turnstone/core/memory.py +++ b/turnstone/core/memory.py @@ -1,8 +1,14 @@ """SQLite database for persistent memories and conversation history.""" +from __future__ import annotations + import os import sqlite3 from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable TURNSTONE_DB = os.path.join(os.getcwd(), ".turnstone.db") db_override: str | None = None @@ -52,9 +58,7 @@ def open_db() -> sqlite3.Connection: "role TEXT NOT NULL, content TEXT, " "tool_name TEXT, tool_args TEXT)" ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_conv_session ON conversations(session_id)" - ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_conv_session ON conversations(session_id)") # Migration: add tool_call_id column if missing (for session resume) try: conn.execute("SELECT tool_call_id FROM conversations LIMIT 0") @@ -68,23 +72,18 @@ def open_db() -> sqlite3.Connection: "title TEXT, created TEXT NOT NULL, updated TEXT NOT NULL)" ) conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(alias)") - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated)" - ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated)") try: # Check if FTS table already exists fts_exists = conn.execute( - "SELECT 1 FROM sqlite_master " - "WHERE type='table' AND name='conversations_fts'" + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='conversations_fts'" ).fetchone() if not fts_exists: conn.execute( "CREATE VIRTUAL TABLE conversations_fts " "USING fts5(content, content=conversations, content_rowid=id)" ) - conn.execute( - "INSERT INTO conversations_fts(conversations_fts) VALUES('rebuild')" - ) + conn.execute("INSERT INTO conversations_fts(conversations_fts) VALUES('rebuild')") conn.commit() _fts5_available = True except Exception: @@ -103,9 +102,7 @@ def load_memories() -> list[tuple[str, str]]: try: conn = open_db() try: - return conn.execute( - "SELECT key, value FROM memories ORDER BY key" - ).fetchall() + return conn.execute("SELECT key, value FROM memories ORDER BY key").fetchall() finally: conn.close() except Exception: @@ -172,7 +169,7 @@ def fts5_query(query: str) -> str: return " ".join(safe) -def search_history(query: str, limit: int = 20) -> list[tuple]: +def search_history(query: str, limit: int = 20) -> list[tuple[Any, ...]]: """Search conversation history. Returns (timestamp, session_id, role, content, tool_name).""" if not query or not query.strip(): return [] @@ -200,7 +197,7 @@ def search_history(query: str, limit: int = 20) -> list[tuple]: return [] -def search_history_recent(limit: int = 20) -> list[tuple]: +def search_history_recent(limit: int = 20) -> list[tuple[Any, ...]]: """Return most recent conversation messages.""" try: conn = open_db() @@ -285,7 +282,8 @@ def get_session_name(session_id: str) -> str | None: (session_id,), ).fetchone() if row: - return row[0] or row[1] or None + value = row[0] or row[1] + return str(value) if value is not None else None finally: conn.close() except Exception: @@ -304,25 +302,24 @@ def resolve_session(alias_or_id: str) -> str | None: (alias_or_id,), ).fetchone() if row: - return row[0] + return str(row[0]) # 2. Exact session_id match row = conn.execute( "SELECT session_id FROM sessions WHERE session_id = ?", (alias_or_id,), ).fetchone() if row: - return row[0] + return str(row[0]) # 3. Session_id prefix match rows = conn.execute( "SELECT session_id FROM sessions WHERE session_id LIKE ?", (alias_or_id + "%",), ).fetchall() if len(rows) == 1: - return rows[0][0] + return str(rows[0][0]) # 4. Fallback: check conversations table for legacy sessions row = conn.execute( - "SELECT DISTINCT session_id FROM conversations " - "WHERE session_id = ? LIMIT 1", + "SELECT DISTINCT session_id FROM conversations WHERE session_id = ? LIMIT 1", (alias_or_id,), ).fetchone() if row: @@ -336,7 +333,7 @@ def resolve_session(alias_or_id: str) -> str | None: (row[0], row[0], row[0]), ) conn.commit() - return row[0] + return str(row[0]) return None finally: conn.close() @@ -346,7 +343,7 @@ def resolve_session(alias_or_id: str) -> str | None: def prune_sessions( retention_days: int = 90, - log_fn=None, + log_fn: Callable[[str], None] | None = None, ) -> tuple[int, int]: """Prune orphaned and stale sessions. @@ -395,15 +392,14 @@ def prune_sessions( parts.append(f"{orphans} empty session{'s' if orphans != 1 else ''}") if stale: parts.append( - f"{stale} session{'s' if stale != 1 else ''} " - f"older than {retention_days} days" + f"{stale} session{'s' if stale != 1 else ''} older than {retention_days} days" ) log_fn(f"[turnstone] Session cleanup: removed {', '.join(parts)}.") return (orphans, stale) -def list_sessions(limit: int = 20) -> list[tuple]: +def list_sessions(limit: int = 20) -> list[tuple[Any, ...]]: """List recent sessions. Returns (session_id, alias, title, created, updated, msg_count) @@ -428,7 +424,7 @@ def list_sessions(limit: int = 20) -> list[tuple]: return [] -def load_session_messages(session_id: str) -> list[dict]: +def load_session_messages(session_id: str) -> list[dict[str, Any]]: """Load messages for a session and reconstruct OpenAI message format. Handles tool_call / tool_result rows by grouping consecutive tool_call @@ -448,7 +444,7 @@ def load_session_messages(session_id: str) -> list[dict]: except Exception: return [] - messages: list[dict] = [] + messages: list[dict[str, Any]] = [] i = 0 while i < len(rows): role, content, tool_name, tool_args, tc_id = rows[i] @@ -465,7 +461,7 @@ def load_session_messages(session_id: str) -> list[dict]: # Collect consecutive tool_call rows into one assistant message. # If the previous message was an assistant with content (text + # tool calls in the same turn), merge tool_calls into it. - assistant_msg: dict = { + assistant_msg: dict[str, Any] = { "role": "assistant", "content": None, "tool_calls": [], diff --git a/turnstone/core/metrics.py b/turnstone/core/metrics.py index d8af6ab6..1deabc60 100644 --- a/turnstone/core/metrics.py +++ b/turnstone/core/metrics.py @@ -1,8 +1,11 @@ """Thread-safe Prometheus-compatible metrics collector for the turnstone web server.""" +from __future__ import annotations + import threading import time from collections import defaultdict +from typing import Any class MetricsCollector: @@ -10,22 +13,22 @@ class MetricsCollector: BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] - def __init__(self): + def __init__(self) -> None: self._lock = threading.Lock() self.start_time = time.monotonic() self.model: str = "" # counters - self._req_total: dict = defaultdict(int) # (method, endpoint, status) -> int - self._tokens: dict = defaultdict(int) # ("prompt"|"completion") -> int + self._req_total: dict[tuple[str, str, str], int] = defaultdict(int) + self._tokens: dict[str, int] = defaultdict(int) # "prompt"|"completion" -> int self._messages: int = 0 - self._tool_calls: dict = defaultdict(int) # tool_name -> int + self._tool_calls: dict[str, int] = defaultdict(int) # tool_name -> int self._errors: int = 0 # histograms: (method, endpoint) -> {buckets: [count…], sum: float, count: int} - self._req_duration: dict = {} + self._req_duration: dict[tuple[str, str], dict[str, Any]] = {} # gauge self._context_ratio: float = 0.0 - def record_request(self, method: str, endpoint: str, status: int, duration: float): + def record_request(self, method: str, endpoint: str, status: int, duration: float) -> None: with self._lock: self._req_total[(method, endpoint, str(status))] += 1 key = (method, endpoint) @@ -42,43 +45,53 @@ class MetricsCollector: h["sum"] += duration h["count"] += 1 - def record_tokens(self, prompt: int, completion: int): + def record_tokens(self, prompt: int, completion: int) -> None: with self._lock: self._tokens["prompt"] += prompt self._tokens["completion"] += completion - def record_tool_call(self, tool_name: str): + def record_tool_call(self, tool_name: str) -> None: with self._lock: self._tool_calls[tool_name] += 1 - def record_error(self): + def record_error(self) -> None: with self._lock: self._errors += 1 - def record_message_sent(self): + def record_message_sent(self) -> None: with self._lock: self._messages += 1 - def record_context_ratio(self, ratio: float): + def record_context_ratio(self, ratio: float) -> None: with self._lock: self._context_ratio = ratio def generate_text( self, - workstream_states: dict, + workstream_states: dict[str, int], total_workstreams: int, - workstream_metrics: list[dict] | None = None, + workstream_metrics: list[dict[str, Any]] | None = None, ) -> str: """Return Prometheus text exposition format (v0.0.4).""" lines: list[str] = [] - def gauge(name, help_text, value, labels=None): + def gauge( + name: str, + help_text: str, + value: float | int, + labels: dict[str, str] | None = None, + ) -> None: lstr = _fmt_labels(labels) lines.append(f"# HELP {name} {help_text}") lines.append(f"# TYPE {name} gauge") lines.append(f"{name}{lstr} {_fmt_value(value)}") - def counter(name, help_text, value, labels=None): + def counter( + name: str, + help_text: str, + value: float | int, + labels: dict[str, str] | None = None, + ) -> None: lstr = _fmt_labels(labels) lines.append(f"# HELP {name} {help_text}") lines.append(f"# TYPE {name} counter") @@ -132,8 +145,7 @@ class MetricsCollector: lines.append("# TYPE turnstone_http_request_duration_seconds histogram") for (method, endpoint), h in sorted(req_duration.items()): prefix = ( - f'turnstone_http_request_duration_seconds{{method="{method}",' - f'endpoint="{endpoint}"' + f'turnstone_http_request_duration_seconds{{method="{method}",endpoint="{endpoint}"' ) for i, b in enumerate(self.BUCKETS): lines.append(f'{prefix},le="{b}"}} {h["buckets"][i]}') @@ -154,9 +166,7 @@ class MetricsCollector: lines.append("# HELP turnstone_tokens_total Total tokens consumed") lines.append("# TYPE turnstone_tokens_total counter") for tok_type in ("prompt", "completion"): - lines.append( - f'turnstone_tokens_total{{type="{tok_type}"}} {tokens.get(tok_type, 0)}' - ) + lines.append(f'turnstone_tokens_total{{type="{tok_type}"}} {tokens.get(tok_type, 0)}') # turnstone_tool_calls_total lines.append("# HELP turnstone_tool_calls_total Total tool executions by name") @@ -212,8 +222,7 @@ class MetricsCollector: for wm in workstream_metrics: lstr = _fmt_labels({"ws_id": wm["ws_id"], "name": wm["name"]}) lines.append( - f"turnstone_workstream_completion_tokens_total{lstr}" - f" {wm['completion_tokens']}" + f"turnstone_workstream_completion_tokens_total{lstr} {wm['completion_tokens']}" ) lines.append( @@ -232,9 +241,7 @@ class MetricsCollector: lines.append("# TYPE turnstone_workstream_tool_calls_total counter") for wm in workstream_metrics: for tool, cnt in sorted(wm["tool_calls"].items()): - lstr = _fmt_labels( - {"ws_id": wm["ws_id"], "name": wm["name"], "tool": tool} - ) + lstr = _fmt_labels({"ws_id": wm["ws_id"], "name": wm["name"], "tool": tool}) lines.append(f"turnstone_workstream_tool_calls_total{lstr} {cnt}") lines.append( @@ -252,7 +259,7 @@ class MetricsCollector: return "\n".join(lines) -def _fmt_labels(labels: dict | None) -> str: +def _fmt_labels(labels: dict[str, str] | None) -> str: if not labels: return "" parts = [f'{k}="{v}"' for k, v in labels.items()] diff --git a/turnstone/core/sandbox.py b/turnstone/core/sandbox.py index 6e639be9..99bffea1 100644 --- a/turnstone/core/sandbox.py +++ b/turnstone/core/sandbox.py @@ -1,9 +1,12 @@ """Sandboxed Python executor for the math tool.""" +from __future__ import annotations + import ast import multiprocessing import re import traceback +from typing import Any _MATH_BLOCKED_BUILTINS = { "open", @@ -56,29 +59,32 @@ _MATH_BLOCKED_MODULES = { class _ASTValidator(ast.NodeVisitor): """Validates AST for dangerous constructs.""" - def __init__(self): + def __init__(self) -> None: self.errors: list[str] = [] - def visit_Import(self, node): + def visit_Import(self, node: ast.Import) -> None: for alias in node.names: if alias.name.split(".")[0] in _MATH_BLOCKED_MODULES: self.errors.append(f"Import of '{alias.name}' is not allowed") self.generic_visit(node) - def visit_ImportFrom(self, node): + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: if node.module and node.module.split(".")[0] in _MATH_BLOCKED_MODULES: self.errors.append(f"Import from '{node.module}' is not allowed") self.generic_visit(node) - def visit_Call(self, node): + def visit_Call(self, node: ast.Call) -> None: if isinstance(node.func, ast.Name) and node.func.id in _MATH_BLOCKED_BUILTINS: self.errors.append(f"Call to '{node.func.id}' is not allowed") self.generic_visit(node) - def visit_Attribute(self, node): - if node.attr.startswith("__") and node.attr.endswith("__"): - if node.attr not in {"__name__", "__doc__", "__class__"}: - self.errors.append(f"Access to '{node.attr}' is not allowed") + def visit_Attribute(self, node: ast.Attribute) -> None: + if ( + node.attr.startswith("__") + and node.attr.endswith("__") + and node.attr not in {"__name__", "__doc__", "__class__"} + ): + self.errors.append(f"Access to '{node.attr}' is not allowed") self.generic_visit(node) @@ -101,7 +107,7 @@ def validate_math_code(code: str) -> list[str]: return v.errors -def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue): +def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[str, str]]) -> None: """Execute code in a subprocess, put (status, output) in queue.""" import signal as _signal import sys as _sys @@ -115,7 +121,7 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue): captured = StringIO() _sys.stdout = captured - def _safe_import(name, *args, **kwargs): + def _safe_import(name: str, *args: Any, **kwargs: Any) -> Any: if name.split(".")[0] in _MATH_BLOCKED_MODULES: raise ImportError(f"Import of '{name}' is blocked") return original_import(name, *args, **kwargs) @@ -137,10 +143,18 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue): safe_builtins["__import__"] = _safe_import # Pre-import safe modules - import math, fractions, itertools, functools, operator - import collections, decimal, random, re, string + import collections + import decimal + import fractions + import functools + import itertools + import math + import operator + import random + import re + import string - ns: dict = { + ns: dict[str, Any] = { "__builtins__": safe_builtins, "math": math, "fractions": fractions, @@ -212,7 +226,11 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue): pass try: - import scipy, scipy.special, scipy.optimize, scipy.integrate, scipy.linalg + import scipy # type: ignore[import-untyped] + import scipy.integrate # type: ignore[import-untyped] + import scipy.linalg # type: ignore[import-untyped] + import scipy.optimize # type: ignore[import-untyped] + import scipy.special # type: ignore[import-untyped] ns["scipy"] = scipy ns["special"] = scipy.special @@ -230,11 +248,7 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue): printed = captured.getvalue() result_var = ns.get("result") if result_var is not None: - out = ( - f"{printed.rstrip()}\nresult = {result_var}" - if printed - else str(result_var) - ) + out = f"{printed.rstrip()}\nresult = {result_var}" if printed else str(result_var) elif printed: out = printed.rstrip() else: @@ -243,9 +257,7 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue): except Exception as e: _sys.stdout = _sys.__stdout__ - result_queue.put( - ("error", f"{type(e).__name__}: {e}\n{traceback.format_exc()}") - ) + result_queue.put(("error", f"{type(e).__name__}: {e}\n{traceback.format_exc()}")) def auto_print_wrap(code: str) -> str: @@ -280,10 +292,8 @@ def execute_math_sandboxed(code: str, timeout: float = 30.0) -> tuple[str, bool] if errors: return "Validation errors:\n" + "\n".join(f"- {e}" for e in errors), True - result_queue: multiprocessing.Queue = multiprocessing.Queue() - proc = multiprocessing.Process( - target=_math_exec_in_process, args=(code, result_queue) - ) + result_queue: multiprocessing.Queue[tuple[str, str]] = multiprocessing.Queue() + proc = multiprocessing.Process(target=_math_exec_in_process, args=(code, result_queue)) proc.start() proc.join(timeout=timeout) diff --git a/turnstone/core/session.py b/turnstone/core/session.py index ae4b3489..ec6bac0f 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -9,57 +9,53 @@ to receive events and handle approval prompts. from __future__ import annotations import concurrent.futures -import ipaddress import json import os import re -import socket import subprocess import tempfile import textwrap import threading import time import uuid -from typing import Protocol -from urllib.parse import urlparse +from typing import TYPE_CHECKING, Any, Protocol import httpx -from openai import OpenAI - -from turnstone.core.tools import ( - TOOLS, - AGENT_TOOLS, - TASK_AGENT_TOOLS, - AGENT_AUTO_TOOLS, - TASK_AUTO_TOOLS, - PRIMARY_KEY_MAP, -) from turnstone.core.edit import find_occurrences, pick_nearest -from turnstone.core.sandbox import execute_math_sandboxed -from turnstone.core.safety import is_command_blocked, sanitize_command -from turnstone.core.web import strip_html, check_ssrf from turnstone.core.memory import ( - open_db, + delete_session, + escape_like, + get_session_name, + get_tavily_key, + list_sessions, load_memories, - save_message, + load_session_messages, normalize_key, + open_db, + register_session, + resolve_session, + save_message, search_history, search_history_recent, - get_tavily_key, - escape_like, - fts5_query, - register_session, - update_session_title, set_session_alias, - resolve_session, - get_session_name, - list_sessions, - load_session_messages, - delete_session, + update_session_title, ) -from turnstone.ui.colors import * # noqa: F401, F403 — ANSI constants and helpers +from turnstone.core.safety import is_command_blocked, sanitize_command +from turnstone.core.sandbox import execute_math_sandboxed +from turnstone.core.tools import ( + AGENT_AUTO_TOOLS, + AGENT_TOOLS, + PRIMARY_KEY_MAP, + TASK_AGENT_TOOLS, + TASK_AUTO_TOOLS, + TOOLS, +) +from turnstone.core.web import check_ssrf, strip_html +from turnstone.ui.colors import DIM, GRAY, GREEN, RED, RESET, YELLOW, bold, cyan, dim +if TYPE_CHECKING: + from openai import OpenAI # --------------------------------------------------------------------------- # SessionUI protocol — the contract every frontend must implement @@ -72,9 +68,9 @@ class SessionUI(Protocol): def on_reasoning_token(self, text: str) -> None: ... def on_content_token(self, text: str) -> None: ... def on_stream_end(self) -> None: ... - def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ... + def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]: ... def on_tool_result(self, name: str, output: str) -> None: ... - def on_status(self, usage: dict, context_window: int, effort: str) -> None: ... + def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ... def on_plan_review(self, content: str) -> str: ... def on_info(self, message: str) -> None: ... def on_error(self, message: str) -> None: ... @@ -93,7 +89,6 @@ class ChatSession: client: OpenAI, model: str, ui: SessionUI, - persona: str | None, instructions: str | None, temperature: float, max_tokens: int, @@ -108,7 +103,6 @@ class ChatSession: self.client = client self.model = model self.ui = ui - self.persona = persona self.instructions = instructions self.temperature = temperature self.max_tokens = max_tokens @@ -131,7 +125,7 @@ class ChatSession: self._title_generated = False register_session(self._session_id) self._read_files: set[str] = set() - self.messages: list[dict] = [] + self.messages: list[dict[str, Any]] = [] self._last_usage: dict[str, int] | None = None self._msg_tokens: list[int] = [] # parallel to self.messages self._system_tokens = 0 # tokens for system_messages @@ -157,7 +151,7 @@ class ChatSession: + output[-half:] ) - def _generate_title(self): + def _generate_title(self) -> None: """Generate a short title for this session via a background LLM call.""" try: # Gather first user message and first assistant reply @@ -225,38 +219,24 @@ class ChatSession: self._last_usage = None self._title_generated = True # don't re-title resumed sessions self._msg_tokens = [ - max(1, int(self._msg_char_count(m) / self._chars_per_token)) - for m in self.messages + max(1, int(self._msg_char_count(m) / self._chars_per_token)) for m in self.messages ] return True - def _init_system_messages(self): + def _init_system_messages(self) -> None: """Build the system/developer prefix messages. - System message format matches training distribution: - Persona: X (optional) - Knowledge cutoff: X (from base model pretraining) - Current date: X (from base model pretraining) - Reasoning: X (low/medium/high) - # Valid channels: ... (dynamic based on tools/reasoning) - Calls to these tools... (only when tools present) - - Developer message uses # Instructions header when combined - with tool definitions (tool defs appended by chat template). + Developer message contains tool patterns (or creative writing + instructions when creative_mode is on), plus any user-supplied + instructions and memory reminders. """ - from datetime import date - - self.system_messages = [] - today = date.today().strftime("%Y-%m-%d") # noqa: F841 - has_tools = not self.creative_mode # noqa: F841 + self.system_messages: list[dict[str, Any]] = [] # -- Chat template kwargs -- - self._chat_template_kwargs_base = { + self._chat_template_kwargs_base: dict[str, Any] = { "reasoning_effort": self.reasoning_effort, } - self._chat_template_kwargs = dict(self._chat_template_kwargs_base) - if self.persona: - self._chat_template_kwargs["model_identity"] = f"Persona: {self.persona}" + self._chat_template_kwargs: dict[str, Any] = dict(self._chat_template_kwargs_base) # -- Developer message -- if self.creative_mode: @@ -311,17 +291,15 @@ class ChatSession: f"REMINDER: You currently have {len(memories)} memories stored. " "Use recall to see them." ) - self.system_messages.append( - {"role": "developer", "content": "\n".join(dev_parts)} - ) + self.system_messages.append({"role": "developer", "content": "\n".join(dev_parts)}) # Agent prefix: system + developer only (no memories) self._agent_system_messages = list(self.system_messages) - def _full_messages(self) -> list[dict]: + def _full_messages(self) -> list[dict[str, Any]]: """System messages + conversation history.""" return self.system_messages + self.messages - def _emit_state(self, state: str): + def _emit_state(self, state: str) -> None: """Notify UI of a workstream state transition.""" self.ui.on_state_change(state) @@ -340,12 +318,12 @@ class ChatSession: _MAX_RETRIES = 3 _RETRY_BASE_DELAY = 1.0 # seconds - def _create_stream_with_retry(self, msgs): + def _create_stream_with_retry(self, msgs: list[dict[str, Any]]) -> Any: """Call chat.completions.create with retry on transient errors.""" - last_err = None + last_err: Exception | None = None for attempt in range(self._MAX_RETRIES + 1): try: - return self.client.chat.completions.create( + return self.client.chat.completions.create( # type: ignore[call-overload] model=self.model, messages=msgs, **({"tools": TOOLS} if not self.creative_mode else {}), @@ -365,9 +343,10 @@ class ChatSession: delay = self._RETRY_BASE_DELAY * (2**attempt) self.ui.on_info(f"[Retrying in {delay:.0f}s: {ename}]") time.sleep(delay) - raise last_err # unreachable, but satisfies type checker + assert last_err is not None # unreachable, but satisfies type checker + raise last_err - def send(self, user_input: str): + def send(self, user_input: str) -> None: """Send user input and handle the response loop (including tool calls).""" self.messages.append({"role": "user", "content": user_input}) self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token))) @@ -394,9 +373,7 @@ class ChatSession: self._assistant_pending_tokens or max( 1, - int( - self._msg_char_count(assistant_msg) / self._chars_per_token - ), + int(self._msg_char_count(assistant_msg) / self._chars_per_token), ) ) @@ -440,9 +417,7 @@ class ChatSession: # Auto-title session after first exchange if not self._title_generated: self._title_generated = True - threading.Thread( - target=self._generate_title, daemon=True - ).start() + threading.Thread(target=self._generate_title, daemon=True).start() self._emit_state("idle") break @@ -450,9 +425,7 @@ class ChatSession: self._emit_state("running") results, user_feedback = self._execute_tools(tool_calls) # Map tool_call_id → tool name for logging - _tc_names = { - c["id"]: c.get("function", {}).get("name", "") for c in tool_calls - } + _tc_names = {c["id"]: c.get("function", {}).get("name", "") for c in tool_calls} for tc_id, output in results: tool_msg = { "role": "tool", @@ -460,9 +433,7 @@ class ChatSession: "content": output, } self.messages.append(tool_msg) - self._msg_tokens.append( - max(1, int(len(output) / self._chars_per_token)) - ) + self._msg_tokens.append(max(1, int(len(output) / self._chars_per_token))) # Log tool result (skip memory tools to avoid noise) _tname = _tc_names.get(tc_id, "") if _tname not in ( @@ -480,9 +451,7 @@ class ChatSession: # Inject user feedback from approval prompt (e.g. "y, use full path") if user_feedback: self.messages.append({"role": "user", "content": user_feedback}) - self._msg_tokens.append( - max(1, int(len(user_feedback) / self._chars_per_token)) - ) + self._msg_tokens.append(max(1, int(len(user_feedback) / self._chars_per_token))) except KeyboardInterrupt: # Remove any partial tool results, then the originating assistant # message with unanswered tool_calls — keep _msg_tokens in sync @@ -514,10 +483,7 @@ class ChatSession: while open_t in text: start = text.find(open_t) end = text.find(close_t, start) - if end != -1: - text = text[:start] + text[end + len(close_t) :] - else: - text = text[:start] + text = text[:start] + text[end + len(close_t) :] if end != -1 else text[:start] return text.strip() # Tags that delimit reasoning blocks in content stream. @@ -526,7 +492,7 @@ class ChatSession: _THINK_CLOSE_TAGS = ("", "") _MAX_TAG_LEN = max(len(t) for t in _THINK_OPEN_TAGS + _THINK_CLOSE_TAGS) - def _stream_response(self, stream) -> dict: + def _stream_response(self, stream: Any) -> dict[str, Any]: """Stream response, dispatching tokens to the UI as they arrive. Handles two reasoning delivery mechanisms: @@ -540,13 +506,13 @@ class ChatSession: """ content_parts: list[str] = [] reasoning_parts: list[str] = [] - tool_calls_acc: dict[int, dict] = {} + tool_calls_acc: dict[int, dict[str, Any]] = {} first_token = True in_think = False # inside a ... block path1_reasoning = False # last reasoning came via reasoning_content field pending = "" # buffer for partial tag detection - def _flush_text(text: str, is_reasoning: bool): + def _flush_text(text: str, is_reasoning: bool) -> None: """Dispatch text to the appropriate UI callback.""" if not text: return @@ -558,7 +524,7 @@ class ChatSession: content_parts.append(text) self.ui.on_content_token(text) - def _drain_pending(): + def _drain_pending() -> None: """Process the pending buffer, flushing content and detecting tags.""" nonlocal pending, in_think @@ -572,6 +538,7 @@ class ChatSession: best_idx, best_tag = idx, tag if best_idx is not None: + assert best_tag is not None _flush_text(pending[:best_idx], True) pending = pending[best_idx + len(best_tag) :] in_think = False @@ -592,6 +559,7 @@ class ChatSession: best_idx, best_tag = idx, tag if best_idx is not None: + assert best_tag is not None _flush_text(pending[:best_idx], False) pending = pending[best_idx + len(best_tag) :] in_think = True @@ -604,7 +572,7 @@ class ChatSession: pending = pending[safe:] break - def _stop_spinner_once(): + def _stop_spinner_once() -> None: """Stop the spinner on first real content. Call is idempotent.""" nonlocal first_token if first_token: @@ -641,7 +609,7 @@ class ChatSession: if delta.content: parts.append(f"content={delta.content!r}") if delta.tool_calls: - parts.append(f"tool_calls=...") + parts.append("tool_calls=...") for k, v in extras.items(): if v is not None: parts.append(f"{k}={v!r}") @@ -649,9 +617,7 @@ class ChatSession: self.ui.on_info(f"{GRAY}[delta: {', '.join(parts)}]{RESET}") # Path 1: reasoning field (vLLM sends as "reasoning" or "reasoning_content") - rc = getattr(delta, "reasoning", None) or getattr( - delta, "reasoning_content", None - ) + rc = getattr(delta, "reasoning", None) or getattr(delta, "reasoning_content", None) if rc: _stop_spinner_once() reasoning_parts.append(rc) @@ -706,9 +672,7 @@ class ChatSession: ) # Drop partial tool calls — they'll have malformed JSON if tool_calls_acc: - self.ui.on_error( - "Discarding partial tool calls from truncated response." - ) + self.ui.on_error("Discarding partial tool calls from truncated response.") tool_calls_acc.clear() elif finish_reason == "content_filter": self.ui.on_error("Warning: response blocked by content filter.") @@ -717,7 +681,7 @@ class ChatSession: self.ui.on_stream_end() # Build assistant message dict - msg: dict = {"role": "assistant"} + msg: dict[str, Any] = {"role": "assistant"} content = "".join(content_parts) if content: @@ -734,7 +698,7 @@ class ChatSession: # -- Debug ---------------------------------------------------------------- - def _debug_print_request(self, msgs: list[dict]): + def _debug_print_request(self, msgs: list[dict[str, Any]]) -> None: """Print the full API request payload when debug mode is on.""" lines = [] lines.append(f"\n{GRAY}{'=' * 60}{RESET}") @@ -753,9 +717,7 @@ class ChatSession: # Truncate long content for readability if len(content) > 300: - display = ( - content[:200] + f"...({len(content)} chars)..." + content[-50:] - ) + display = content[:200] + f"...({len(content)} chars)..." + content[-50:] else: display = content # Escape newlines for compact display @@ -780,7 +742,7 @@ class ChatSession: # -- Token tracking & status ---------------------------------------------- - def _msg_char_count(self, msg: dict) -> int: + def _msg_char_count(self, msg: dict[str, Any]) -> int: """Count characters in a message, including tool call arguments.""" n = len(msg.get("content") or "") for tc in msg.get("tool_calls", []): @@ -788,7 +750,7 @@ class ChatSession: n += len(tc.get("function", {}).get("arguments", "")) return n - def _update_token_table(self, assistant_msg: dict): + def _update_token_table(self, assistant_msg: dict[str, Any]) -> None: """Update per-message token estimates using API usage data.""" if not self._last_usage: return @@ -809,14 +771,13 @@ class ChatSession: # Re-estimate all message token counts with calibrated ratio self._msg_tokens = [ - max(1, int(self._msg_char_count(m) / self._chars_per_token)) - for m in self.messages + max(1, int(self._msg_char_count(m) / self._chars_per_token)) for m in self.messages ] # Stash completion_tokens for the assistant message about to be appended self._assistant_pending_tokens = compl_tok - def _print_status_line(self): + def _print_status_line(self) -> None: """Emit status info via the UI.""" if not self._last_usage: return @@ -824,7 +785,7 @@ class ChatSession: # -- Conversation compaction ------------------------------------------------ - def _format_messages_for_summary(self, messages: list[dict]) -> str: + def _format_messages_for_summary(self, messages: list[dict[str, Any]]) -> str: """Format messages into a readable string for the summarization prompt.""" # Build tool_call_id → tool_name lookup for labeling tool results tc_names: dict[str, str] = {} @@ -860,7 +821,7 @@ class ChatSession: parts.append(f"{role}: {content}") return "\n\n".join(parts) - def _compact_messages(self, auto: bool = False): + def _compact_messages(self, auto: bool = False) -> None: """Compact conversation history by summarizing all messages. Summarizes the entire conversation via a separate model call, @@ -949,12 +910,13 @@ class ChatSession: self.ui.on_thinking_start() try: - last_err = None + _last_err: Exception | None = None + response: Any = None for attempt in range(self._MAX_RETRIES + 1): try: response = self.client.chat.completions.create( model=self.model, - messages=summary_msgs, + messages=summary_msgs, # type: ignore[arg-type] max_completion_tokens=summary_max_tokens, temperature=0.3, stream=False, @@ -968,15 +930,13 @@ class ChatSession: break except Exception as e: ename = type(e).__name__ - if ( - ename not in self._RETRYABLE_ERRORS - or attempt == self._MAX_RETRIES - ): + if ename not in self._RETRYABLE_ERRORS or attempt == self._MAX_RETRIES: raise - last_err = e + _last_err = e delay = self._RETRY_BASE_DELAY * (2**attempt) self.ui.on_info(f"[Compact retrying in {delay:.0f}s: {ename}]") time.sleep(delay) + assert response is not None choice = response.choices[0] summary = choice.message.content or "" # Strip any / tags the summarizer may emit @@ -1029,7 +989,7 @@ class ChatSession: # Phase 3 — execute: run approved tools (parallel if multiple) def _execute_tools( - self, tool_calls: list[dict] + self, tool_calls: list[dict[str, Any]] ) -> tuple[list[tuple[str, str]], str | None]: """Execute tool calls with batch preview and approval. @@ -1052,12 +1012,13 @@ class ChatSession: user_feedback = None # feedback is in the denial_msg # Phase 3: execute - def run_one(item: dict) -> tuple[str, str]: + def run_one(item: dict[str, Any]) -> tuple[str, str]: if item.get("error"): return item["call_id"], item["error"] if item.get("denied"): return item["call_id"], item.get("denial_msg", "Denied by user") - return item["execute"](item) + result: tuple[str, str] = item["execute"](item) + return result if len(items) == 1: results = [run_one(items[0])] @@ -1089,7 +1050,7 @@ class ChatSession: return results, user_feedback - def _prepare_tool(self, tc: dict) -> dict: + def _prepare_tool(self, tc: dict[str, Any]) -> dict[str, Any]: """Parse a tool call and prepare preview info for display.""" call_id = tc["id"] func_name = tc["function"]["name"] @@ -1120,11 +1081,7 @@ class ChatSession: args = {key: val} break # Fallback 2: bare string (no JSON wrapper at all) - if ( - args is None - and raw_args.strip() - and not raw_args.strip().startswith("{") - ): + if args is None and raw_args.strip() and not raw_args.strip().startswith("{"): pk = PRIMARY_KEY_MAP.get(func_name) if pk: args = {pk: raw_args} @@ -1165,11 +1122,12 @@ class ChatSession: "needs_approval": False, "error": f"Unknown tool: {func_name}", } + assert args is not None # guaranteed by the early return on args is None above return preparer(call_id, args) # -- Prepare methods (build preview, validate, no side effects) ------------ - def _prepare_bash(self, call_id: str, args: dict) -> dict: + def _prepare_bash(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: command = sanitize_command(args.get("command", "")) if not command: return { @@ -1204,7 +1162,7 @@ class ChatSession: "command": command, } - def _prepare_read_file(self, call_id: str, args: dict) -> dict: + def _prepare_read_file(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: path = args.get("path", "") if not path: return { @@ -1278,7 +1236,7 @@ class ChatSession: "limit": limit, } - def _prepare_search(self, call_id: str, args: dict) -> dict: + def _prepare_search(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: pattern = args.get("query", "") if not pattern: return { @@ -1302,7 +1260,7 @@ class ChatSession: "path": path, } - def _prepare_write_file(self, call_id: str, args: dict) -> dict: + def _prepare_write_file(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: path = args.get("path", "") content = args.get("content", "") if not path: @@ -1343,7 +1301,7 @@ class ChatSession: "content": content, } - def _prepare_edit_file(self, call_id: str, args: dict) -> dict: + def _prepare_edit_file(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: path = args.get("path", "") old_string = args.get("old_string", "") new_string = args.get("new_string", "") @@ -1386,7 +1344,7 @@ class ChatSession: # Pre-read to validate and build diff preview try: - with open(path, "r") as f: + with open(path) as f: content = f.read() occurrences = find_occurrences(content, old_string) if len(occurrences) == 0: @@ -1440,9 +1398,7 @@ class ChatSession: for line in new_preview.splitlines(): preview_parts.append(f" {GREEN}+ {line}{RESET}") else: - preview_parts.append( - f" {YELLOW}(deletion — {len(old_string)} chars removed){RESET}" - ) + preview_parts.append(f" {YELLOW}(deletion — {len(old_string)} chars removed){RESET}") return { "call_id": call_id, @@ -1459,7 +1415,7 @@ class ChatSession: "near_line": near_line, } - def _prepare_math(self, call_id: str, args: dict) -> dict: + def _prepare_math(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: code = args.get("code", "") if isinstance(code, list): code = "\n".join(code) @@ -1488,7 +1444,7 @@ class ChatSession: "code": code, } - def _prepare_man(self, call_id: str, args: dict) -> dict: + def _prepare_man(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: """Prepare a man/info page lookup.""" page = (args.get("page") or "").strip() if not page: @@ -1526,7 +1482,7 @@ class ChatSession: "section": section, } - def _prepare_web_fetch(self, call_id: str, args: dict) -> dict: + def _prepare_web_fetch(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: url = args.get("url", "").strip() question = args.get("question", "").strip() if not url: @@ -1581,7 +1537,7 @@ class ChatSession: "question": question, } - def _prepare_web_search(self, call_id: str, args: dict) -> dict: + def _prepare_web_search(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: """Prepare a web search via Tavily for approval.""" query = (args.get("query") or "").strip() if not query: @@ -1628,7 +1584,7 @@ class ChatSession: "topic": topic, } - def _prepare_task(self, call_id: str, args: dict) -> dict: + def _prepare_task(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: """Prepare a general-purpose sub-agent task for approval.""" prompt = (args.get("prompt") or "").strip() if not prompt: @@ -1652,7 +1608,7 @@ class ChatSession: "prompt": prompt, } - def _prepare_plan(self, call_id: str, args: dict) -> dict: + def _prepare_plan(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: """Prepare a planning agent for approval.""" prompt = (args.get("prompt") or "").strip() if not prompt: @@ -1676,7 +1632,7 @@ class ChatSession: "prompt": prompt, } - def _prepare_remember(self, call_id: str, args: dict) -> dict: + def _prepare_remember(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: """Prepare a remember (save memory) action.""" key = normalize_key((args.get("key") or "").strip()) value = (args.get("value") or "").strip() @@ -1700,7 +1656,7 @@ class ChatSession: "value": value, } - def _prepare_forget(self, call_id: str, args: dict) -> dict: + def _prepare_forget(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: """Prepare a forget (delete memory) action.""" key = normalize_key((args.get("key") or "").strip()) if not key: @@ -1722,7 +1678,7 @@ class ChatSession: "key": key, } - def _prepare_recall(self, call_id: str, args: dict) -> dict: + def _prepare_recall(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: """Prepare a recall action.""" query = (args.get("query") or "").strip() limit = args.get("limit", 20) @@ -1744,7 +1700,7 @@ class ChatSession: # -- Execute methods (do the work, report output via UI) ------------------- - def _exec_bash(self, item: dict) -> tuple[str, str]: + def _exec_bash(self, item: dict[str, Any]) -> tuple[str, str]: """Execute a bash command via temp script.""" call_id, command = item["call_id"], item["command"] try: @@ -1782,7 +1738,7 @@ class ChatSession: self.ui.on_error(msg) return call_id, msg - def _exec_read_file(self, item: dict) -> tuple[str, str]: + def _exec_read_file(self, item: dict[str, Any]) -> tuple[str, str]: """Read a file and return numbered lines, optionally sliced.""" call_id, path = item["call_id"], item["path"] offset = item.get("offset") # 1-based, or None @@ -1790,7 +1746,7 @@ class ChatSession: resolved = os.path.realpath(path) try: - with open(path, "r") as f: + with open(path) as f: all_lines = f.readlines() except FileNotFoundError: self._read_files.discard(resolved) @@ -1823,7 +1779,7 @@ class ChatSession: return call_id, output if output else "(empty file)" - def _exec_search(self, item: dict) -> tuple[str, str]: + def _exec_search(self, item: dict[str, Any]) -> tuple[str, str]: """Search file contents for a regex pattern using grep.""" call_id = item["call_id"] pattern, path = item["pattern"], item["path"] @@ -1849,14 +1805,10 @@ class ChatSession: if result.returncode == 1: output = "(no matches)" elif result.returncode > 1: - output = ( - result.stderr.strip() or f"grep error (exit {result.returncode})" - ) + output = result.stderr.strip() or f"grep error (exit {result.returncode})" # Count matches BEFORE truncation - match_count = ( - output.count("\n") + 1 if result.returncode == 0 and output else 0 - ) + match_count = output.count("\n") + 1 if result.returncode == 0 and output else 0 original_len = len(output) output = self._truncate_output(output) @@ -1883,12 +1835,11 @@ class ChatSession: def _run_agent( self, - agent_messages: list[dict], + agent_messages: list[dict[str, Any]], label: str = "agent", - tools: list[dict] | None = None, + tools: list[dict[str, Any]] | None = None, auto_tools: set[str] | None = None, reasoning_effort: str | None = None, - model_identity: str | None = None, ) -> str: """Run an autonomous agent loop. @@ -1898,7 +1849,6 @@ class ChatSession: tools: Tool definitions to send to the API. Defaults to AGENT_TOOLS (read-only). auto_tools: Set of tool names the agent may execute. Defaults to _AGENT_AUTO_TOOLS. reasoning_effort: Override reasoning effort for this agent. - model_identity: Optional persona/identity string passed via chat_template_kwargs. Returns: Final content string from the agent. @@ -1912,17 +1862,18 @@ class ChatSession: kwargs = dict(self._chat_template_kwargs_base) if reasoning_effort: kwargs["reasoning_effort"] = reasoning_effort - if model_identity: - kwargs["model_identity"] = model_identity - def _api_call(messages, _tools=tools): - last_err = None + def _api_call( + messages: list[dict[str, Any]], + _tools: list[dict[str, Any]] | None = tools, + ) -> Any: + last_err: Exception | None = None for attempt in range(self._MAX_RETRIES + 1): try: return self.client.chat.completions.create( model=self.model, - messages=messages, - tools=_tools, + messages=messages, # type: ignore[arg-type] + tools=_tools, # type: ignore[arg-type] max_completion_tokens=self.max_tokens, temperature=self.temperature, extra_body={ @@ -1931,16 +1882,14 @@ class ChatSession: ) except Exception as e: ename = type(e).__name__ - if ( - ename not in self._RETRYABLE_ERRORS - or attempt == self._MAX_RETRIES - ): + if ename not in self._RETRYABLE_ERRORS or attempt == self._MAX_RETRIES: raise last_err = e delay = self._RETRY_BASE_DELAY * (2**attempt) self.ui.on_info(f"[{label} retrying in {delay:.0f}s: {ename}]") time.sleep(delay) - raise last_err # unreachable + assert last_err is not None # unreachable + raise last_err turn = 0 while max_tool_turns < 0 or turn < max_tool_turns: @@ -2054,7 +2003,7 @@ class ChatSession: self.ui.on_info(f"[{label} done] {len(content)} chars") return content - def _exec_task(self, item: dict) -> tuple[str, str]: + def _exec_task(self, item: dict[str, Any]) -> tuple[str, str]: """Delegate to a general-purpose autonomous sub-agent.""" call_id, prompt = item["call_id"], item["prompt"] task_instruction = { @@ -2104,7 +2053,7 @@ class ChatSession: "and functions in every step." ) - def _exec_plan(self, item: dict) -> tuple[str, str]: + def _exec_plan(self, item: dict[str, Any]) -> tuple[str, str]: """Run a planning agent and write the result to .plan-.md.""" call_id, prompt = item["call_id"], item["prompt"] plan_path = f".plan-{self._session_id}.md" @@ -2112,7 +2061,7 @@ class ChatSession: # If plan was called before in this session, the previous assistant # tool_call + tool result are already in self.messages — pass them # directly to the inner agent so it refines rather than restarts. - prior_plan_msgs: list[dict] = [] + prior_plan_msgs: list[dict[str, Any]] = [] for i, msg in enumerate(self.messages): if msg.get("role") == "assistant" and msg.get("tool_calls"): for tc in msg["tool_calls"]: @@ -2127,6 +2076,7 @@ class ChatSession: break agent_messages = list(self._agent_system_messages) + agent_messages.append({"role": "developer", "content": self._PLAN_IDENTITY}) agent_messages.extend(prior_plan_msgs) agent_messages.append({"role": "user", "content": prompt}) @@ -2135,7 +2085,6 @@ class ChatSession: agent_messages, label="plan", reasoning_effort="high", - model_identity=self._PLAN_IDENTITY, ) except KeyboardInterrupt: return call_id, "(plan interrupted by user)" @@ -2153,7 +2102,7 @@ class ChatSession: return call_id, content - def _exec_remember(self, item: dict) -> tuple[str, str]: + def _exec_remember(self, item: dict[str, Any]) -> tuple[str, str]: """Save a persistent memory.""" call_id, key, value = item["call_id"], item["key"], item["value"] try: @@ -2183,7 +2132,7 @@ class ChatSession: except Exception as e: return call_id, f"Error: {e}" - def _exec_forget(self, item: dict) -> tuple[str, str]: + def _exec_forget(self, item: dict[str, Any]) -> tuple[str, str]: """Remove a persistent memory by key.""" call_id, key = item["call_id"], item["key"] try: @@ -2203,7 +2152,7 @@ class ChatSession: except Exception as e: return call_id, f"Error: {e}" - def _exec_recall(self, item: dict) -> tuple[str, str]: + def _exec_recall(self, item: dict[str, Any]) -> tuple[str, str]: """Search memories and conversation history.""" call_id = item["call_id"] query, limit = item["query"], item["limit"] @@ -2214,18 +2163,14 @@ class ChatSession: conn = open_db() try: if not query: - rows = conn.execute( - "SELECT key, value FROM memories ORDER BY key" - ).fetchall() + rows = conn.execute("SELECT key, value FROM memories ORDER BY key").fetchall() else: terms = query.split() clauses = [] params: list[str] = [] for t in terms: escaped = escape_like(t) - clauses.append( - "(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')" - ) + clauses.append("(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')") params.extend([f"%{escaped}%", f"%{escaped}%"]) rows = conn.execute( "SELECT key, value FROM memories WHERE " @@ -2234,9 +2179,7 @@ class ChatSession: params, ).fetchall() if rows: - parts.append( - "Memories:\n" + "\n".join(f" {k}={v}" for k, v in rows) - ) + parts.append("Memories:\n" + "\n".join(f" {k}={v}" for k, v in rows)) elif not query: parts.append("No memories stored.") finally: @@ -2255,15 +2198,13 @@ class ChatSession: if content and len(content) > 500: text += "..." lines.append(f"[{ts} {sid}] {label}: {text}") - parts.append( - f"Conversations ({len(conv_rows)} matches):\n" + "\n".join(lines) - ) + parts.append(f"Conversations ({len(conv_rows)} matches):\n" + "\n".join(lines)) output = "\n\n".join(parts) if parts else f"No results for '{query}'." self.ui.on_tool_result("recall", output) return call_id, output - def _exec_write_file(self, item: dict) -> tuple[str, str]: + def _exec_write_file(self, item: dict[str, Any]) -> tuple[str, str]: """Write content to a file, creating parent directories as needed.""" call_id = item["call_id"] path, content, resolved = item["path"], item["content"], item["resolved"] @@ -2276,7 +2217,7 @@ class ChatSession: except Exception as e: return call_id, f"Error writing {path}: {e}" - def _exec_edit_file(self, item: dict) -> tuple[str, str]: + def _exec_edit_file(self, item: dict[str, Any]) -> tuple[str, str]: """Replace an exact string in a file (re-reads to avoid TOCTOU). When near_line is set, picks the occurrence nearest that line @@ -2290,7 +2231,7 @@ class ChatSession: ) near_line = item.get("near_line") try: - with open(path, "r") as f: + with open(path) as f: content = f.read() occurrences = find_occurrences(content, old_string) if len(occurrences) == 0: @@ -2317,7 +2258,7 @@ class ChatSession: except Exception as e: return call_id, f"Error writing {path}: {e}" - def _exec_math(self, item: dict) -> tuple[str, str]: + def _exec_math(self, item: dict[str, Any]) -> tuple[str, str]: """Execute Python code in sandboxed subprocess.""" call_id, code = item["call_id"], item["code"] output, is_error = execute_math_sandboxed(code, timeout=self.tool_timeout) @@ -2329,7 +2270,7 @@ class ChatSession: return call_id, f"Error:\n{output}" return call_id, output if output else "(no output)" - def _exec_man(self, item: dict) -> tuple[str, str]: + def _exec_man(self, item: dict[str, Any]) -> tuple[str, str]: """Look up a man or info page.""" call_id = item["call_id"] page = item["page"] @@ -2341,6 +2282,7 @@ class ChatSession: cmd.append(section) cmd.append(page) + text = "" try: result = subprocess.run( cmd, @@ -2378,7 +2320,7 @@ class ChatSession: return call_id, text - def _exec_web_fetch(self, item: dict) -> tuple[str, str]: + def _exec_web_fetch(self, item: dict[str, Any]) -> tuple[str, str]: """Fetch a URL, then summarize/extract using an API call.""" call_id, url = item["call_id"], item["url"] question = item.get("question", "Summarize the key content of this page.") @@ -2457,15 +2399,13 @@ class ChatSession: ) answer = response.choices[0].message.content or "(no answer)" except Exception as e: - answer = ( - f"Extraction failed (page was fetched but summarization errored): {e}" - ) + answer = f"Extraction failed (page was fetched but summarization errored): {e}" self.ui.on_tool_result("web_fetch", answer) return call_id, answer - def _exec_web_search(self, item: dict) -> tuple[str, str]: + def _exec_web_search(self, item: dict[str, Any]) -> tuple[str, str]: """Search the web via Tavily API.""" call_id = item["call_id"] query = item["query"] @@ -2522,23 +2462,10 @@ class ChatSession: if cmd in ("/exit", "/quit", "/q"): return True - elif cmd == "/persona": - if not arg: - if self.persona: - self.ui.on_info(f"Current persona: {cyan(self.persona)}") - else: - self.ui.on_info("No persona set. Usage: /persona ") - else: - self.persona = arg.strip() - self._init_system_messages() - self.ui.on_info(f"Switched persona to {cyan(self.persona)}") - elif cmd == "/instructions": if not arg: if self.instructions: - self.ui.on_info( - f"Current instructions: {self.instructions[:100]}..." - ) + self.ui.on_info(f"Current instructions: {self.instructions[:100]}...") else: self.ui.on_info("No instructions set. Usage: /instructions ") else: @@ -2569,7 +2496,7 @@ class ChatSession: self.ui.on_info("No saved sessions.") else: lines = ["Sessions:\n"] - for sid, alias, title, created, updated, count in rows: + for sid, alias, title, _created, updated, count in rows: display_name = alias or sid display_title = f" {title}" if title else "" marker = " *" if sid == self._session_id else " " @@ -2593,8 +2520,7 @@ class ChatSession: self.ui.on_info("Already in that session.") elif self.resume_session(target_id): self.ui.on_info( - f"Resumed session {bold(target_id)} " - f"({len(self.messages)} messages loaded)" + f"Resumed session {bold(target_id)} ({len(self.messages)} messages loaded)" ) name = get_session_name(target_id) if name: @@ -2614,8 +2540,7 @@ class ChatSession: elif cmd == "/delete": if not arg: self.ui.on_info( - "Usage: /delete \n" - "Use /sessions to list sessions." + "Usage: /delete \nUse /sessions to list sessions." ) else: target_id = resolve_session(arg.strip()) @@ -2672,9 +2597,7 @@ class ChatSession: if value in valid: self.reasoning_effort = value self._init_system_messages() - self.ui.on_info( - f"Reasoning effort set to {cyan(self.reasoning_effort)}" - ) + self.ui.on_info(f"Reasoning effort set to {cyan(self.reasoning_effort)}") else: self.ui.on_info(f"Invalid. Choose from: {', '.join(valid)}") @@ -2710,7 +2633,6 @@ class ChatSession: "\n".join( [ "── Slash Commands ─────────────────────────────────────", - " /persona Set persona (system message)", " /instructions Set developer instructions", " /clear Clear context (session preserved in database)", " /new Start a new session (old session stays resumable)", @@ -2736,8 +2658,6 @@ class ChatSession: ) else: - self.ui.on_info( - f"Unknown command: {cmd}. Type /help for available commands." - ) + self.ui.on_info(f"Unknown command: {cmd}. Type /help for available commands.") return False diff --git a/turnstone/core/tools.py b/turnstone/core/tools.py index 9b9fb3ab..abff45d7 100644 --- a/turnstone/core/tools.py +++ b/turnstone/core/tools.py @@ -1,13 +1,16 @@ """Tool definitions — auto-loaded from turnstone/tools/*.json.""" +from __future__ import annotations + import json from pathlib import Path +from typing import Any _TOOLS_DIR = Path(__file__).resolve().parent.parent / "tools" _META_KEYS = {"agent", "task_agent", "auto_approve", "primary_key"} -def _load_tools() -> tuple[list[dict], dict]: +def _load_tools() -> tuple[list[dict[str, Any]], dict[str, Any]]: """Load all .json files from the tools directory. Returns (tool_defs, metadata) where: diff --git a/turnstone/core/workstream.py b/turnstone/core/workstream.py index e270f3a7..c76832db 100644 --- a/turnstone/core/workstream.py +++ b/turnstone/core/workstream.py @@ -12,9 +12,11 @@ import threading import time import uuid from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Callable + from turnstone.core.session import ChatSession, SessionUI @@ -48,7 +50,7 @@ class Workstream: last_active: float = field(default_factory=time.monotonic, repr=False) _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) - def __post_init__(self): + def __post_init__(self) -> None: if not self.name: self.name = f"ws-{self.id[:4]}" @@ -65,7 +67,7 @@ class WorkstreamManager: def __init__( self, - session_factory: Callable[[SessionUI], ChatSession], + session_factory: Callable[[SessionUI | None], ChatSession], ): """ Args: @@ -73,7 +75,7 @@ class WorkstreamManager: config (client, model, temperature, …) so the manager can create sessions without knowing those details. """ - self._session_factory = session_factory + self._session_factory: Callable[[SessionUI | None], ChatSession] = session_factory self._workstreams: dict[str, Workstream] = {} self._order: list[str] = [] # creation order self._active_id: str | None = None @@ -94,9 +96,7 @@ class WorkstreamManager: ws.session = self._session_factory(ws.ui) with self._lock: if len(self._workstreams) >= self.MAX_WORKSTREAMS: - raise RuntimeError( - f"Maximum of {self.MAX_WORKSTREAMS} workstreams reached" - ) + raise RuntimeError(f"Maximum of {self.MAX_WORKSTREAMS} workstreams reached") self._workstreams[ws.id] = ws self._order.append(ws.id) if self._active_id is None: @@ -117,10 +117,10 @@ class WorkstreamManager: # Unblock any waiting approval/plan events so worker thread can exit if ws.ui: if hasattr(ws.ui, "_approval_event"): - ws.ui._approval_result = (False, None) + ws.ui._approval_result = False, None # type: ignore[attr-defined] ws.ui._approval_event.set() if hasattr(ws.ui, "_plan_event"): - ws.ui._plan_result = "reject" + ws.ui._plan_result = "reject" # type: ignore[attr-defined] ws.ui._plan_event.set() if hasattr(ws.ui, "_fg_event"): ws.ui._fg_event.set() @@ -143,11 +143,7 @@ class WorkstreamManager: def list_all(self) -> list[Workstream]: """Return workstreams in creation order.""" with self._lock: - return [ - self._workstreams[wid] - for wid in self._order - if wid in self._workstreams - ] + return [self._workstreams[wid] for wid in self._order if wid in self._workstreams] def index_of(self, ws_id: str) -> int: """1-based index of a workstream, or 0 if not found.""" @@ -183,7 +179,7 @@ class WorkstreamManager: # -- state management --------------------------------------------------- - def set_state(self, ws_id: str, state: WorkstreamState, error_msg: str = ""): + def set_state(self, ws_id: str, state: WorkstreamState, error_msg: str = "") -> None: """Update a workstream's state. Called by UI adapters.""" ws = self._workstreams.get(ws_id) if ws: @@ -207,8 +203,7 @@ class WorkstreamManager: [ ws for ws in snapshot - if ws.state == WorkstreamState.IDLE - and (now - ws.last_active) > max_age_seconds + if ws.state == WorkstreamState.IDLE and (now - ws.last_active) > max_age_seconds ], key=lambda ws: ws.last_active, # oldest first ) diff --git a/turnstone/eval.py b/turnstone/eval.py index b4e3712e..8eff87c4 100644 --- a/turnstone/eval.py +++ b/turnstone/eval.py @@ -23,13 +23,15 @@ import sys import tempfile import textwrap import time +from collections.abc import Iterator from datetime import datetime +from typing import Any -from openai import OpenAI +from openai import OpenAI, Stream -from turnstone.core.session import ChatSession -from turnstone.core.tools import TOOLS, PRIMARY_KEY_MAP import turnstone.core.memory as _memory_module +from turnstone.core.session import ChatSession +from turnstone.core.tools import PRIMARY_KEY_MAP, TOOLS # ─── ANSI & logging helpers ─────────────────────────────────────────────────── @@ -45,44 +47,47 @@ BOLD = "\033[1m" class NullUI: """UI adapter that discards all output. Used by HeadlessSession.""" - def on_thinking_start(self): + def on_thinking_start(self) -> None: pass - def on_thinking_stop(self): + def on_thinking_stop(self) -> None: pass - def on_reasoning_token(self, text): + def on_reasoning_token(self, text: str) -> None: pass - def on_content_token(self, text): + def on_content_token(self, text: str) -> None: pass - def on_stream_end(self): + def on_stream_end(self) -> None: pass - def approve_tools(self, items): + def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]: return True, None - def on_tool_result(self, name, output): + def on_tool_result(self, name: str, output: str) -> None: pass - def on_status(self, usage, context_window, effort): + def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: pass - def on_plan_review(self, content): + def on_plan_review(self, content: str) -> str: return "" - def on_info(self, message): + def on_info(self, message: str) -> None: pass - def on_error(self, message): + def on_error(self, message: str) -> None: pass - def on_state_change(self, state): + def on_state_change(self, state: str) -> None: + pass + + def on_rename(self, name: str) -> None: pass -def _log(msg: str, dim: bool = False): +def _log(msg: str, dim: bool = False) -> None: """Print a log line with optional dim styling.""" if dim: sys.stderr.write(f"{DIM}{msg}{RESET}\n") @@ -91,7 +96,7 @@ def _log(msg: str, dim: bool = False): sys.stderr.flush() -def _fmt_args(args: dict, max_len: int = 80) -> str: +def _fmt_args(args: dict[str, Any], max_len: int = 80) -> str: """Format tool args as a compact one-line summary.""" parts = [] for k, v in args.items(): @@ -109,7 +114,7 @@ def _fmt_args(args: dict, max_len: int = 80) -> str: @contextlib.contextmanager -def _suppress_stdout(): +def _suppress_stdout() -> Iterator[None]: """Redirect stdout to devnull temporarily.""" old = sys.stdout sys.stdout = io.StringIO() @@ -132,15 +137,43 @@ class HeadlessSession(ChatSession): - send_headless() uses non-streaming API """ - def __init__(self, client, model, system_prompt_override=None, **kwargs): - kwargs.setdefault("ui", NullUI()) - super().__init__(client=client, model=model, **kwargs) - self.tool_call_log: list[dict] = [] + def __init__( + self, + client: OpenAI, + model: str, + system_prompt_override: str | None = None, + instructions: str | None = None, + temperature: float = 0.7, + max_tokens: int = 32768, + tool_timeout: int = 30, + reasoning_effort: str = "medium", + context_window: int = 131072, + compact_max_tokens: int = 32768, + auto_compact_pct: float = 0.8, + agent_max_turns: int = -1, + tool_truncation: int = 0, + ) -> None: + super().__init__( + client=client, + model=model, + ui=NullUI(), + instructions=instructions, + temperature=temperature, + max_tokens=max_tokens, + tool_timeout=tool_timeout, + reasoning_effort=reasoning_effort, + context_window=context_window, + compact_max_tokens=compact_max_tokens, + auto_compact_pct=auto_compact_pct, + agent_max_turns=agent_max_turns, + tool_truncation=tool_truncation, + ) + self.tool_call_log: list[dict[str, Any]] = [] self.auto_approve = True if system_prompt_override is not None: self._override_system_prompt(system_prompt_override) - def _override_system_prompt(self, content: str): + def _override_system_prompt(self, content: str) -> None: """Replace the developer message content with a custom prompt.""" for i, msg in enumerate(self.system_messages): if msg["role"] == "developer": @@ -154,7 +187,7 @@ class HeadlessSession(ChatSession): max_turns: int = 10, verbose: bool = False, log_prefix: str = "", - ) -> list[dict]: + ) -> list[dict[str, Any]]: """Run a complete conversation turn headlessly. Uses non-streaming API calls. Captures all tool calls into @@ -176,8 +209,8 @@ class HeadlessSession(ChatSession): response = self.client.chat.completions.create( model=self.model, - messages=msgs, - tools=TOOLS, + messages=msgs, # type: ignore[arg-type] + tools=TOOLS, # type: ignore[arg-type] max_completion_tokens=self.max_tokens, temperature=self.temperature, stream=False, @@ -189,8 +222,9 @@ class HeadlessSession(ChatSession): ) elapsed = time.monotonic() - t0 + assert not isinstance(response, Stream) choice = response.choices[0] - assistant_msg: dict = { + assistant_msg: dict[str, Any] = { "role": "assistant", "content": choice.message.content or None, } @@ -203,8 +237,8 @@ class HeadlessSession(ChatSession): "id": tc.id, "type": "function", "function": { - "name": tc.function.name, - "arguments": tc.function.arguments, + "name": tc.function.name, # type: ignore[union-attr] + "arguments": tc.function.arguments, # type: ignore[union-attr] }, } for tc in calls @@ -237,15 +271,16 @@ class HeadlessSession(ChatSession): # Log tool calls if verbose: - names = [tc.function.name for tc in choice.message.tool_calls] + names = [tc.function.name for tc in choice.message.tool_calls] # type: ignore[union-attr] _log(f"{log_prefix} turn {turn}: tools -> {names}") # Execute tools with stdout suppressed with _suppress_stdout(): results, _ = self._execute_tools(assistant_msg["tool_calls"]) - for tc, (tc_id, output) in zip(assistant_msg["tool_calls"], results): + for tc, (tc_id, output) in zip(assistant_msg["tool_calls"], results, strict=False): func_name = tc["function"]["name"] + args: dict[str, Any] try: args = json.loads(tc["function"]["arguments"]) except json.JSONDecodeError: @@ -281,9 +316,7 @@ class HeadlessSession(ChatSession): "content": output, } self.messages.append(tool_msg) - self._msg_tokens.append( - max(1, int(len(output) / self._chars_per_token)) - ) + self._msg_tokens.append(max(1, int(len(output) / self._chars_per_token))) return self.tool_call_log @@ -295,14 +328,14 @@ def _run_single_test( client: OpenAI, model: str, system_prompt: str, - case: dict, + case: dict[str, Any], temperature: float, max_tokens: int, reasoning_effort: str, context_window: int, verbose: bool = False, log_prefix: str = "", -) -> dict: +) -> dict[str, Any]: """Run a single test case once in an isolated temp directory. Must be called serially — uses os.chdir which is process-global. @@ -325,9 +358,7 @@ def _run_single_test( f.write(content) if verbose and setup_files: - _log( - f"{log_prefix} setup: created {[p for p, _ in setup_files]}", dim=True - ) + _log(f"{log_prefix} setup: created {[p for p, _ in setup_files]}", dim=True) os.chdir(workdir) @@ -335,7 +366,6 @@ def _run_single_test( client=client, model=model, system_prompt_override=system_prompt, - persona=None, instructions=None, temperature=temperature, max_tokens=max_tokens, @@ -346,7 +376,8 @@ def _run_single_test( max_turns = case.get("max_turns", 10) # Retry on transient API errors to avoid poisoning eval scores - _last_err = None + tool_log: list[dict[str, Any]] = [] + _last_err: Exception | None = None for _attempt in range(3): try: tool_log = session.send_headless( @@ -363,7 +394,7 @@ def _run_single_test( _time.sleep(2**_attempt) else: - raise _last_err + raise _last_err or RuntimeError("send_headless failed after 3 attempts") final_content = "" for msg in reversed(session.messages): @@ -388,7 +419,7 @@ def _run_single_test( # ─── Scoring ───────────────────────────────────────────────────────────────── -def _match_action(actual: dict, expected: dict) -> bool: +def _match_action(actual: dict[str, Any], expected: dict[str, Any]) -> bool: """Check if a single actual tool call matches an expected action spec.""" if actual["tool"] != expected["tool"]: return False @@ -419,10 +450,10 @@ def _match_action(actual: dict, expected: dict) -> bool: def score_run( - tool_log: list[dict], - expected_actions: list[dict], + tool_log: list[dict[str, Any]], + expected_actions: list[dict[str, Any]], match_mode: str = "ordered_subset", -) -> dict: +) -> dict[str, Any]: """Score a single run's tool log against expected actions. Returns dict with: pass, score, matched, unmatched, extra_tools, detail. @@ -441,7 +472,7 @@ def score_run( if match_mode == "exact": matched = [] - for i, (actual, expected) in enumerate(zip(tool_log, expected_actions)): + for i, (actual, expected) in enumerate(zip(tool_log, expected_actions, strict=False)): if _match_action(actual, expected): matched.append(i) score = len(matched) / n_expected @@ -537,25 +568,23 @@ def _run_iteration( client: OpenAI, model: str, system_prompt: str, - cases: list[dict], + cases: list[dict[str, Any]], n_runs: int, temperature: float, max_tokens: int, reasoning_effort: str, context_window: int, verbose: bool = False, -) -> dict: +) -> dict[str, Any]: """Run all test cases n_runs times and score them.""" - case_results = {} + case_results: dict[str, Any] = {} for ci, case in enumerate(cases): case_id = case["id"] case_n = case.get("n_runs", n_runs) - runs = [] + runs: list[dict[str, Any]] = [] - print( - f"\n {CYAN}[{ci + 1}/{len(cases)}]{RESET} {BOLD}{case_id}{RESET} ({case_n} runs)" - ) + print(f"\n {CYAN}[{ci + 1}/{len(cases)}]{RESET} {BOLD}{case_id}{RESET} ({case_n} runs)") if verbose: _log(f" prompt: {case['user_prompt']}", dim=True) @@ -582,12 +611,8 @@ def _run_iteration( match_mode=case.get("match_mode", "ordered_subset"), ) - score_result["tool_sequence"] = [ - t["tool"] for t in run_result["tool_log"] - ] - score_result["tool_args"] = [ - {t["tool"]: t["args"]} for t in run_result["tool_log"] - ] + score_result["tool_sequence"] = [t["tool"] for t in run_result["tool_log"]] + score_result["tool_args"] = [{t["tool"]: t["args"]} for t in run_result["tool_log"]] score_result["elapsed"] = run_result.get("elapsed", 0) # Detect JSON dumped into final channel (tool call not made) @@ -619,9 +644,7 @@ def _run_iteration( status_label = "PASS" if passed else "FAIL" tools = score_result.get("tool_sequence", []) elapsed = score_result.get("elapsed", 0) - json_flag = ( - f" {YELLOW}[JSON_DUMP]{RESET}" if score_result.get("json_dump") else "" - ) + json_flag = f" {YELLOW}[JSON_DUMP]{RESET}" if score_result.get("json_dump") else "" print( f" Run {run_idx + 1}: " f"{status_color}[{status_label}]{RESET} " @@ -642,9 +665,7 @@ def _run_iteration( # Aggregate total_runs = sum(len(cr["runs"]) for cr in case_results.values()) - total_passes = sum( - sum(1 for r in cr["runs"] if r["pass"]) for cr in case_results.values() - ) + total_passes = sum(sum(1 for r in cr["runs"] if r["pass"]) for cr in case_results.values()) total_json_dumps = sum( sum(1 for r in cr["runs"] if r.get("json_dump")) for cr in case_results.values() ) @@ -661,9 +682,7 @@ def _run_iteration( if case_results else 0 ), - "per_case_pass_rates": { - cid: cr["pass_rate"] for cid, cr in case_results.items() - }, + "per_case_pass_rates": {cid: cr["pass_rate"] for cid, cr in case_results.items()}, }, } @@ -723,10 +742,10 @@ def _observe_and_update_optimizer( client: OpenAI, model: str, optimizer_system: str, - iterations: list[dict], + iterations: list[dict[str, Any]], ) -> str: """Analyze optimizer behavior and return a modified OPTIMIZER_SYSTEM.""" - parts = [] + parts: list[str] = [] for i in range(1, len(iterations)): prev, curr = iterations[i - 1], iterations[i] prev_agg = prev.get("aggregate", {}) @@ -744,8 +763,8 @@ def _observe_and_update_optimizer( prev_rates = prev_agg.get("per_case_pass_rates", {}) curr_rates = curr_agg.get("per_case_pass_rates", {}) - improved = [] - regressed = [] + improved: list[str] = [] + regressed: list[str] = [] for case_id in set(prev_rates) | set(curr_rates): p = prev_rates.get(case_id, 0) c = curr_rates.get(case_id, 0) @@ -764,7 +783,7 @@ def _observe_and_update_optimizer( # Summarize what the optimizer's output looked like (without showing # full developer messages, which cause the observer to mimic them) - behavior_notes = [] + behavior_notes: list[str] = [] for it in iterations[-3:]: idx = it.get("iteration", "?") prompt = it.get("prompt", "") @@ -772,7 +791,7 @@ def _observe_and_update_optimizer( has_bullets = "- " in prompt or "* " in prompt has_numbers = bool(re.search(r"^\d+\.", prompt, re.MULTILINE)) has_headers = "**" in prompt or "##" in prompt - notes = [] + notes: list[str] = [] if has_bullets or has_numbers: notes.append("used bullet/numbered lists") if has_headers: @@ -791,7 +810,7 @@ def _observe_and_update_optimizer( f"```\n{optimizer_system}\n```\n\n" f"## What the Rewriter Produced (do NOT mimic this)\n" + "\n".join(behavior_notes) - + f"\n\n## Iteration History\n" + + "\n\n## Iteration History\n" + "\n".join(parts) ) @@ -835,14 +854,14 @@ def _propose_prompt_modification( client: OpenAI, model: str, current_prompt: str, - test_cases: list[dict], - iteration_result: dict, - history: list[dict], + test_cases: list[dict[str, Any]], + iteration_result: dict[str, Any], + history: list[dict[str, Any]], optimizer_system: str = OPTIMIZER_SYSTEM, ) -> str: """Use the model to propose a new prompt based on evaluation results.""" # Build summary of results - summary_parts = [] + summary_parts: list[str] = [] for case_id, case_result in iteration_result["cases"].items(): case_def = next((c for c in test_cases if c["id"] == case_id), None) if not case_def: @@ -858,7 +877,7 @@ def _propose_prompt_modification( ) # Build history summary (last 3 iterations) - history_parts = [] + history_parts: list[str] = [] for h in history[-3:]: agg = h.get("aggregate", {}) history_parts.append( @@ -926,7 +945,7 @@ def run_optimization( model: str | None, test_file: str, initial_prompt: str | None = None, - n_runs: int = 3, + n_runs: int | None = 3, max_iterations: int = 5, temperature: float = 0.7, max_tokens: int = 32768, @@ -934,7 +953,7 @@ def run_optimization( output_file: str = "eval_results.json", context_window: int = 131072, verbose: bool = False, -): +) -> dict[str, Any]: """Main optimization loop.""" client = OpenAI( base_url=base_url, @@ -946,9 +965,9 @@ def run_optimization( # Load test cases with open(test_file) as f: - suite = json.load(f) + suite: dict[str, Any] = json.load(f) - cases = suite["cases"] + cases: list[dict[str, Any]] = suite["cases"] for i, case in enumerate(cases): if "id" not in case: raise SystemExit(f"Test case {i} missing required 'id' field") @@ -956,8 +975,7 @@ def run_optimization( raise SystemExit(f"Test case '{case.get('id', i)}' missing 'user_prompt'") defaults = suite.get("defaults", {}) # Precedence: CLI arg (non-None) > tests.json defaults > code default (3) - if n_runs is None: - n_runs = defaults.get("n_runs", 3) + resolved_n_runs: int = n_runs if n_runs is not None else int(defaults.get("n_runs", 3)) # Get initial prompt if initial_prompt is None: @@ -965,7 +983,7 @@ def run_optimization( tmp = ChatSession( client=client, model=model, - persona=None, + ui=NullUI(), instructions=None, temperature=temperature, max_tokens=max_tokens, @@ -973,9 +991,7 @@ def run_optimization( reasoning_effort=reasoning_effort, context_window=context_window, ) - initial_prompt = next( - m["content"] for m in tmp.system_messages if m["role"] == "developer" - ) + initial_prompt = next(m["content"] for m in tmp.system_messages if m["role"] == "developer") # Strip memory reminder — it's a runtime artifact, not part of the prompt initial_prompt = re.sub( r"\n*REMINDER: You currently have \d+ memories stored\..*$", @@ -984,13 +1000,13 @@ def run_optimization( ).strip() current_prompt = initial_prompt - results = { + results: dict[str, Any] = { "meta": { "model": model, "base_url": base_url, "started": datetime.now().isoformat(), "test_suite": test_file, - "n_runs_default": n_runs, + "n_runs_default": resolved_n_runs, }, "iterations": [], } @@ -1007,7 +1023,7 @@ def run_optimization( model=model, system_prompt=current_prompt, cases=cases, - n_runs=n_runs, + n_runs=resolved_n_runs, temperature=temperature, max_tokens=max_tokens, reasoning_effort=reasoning_effort, @@ -1092,10 +1108,7 @@ def run_optimization( if new_prompt != current_prompt: diff = _simple_diff(current_prompt, new_prompt) - print( - f"Prompt modified " - f"({len(current_prompt)} -> {len(new_prompt)} chars)" - ) + print(f"Prompt modified ({len(current_prompt)} -> {len(new_prompt)} chars)") if diff: print(diff) iter_result["prompt_diff"] = diff @@ -1126,7 +1139,7 @@ def _detect_model(client: OpenAI) -> str: # ─── CLI ───────────────────────────────────────────────────────────────────── -def main(): +def main() -> None: parser = argparse.ArgumentParser( description="Prompt optimization and evaluation for turnstone", formatter_class=argparse.RawDescriptionHelpFormatter, diff --git a/turnstone/mq/__init__.py b/turnstone/mq/__init__.py index 51389488..6eb12b25 100644 --- a/turnstone/mq/__init__.py +++ b/turnstone/mq/__init__.py @@ -6,6 +6,6 @@ commands and subscribe to progress. """ from turnstone.mq.broker import MessageBroker, RedisBroker -from turnstone.mq.client import TurnstoneClient, TurnResult +from turnstone.mq.client import TurnResult, TurnstoneClient __all__ = ["MessageBroker", "RedisBroker", "TurnstoneClient", "TurnResult"] diff --git a/turnstone/mq/bridge.py b/turnstone/mq/bridge.py index d71b51ee..ae558d85 100644 --- a/turnstone/mq/bridge.py +++ b/turnstone/mq/bridge.py @@ -9,6 +9,7 @@ Run as: ``turnstone-bridge --server-url http://localhost:8080`` from __future__ import annotations +import contextlib import json import logging import os @@ -16,7 +17,7 @@ import socket import threading import time import uuid -from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Any import httpx @@ -46,12 +47,13 @@ from turnstone.mq.protocol import ( WorkstreamRenameEvent, ) +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + log = logging.getLogger("turnstone.mq.bridge") # Server's default safe tools (auto-approved without user confirmation) -DEFAULT_SAFE_TOOLS = frozenset( - ["read_file", "search", "man", "remember", "recall", "forget"] -) +DEFAULT_SAFE_TOOLS = frozenset(["read_file", "search", "man", "remember", "recall", "forget"]) def _default_node_id() -> str: @@ -85,7 +87,7 @@ class Bridge: node_id: str = "", heartbeat_ttl: int = 60, auth_token: str = "", - ): + ) -> None: self._server_url = server_url.rstrip("/") self._broker = broker or RedisBroker() self._approval_timeout = approval_timeout @@ -99,9 +101,7 @@ class Bridge: headers: dict[str, str] = {} if auth_token: headers["Authorization"] = f"Bearer {auth_token}" - self._http = httpx.Client( - base_url=self._server_url, timeout=30, headers=headers - ) + self._http = httpx.Client(base_url=self._server_url, timeout=30, headers=headers) # Protected by _lock — accessed from main, global SSE, and per-ws SSE threads self._lock = threading.Lock() @@ -160,9 +160,7 @@ class Bridge: self._dispatch(msg) except Exception as exc: log.error("Failed to process inbound message: %s", exc) - self._publish_global( - ErrorEvent(message=f"Failed to process message: {exc}") - ) + self._publish_global(ErrorEvent(message=f"Failed to process message: {exc}")) def _dispatch(self, msg: InboundMessage) -> None: # Messages that need routing (have ws_id or target_node) @@ -272,9 +270,7 @@ class Bridge: def _handle_command(self, msg: InboundMessage) -> None: ws_id = getattr(msg, "ws_id", "") command = getattr(msg, "command", "") - resp = self._http.post( - "/api/command", json={"command": command, "ws_id": ws_id} - ) + resp = self._http.post("/api/command", json={"command": command, "ws_id": ws_id}) data = resp.json() self._publish_ws( ws_id, @@ -356,7 +352,7 @@ class Bridge: ) ) return "" - ws_id = data["ws_id"] + ws_id: str = data["ws_id"] ws_name = data.get("name", "") self._broker.set_ws_owner(ws_id, self._node_id) @@ -425,21 +421,15 @@ class Bridge: log.debug("WS SSE reconnecting (%s): %s", ws_id, exc) time.sleep(2) - def _handle_ws_event(self, ws_id: str, data: dict) -> None: + def _handle_ws_event(self, ws_id: str, data: dict[str, Any]) -> None: etype = data.get("type", "") if etype == "content": - self._publish_ws( - ws_id, ContentEvent(ws_id=ws_id, text=data.get("text", "")) - ) + self._publish_ws(ws_id, ContentEvent(ws_id=ws_id, text=data.get("text", ""))) elif etype == "reasoning": - self._publish_ws( - ws_id, ReasoningEvent(ws_id=ws_id, text=data.get("text", "")) - ) + self._publish_ws(ws_id, ReasoningEvent(ws_id=ws_id, text=data.get("text", ""))) elif etype == "tool_info": - self._publish_ws( - ws_id, ToolInfoEvent(ws_id=ws_id, items=data.get("items", [])) - ) + self._publish_ws(ws_id, ToolInfoEvent(ws_id=ws_id, items=data.get("items", []))) elif etype == "approve_request": self._handle_approval(ws_id, data) elif etype == "plan_review": @@ -467,17 +457,13 @@ class Bridge: ), ) elif etype == "error": - self._publish_ws( - ws_id, ErrorEvent(ws_id=ws_id, message=data.get("message", "")) - ) + self._publish_ws(ws_id, ErrorEvent(ws_id=ws_id, message=data.get("message", ""))) elif etype == "info": - self._publish_ws( - ws_id, InfoEvent(ws_id=ws_id, message=data.get("message", "")) - ) + self._publish_ws(ws_id, InfoEvent(ws_id=ws_id, message=data.get("message", ""))) elif etype == "stream_end": self._publish_ws(ws_id, StreamEndEvent(ws_id=ws_id)) - def _handle_approval(self, ws_id: str, data: dict) -> None: + def _handle_approval(self, ws_id: str, data: dict[str, Any]) -> None: """Handle an approval request — auto-approve or forward to client.""" items = data.get("items", []) @@ -506,9 +492,7 @@ class Bridge: ) def _wait_approval() -> None: - raw_resp = self._broker.pop_response( - request_id, timeout=self._approval_timeout - ) + raw_resp = self._broker.pop_response(request_id, timeout=self._approval_timeout) if raw_resp: resp_msg = InboundMessage.from_json(raw_resp) approved = getattr(resp_msg, "approved", False) @@ -524,7 +508,7 @@ class Bridge: threading.Thread(target=_wait_approval, daemon=True).start() - def _handle_plan_review(self, ws_id: str, data: dict) -> None: + def _handle_plan_review(self, ws_id: str, data: dict[str, Any]) -> None: """Handle a plan review request — auto-approve or forward to client.""" with self._lock: if self._ws_auto_approve.get(ws_id): @@ -542,20 +526,14 @@ class Bridge: ) def _wait_plan() -> None: - raw_resp = self._broker.pop_response( - request_id, timeout=self._approval_timeout - ) + raw_resp = self._broker.pop_response(request_id, timeout=self._approval_timeout) if raw_resp: resp_msg = InboundMessage.from_json(raw_resp) feedback = getattr(resp_msg, "feedback", "") - self._http.post( - "/api/plan", json={"feedback": feedback, "ws_id": ws_id} - ) + self._http.post("/api/plan", json={"feedback": feedback, "ws_id": ws_id}) else: log.warning("Plan review timeout for ws %s — rejecting", ws_id) - self._http.post( - "/api/plan", json={"feedback": "reject", "ws_id": ws_id} - ) + self._http.post("/api/plan", json={"feedback": "reject", "ws_id": ws_id}) threading.Thread(target=_wait_plan, daemon=True).start() @@ -565,7 +543,7 @@ class Bridge: approved: bool, feedback: str | None = None, ) -> None: - body: dict = {"approved": approved, "ws_id": ws_id} + body: dict[str, Any] = {"approved": approved, "ws_id": ws_id} if feedback: body["feedback"] = feedback self._http.post("/api/approve", json=body) @@ -592,7 +570,7 @@ class Bridge: log.debug("Global SSE reconnecting: %s", exc) time.sleep(2) - def _handle_global_event(self, data: dict) -> None: + def _handle_global_event(self, data: dict[str, Any]) -> None: etype = data.get("type", "") ws_id = data.get("ws_id", "") @@ -617,17 +595,11 @@ class Bridge: with self._lock: cid = self._active_sends.pop(ws_id, None) if cid: - self._publish_ws( - ws_id, TurnCompleteEvent(ws_id=ws_id, correlation_id=cid) - ) + self._publish_ws(ws_id, TurnCompleteEvent(ws_id=ws_id, correlation_id=cid)) elif etype == "ws_rename": - self._publish_global( - WorkstreamRenameEvent(ws_id=ws_id, name=data.get("name", "")) - ) - self._publish_cluster( - WorkstreamRenameEvent(ws_id=ws_id, name=data.get("name", "")) - ) + self._publish_global(WorkstreamRenameEvent(ws_id=ws_id, name=data.get("name", ""))) + self._publish_cluster(WorkstreamRenameEvent(ws_id=ws_id, name=data.get("name", ""))) elif etype == "ws_closed": self._publish_global(WorkstreamClosedEvent(ws_id=ws_id)) @@ -655,9 +627,7 @@ class Bridge: def _handle_list_nodes(self, msg: InboundMessage) -> None: nodes = self._broker.list_nodes() - self._publish_global( - NodeListEvent(correlation_id=msg.correlation_id, nodes=nodes) - ) + self._publish_global(NodeListEvent(correlation_id=msg.correlation_id, nodes=nodes)) # -- publish helpers ----------------------------------------------------- @@ -677,14 +647,13 @@ class Bridge: # --------------------------------------------------------------------------- -def _iter_sse_data(resp: httpx.Response) -> Iterator[dict]: +def _iter_sse_data(resp: httpx.Response) -> Iterator[dict[str, Any]]: """Yield parsed JSON dicts from an SSE stream.""" for line in resp.iter_lines(): if line.startswith("data: "): - try: - yield json.loads(line[6:]) - except json.JSONDecodeError: - pass + with contextlib.suppress(json.JSONDecodeError): + data: dict[str, Any] = json.loads(line[6:]) + yield data # SSE keepalive comments (lines starting with ':') are ignored diff --git a/turnstone/mq/broker.py b/turnstone/mq/broker.py index e531c62c..a0cf7dbd 100644 --- a/turnstone/mq/broker.py +++ b/turnstone/mq/broker.py @@ -7,9 +7,14 @@ RedisBroker is the default provider. from __future__ import annotations +import contextlib import json -import threading -from typing import Callable, Protocol +from typing import TYPE_CHECKING, Any, Protocol, cast + +if TYPE_CHECKING: + from collections.abc import Callable + + import redis as _redis_t class MessageBroker(Protocol): @@ -74,11 +79,11 @@ class MessageBroker(Protocol): """Remove workstream ownership (on close).""" ... - def register_node(self, node_id: str, metadata: dict, ttl: int = 60) -> None: + def register_node(self, node_id: str, metadata: dict[str, Any], ttl: int = 60) -> None: """Register or refresh a node's heartbeat with metadata.""" ... - def list_nodes(self) -> list[dict]: + def list_nodes(self) -> list[dict[str, Any]]: """List all active nodes (those with unexpired heartbeats).""" ... @@ -117,12 +122,12 @@ class RedisBroker: prefix: str = "turnstone", password: str | None = None, response_ttl: int = 600, - ): + ) -> None: import redis self._prefix = prefix self._response_ttl = response_ttl - self._pool = redis.ConnectionPool( + self._pool: _redis_t.ConnectionPool = redis.ConnectionPool( host=host, port=port, db=db, @@ -130,9 +135,12 @@ class RedisBroker: decode_responses=True, retry_on_timeout=True, ) - self._redis = redis.Redis(connection_pool=self._pool) + self._redis: _redis_t.Redis[str] = cast( + "_redis_t.Redis[str]", + redis.Redis(connection_pool=self._pool), + ) self._pubsub = self._redis.pubsub(ignore_subscribe_messages=True) - self._listener_thread: threading.Thread | None = None + self._listener_thread: Any = None self._running = True # -- inbound queue ------------------------------------------------------- @@ -161,11 +169,12 @@ class RedisBroker: self._redis.publish(channel, event) def subscribe_outbound(self, channel: str, callback: Callable[[str], None]) -> None: - self._pubsub.subscribe(**{channel: lambda msg: callback(msg["data"])}) + def _handler(msg: dict[str, Any]) -> None: + callback(msg["data"]) + + self._pubsub.subscribe(**{channel: _handler}) if self._listener_thread is None or not self._listener_thread.is_alive(): - self._listener_thread = self._pubsub.run_in_thread( - sleep_time=0.1, daemon=True - ) + self._listener_thread = self._pubsub.run_in_thread(sleep_time=0.1, daemon=True) def unsubscribe_outbound(self, channel: str) -> None: self._pubsub.unsubscribe(channel) @@ -197,19 +206,19 @@ class RedisBroker: def del_ws_owner(self, ws_id: str) -> None: self._redis.delete(f"{self._prefix}:ws:{ws_id}") - def register_node(self, node_id: str, metadata: dict, ttl: int = 60) -> None: + def register_node(self, node_id: str, metadata: dict[str, Any], ttl: int = 60) -> None: key = f"{self._prefix}:node:{node_id}" self._redis.set(key, json.dumps(metadata), ex=ttl) - def list_nodes(self) -> list[dict]: + def list_nodes(self) -> list[dict[str, Any]]: pattern = f"{self._prefix}:node:*" prefix_len = len(f"{self._prefix}:node:") - nodes = [] + nodes: list[dict[str, Any]] = [] for key in self._redis.scan_iter(match=pattern, count=100): raw = self._redis.get(key) if raw: try: - meta = json.loads(raw) + meta: dict[str, Any] = json.loads(raw) except json.JSONDecodeError: meta = {} meta["node_id"] = key[prefix_len:] @@ -230,8 +239,6 @@ class RedisBroker: if self._listener_thread is not None: self._listener_thread.stop() self._listener_thread = None - try: + with contextlib.suppress(Exception): self._pubsub.close() - except Exception: - pass self._pool.disconnect() diff --git a/turnstone/mq/client.py b/turnstone/mq/client.py index 712c5046..f6e74dd8 100644 --- a/turnstone/mq/client.py +++ b/turnstone/mq/client.py @@ -17,7 +17,7 @@ from __future__ import annotations import threading from dataclasses import dataclass, field -from typing import Callable +from typing import TYPE_CHECKING, Any from turnstone.mq.broker import MessageBroker, RedisBroker from turnstone.mq.protocol import ( @@ -38,6 +38,9 @@ from turnstone.mq.protocol import ( WorkstreamCreatedEvent, ) +if TYPE_CHECKING: + from collections.abc import Callable + @dataclass class TurnResult: @@ -76,7 +79,7 @@ class TurnstoneClient: broker: MessageBroker | None = None, prefix: str = "turnstone", **redis_kwargs: object, - ): + ) -> None: """Create a client. Pass ``broker`` for a custom broker, or provide Redis kwargs @@ -112,7 +115,7 @@ class TurnstoneClient: auto_approve_tools=auto_approve_tools or [], target_node=target_node, ) - node = target_node or (self._broker.get_ws_owner(ws_id) if ws_id else "") + node = target_node or (self._broker.get_ws_owner(ws_id) if ws_id else "") or "" self._broker.push_inbound(msg.to_json(), node_id=node) return msg.correlation_id @@ -157,7 +160,7 @@ class TurnstoneClient: self._broker.push_inbound(msg.to_json()) return msg.correlation_id - def list_nodes(self) -> list[dict]: + def list_nodes(self) -> list[dict[str, Any]]: """List active bridge nodes (reads directly from broker).""" return self._broker.list_nodes() @@ -234,15 +237,10 @@ class TurnstoneClient: if on_event: on_event(event) - if ( - isinstance(event, WorkstreamCreatedEvent) - and event.correlation_id == cid - ): + if isinstance(event, WorkstreamCreatedEvent) and event.correlation_id == cid: actual_ws_id = event.ws_id result.ws_id = event.ws_id - self._broker.subscribe_outbound( - f"{self._prefix}:events:{actual_ws_id}", _on_ws - ) + self._broker.subscribe_outbound(f"{self._prefix}:events:{actual_ws_id}", _on_ws) def _on_ws(raw: str) -> None: event = OutboundEvent.from_json(raw) @@ -263,12 +261,10 @@ class TurnstoneClient: # Subscribe BEFORE pushing — ensures we don't miss early events self._broker.subscribe_outbound(f"{self._prefix}:events:global", _on_global) if actual_ws_id: - self._broker.subscribe_outbound( - f"{self._prefix}:events:{actual_ws_id}", _on_ws - ) + self._broker.subscribe_outbound(f"{self._prefix}:events:{actual_ws_id}", _on_ws) # Now push the message (route to target node or ws owner if known) - node = target_node or (self._broker.get_ws_owner(ws_id) if ws_id else "") + node = target_node or (self._broker.get_ws_owner(ws_id) if ws_id else "") or "" self._broker.push_inbound(msg.to_json(), node_id=node) done.wait(timeout=timeout) @@ -290,10 +286,7 @@ class TurnstoneClient: ws_id: str = "", ) -> None: """Subscribe to events for a specific workstream or global events.""" - if ws_id: - channel = f"{self._prefix}:events:{ws_id}" - else: - channel = f"{self._prefix}:events:global" + channel = f"{self._prefix}:events:{ws_id}" if ws_id else f"{self._prefix}:events:global" def _cb(raw: str) -> None: event = OutboundEvent.from_json(raw) @@ -303,10 +296,7 @@ class TurnstoneClient: def unsubscribe(self, ws_id: str = "") -> None: """Unsubscribe from a workstream or global channel.""" - if ws_id: - channel = f"{self._prefix}:events:{ws_id}" - else: - channel = f"{self._prefix}:events:global" + channel = f"{self._prefix}:events:{ws_id}" if ws_id else f"{self._prefix}:events:global" self._broker.unsubscribe_outbound(channel) # -- lifecycle ----------------------------------------------------------- diff --git a/turnstone/mq/protocol.py b/turnstone/mq/protocol.py index 7e9384c7..99e4638c 100644 --- a/turnstone/mq/protocol.py +++ b/turnstone/mq/protocol.py @@ -10,8 +10,8 @@ from __future__ import annotations import json import time import uuid -from dataclasses import asdict, dataclass, field - +from dataclasses import asdict, dataclass, field, fields +from typing import Any # --------------------------------------------------------------------------- # Inbound messages (client → bridge) @@ -36,7 +36,7 @@ class InboundMessage: klass = _INBOUND_REGISTRY.get(msg_type) if klass is None: raise ValueError(f"Unknown inbound message type: {msg_type!r}") - valid = {f for f in klass.__dataclass_fields__} + valid = {f.name for f in fields(klass)} return klass(**{k: v for k, v in data.items() if k in valid}) @@ -146,7 +146,7 @@ class OutboundEvent: data = json.loads(raw) msg_type = data.get("type", "") klass = _OUTBOUND_REGISTRY.get(msg_type, OutboundEvent) - valid = {f for f in klass.__dataclass_fields__} + valid = {f.name for f in fields(klass)} return klass(**{k: v for k, v in data.items() if k in valid}) @@ -180,7 +180,7 @@ class ToolInfoEvent(OutboundEvent): """Tool call info (auto-approved tools).""" type: str = "tool_info" - items: list = field(default_factory=list) + items: list[dict[str, Any]] = field(default_factory=list) @dataclass @@ -192,7 +192,7 @@ class ApprovalRequestEvent(OutboundEvent): """ type: str = "approval_request" - items: list = field(default_factory=list) + items: list[dict[str, Any]] = field(default_factory=list) @dataclass @@ -275,7 +275,7 @@ class WorkstreamListEvent(OutboundEvent): """Workstream list response.""" type: str = "ws_list" - workstreams: list = field(default_factory=list) + workstreams: list[dict[str, Any]] = field(default_factory=list) @dataclass @@ -291,7 +291,7 @@ class HealthResponseEvent(OutboundEvent): """Health status response.""" type: str = "health_response" - data: dict = field(default_factory=dict) + data: dict[str, Any] = field(default_factory=dict) @dataclass @@ -315,7 +315,7 @@ class NodeListEvent(OutboundEvent): """List of active bridge nodes.""" type: str = "node_list" - nodes: list = field(default_factory=list) + nodes: list[dict[str, Any]] = field(default_factory=list) @dataclass @@ -336,8 +336,17 @@ class ClusterStateEvent(OutboundEvent): # Type registries (built after all classes are defined) # --------------------------------------------------------------------------- + +def _type_default(cls: type[Any]) -> str: + """Return the default value of the 'type' field for a dataclass.""" + for f in fields(cls): + if f.name == "type": + return f.default # type: ignore[return-value] + return "" + + _INBOUND_REGISTRY: dict[str, type[InboundMessage]] = { - cls.__dataclass_fields__["type"].default: cls + _type_default(cls): cls for cls in [ SendMessage, ApproveMessage, @@ -352,7 +361,7 @@ _INBOUND_REGISTRY: dict[str, type[InboundMessage]] = { } _OUTBOUND_REGISTRY: dict[str, type[OutboundEvent]] = { - cls.__dataclass_fields__["type"].default: cls + _type_default(cls): cls for cls in [ AckEvent, ContentEvent, diff --git a/turnstone/server.py b/turnstone/server.py index 43fbd18d..faf6da1b 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -9,7 +9,10 @@ Supports multiple concurrent workstreams (tabs), each with independent ChatSession and event streams. """ +from __future__ import annotations + import argparse +import contextlib import json import os import queue @@ -17,17 +20,18 @@ import sys import textwrap import threading import time -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from socketserver import ThreadingMixIn -from urllib.parse import urlparse, parse_qs +from typing import Any +from urllib.parse import ParseResult, parse_qs, urlparse from openai import OpenAI from turnstone.core.metrics import metrics as _metrics from turnstone.core.session import ChatSession, SessionUI # noqa: F401 from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection -from turnstone.core.workstream import WorkstreamManager, WorkstreamState +from turnstone.core.workstream import Workstream, WorkstreamManager # --------------------------------------------------------------------------- # Static assets — loaded once at startup from turnstone/ui/static/ @@ -53,15 +57,15 @@ class WebUI: # Shared global event queue for state-change broadcasts across all # workstreams. Set by main() before any WebUI instances are created. - _global_queue: queue.Queue | None = None + _global_queue: queue.Queue[dict[str, Any]] | None = None - def __init__(self, ws_id: str = ""): + def __init__(self, ws_id: str = "") -> None: self.ws_id = ws_id - self._event_queue: queue.Queue = queue.Queue() + self._event_queue: queue.Queue[dict[str, Any]] = queue.Queue() self._sse_generation = 0 # incremented on each new SSE connection self._approval_event = threading.Event() self._approval_result: tuple[bool, str | None] = (False, None) - self._pending_approval: dict | None = None # re-sent on SSE reconnect + self._pending_approval: dict[str, Any] | None = None # re-sent on SSE reconnect self._plan_event = threading.Event() self._plan_result: str = "" self.auto_approve = False @@ -76,10 +80,10 @@ class WebUI: self._ws_current_activity: str = "" self._ws_activity_state: str = "" # "tool" | "approval" | "thinking" | "" - def _enqueue(self, data: dict): + def _enqueue(self, data: dict[str, Any]) -> None: self._event_queue.put(data) - def _broadcast_state(self, state: str): + def _broadcast_state(self, state: str) -> None: """Send a state-change event to the global SSE channel.""" if WebUI._global_queue is not None: with self._ws_lock: @@ -99,7 +103,7 @@ class WebUI: } ) - def _broadcast_activity(self): + def _broadcast_activity(self) -> None: """Send an activity-change event to the global SSE channel.""" if WebUI._global_queue is not None: with self._ws_lock: @@ -116,33 +120,31 @@ class WebUI: # --- SessionUI protocol --- - def on_thinking_start(self): + def on_thinking_start(self) -> None: with self._ws_lock: self._ws_current_activity = "Thinking\u2026" self._ws_activity_state = "thinking" self._broadcast_activity() self._enqueue({"type": "thinking_start"}) - def on_thinking_stop(self): + def on_thinking_stop(self) -> None: self._enqueue({"type": "thinking_stop"}) - def on_reasoning_token(self, text: str): + def on_reasoning_token(self, text: str) -> None: self._enqueue({"type": "reasoning", "text": text}) - def on_content_token(self, text: str): + def on_content_token(self, text: str) -> None: self._enqueue({"type": "content", "text": text}) - def on_stream_end(self): + def on_stream_end(self) -> None: with self._ws_lock: self._ws_current_activity = "" self._ws_activity_state = "" self._broadcast_activity() self._enqueue({"type": "stream_end"}) - def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: - pending = [ - it for it in items if it.get("needs_approval") and not it.get("error") - ] + def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]: + pending = [it for it in items if it.get("needs_approval") and not it.get("error")] # Always send tool info to the browser serialized = [] @@ -152,9 +154,7 @@ class WebUI: "header": item.get("header", ""), "preview": item.get("preview", ""), "func_name": item.get("func_name", ""), - "approval_label": item.get( - "approval_label", item.get("func_name", "") - ), + "approval_label": item.get("approval_label", item.get("func_name", "")), "needs_approval": item.get("needs_approval", False), "error": item.get("error"), } @@ -166,9 +166,7 @@ class WebUI: label = first.get("func_name", "") preview = first.get("preview", "")[:80] with self._ws_lock: - self._ws_current_activity = ( - f"\u2699 {label}: {preview}" if label else "" - ) + self._ws_current_activity = f"\u2699 {label}: {preview}" if label else "" self._ws_activity_state = "tool" if label else "" self._broadcast_activity() self._enqueue({"type": "tool_info", "items": serialized}) @@ -179,9 +177,7 @@ class WebUI: label = first_pending.get("func_name", "") preview = first_pending.get("preview", "")[:60] with self._ws_lock: - self._ws_current_activity = ( - f"\u23f3 Awaiting approval: {label} \u2014 {preview}" - ) + self._ws_current_activity = f"\u23f3 Awaiting approval: {label} \u2014 {preview}" self._ws_activity_state = "approval" self._broadcast_activity() @@ -203,7 +199,7 @@ class WebUI: return approved, feedback - def on_tool_result(self, name: str, output: str): + def on_tool_result(self, name: str, output: str) -> None: _metrics.record_tool_call(name) with self._ws_lock: self._ws_tool_calls[name] = self._ws_tool_calls.get(name, 0) + 1 @@ -212,19 +208,15 @@ class WebUI: self._broadcast_activity() self._enqueue({"type": "tool_result", "name": name, "output": output}) - def on_status(self, usage: dict, context_window: int, effort: str): + def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: total_tok = usage["prompt_tokens"] + usage["completion_tokens"] pct = total_tok / context_window * 100 if context_window > 0 else 0 _metrics.record_tokens(usage["prompt_tokens"], usage["completion_tokens"]) - _metrics.record_context_ratio( - total_tok / context_window if context_window > 0 else 0.0 - ) + _metrics.record_context_ratio(total_tok / context_window if context_window > 0 else 0.0) with self._ws_lock: self._ws_prompt_tokens += usage["prompt_tokens"] self._ws_completion_tokens += usage["completion_tokens"] - self._ws_context_ratio = ( - total_tok / context_window if context_window > 0 else 0.0 - ) + self._ws_context_ratio = total_tok / context_window if context_window > 0 else 0.0 self._enqueue( { "type": "status", @@ -243,29 +235,27 @@ class WebUI: self._plan_event.wait() return self._plan_result - def on_info(self, message: str): + def on_info(self, message: str) -> None: self._enqueue({"type": "info", "message": message}) - def on_error(self, message: str): + def on_error(self, message: str) -> None: _metrics.record_error() self._enqueue({"type": "error", "message": message}) - def on_state_change(self, state: str): + def on_state_change(self, state: str) -> None: self._broadcast_state(state) - def on_rename(self, name: str): + def on_rename(self, name: str) -> None: """Update the workstream's display name and broadcast to all clients.""" if WebUI._global_queue is not None: - WebUI._global_queue.put( - {"type": "ws_rename", "ws_id": self.ws_id, "name": name} - ) + WebUI._global_queue.put({"type": "ws_rename", "ws_id": self.ws_id, "name": name}) - def resolve_approval(self, approved: bool, feedback: str | None = None): + def resolve_approval(self, approved: bool, feedback: str | None = None) -> None: """Called by the HTTP handler when the user approves/denies.""" self._approval_result = (approved, feedback) self._approval_event.set() - def resolve_plan(self, feedback: str): + def resolve_plan(self, feedback: str) -> None: """Called by the HTTP handler when the user responds to a plan.""" self._plan_result = feedback self._plan_event.set() @@ -276,7 +266,9 @@ class WebUI: # --------------------------------------------------------------------------- -def _build_history(session, has_pending_approval: bool = False) -> list[dict]: +def _build_history( + session: ChatSession, has_pending_approval: bool = False +) -> list[dict[str, Any]]: """Build a history replay list from session messages. When ``has_pending_approval`` is True, the last assistant entry's @@ -312,10 +304,10 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): """ # Suppress default logging to stderr - def log_message(self, format, *args): + def log_message(self, fmt: str, *args: Any) -> None: # noqa: N802 pass - def _set_headers(self, status=200, content_type="application/json"): + def _set_headers(self, status: int = 200, content_type: str = "application/json") -> None: self._response_status = status self.send_response(status) self.send_header("Content-Type", content_type) @@ -323,28 +315,30 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() - def _read_body(self) -> dict: + def _read_body(self) -> dict[str, Any]: length = int(self.headers.get("Content-Length", 0)) if length == 0: return {} raw = self.rfile.read(length) try: - return json.loads(raw.decode("utf-8")) + result: dict[str, Any] = json.loads(raw.decode("utf-8")) + return result except (json.JSONDecodeError, UnicodeDecodeError, ValueError): return {} - def _send_json(self, data: dict, status=200): + def _send_json(self, data: dict[str, Any], status: int = 200) -> None: self._set_headers(status, "application/json") self.wfile.write(json.dumps(data).encode("utf-8")) - def _get_ws(self, ws_id: str | None): + def _get_ws(self, ws_id: str | None) -> tuple[Workstream, WebUI] | tuple[None, None]: """Look up workstream by id. Returns (Workstream, WebUI) or (None, None).""" if not ws_id: return None, None mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] ws = mgr.get(ws_id) - if ws: - return ws, ws.ui + if ws and ws.ui: + ui: WebUI = ws.ui # type: ignore[assignment] + return ws, ui return None, None def _check_auth(self, method: str, path: str) -> bool: @@ -354,14 +348,12 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): auth_config = self.server.auth_config # type: ignore[attr-defined] auth_header = self.headers.get("Authorization") cookie_header = self.headers.get("Cookie") - allowed, status, msg = check_request( - auth_config, method, path, auth_header, cookie_header - ) + allowed, status, msg = check_request(auth_config, method, path, auth_header, cookie_header) if not allowed: self._send_json({"error": msg}, status) return allowed - def do_GET(self): + def do_GET(self) -> None: _t0 = time.monotonic() self._response_status = 200 parsed = urlparse(self.path) @@ -374,7 +366,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): "GET", parsed.path, self._response_status, time.monotonic() - _t0 ) - def _do_GET(self, parsed): + def _do_GET(self, parsed: ParseResult) -> None: # noqa: N802 if parsed.path == "/": self._set_headers(200, "text/html; charset=utf-8") self.wfile.write(_HTML.encode("utf-8")) @@ -420,6 +412,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): break # Send connected event with model info + assert ws.session is not None session: ChatSession = ws.session connected_data = json.dumps( { @@ -428,22 +421,20 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): "skip_permissions": ui.auto_approve, } ) - self.wfile.write(f"data: {connected_data}\n\n".encode("utf-8")) + self.wfile.write(f"data: {connected_data}\n\n".encode()) self.wfile.flush() # Send conversation history for replay - history = _build_history( - session, has_pending_approval=ui._pending_approval is not None - ) + history = _build_history(session, has_pending_approval=ui._pending_approval is not None) if history: history_data = json.dumps({"type": "history", "messages": history}) - self.wfile.write(f"data: {history_data}\n\n".encode("utf-8")) + self.wfile.write(f"data: {history_data}\n\n".encode()) self.wfile.flush() # Re-inject a pending approval request if one was interrupted by a tab switch. if ui._pending_approval is not None: pa_data = json.dumps(ui._pending_approval) - self.wfile.write(f"data: {pa_data}\n\n".encode("utf-8")) + self.wfile.write(f"data: {pa_data}\n\n".encode()) self.wfile.flush() # Long-running SSE loop @@ -452,7 +443,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): try: event = ui._event_queue.get(timeout=5) data = json.dumps(event) - self.wfile.write(f"data: {data}\n\n".encode("utf-8")) + self.wfile.write(f"data: {data}\n\n".encode()) self.wfile.flush() except queue.Empty: # Send keepalive comment to prevent timeout @@ -469,12 +460,10 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() - gq: queue.Queue = self.server.global_queue # type: ignore[attr-defined] - # Each global SSE client gets its own consumer queue # (since queue.Queue is single-consumer, we fan out via a listener list) - client_queue: queue.Queue = queue.Queue(maxsize=500) - listeners: list = self.server.global_listeners # type: ignore[attr-defined] + client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=500) + listeners: list[queue.Queue[dict[str, Any]]] = self.server.global_listeners # type: ignore[attr-defined] listeners_lock: threading.Lock = self.server.global_listeners_lock # type: ignore[attr-defined] with listeners_lock: listeners.append(client_queue) @@ -484,7 +473,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): try: event = client_queue.get(timeout=5) data = json.dumps(event) - self.wfile.write(f"data: {data}\n\n".encode("utf-8")) + self.wfile.write(f"data: {data}\n\n".encode()) self.wfile.flush() except queue.Empty: self.wfile.write(b": keepalive\n\n") @@ -540,7 +529,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): self._set_headers(404, "text/plain") self.wfile.write(b"Not found") - def do_POST(self): + def do_POST(self) -> None: _t0 = time.monotonic() self._response_status = 200 try: @@ -552,7 +541,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): "POST", self.path, self._response_status, time.monotonic() - _t0 ) - def _do_POST(self): + def _do_POST(self) -> None: # noqa: N802 if self.path == "/api/send": body = self._read_body() message = body.get("message", "").strip() @@ -577,9 +566,13 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): self._send_json({"status": "busy"}) return - def run(): + session = ws.session + assert session is not None + + def run() -> None: + assert ui is not None try: - ws.session.send(message) + session.send(message) except Exception as e: ui.on_error(f"Error: {e}") ui._enqueue({"type": "stream_end"}) @@ -633,6 +626,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): if not ws or not ui: self._send_json({"error": "Unknown workstream"}, 404) return + assert ws.session is not None try: should_exit = ws.session.handle_command(command) @@ -669,6 +663,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): name=body.get("name", ""), ui_factory=lambda wid: WebUI(ws_id=wid), ) + assert isinstance(ws.ui, WebUI) if skip or body.get("auto_approve", False): ws.ui.auto_approve = True self._send_json({"ws_id": ws.id, "name": ws.name}) @@ -677,8 +672,8 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): elif self.path == "/api/workstreams/close": body = self._read_body() - ws_id = body.get("ws_id") - mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] + ws_id = str(body.get("ws_id", "")) + mgr = self.server.workstreams # type: ignore[attr-defined] if mgr.close(ws_id): self._send_json({"status": "ok"}) else: @@ -697,9 +692,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): self.send_header("Set-Cookie", make_set_cookie(token)) self.send_header("Cache-Control", "no-cache") self.end_headers() - self.wfile.write( - json.dumps({"status": "ok", "role": role}).encode("utf-8") - ) + self.wfile.write(json.dumps({"status": "ok", "role": role}).encode("utf-8")) else: self._send_json({"error": "Invalid token"}, 401) @@ -717,11 +710,11 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): self._set_headers(404, "text/plain") self.wfile.write(b"Not found") - def _handle_health(self): + def _handle_health(self) -> None: """Return server health status as JSON.""" mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] wss = mgr.list_all() - states: dict = { + states: dict[str, int] = { "idle": 0, "thinking": 0, "running": 0, @@ -740,7 +733,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): } self._send_json(data) - def _handle_dashboard(self): + def _handle_dashboard(self) -> None: """Return enriched workstream data + aggregate stats for the dashboard.""" from turnstone.core.memory import get_session_name @@ -795,11 +788,11 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): } ) - def _handle_metrics(self): + def _handle_metrics(self) -> None: """Return Prometheus text exposition format metrics.""" mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] wss = mgr.list_all() - states: dict = { + states: dict[str, int] = { "idle": 0, "thinking": 0, "running": 0, @@ -832,7 +825,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler): self._set_headers(200, "text/plain; version=0.0.4; charset=utf-8") self.wfile.write(content.encode("utf-8")) - def do_OPTIONS(self): + def do_OPTIONS(self) -> None: """Handle CORS preflight.""" self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") @@ -886,23 +879,23 @@ def detect_model(client: OpenAI) -> str: def _idle_cleanup_thread( - mgr: WorkstreamManager, timeout_sec: float, global_queue: queue.Queue -): + mgr: WorkstreamManager, timeout_sec: float, global_queue: queue.Queue[dict[str, Any]] +) -> None: """Periodically close IDLE workstreams that have been inactive too long.""" check_every = min(300.0, timeout_sec / 4) # check at ¼ of timeout, max 5 min while True: time.sleep(check_every) closed = mgr.close_idle(timeout_sec) for ws_id in closed: - try: + with contextlib.suppress(queue.Full): global_queue.put_nowait({"type": "ws_closed", "ws_id": ws_id}) - except queue.Full: - pass def _global_fanout_thread( - source_queue: queue.Queue, listeners: list, lock: threading.Lock -): + source_queue: queue.Queue[dict[str, Any]], + listeners: list[queue.Queue[dict[str, Any]]], + lock: threading.Lock, +) -> None: """Reads events from the source queue and copies them to all listener queues.""" while True: try: @@ -910,10 +903,8 @@ def _global_fanout_thread( with lock: snapshot = list(listeners) for lq in snapshot: - try: - lq.put_nowait(event) - except queue.Full: - pass # drop if a listener is backed up + with contextlib.suppress(queue.Full): + lq.put_nowait(event) # drop if a listener is backed up except Exception: pass @@ -923,7 +914,7 @@ def _global_fanout_thread( # --------------------------------------------------------------------------- -def main(): +def main() -> None: parser = argparse.ArgumentParser( description="turnstone web server — browser-based chat UI.", formatter_class=argparse.RawDescriptionHelpFormatter, @@ -945,11 +936,6 @@ def main(): default=None, help="Model name (default: auto-detect from server)", ) - parser.add_argument( - "--persona", - default=None, - help="Persona name injected as system message", - ) parser.add_argument( "--instructions", default=None, @@ -1068,24 +1054,21 @@ def main(): ) # Detect or use provided model - if args.model: - model = args.model - else: - model = detect_model(client) + model = args.model or detect_model(client) # Set up global event queue for state-change broadcasts - global_queue: queue.Queue = queue.Queue() - global_listeners: list = [] + global_queue: queue.Queue[dict[str, Any]] = queue.Queue() + global_listeners: list[queue.Queue[dict[str, Any]]] = [] global_listeners_lock = threading.Lock() WebUI._global_queue = global_queue # Session factory — captures shared config - def session_factory(ui): + def session_factory(ui: SessionUI | None) -> ChatSession: + assert ui is not None return ChatSession( client=client, model=model, ui=ui, - persona=args.persona, instructions=args.instructions, temperature=args.temperature, max_tokens=args.max_tokens, @@ -1104,10 +1087,12 @@ def main(): name="default", ui_factory=lambda wid: WebUI(ws_id=wid), ) + assert isinstance(ws.ui, WebUI) if args.skip_permissions: ws.ui.auto_approve = True # Handle --resume + assert ws.session is not None if args.resume: from turnstone.core.memory import resolve_session @@ -1157,8 +1142,6 @@ def main(): print(f"turnstone web server running on http://{args.host}:{args.port}") print(f"Model: {model}") - if args.persona: - print(f"Persona: {args.persona}") print("Press Ctrl+C to stop.") try: diff --git a/turnstone/sim/cli.py b/turnstone/sim/cli.py index 99707b40..bcb73fd3 100644 --- a/turnstone/sim/cli.py +++ b/turnstone/sim/cli.py @@ -7,6 +7,7 @@ import asyncio import json import logging import sys +from typing import Any from turnstone.sim.cluster import SimCluster from turnstone.sim.config import SimConfig @@ -85,9 +86,7 @@ def main() -> None: parser.add_argument("--redis-password", default=None) parser.add_argument("--redis-db", type=int, default=0) parser.add_argument("--prefix", default="turnstone") - parser.add_argument( - "--seed", type=int, default=None, help="Random seed for reproducibility" - ) + parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility") parser.add_argument("--metrics-file", default="", help="Write JSON metrics to file") parser.add_argument( "--log-level", @@ -152,7 +151,7 @@ async def _run(config: SimConfig) -> None: await cluster.stop() -def _print_report(report: dict, config: SimConfig) -> None: +def _print_report(report: dict[str, Any], config: SimConfig) -> None: lat = report.get("latency", {}) tp = report.get("throughput", {}) util = report.get("utilization", {}) diff --git a/turnstone/sim/cluster.py b/turnstone/sim/cluster.py index 3bf617a6..0ae4c63f 100644 --- a/turnstone/sim/cluster.py +++ b/turnstone/sim/cluster.py @@ -7,14 +7,17 @@ import logging import math import time from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING, Any, cast import redis from turnstone.mq.broker import RedisBroker -from turnstone.sim.config import SimConfig from turnstone.sim.metrics import MetricsCollector from turnstone.sim.node import SimNode +if TYPE_CHECKING: + from turnstone.sim.config import SimConfig + log = logging.getLogger("turnstone.sim.cluster") # How many node queues a single dispatcher watches via one BLPOP call. @@ -29,16 +32,15 @@ class PooledBroker(RedisBroker): pool: redis.ConnectionPool, prefix: str = "turnstone", response_ttl: int = 600, - ): + ) -> None: # Bypass RedisBroker.__init__ — set up manually with the shared pool. - import threading self._prefix = prefix self._response_ttl = response_ttl - self._pool = pool - self._redis = redis.Redis(connection_pool=pool) + self._pool: redis.ConnectionPool = pool + self._redis: redis.Redis[str] = cast("redis.Redis[str]", redis.Redis(connection_pool=pool)) self._pubsub = self._redis.pubsub(ignore_subscribe_messages=True) - self._listener_thread: threading.Thread | None = None + self._listener_thread: Any = None self._running = True def close(self) -> None: @@ -56,11 +58,11 @@ class InboundDispatcher: def __init__( self, - redis_client: redis.Redis, + redis_client: redis.Redis[str], node_ids: list[str], nodes: dict[str, SimNode], prefix: str, - ): + ) -> None: self._redis = redis_client self._node_ids = node_ids self._nodes = nodes @@ -72,9 +74,7 @@ class InboundDispatcher: self._keys.append(f"{prefix}:inbound") # Pre-compute key → node_id mapping - self._key_to_node: dict[str, str] = { - f"{prefix}:inbound:{nid}": nid for nid in node_ids - } + self._key_to_node: dict[str, str] = {f"{prefix}:inbound:{nid}": nid for nid in node_ids} async def run(self) -> None: while self._running: @@ -146,15 +146,16 @@ class SimCluster: await cluster.stop() """ - def __init__(self, config: SimConfig): + def __init__(self, config: SimConfig) -> None: self._config = config self._metrics = MetricsCollector() self._nodes: dict[str, SimNode] = {} self._node_order: list[str] = [] self._dispatchers: list[InboundDispatcher] = [] - self._tasks: list[asyncio.Task] = [] + self._tasks: list[asyncio.Task[None]] = [] self._pool: redis.ConnectionPool | None = None - self._redis_client: redis.Redis | None = None + self._redis_client: redis.Redis[str] | None = None + self._executor: ThreadPoolExecutor | None = None self._running = True @property @@ -174,7 +175,7 @@ class SimCluster: self._executor = ThreadPoolExecutor(max_workers=64) # Shared Redis pool - self._pool = redis.ConnectionPool( + pool: redis.ConnectionPool = redis.ConnectionPool( host=self._config.redis_host, port=self._config.redis_port, db=self._config.redis_db, @@ -183,13 +184,14 @@ class SimCluster: retry_on_timeout=True, max_connections=64, ) - self._redis_client = redis.Redis(connection_pool=self._pool) + self._pool = pool + self._redis_client = cast("redis.Redis[str]", redis.Redis(connection_pool=pool)) # Create nodes for i in range(self._config.num_nodes): node_id = f"sim-{i:04d}" broker = PooledBroker( - self._pool, + pool, prefix=self._config.prefix, ) node = SimNode(node_id, broker, self._config, self._metrics) @@ -203,7 +205,7 @@ class SimCluster: start = i * NODES_PER_DISPATCHER batch_ids = all_ids[start : start + NODES_PER_DISPATCHER] # Each dispatcher gets its own Redis client from the shared pool - client = redis.Redis(connection_pool=self._pool) + client: redis.Redis[str] = cast("redis.Redis[str]", redis.Redis(connection_pool=pool)) dispatcher = InboundDispatcher( client, batch_ids, @@ -246,9 +248,7 @@ class SimCluster: while self._running: await asyncio.sleep(self._config.metrics_interval) counts = { - nid: node.workstream_count - for nid, node in self._nodes.items() - if node._running + nid: node.workstream_count for nid, node in self._nodes.items() if node._running } self._metrics.snapshot_utilization(counts) @@ -261,12 +261,14 @@ class SimCluster: ] await asyncio.gather(*tasks, return_exceptions=True) + assert self._redis_client is not None + redis_client = self._redis_client registered = 0 deadline = time.monotonic() + 30 while time.monotonic() < deadline: keys = await loop.run_in_executor( self._executor, - self._redis_client.keys, + redis_client.keys, f"{self._config.prefix}:node:sim-*", ) registered = len(keys) @@ -298,7 +300,7 @@ class SimCluster: d.remove_node(node_id) log.info("Killed node %s", node_id) - def report(self) -> dict: + def report(self) -> dict[str, Any]: """Generate final metrics report.""" return self._metrics.summary() @@ -312,8 +314,8 @@ class SimCluster: for task in self._tasks: task.cancel() await asyncio.gather(*self._tasks, return_exceptions=True) - if hasattr(self, "_executor"): + if self._executor is not None: self._executor.shutdown(wait=False) - if self._pool: + if self._pool is not None: self._pool.disconnect() log.info("Cluster stopped") diff --git a/turnstone/sim/engine.py b/turnstone/sim/engine.py index 2963ecbb..304437f0 100644 --- a/turnstone/sim/engine.py +++ b/turnstone/sim/engine.py @@ -4,8 +4,10 @@ from __future__ import annotations import asyncio import random +from typing import TYPE_CHECKING, Any -from turnstone.sim.config import SimConfig +if TYPE_CHECKING: + from turnstone.sim.config import SimConfig _WORD_POOL = [ "the", @@ -66,7 +68,7 @@ class SimEngine: async def simulate_llm_response( self, first_round: bool, turn_number: int - ) -> tuple[str, list[dict]]: + ) -> tuple[str, list[dict[str, Any]]]: """Simulate an LLM response. Returns ``(content_text, tool_calls)`` where *tool_calls* may be diff --git a/turnstone/sim/metrics.py b/turnstone/sim/metrics.py index 47d768ce..94f795aa 100644 --- a/turnstone/sim/metrics.py +++ b/turnstone/sim/metrics.py @@ -5,6 +5,7 @@ from __future__ import annotations import threading import time from collections import defaultdict +from typing import Any class MetricsCollector: @@ -48,7 +49,7 @@ class MetricsCollector: with self._lock: self._ws_counts.append(dict(ws_counts)) - def summary(self) -> dict: + def summary(self) -> dict[str, Any]: """Generate final metrics report with percentiles and aggregates.""" with self._lock: latencies = sorted(self._turn_latencies) @@ -60,7 +61,7 @@ class MetricsCollector: duration = 0.0 # Utilization from latest snapshot - util: dict = {} + util: dict[str, Any] = {} if self._ws_counts: last = self._ws_counts[-1] counts = list(last.values()) diff --git a/turnstone/sim/node.py b/turnstone/sim/node.py index ddf1d7a5..fae9c67a 100644 --- a/turnstone/sim/node.py +++ b/turnstone/sim/node.py @@ -13,6 +13,7 @@ import json import logging import time import uuid +from typing import TYPE_CHECKING from turnstone.mq.protocol import ( AckEvent, @@ -22,6 +23,7 @@ from turnstone.mq.protocol import ( HealthResponseEvent, InboundMessage, NodeListEvent, + OutboundEvent, StateChangeEvent, StatusEvent, StreamEndEvent, @@ -31,9 +33,12 @@ from turnstone.mq.protocol import ( WorkstreamCreatedEvent, WorkstreamListEvent, ) -from turnstone.sim.config import SimConfig from turnstone.sim.engine import SimEngine, ToolSimulationError -from turnstone.sim.metrics import MetricsCollector + +if TYPE_CHECKING: + from turnstone.mq.broker import RedisBroker + from turnstone.sim.config import SimConfig + from turnstone.sim.metrics import MetricsCollector log = logging.getLogger("turnstone.sim.node") @@ -212,7 +217,7 @@ class SimNode: def __init__( self, node_id: str, - broker: object, + broker: RedisBroker, config: SimConfig, metrics: MetricsCollector, ): @@ -420,19 +425,19 @@ class SimNode: # -- event publishing helpers -------------------------------------------- - def _publish_global(self, event: object) -> None: + def _publish_global(self, event: OutboundEvent) -> None: self._broker.publish_outbound( f"{self._prefix}:events:global", event.to_json(), ) - def _publish_ws(self, ws_id: str, event: object) -> None: + def _publish_ws(self, ws_id: str, event: OutboundEvent) -> None: self._broker.publish_outbound( f"{self._prefix}:events:{ws_id}", event.to_json(), ) - def _publish_cluster(self, event: object) -> None: + def _publish_cluster(self, event: OutboundEvent) -> None: self._broker.publish_outbound( f"{self._prefix}:events:cluster", event.to_json(), diff --git a/turnstone/sim/scenario.py b/turnstone/sim/scenario.py index f619e796..0c6839a4 100644 --- a/turnstone/sim/scenario.py +++ b/turnstone/sim/scenario.py @@ -5,15 +5,15 @@ from __future__ import annotations import asyncio import logging import time -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Any, Protocol from turnstone.mq.broker import RedisBroker from turnstone.mq.protocol import SendMessage -from turnstone.sim.config import SimConfig -from turnstone.sim.metrics import MetricsCollector if TYPE_CHECKING: from turnstone.sim.cluster import SimCluster + from turnstone.sim.config import SimConfig + from turnstone.sim.metrics import MetricsCollector log = logging.getLogger("turnstone.sim.scenario") @@ -157,6 +157,7 @@ class LifecycleScenario: from turnstone.mq.protocol import ( CloseWorkstreamMessage, CreateWorkstreamMessage, + InboundMessage, ) broker = _make_broker(config) @@ -166,7 +167,7 @@ class LifecycleScenario: # Phase 1: Create workstreams create_count = min(50, config.num_nodes * 2) for i in range(create_count): - msg = CreateWorkstreamMessage( + msg: InboundMessage = CreateWorkstreamMessage( name=f"lifecycle-ws-{i}", auto_approve=True, ) @@ -225,7 +226,7 @@ def _make_broker(config: SimConfig) -> RedisBroker: ) -SCENARIOS: dict[str, type] = { +SCENARIOS: dict[str, type[Any]] = { "steady": SteadyStateScenario, "burst": BurstScenario, "node_failure": NodeFailureScenario, diff --git a/turnstone/ui/colors.py b/turnstone/ui/colors.py index c813f26f..5d4f362a 100644 --- a/turnstone/ui/colors.py +++ b/turnstone/ui/colors.py @@ -22,25 +22,25 @@ CYAN = "\033[36m" if _use_color else "" GRAY = "\033[90m" if _use_color else "" -def red(s): +def red(s: str) -> str: return f"{RED}{s}{RESET}" -def yellow(s): +def yellow(s: str) -> str: return f"{YELLOW}{s}{RESET}" -def dim(s): +def dim(s: str) -> str: return f"{DIM}{s}{RESET}" -def bold(s): +def bold(s: str) -> str: return f"{BOLD}{s}{RESET}" -def cyan(s): +def cyan(s: str) -> str: return f"{CYAN}{s}{RESET}" -def green(s): +def green(s: str) -> str: return f"{GREEN}{s}{RESET}" diff --git a/turnstone/ui/markdown.py b/turnstone/ui/markdown.py index b8a01253..96166cc6 100644 --- a/turnstone/ui/markdown.py +++ b/turnstone/ui/markdown.py @@ -13,7 +13,7 @@ class MarkdownRenderer: (fenced code blocks) track state across lines. """ - def __init__(self): + def __init__(self) -> None: self.in_code_block = False self._buf = "" @@ -52,9 +52,7 @@ class MarkdownRenderer: # Inline formatting (order matters: bold before italic) line = re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", line) line = re.sub(r"__(.+?)__", f"{BOLD}\\1{RESET}", line) - line = re.sub( - r"(? None: self._stop_event.clear() self._thread = threading.Thread(target=self._spin, daemon=True) self._thread.start() - def _spin(self): + def _spin(self) -> None: i = 0 while not self._stop_event.wait(0.08): frame = self._FRAMES[i % len(self._FRAMES)] @@ -29,7 +29,7 @@ class Spinner: sys.stderr.flush() i += 1 - def stop(self): + def stop(self) -> None: if self._stop_event.is_set(): return self._stop_event.set() @@ -39,9 +39,9 @@ class Spinner: sys.stderr.write("\r\033[2K") sys.stderr.flush() - def __enter__(self): + def __enter__(self) -> "Spinner": self.start() return self - def __exit__(self, *_): + def __exit__(self, *_: object) -> None: self.stop() diff --git a/turnstone/ui/static/app.js b/turnstone/ui/static/app.js index b7e4e1a4..b9093e39 100644 --- a/turnstone/ui/static/app.js +++ b/turnstone/ui/static/app.js @@ -243,7 +243,7 @@ function toggleTheme() { var current = document.documentElement.dataset.theme; var next = current === "light" ? "" : "light"; document.documentElement.dataset.theme = next; - localStorage.setItem("pcode-theme", next || "dark"); + localStorage.setItem("turnstone-theme", next || "dark"); updateThemeMenuItem(); } function updateThemeMenuItem() { @@ -265,7 +265,7 @@ function updateThemeMenuItem() { ); } (function () { - if (localStorage.getItem("pcode-theme") === "light") + if (localStorage.getItem("turnstone-theme") === "light") document.documentElement.dataset.theme = "light"; updateThemeMenuItem(); })(); @@ -613,7 +613,7 @@ function switchTab(wsId) { // Push history entry so back button can retrace tab navigation. if (!_historyNavigation) { - history.pushState({ pcode: "workstream", wsId: wsId }, ""); + history.pushState({ turnstone: "workstream", wsId: wsId }, ""); } } @@ -1829,7 +1829,7 @@ authFetch("/api/workstreams") } connectGlobalSSE(); // Seed the history stack so back-from-workstream returns here. - history.replaceState({ pcode: "dashboard" }, ""); + history.replaceState({ turnstone: "dashboard" }, ""); showDashboard(); }); @@ -1837,7 +1837,7 @@ authFetch("/api/workstreams") window.addEventListener("popstate", function (e) { _historyNavigation = true; try { - if (e.state && e.state.pcode === "workstream") { + if (e.state && e.state.turnstone === "workstream") { // Navigating to a workstream state (forward, or back between tabs). if (dashboardVisible) hideDashboard(); if (e.state.wsId && workstreams[e.state.wsId]) switchTab(e.state.wsId); diff --git a/turnstone/ui/static/index.html b/turnstone/ui/static/index.html index 352e7487..d17aaedf 100644 --- a/turnstone/ui/static/index.html +++ b/turnstone/ui/static/index.html @@ -4,6 +4,9 @@ turnstone + + + @@ -22,10 +25,10 @@ -

pcode

+

turnstone

- +
diff --git a/turnstone/ui/static/style.css b/turnstone/ui/static/style.css index 9ce24e9e..4c03f64f 100644 --- a/turnstone/ui/static/style.css +++ b/turnstone/ui/static/style.css @@ -1,124 +1,450 @@ +/* ========================================================================== + turnstone server UI — "Instrument Panel" aesthetic + Shared design system with turnstone console + ========================================================================== */ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + :root { - --bg: #1a1b26; --bg-surface: #24283b; --bg-highlight: #292e42; - --fg: #c8d1f5; --fg-dim: #828db5; --fg-bright: #a9b1d6; - --accent: #7aa2f7; --green: #9ece6a; --red: #f7768e; - --yellow: #e0af68; --cyan: #7dcfff; --magenta: #bb9af7; - --border: #3b4261; --code-bg: #1f2335; - --radius: 8px; + /* Surface palette — deep charcoal with blue undertone */ + --bg: #0b0f19; + --bg-surface: #111827; + --bg-highlight: #1c2333; + --bg-elevated: #1f2a3d; + + /* Text hierarchy */ + --fg: #d1d5e4; + --fg-dim: #8a93ad; + --fg-bright: #e8ecf4; + + /* Accent — warm amber (the signature color) */ + --accent: #e5a042; + --accent-dim: rgba(229, 160, 66, 0.15); + --accent-glow: rgba(229, 160, 66, 0.08); + + /* Semantic indicators */ + --green: #34d399; + --red: #f87171; + --yellow: #fbbf24; + --cyan: #67e8f9; + --magenta: #c084fc; + + /* Glow variants for LED effects */ + --green-glow: rgba(52, 211, 153, 0.25); + --red-glow: rgba(248, 113, 113, 0.25); + --yellow-glow: rgba(251, 191, 36, 0.25); + --accent-glow-strong: rgba(229, 160, 66, 0.3); + --cyan-glow: rgba(103, 232, 249, 0.2); + + /* Structure */ + --border: rgba(255, 255, 255, 0.06); + --border-strong: rgba(255, 255, 255, 0.1); + --code-bg: #0d1117; + --radius: 6px; + --radius-sm: 3px; --dash-grid: 72px 120px 100px 1fr 60px 48px; + + /* Typography */ + --font-mono: 'IBM Plex Mono', 'SF Mono', 'Cascadia Code', monospace; + --font-display: 'Outfit', 'Segoe UI', system-ui, sans-serif; } + [data-theme="light"] { - --bg: #f5f5f5; --bg-surface: #ffffff; --bg-highlight: #e8e8ec; - --fg: #1a1a2e; --fg-dim: #4b5563; --fg-bright: #374151; - --accent: #1d4ed8; --green: #15803d; --red: #b91c1c; - --yellow: #92400e; --cyan: #0e7490; --magenta: #7e22ce; - --border: #d1d5db; --code-bg: #eaeaef; + --bg: #f3f4f6; + --bg-surface: #ffffff; + --bg-highlight: #e9ecf0; + --bg-elevated: #f9fafb; + --fg: #1e293b; + --fg-dim: #576275; + --fg-bright: #0f172a; + --accent: #8c5e1b; + --accent-dim: rgba(140, 94, 27, 0.1); + --accent-glow: rgba(140, 94, 27, 0.05); + --green: #047857; + --red: #dc2626; + --yellow: #b45309; + --cyan: #0e7490; + --magenta: #7c3aed; + --green-glow: rgba(4, 120, 87, 0.25); + --red-glow: rgba(220, 38, 38, 0.25); + --yellow-glow: rgba(180, 83, 9, 0.25); + --accent-glow-strong: rgba(140, 94, 27, 0.15); + --cyan-glow: rgba(14, 116, 144, 0.2); + --border: rgba(0, 0, 0, 0.08); + --border-strong: rgba(0, 0, 0, 0.12); + --code-bg: #f0f1f5; } -html, body { height: 100%; background: var(--bg); color: var(--fg); font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 14px; } -body { display: flex; flex-direction: column; } -/* Header */ -#header { padding: 8px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; flex-shrink: 0; } -#header h1 { font-size: 16px; color: var(--accent); font-weight: 600; } -#status-bar { font-size: 12px; color: var(--fg-dim); margin-left: auto; } -.skip-permissions-warning { font-size: 12px; color: var(--yellow); font-weight: 600; padding: 2px 8px; border: 1px solid var(--yellow); border-radius: var(--radius); } +html, body { + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +body { + display: flex; + flex-direction: column; + background-image: + radial-gradient(ellipse at 20% 0%, rgba(229, 160, 66, 0.03) 0%, transparent 50%), + radial-gradient(ellipse at 80% 100%, rgba(103, 232, 249, 0.02) 0%, transparent 50%); +} -/* Hamburger menu */ +/* ========================================================================== + Header + ========================================================================== */ +#header { + padding: 10px 16px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-strong); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + position: relative; +} +#header::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-dim), transparent); +} +#header h1 { + font-family: var(--font-display); + font-size: 15px; + font-weight: 700; + color: var(--accent); + letter-spacing: 0.02em; +} +#model-name { + font-size: 11px; + color: var(--fg-dim); + font-family: var(--font-display); + letter-spacing: 0.02em; +} +#status-bar { font-size: 11px; color: var(--fg-dim); margin-left: auto; } +#status-bar.disconnected { color: var(--red); } +.skip-permissions-warning { + font-family: var(--font-display); + font-size: 11px; + font-weight: 600; + color: var(--yellow); + padding: 2px 8px; + border: 1px solid var(--yellow); + border-radius: var(--radius-sm); + letter-spacing: 0.02em; +} + +.header-btn { + background: none; + border: 1px solid var(--border-strong); + color: var(--fg-dim); + border-radius: var(--radius-sm); + padding: 3px 10px; + cursor: pointer; + font: inherit; + font-size: 11px; + transition: background 0.15s, border-color 0.15s, color 0.15s; + letter-spacing: 0.02em; +} +.header-btn:hover { + background: var(--bg-highlight); + color: var(--fg-bright); + border-color: var(--accent-dim); +} + +/* ========================================================================== + Hamburger menu + ========================================================================== */ #hamburger-wrap { position: relative; } -#hamburger-btn { background:none; border:1px solid var(--border); color:var(--fg); border-radius:var(--radius); width:32px; height:32px; cursor:pointer; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; padding:0; flex-shrink:0; } -#hamburger-btn:hover { background:var(--bg-highlight); border-color:var(--fg-dim); } -#hamburger-btn:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } -#hamburger-btn span { display:block; width:14px; height:2px; background:var(--fg); border-radius:1px; } -#hamburger-menu { display:none; position:absolute; top:calc(100% + 6px); left:0; background:var(--bg-surface); border:1px solid var(--border); border-radius:var(--radius); min-width:180px; box-shadow:0 4px 16px rgba(0,0,0,0.3); z-index:40; overflow:hidden; } -[data-theme="light"] #hamburger-menu { box-shadow:0 4px 16px rgba(0,0,0,0.12); } -#hamburger-menu.open { display:block; } -.hmenu-item { display:flex; align-items:center; gap:10px; width:100%; padding:10px 14px; background:none; border:none; color:var(--fg); font:inherit; font-size:13px; cursor:pointer; text-align:left; white-space:nowrap; } -.hmenu-item:hover { background:var(--bg-highlight); } -.hmenu-item:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } -.hmenu-item .hmenu-icon { width:16px; text-align:center; font-size:14px; opacity:0.8; } -.hmenu-sep { height:1px; background:var(--border); margin:4px 0; } -@media (max-width:600px) { - #hamburger-btn { width:40px; height:40px; } - .hmenu-item { padding:12px 14px; } +#hamburger-btn { + background: none; + border: 1px solid var(--border-strong); + color: var(--fg); + border-radius: var(--radius-sm); + width: 32px; + height: 32px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} +#hamburger-btn:hover { background: var(--bg-highlight); border-color: var(--accent-dim); } +#hamburger-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +#hamburger-btn span { display: block; width: 14px; height: 2px; background: var(--fg); border-radius: 1px; } +#hamburger-menu { + display: none; + position: absolute; + top: calc(100% + 6px); + left: 0; + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + min-width: 180px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + z-index: 40; + overflow: hidden; +} +[data-theme="light"] #hamburger-menu { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); } +#hamburger-menu.open { display: block; } +.hmenu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + background: none; + border: none; + color: var(--fg); + font: inherit; + font-family: var(--font-display); + font-size: 13px; + cursor: pointer; + text-align: left; + white-space: nowrap; + transition: background 0.1s; +} +.hmenu-item:hover { background: var(--bg-highlight); } +.hmenu-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.hmenu-item .hmenu-icon { width: 16px; text-align: center; font-size: 14px; opacity: 0.8; } +.hmenu-sep { height: 1px; background: var(--border-strong); margin: 4px 0; } +@media (max-width: 600px) { + #hamburger-btn { width: 40px; height: 40px; } + .hmenu-item { padding: 12px 14px; } + .ws-tab .tab-close { opacity: 1; padding: 4px 6px; font-size: 16px; } } -/* Tab bar */ -#tab-bar { display: flex; align-items: center; gap: 2px; padding: 4px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); flex-shrink: 0; overflow-x: auto; } -#tab-bar::-webkit-scrollbar { height: 4px; } -#tab-bar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } -.ws-tab { padding: 5px 10px; border-radius: 6px 6px 0 0; cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); border-bottom: none; color: var(--fg-dim); white-space: nowrap; user-select: none; position: relative; } +/* ========================================================================== + Tab bar + ========================================================================== */ +#tab-bar { + display: flex; + align-items: center; + gap: 2px; + padding: 4px 16px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + overflow-x: auto; +} +#tab-bar::-webkit-scrollbar { height: 3px; } +#tab-bar::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 2px; } + +.ws-tab { + padding: 6px 10px; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-bottom: none; + color: var(--fg-dim); + white-space: nowrap; + user-select: none; + position: relative; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} .ws-tab:hover { background: var(--bg-highlight); color: var(--fg-bright); } -.ws-tab.active { background: var(--bg-highlight); color: var(--fg-bright); border-bottom: 2px solid var(--accent); } +.ws-tab.active { + background: var(--bg-highlight); + color: var(--fg-bright); + border-bottom: 2px solid var(--accent); +} + +/* Tab state indicators — LED glow */ .ws-tab .tab-indicator { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .ws-tab .tab-indicator[data-state="idle"] { background: var(--fg-dim); opacity: 0.3; } -.ws-tab .tab-indicator[data-state="thinking"] { background: var(--cyan); animation: pulse 1.5s ease-in-out infinite; } -.ws-tab .tab-indicator[data-state="running"] { background: var(--green); border-radius: 2px; animation: pulse 1s ease-in-out infinite; } -.ws-tab .tab-indicator[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); animation: pulse 1s ease-in-out infinite; } -.ws-tab .tab-indicator[data-state="error"] { background: var(--red); } -@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } -@media (prefers-reduced-motion: reduce) { - .ws-tab .tab-indicator[data-state="thinking"], - .ws-tab .tab-indicator[data-state="running"], - .ws-tab .tab-indicator[data-state="attention"], - .dash-state-dot[data-state="running"], - .dash-state-dot[data-state="thinking"], - .dash-state-dot[data-state="attention"] { animation: none; opacity: 1; } - .thinking-indicator::after { animation: none; content: '...'; } -} -.ws-tab .tab-close { background: none; border: none; color: var(--fg-dim); font-size: 14px; cursor: pointer; padding: 0 2px; line-height: 1; opacity:0; transition:opacity 0.15s; } -.ws-tab:hover .tab-close, .ws-tab:focus-within .tab-close, .ws-tab .tab-close:focus-visible { opacity:1; } -.ws-tab .tab-close:hover { color: var(--red); } -#new-tab-btn { background: none; border: 1px dashed var(--border); color: var(--fg-dim); border-radius: 6px 6px 0 0; padding: 5px 10px; cursor: pointer; font-family: inherit; font-size: 14px; line-height: 1; } -#new-tab-btn:hover { background: var(--bg-highlight); color: var(--fg-bright); border-color: var(--accent); } +.ws-tab .tab-indicator[data-state="thinking"] { background: var(--cyan); box-shadow: 0 0 6px var(--cyan-glow); animation: pulse 1.5s ease-in-out infinite; will-change: opacity; } +.ws-tab .tab-indicator[data-state="running"] { background: var(--green); border-radius: 2px; box-shadow: 0 0 6px var(--green-glow); animation: pulse 1s ease-in-out infinite; will-change: opacity; } +.ws-tab .tab-indicator[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); animation: pulse 1s ease-in-out infinite; will-change: opacity; } +.ws-tab .tab-indicator[data-state="error"] { background: var(--red); box-shadow: 0 0 4px var(--red-glow); } -/* Messages */ -#messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; } -.msg { padding: 10px 14px; border-radius: var(--radius); max-width: 95%; line-height: 1.55; word-wrap: break-word; overflow-wrap: break-word; } -.msg-user { background: var(--bg-highlight); border: 1px solid var(--border); align-self: flex-end; color: var(--fg-bright); } +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.ws-tab .tab-close { + background: none; + border: none; + color: var(--fg-dim); + font-size: 14px; + cursor: pointer; + padding: 0 2px; + line-height: 1; + opacity: 0; + transition: opacity 0.15s, color 0.1s; +} +.ws-tab:hover .tab-close, .ws-tab:focus-within .tab-close, .ws-tab .tab-close:focus-visible { opacity: 1; } +.ws-tab .tab-close:hover { color: var(--red); } + +#new-tab-btn { + background: none; + border: 1px dashed var(--border-strong); + color: var(--fg-dim); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + padding: 6px 10px; + cursor: pointer; + font-family: inherit; + font-size: 14px; + line-height: 1; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +#new-tab-btn:hover { background: var(--bg-highlight); color: var(--accent); border-color: var(--accent); } + +/* ========================================================================== + Messages + ========================================================================== */ +#messages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 14px; +} +.msg { + padding: 10px 14px; + border-radius: var(--radius); + max-width: 95%; + line-height: 1.6; + word-wrap: break-word; + overflow-wrap: break-word; +} +.msg-user { + background: var(--bg-highlight); + border: 1px solid var(--border-strong); + align-self: flex-end; + color: var(--fg-bright); +} .msg-assistant { align-self: flex-start; } -.msg-info { color: var(--cyan); font-size: 13px; padding: 4px 14px; white-space: pre-wrap; font-family: inherit; } -.msg-error { color: var(--red); font-size: 13px; padding: 4px 14px; } -.msg-tool { background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--yellow); font-size: 13px; padding: 8px 12px; align-self: flex-start; max-width: 95%; } -.msg-tool .tool-header { color: var(--yellow); font-weight: 600; margin-bottom: 4px; } +.msg-info { color: var(--cyan); font-size: 12px; padding: 4px 14px; white-space: pre-wrap; font-family: inherit; } +.msg-error { color: var(--red); font-size: 12px; padding: 4px 14px; } +.msg-tool { + background: var(--bg-surface); + border: 1px solid var(--border); + border-left: 3px solid var(--yellow); + font-size: 12px; + padding: 8px 12px; + align-self: flex-start; + max-width: 95%; +} +.msg-tool .tool-header { color: var(--yellow); font-weight: 600; margin-bottom: 4px; font-size: 11px; } .msg-tool .tool-preview { color: var(--fg-dim); white-space: pre-wrap; font-size: 12px; max-height: 300px; overflow-y: auto; } /* Streaming content */ .reasoning { color: var(--fg-dim); font-style: italic; } -.thinking-indicator { color: var(--fg-dim); font-size: 13px; padding: 6px 14px; } +.thinking-indicator { color: var(--fg-dim); font-size: 12px; padding: 6px 14px; } .thinking-indicator::after { content: '...'; animation: dots 1.5s steps(3, end) infinite; } @keyframes dots { 0% { content: '.'; } 33% { content: '..'; } 66% { content: '...'; } } -/* Markdown styling */ -.msg-assistant h1, .msg-assistant h2, .msg-assistant h3 { color: var(--accent); margin: 8px 0 4px; } -.msg-assistant h1 { font-size: 18px; } .msg-assistant h2 { font-size: 16px; } .msg-assistant h3 { font-size: 14px; } +/* ========================================================================== + Markdown styling + ========================================================================== */ +.msg-assistant h1, .msg-assistant h2, .msg-assistant h3 { color: var(--accent); margin: 8px 0 4px; font-family: var(--font-display); } +.msg-assistant h1 { font-size: 18px; } +.msg-assistant h2 { font-size: 16px; } +.msg-assistant h3 { font-size: 14px; } .msg-assistant strong { color: var(--fg-bright); } .msg-assistant em { color: var(--magenta); } -.msg-assistant code { background: var(--code-bg); padding: 2px 5px; border-radius: 3px; font-size: 13px; } -.msg-assistant pre { background: var(--code-bg); padding: 10px 12px; border-radius: var(--radius); overflow-x: auto; margin: 6px 0; border: 1px solid var(--border); } -.msg-assistant pre code { background: none; padding: 0; } +.msg-assistant code { + background: var(--code-bg); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 12px; + border: 1px solid var(--border); +} +.msg-assistant pre { + background: var(--code-bg); + padding: 12px 14px; + border-radius: var(--radius); + overflow-x: auto; + margin: 8px 0; + border: 1px solid var(--border); +} +.msg-assistant pre code { background: none; padding: 0; border: none; } .msg-assistant ul, .msg-assistant ol { margin: 4px 0 4px 20px; } .msg-assistant li { margin: 2px 0; } .msg-assistant a { color: var(--accent); text-decoration: underline; } -.msg-assistant blockquote { border-left: 3px solid var(--border); padding-left: 10px; color: var(--fg-dim); margin: 4px 0; } +.msg-assistant blockquote { border-left: 3px solid var(--accent-dim); padding-left: 12px; color: var(--fg-dim); margin: 6px 0; } .msg-assistant hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; } .msg-assistant p { margin: 4px 0; } -/* Input area */ -#input-area { padding: 12px 16px; background: var(--bg-surface); border-top: 1px solid var(--border); display: flex; gap: 8px; flex-shrink: 0; } -#input-area textarea { flex: 1; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; font-family: inherit; font-size: 14px; resize: none; outline: none; min-height: 40px; max-height: 200px; } -#input-area textarea:focus { border-color: var(--accent); } -#input-area button { background: var(--accent); color: var(--bg); border: none; border-radius: var(--radius); padding: 8px 16px; font-family: inherit; font-size: 14px; cursor: pointer; font-weight: 600; } -#input-area button:hover { opacity: 0.9; } -#input-area button:disabled { opacity: 0.4; cursor: not-allowed; } +/* ========================================================================== + Input area + ========================================================================== */ +#input-area { + padding: 12px 16px; + background: var(--bg-surface); + border-top: 1px solid var(--border-strong); + display: flex; + gap: 8px; + flex-shrink: 0; +} +#input-area textarea { + flex: 1; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 9px 12px; + font-family: var(--font-mono); + font-size: 13px; + resize: none; + outline: none; + min-height: 40px; + max-height: 200px; + transition: border-color 0.15s, box-shadow 0.15s; +} +#input-area textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } +#input-area button { + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius-sm); + padding: 9px 18px; + font-family: var(--font-display); + font-size: 13px; + cursor: pointer; + font-weight: 600; + letter-spacing: 0.02em; + transition: filter 0.15s; +} +#input-area button:hover { filter: brightness(1.1); } +#input-area button:disabled { opacity: 0.35; cursor: not-allowed; filter: none; } -/* Inline approval blocks */ -.approval-block { background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--yellow); border-radius: var(--radius); padding: 0; align-self: flex-start; max-width: 95%; font-size: 13px; } +/* ========================================================================== + Inline approval blocks + ========================================================================== */ +.approval-block { + background: var(--bg-surface); + border: 1px solid var(--border); + border-left: 3px solid var(--yellow); + border-radius: var(--radius); + padding: 0; + align-self: flex-start; + max-width: 95%; + font-size: 12px; +} .approval-block.approved { border-left-color: var(--green); } .approval-block.denied { border-left-color: var(--red); } -.approval-tool { padding: 6px 12px; border-bottom: 1px solid var(--border); } +.approval-tool { padding: 8px 12px; border-bottom: 1px solid var(--border); } .approval-tool:last-of-type { border-bottom: none; } -.approval-tool .tool-name { color: var(--yellow); font-weight: 600; font-size: 12px; margin-bottom: 2px; } +.approval-tool .tool-name { color: var(--yellow); font-weight: 600; font-size: 11px; margin-bottom: 3px; } .approval-tool .tool-cmd { color: var(--fg-bright); white-space: pre-wrap; word-break: break-all; } .approval-tool .tool-cmd .dollar { color: var(--green); } .approval-tool .tool-diff { white-space: pre-wrap; font-size: 12px; margin-top: 4px; } @@ -127,173 +453,536 @@ body { display: flex; flex-direction: column; } .approval-tool .tool-diff .diff-warn { color: var(--yellow); } .approval-prompt { padding: 8px 12px; font-size: 12px; border-top: 1px solid var(--border); background: var(--bg-highlight); } .approval-actions { display: flex; gap: 6px; margin-bottom: 6px; } -.approval-btn { background: var(--bg); border: 1px solid var(--border); color: var(--fg-bright); border-radius: 4px; padding: 4px 12px; font-family: inherit; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; } +.approval-btn { + background: var(--bg); + border: 1px solid var(--border-strong); + color: var(--fg-bright); + border-radius: var(--radius-sm); + padding: 4px 12px; + font-family: inherit; + font-size: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + transition: background 0.1s, border-color 0.1s, color 0.1s; +} .approval-btn:hover { background: var(--bg-highlight); } -.approval-btn .key { display: inline-block; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; font-size: 11px; color: var(--accent); font-weight: 600; min-width: 18px; text-align: center; } +.approval-btn .key { + display: inline-block; + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 0 4px; + font-size: 10px; + color: var(--accent); + font-weight: 600; + min-width: 18px; + text-align: center; +} .btn-approve:hover { border-color: var(--green); color: var(--green); } .btn-deny:hover { border-color: var(--red); color: var(--red); } .btn-always:hover { border-color: var(--accent); color: var(--accent); } -.approval-feedback-input { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; font-family: inherit; font-size: 12px; outline: none; } +.approval-feedback-input { + width: 100%; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 4px 8px; + font-family: inherit; + font-size: 12px; + outline: none; + transition: border-color 0.15s; +} .approval-feedback-input:focus { border-color: var(--accent); } .approval-feedback-input::placeholder { color: var(--fg-dim); } .approval-badge { padding: 6px 12px; font-size: 12px; font-weight: 600; border-top: 1px solid var(--border); } .approval-badge.badge-approved { color: var(--green); } .approval-badge.badge-denied { color: var(--red); } -.tool-output { padding: 6px 12px; background: var(--code-bg); border-top: 1px solid var(--border); white-space: pre-wrap; font-size: 12px; color: var(--fg-dim); max-height: 300px; overflow-y: auto; } +.tool-output { + padding: 8px 12px; + background: var(--code-bg); + border-top: 1px solid var(--border); + white-space: pre-wrap; + font-size: 12px; + color: var(--fg-dim); + max-height: 300px; + overflow-y: auto; +} .tool-output.collapsed { max-height: 150px; position: relative; } -.tool-output.collapsed::after { content: 'click to expand'; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; padding: 4px; background: linear-gradient(transparent, var(--code-bg) 60%); color: var(--fg-dim); font-size: 11px; cursor: pointer; } +.tool-output.collapsed::after { + content: 'click to expand'; + position: absolute; + bottom: 0; + left: 0; + right: 0; + text-align: center; + padding: 6px; + background: linear-gradient(transparent, var(--code-bg) 60%); + color: var(--fg-dim); + font-size: 10px; + font-family: var(--font-display); + cursor: pointer; + letter-spacing: 0.03em; +} -/* Plan review dialog */ -#plan-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100; justify-content: center; align-items: center; } +/* ========================================================================== + Plan review dialog + ========================================================================== */ +#plan-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 100; + justify-content: center; + align-items: center; +} #plan-overlay.active { display: flex; } -#plan-dialog { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; max-width: 700px; width: 90%; max-height: 80vh; overflow-y: auto; } -#plan-dialog h3 { color: var(--accent); margin-bottom: 12px; font-size: 15px; } -#plan-content { white-space: pre-wrap; font-size: 13px; color: var(--fg-bright); margin-bottom: 16px; max-height: 50vh; overflow-y: auto; background: var(--code-bg); padding: 12px; border-radius: var(--radius); } -#plan-feedback { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; font-family: inherit; font-size: 13px; margin-bottom: 12px; } +#plan-dialog { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 24px; + max-width: 700px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5); + position: relative; +} +#plan-dialog::before { + content: ''; + position: absolute; + top: -1px; + left: 20%; + right: 20%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + border-radius: 1px; +} +#plan-dialog h3 { + font-family: var(--font-display); + color: var(--accent); + margin-bottom: 14px; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.04em; +} +#plan-content { + white-space: pre-wrap; + font-size: 12px; + color: var(--fg-bright); + margin-bottom: 16px; + max-height: 50vh; + overflow-y: auto; + background: var(--code-bg); + padding: 14px; + border-radius: var(--radius); + border: 1px solid var(--border); +} +#plan-feedback { + width: 100%; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 9px 12px; + font-family: inherit; + font-size: 13px; + margin-bottom: 14px; + outline: none; + transition: border-color 0.15s; +} +#plan-feedback:focus { border-color: var(--accent); } #plan-buttons { display: flex; gap: 8px; justify-content: flex-end; } -#plan-buttons button { padding: 8px 20px; border: none; border-radius: var(--radius); font-family: inherit; font-size: 13px; cursor: pointer; font-weight: 600; } +#plan-buttons button { + padding: 8px 20px; + border: none; + border-radius: var(--radius-sm); + font-family: var(--font-display); + font-size: 13px; + cursor: pointer; + font-weight: 600; + letter-spacing: 0.02em; + transition: filter 0.15s; +} +#plan-buttons button:hover { filter: brightness(1.1); } #btn-plan-approve { background: var(--green); color: var(--bg); } #btn-plan-reject { background: var(--red); color: var(--bg); } -/* Scrollbar */ -::-webkit-scrollbar { width: 8px; } -::-webkit-scrollbar-track { background: var(--bg); } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +/* ========================================================================== + Scrollbar + ========================================================================== */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); } -/* Focus indicators */ +/* ========================================================================== + Focus indicators + ========================================================================== */ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -#input-area textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); } +#input-area textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } .approval-btn:focus-visible { outline-offset: 1px; } -/* Empty state */ -.empty-state { color: var(--fg-dim); text-align: center; padding: 48px 16px; font-size: 13px; } +/* ========================================================================== + Empty state + ========================================================================== */ +.empty-state { + color: var(--fg-dim); + text-align: center; + padding: 48px 16px; + font-family: var(--font-display); + font-size: 13px; + font-style: italic; + opacity: 0.7; +} -/* Disconnected status */ -#status-bar.disconnected { color: var(--red); } +/* ========================================================================== + Dashboard overlay + ========================================================================== */ +.dashboard-overlay { display: none; position: fixed; inset: 0; background: var(--bg); z-index: 50; overflow-y: auto; } +.dashboard-overlay.active { display: flex; justify-content: center; align-items: flex-start; } +.dashboard-content { width: 100%; max-width: 960px; padding: 32px 20px 24px; } +.dashboard-input-row { display: flex; gap: 8px; margin-bottom: 24px; } +.dashboard-input { + flex: 1; + background: var(--bg-surface); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 12px 16px; + font: inherit; + font-size: 14px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +.dashboard-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } +.dashboard-input::placeholder { color: var(--fg-dim); } +.dashboard-new-btn { + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius); + padding: 12px 20px; + font: inherit; + font-family: var(--font-display); + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + letter-spacing: 0.02em; + transition: filter 0.15s; +} +.dashboard-new-btn:hover { filter: brightness(1.1); } +.dashboard-new-btn:disabled { opacity: 0.35; cursor: not-allowed; filter: none; } +.dashboard-section { margin-bottom: 24px; } +.dashboard-section-title { + font-family: var(--font-display); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent); + margin-bottom: 12px; +} +.dashboard-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; } +.dashboard-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.dashboard-card:hover { border-color: var(--accent); background: var(--bg-highlight); } +.dashboard-card:active { background: var(--bg-highlight); border-color: var(--accent); } +.dashboard-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.dashboard-card .card-title { font-size: 13px; color: var(--fg-bright); font-weight: 500; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dashboard-card .card-meta { font-size: 11px; color: var(--fg-dim); } +.dashboard-empty { color: var(--fg-dim); font-size: 12px; padding: 12px 0; font-family: var(--font-display); font-style: italic; opacity: 0.7; } -/* Dashboard overlay */ -.dashboard-overlay { display:none; position:fixed; inset:0; background:var(--bg); z-index:50; overflow-y:auto; } -.dashboard-overlay.active { display:flex; justify-content:center; align-items:flex-start; } -.dashboard-content { width:100%; max-width:960px; padding:32px 16px 24px; } -.dashboard-input-row { display:flex; gap:8px; margin-bottom:24px; } -.dashboard-input { flex:1; background:var(--bg-surface); color:var(--fg); border:1px solid var(--border); border-radius:var(--radius); padding:12px 16px; font:inherit; font-size:15px; outline:none; } -.dashboard-input:focus { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 30%,transparent); } -.dashboard-input::placeholder { color:var(--fg-dim); } -.dashboard-new-btn { background:var(--accent); color:var(--bg); border:none; border-radius:var(--radius); padding:12px 20px; font:inherit; font-size:14px; font-weight:600; cursor:pointer; white-space:nowrap; } -.dashboard-new-btn:hover { opacity:0.9; } -.dashboard-new-btn:disabled { opacity:0.4; cursor:not-allowed; } -.dashboard-section { margin-bottom:24px; } -.dashboard-section-title { font-size:12px; text-transform:uppercase; letter-spacing:0.05em; color:var(--fg-dim); margin-bottom:10px; font-weight:600; } -.dashboard-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:10px; } -.dashboard-card { background:var(--bg-surface); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; cursor:pointer; transition:border-color 0.15s; } -.dashboard-card:hover { border-color:var(--accent); } -.dashboard-card:active { background:var(--bg-highlight); border-color:var(--accent); } -.dashboard-card:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } -.dashboard-card .card-title { font-size:13px; color:var(--fg-bright); font-weight:600; margin-bottom:4px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.dashboard-card .card-meta { font-size:11px; color:var(--fg-dim); } -.dashboard-empty { color:var(--fg-dim); font-size:13px; padding:8px 0; } +/* ========================================================================== + Dashboard table — shared with console + ========================================================================== */ +.dash-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + background: var(--code-bg); + border-radius: var(--radius) var(--radius) 0 0; + border: 1px solid var(--border); + border-bottom: none; +} +.dash-header-title { + font-family: var(--font-display); + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; +} +.dash-header-summary { color: var(--fg-dim); font-size: 11px; } -/* Dashboard header bar */ -.dash-header { display:flex; justify-content:space-between; align-items:center; padding:8px 16px; background:var(--code-bg); border-radius:var(--radius) var(--radius) 0 0; } -.dash-header-title { color:var(--accent); font-size:12px; font-weight:bold; letter-spacing:0.05em; } -.dash-header-summary { color:var(--fg-dim); font-size:11px; } +.dash-colheaders { + display: grid; + grid-template-columns: var(--dash-grid); + padding: 6px 16px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-strong); + font-size: 10px; + font-family: var(--font-display); + font-weight: 600; + color: var(--fg-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + position: sticky; + top: 0; + z-index: 10; +} +.dash-col-tokens, .dash-col-ctx { text-align: right; } -/* Dashboard column headers */ -.dash-colheaders { display:grid; grid-template-columns:var(--dash-grid); padding:4px 16px; background:var(--bg-surface); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-dim); text-transform:uppercase; letter-spacing:0.03em; } -.dash-col-tokens,.dash-col-ctx { text-align:right; } +.dash-table { min-height: 40px; } +.dash-row { + position: relative; + border-left: 3px solid transparent; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} +.dash-row:nth-child(odd) { background: var(--bg); } +.dash-row:nth-child(even) { background: rgba(255, 255, 255, 0.01); } +.dash-row:hover { background: var(--bg-highlight); box-shadow: inset 0 0 0 1px var(--border); } +.dash-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.dash-row[data-state="running"] { border-left-color: var(--green); } +.dash-row[data-state="thinking"] { border-left-color: var(--cyan); } +.dash-row[data-state="attention"] { border-left-color: var(--yellow); } +.dash-row[data-state="idle"] { border-left-color: var(--fg-dim); opacity: 0.6; } +.dash-row[data-state="error"] { border-left-color: var(--red); } +.dash-row-main { display: grid; grid-template-columns: var(--dash-grid); padding: 9px 16px 3px; align-items: center; font-size: 12px; } +.dash-row-sub { padding: 0 16px 8px 88px; font-size: 11px; color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dash-row-sub.sub-attention { color: var(--yellow); } -/* Dashboard table */ -.dash-table { min-height:40px; } -.dash-row { position:relative; border-left:3px solid transparent; cursor:pointer; transition:background 0.15s; } -.dash-row:nth-child(odd) { background:var(--bg); } -.dash-row:nth-child(even) { background:var(--bg-surface); } -.dash-row:hover { background:var(--bg-highlight); box-shadow:inset 0 0 0 1px var(--border); } -.dash-row:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } -.dash-row[data-state="running"] { border-left-color:var(--green); } -.dash-row[data-state="thinking"] { border-left-color:var(--accent); } -.dash-row[data-state="attention"] { border-left-color:var(--yellow); } -.dash-row[data-state="idle"] { border-left-color:var(--fg-dim); opacity:0.7; } -.dash-row[data-state="error"] { border-left-color:var(--red); } -.dash-row-main { display:grid; grid-template-columns:var(--dash-grid); padding:8px 16px 2px; align-items:center; font-size:12px; } -.dash-row-sub { padding:0 16px 8px 88px; font-size:11px; color:var(--fg-dim); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } -.dash-row-sub.sub-attention { color:var(--yellow); } +/* State dots with LED glow */ +.dash-cell-state { display: flex; align-items: center; gap: 6px; font-size: 11px; } +.dash-state-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } +.dash-state-dot[data-state="running"] { background: var(--green); border-radius: 2px; box-shadow: 0 0 6px var(--green-glow); animation: pulse 2s infinite; will-change: opacity; } +.dash-state-dot[data-state="thinking"] { background: var(--cyan); box-shadow: 0 0 6px var(--cyan-glow); animation: pulse 2.2s infinite; will-change: opacity; } +.dash-state-dot[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); animation: pulse 1.8s infinite; will-change: opacity; } +.dash-state-dot[data-state="idle"] { background: var(--fg-dim); opacity: 0.4; } +.dash-state-dot[data-state="error"] { background: var(--red); border-radius: 0; box-shadow: 0 0 6px var(--red-glow); } +.dash-state-label { white-space: nowrap; font-weight: 500; } +.dash-state-label[data-state="running"] { color: var(--green); } +.dash-state-label[data-state="thinking"] { color: var(--cyan); } +.dash-state-label[data-state="attention"] { color: var(--yellow); } +.dash-state-label[data-state="idle"] { color: var(--fg-dim); } +.dash-state-label[data-state="error"] { color: var(--red); } -/* State indicator cell */ -.dash-cell-state { display:flex; align-items:center; gap:6px; font-size:11px; } -.dash-state-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; } -.dash-state-dot[data-state="running"] { background:var(--green); border-radius:2px; animation:pulse 2s infinite; } -.dash-state-dot[data-state="thinking"] { background:var(--accent); animation:pulse 2.2s infinite; } -.dash-state-dot[data-state="attention"] { background:var(--yellow); border-radius:1px; transform:rotate(45deg); animation:pulse 1.8s infinite; } -.dash-state-dot[data-state="idle"] { background:var(--fg-dim); } -.dash-state-dot[data-state="error"] { background:var(--red); border-radius:0; } -.dash-state-label { white-space:nowrap; } -.dash-state-label[data-state="running"] { color:var(--green); } -.dash-state-label[data-state="thinking"] { color:var(--accent); } -.dash-state-label[data-state="attention"] { color:var(--yellow); } -.dash-state-label[data-state="idle"] { color:var(--fg-dim); } -.dash-state-label[data-state="error"] { color:var(--red); } - -/* Table cells */ -.dash-cell-name { font-weight:bold; color:var(--fg-bright); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.dash-row[data-state="idle"] .dash-cell-name { color:var(--fg-dim); } -.dash-cell-node { color:var(--fg-dim); font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.dash-cell-task { color:var(--fg-bright); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.dash-row[data-state="idle"] .dash-cell-task { color:var(--fg-dim); } -.dash-cell-tokens { text-align:right; color:var(--fg-dim); font-size:11px; } -.dash-cell-ctx { text-align:right; font-size:11px; } -.dash-cell-ctx.ctx-low { color:var(--green); } -.dash-cell-ctx.ctx-mid { color:var(--yellow); } -.dash-cell-ctx.ctx-high { color:var(--red); } -.dash-cell-ctx.ctx-danger { color:var(--red); font-weight:bold; } -.dash-cell-ctx.ctx-idle { color:var(--fg-dim); } +.dash-cell-name { font-weight: 500; color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dash-row[data-state="idle"] .dash-cell-name { color: var(--fg-dim); } +.dash-cell-node { color: var(--fg-dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dash-cell-task { color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dash-row[data-state="idle"] .dash-cell-task { color: var(--fg-dim); } +.dash-cell-tokens { text-align: right; color: var(--fg-dim); font-size: 11px; font-variant-numeric: tabular-nums; } +.dash-cell-ctx { text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; } +.dash-cell-ctx.ctx-low { color: var(--green); } +.dash-cell-ctx.ctx-mid { color: var(--yellow); } +.dash-cell-ctx.ctx-high { color: var(--red); } +.dash-cell-ctx.ctx-danger { color: var(--red); font-weight: 600; } +.dash-cell-ctx.ctx-idle { color: var(--fg-dim); } /* Dashboard footer */ -.dash-footer { display:flex; justify-content:space-between; align-items:center; padding:8px 16px; background:var(--code-bg); border-top:1px solid var(--border); border-radius:0 0 var(--radius) var(--radius); font-size:11px; margin-bottom:24px; } -.dash-footer-nodes { color:var(--fg-dim); display:flex; align-items:center; gap:6px; } -.dash-footer-node-dot { width:6px; height:6px; border-radius:50%; background:var(--green); display:inline-block; flex-shrink:0; } -.dash-footer-stats { color:var(--fg-dim); } +.dash-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + background: var(--code-bg); + border-top: 1px solid var(--border); + border-radius: 0 0 var(--radius) var(--radius); + font-size: 11px; + margin-bottom: 24px; +} +.dash-footer-nodes { color: var(--fg-dim); display: flex; align-items: center; gap: 6px; } +.dash-footer-node-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); display: inline-block; flex-shrink: 0; box-shadow: 0 0 4px var(--green-glow); } +.dash-footer-stats { color: var(--fg-dim); font-variant-numeric: tabular-nums; } /* Dashboard responsive */ -@media (max-width:700px) { - :root { --dash-grid:68px 110px 1fr 56px 44px; } - .dash-col-node,.dash-cell-node { display:none; } - .dash-row-sub { padding-left:76px; } +@media (max-width: 700px) { + :root { --dash-grid: 68px 110px 1fr 56px 44px; } + .dash-col-node, .dash-cell-node { display: none; } + .dash-row-sub { padding-left: 76px; } } -@media (max-width:600px) { - .dashboard-input-row { flex-direction:column; } - .dashboard-new-btn { width:100%; } +@media (max-width: 600px) { + .dashboard-input-row { flex-direction: column; } + .dashboard-new-btn { width: 100%; } } -@media (max-width:480px) { - .dashboard-content { padding:24px 12px 16px; } - .dashboard-cards { grid-template-columns:1fr; } - :root { --dash-grid:50px 1fr 50px; } - .dash-col-node,.dash-cell-node,.dash-col-task,.dash-cell-task,.dash-col-ctx,.dash-cell-ctx { display:none; } - .dash-row-sub { padding-left:66px; } +@media (max-width: 480px) { + .dashboard-content { padding: 24px 12px 16px; } + .dashboard-cards { grid-template-columns: 1fr; } + :root { --dash-grid: 50px 1fr 50px; } + .dash-col-node, .dash-cell-node, .dash-col-task, .dash-cell-task, .dash-col-ctx, .dash-cell-ctx { display: none; } + .dash-row-sub { padding-left: 66px; } } -/* Login overlay */ -#login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; } -#login-box { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; width: 320px; max-width: 90vw; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } -#login-box h2 { color: var(--accent); font-size: 16px; margin-bottom: 16px; } -#login-box input { width: 100%; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); font: inherit; font-size: 13px; margin-bottom: 12px; } -#login-box input:focus-visible { border-color: var(--accent); outline: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); } -#login-box input::placeholder { color: var(--fg-dim); } -#login-box button { width: 100%; padding: 12px; background: var(--accent); color: var(--bg); border: none; border-radius: 4px; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } -#login-box button:hover { opacity: 0.9; } +/* ========================================================================== + Reduced motion + ========================================================================== */ +@media (prefers-reduced-motion: reduce) { + .ws-tab .tab-indicator[data-state="thinking"], + .ws-tab .tab-indicator[data-state="running"], + .ws-tab .tab-indicator[data-state="attention"], + .dash-state-dot[data-state="running"], + .dash-state-dot[data-state="thinking"], + .dash-state-dot[data-state="attention"] { animation: none; opacity: 1; } + .thinking-indicator::after { animation: none; content: '...'; } + .ws-tab, .ws-tab .tab-close, #new-tab-btn, + .header-btn, .hmenu-item, .dashboard-card, + .approval-btn, .approval-feedback-input, + #plan-buttons button, #input-area button, + .dashboard-new-btn, .dashboard-input, + .dash-row { transition: none; } +} + +/* ========================================================================== + Login overlay + ========================================================================== */ +#login-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +#login-box { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 36px; + width: 340px; + max-width: 90vw; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.03), + 0 24px 48px -12px rgba(0, 0, 0, 0.5), + 0 0 80px -20px var(--accent-dim); + position: relative; +} +#login-box::before { + content: ''; + position: absolute; + top: -1px; + left: 20%; + right: 20%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + border-radius: 1px; +} +#login-box h2 { + font-family: var(--font-display); + color: var(--accent); + font-size: 16px; + font-weight: 700; + margin-bottom: 20px; + letter-spacing: 0.02em; +} +#login-box input { + width: 100%; + padding: 11px 14px; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--fg); + font: inherit; + font-size: 13px; + margin-bottom: 14px; + transition: border-color 0.15s, box-shadow 0.15s; +} +#login-box input:focus-visible { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-dim); } +#login-box input::placeholder { color: var(--fg-dim); opacity: 0.6; } +#login-box button { + width: 100%; + padding: 11px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius-sm); + font: inherit; + font-family: var(--font-display); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: filter 0.15s; + letter-spacing: 0.02em; +} +#login-box button:hover { filter: brightness(1.1); } #login-box button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; } -#login-box button:disabled { opacity: 0.5; cursor: not-allowed; } +#login-box button:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } #login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -@media (max-width: 380px) { #login-box { padding: 24px 20px; } } +@media (max-width: 380px) { #login-box { padding: 28px 20px; } } -/* Keyboard shortcuts overlay */ -#kb-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 999; } -#kb-box { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px 28px; width: 360px; max-width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } -#kb-box h2 { color: var(--accent); font-size: 14px; margin-bottom: 14px; } -.kb-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; } -.kb-key { color: var(--fg-bright); background: var(--bg-highlight); border: 1px solid var(--border); border-radius: 3px; padding: 1px 6px; font-family: inherit; font-size: 11px; white-space: nowrap; } -.kb-desc { color: var(--fg-dim); } -.kb-section { color: var(--fg-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 12px; margin-bottom: 4px; } +/* ========================================================================== + Keyboard shortcuts overlay + ========================================================================== */ +#kb-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; +} +#kb-box { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 28px; + width: 360px; + max-width: 90vw; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5); +} +#kb-box h2 { + font-family: var(--font-display); + color: var(--accent); + font-size: 13px; + font-weight: 600; + margin-bottom: 16px; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.kb-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 12px; } +.kb-key { + color: var(--fg-bright); + background: var(--bg-highlight); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 2px 8px; + font-family: var(--font-mono); + font-size: 11px; + white-space: nowrap; +} +.kb-desc { color: var(--fg-dim); font-family: var(--font-display); } +.kb-section { + font-family: var(--font-display); + color: var(--fg-dim); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-top: 14px; + margin-bottom: 6px; +} .kb-section:first-child { margin-top: 0; } -#kb-box .kb-hint { color: var(--fg-dim); font-size: 11px; text-align: center; margin-top: 14px; } +#kb-box .kb-hint { color: var(--fg-dim); font-size: 11px; text-align: center; margin-top: 16px; font-family: var(--font-display); }