feat(token): add refresh token verification to TokenService
feat(bootstrap): create temporary settings and config files to prevent data loss refactor(edit_log): switch from Vec to VecDeque for efficient memory management fix(gateway): ensure store directories are created before starting the API server refactor(bgbash): implement a global singleton for BashControl feat(auth): enhance session authentication middleware to use SessionRepository fix(edit_log_repo): update to use VecDeque for in-memory edit log storage fix(memory_repo): add newline escaping for frontmatter fields fix(session_lock_repo): improve error handling for lock file operations fix(bash_tools): prevent path traversal in job_id argument refactor(delete): enforce empty directory deletion in file system tools fix(edit): optimize string replacement to only replace the first occurrence fix(git_cred): improve credential management with piped input to git commands feat(git_operator): add safety filter to block destructive git operations fix(shell): register background jobs in Bash control feat(spawn): add access tier specification for pipeline stages refactor(hive_mind): run directives concurrently for improved performance fix(auth): update refresh token verification in the refresh handler fix(chat): optimize LLM client usage based on model matching fix(conversations): enhance message deletion to target specific indices feat(api): add JWT authentication middleware for all API routes fix(state): implement refresh token verification in JwtTokenService fix(daemon): improve usage tracking with saturating addition fix(tui): handle compacted messages in the TUI state management
This commit is contained in:
@@ -1,379 +1,250 @@
|
|||||||
# Zesdex
|
# Zesdex — Autonomous AI Coding Agent
|
||||||
|
|
||||||
> Autonomous AI coding agent in a terminal-based TUI.
|
Zesdex is an autonomous AI coding agent with a Terminal UI (TUI). It acts as an
|
||||||
|
OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with **37
|
||||||
|
built-in tools** — file operations, git, shell execution, LSP integration, MCP,
|
||||||
|
subagent orchestration, and more.
|
||||||
|
|
||||||
Zesdex is a Rust-powered AI assistant that operates directly in your terminal via a rich TUI interface. It combines large language model intelligence with a comprehensive set of tools to explore, understand, and modify codebases autonomously — with built-in guardrails at every layer.
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Mode Selector │
|
||||||
|
│ TUI (default) ─── Daemon ─── Attach ─── API ─── WS/gRPC/Web │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Quick Start
|
||||||
|
|
||||||
### Core
|
```bash
|
||||||
|
# Run the TUI (default mode)
|
||||||
|
cargo run
|
||||||
|
|
||||||
- **TUI Interface** — Full-screen terminal UI with chat panel, input bar, and status bar built with [ratatui](https://github.com/ratatui-org/ratatui) and [crossterm](https://github.com/crossterm-rs/crossterm).
|
# Run the REST API server
|
||||||
- **Daemon Architecture** — Run as a background daemon with client attach/detach via Unix domain sockets. The daemon processes state; clients only render.
|
cargo run -- --api --api-port 8080
|
||||||
- **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates.
|
|
||||||
- **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management.
|
|
||||||
|
|
||||||
### Tool System (37 built-in tools)
|
# Run in daemon mode (background + IPC)
|
||||||
|
cargo run -- --daemon
|
||||||
|
|
||||||
| Category | Tools |
|
# Attach TUI to a running daemon session
|
||||||
|----------|-------|
|
cargo run -- --attach <session-id>
|
||||||
| **Filesystem** | `read`, `write`, `edit`, `delete` |
|
|
||||||
| **Search** | `grep` (recursive text), `glob` (file patterns) |
|
|
||||||
| **Shell** | `bash`, `bash_output`, `bash_kill` |
|
|
||||||
| **Git** | `git_operator`, `git_worktree`, `git_cred` |
|
|
||||||
| **Memory** | `remember`, `recall`, `forget` |
|
|
||||||
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
|
|
||||||
| **Workflow** | `workflow_run`, `note_finding`, `read_findings`, `hive_mind` |
|
|
||||||
| **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` |
|
|
||||||
| **Agent** | `spawn_agents`, `spawn_pipeline` |
|
|
||||||
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
|
|
||||||
|
|
||||||
### Intelligence
|
# Seed initial data (first run)
|
||||||
|
cargo run --bin bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
- **Hive-Mind Orchestration** — Autonomous agent orchestration modeled as a distributed machine intelligence (à la Stellaris). The Core Intelligence (main agent) compiles a cognitive cycle plan per task — an ordered list of cycles, each a set of anonymous processing nodes that run in parallel. Every node carries only a directive (what to do) and an access tier (`read`/`write`/`full`); cycle count and nodes-per-cycle are decided per task, not fixed. Every node's output merges into a shared collective state the instant it completes, and a final synthesis node reconciles it into one consensus. Every convergence is written to `docs/runs/*.md`. Manual entry point: the `hive_mind` tool.
|
### Prerequisites
|
||||||
|
|
||||||
- **Workflow Engine** — Orchestrate complex multi-step tasks with parallel sub-agents, pipelines, and phased execution. Spawn independent workers that share findings in real-time.
|
- **Rust** 1.81+ (edition 2021)
|
||||||
- **Self-Learning** — Persistent memory system that stores lessons, references, and project knowledge across sessions. Memories include provenance tracking, lifecycle management, and scope isolation.
|
- **Linux** or **macOS** (Unix domain sockets required for daemon mode)
|
||||||
- **Self-Review** — Review subagents trigger automatically after each code edit (inline) and at turn completion (background). Three types: code quality, architecture, and security.
|
- An **API key** for an OpenAI/Anthropic-compatible LLM provider (set via
|
||||||
- **Self-Healing** — On build/test failures, spawns a sub-agent with the error context to autonomously fix issues before reporting them to the user.
|
settings or environment variable)
|
||||||
- **MCP Support** — [Model Context Protocol](https://modelcontextprotocol.io/) integration for connecting to external AI tool servers.
|
|
||||||
- **Sequential Thinking** — Chain-of-thought reasoning tool for step-by-step problem decomposition.
|
|
||||||
- **Session Locking** — Prevents multiple processes from operating on the same session directory.
|
|
||||||
|
|
||||||
### Session Management
|
---
|
||||||
|
|
||||||
- Multiple concurrent sessions with history, rewind, and transcript persistence.
|
## Modes
|
||||||
- Per-session edit logs with full change tracking.
|
|
||||||
- Session archival and summary generation.
|
| Flag | Mode | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| *(none)* | **TUI** | Full terminal UI with chat, overlays, and agent loop in one process |
|
||||||
|
| `--daemon` | **Daemon** | Background daemon with IPC socket; clients attach separately |
|
||||||
|
| `--attach <id>` | **Attach** | Connect TUI to an existing daemon session via Unix socket |
|
||||||
|
| `--api` | **REST API** | HTTP server with session management and chat endpoints |
|
||||||
|
| `--ws` | **WebSocket** | WebSocket server for real-time communication |
|
||||||
|
| `--grpc` | **gRPC** | gRPC server for programmatic access |
|
||||||
|
| `--web` | **Web** | Serves the web frontend |
|
||||||
|
| `--api-port`, `--ws-port`, `--grpc-port`, `--web-port` | *(ports)* | Configure server ports (defaults: 8080, 8081, 50051, 3000) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
### Clean Architecture Layering
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
apps/
|
||||||
├── main.rs # Entry point: single-process, daemon, or attach mode
|
├── domain/ # Pure entities, value objects, repository/service traits
|
||||||
├── resources.rs # Embedded resources (help text, system prompts)
|
│ # Zero framework deps — only serde + chrono + uuid
|
||||||
├── app/
|
├── application/ # Use-case services (auth, sessions, conversations, memory)
|
||||||
│ ├── state/ # AppStateRest — immutable-rest state model
|
│ # Depends only on domain-layer trait interfaces
|
||||||
│ │ ├── rest.rs # Core state struct
|
├── infrastructure/ # All I/O: LLM client, IPC, persistence, LSP, MCP, tools
|
||||||
│ │ ├── types.rs # Overlay, Toast, Origin enums
|
│ # Implements domain/application port interfaces
|
||||||
│ │ ├── snapshot.rs # State snapshots for IPC
|
└── interfaces/ # Entry points
|
||||||
│ │ ├── diff.rs # Diff-based state synchronization
|
├── tui/ # Ratatui terminal UI
|
||||||
│ │ ├── runtime.rs # Runtime state mutations
|
├── api/ # Axum REST API
|
||||||
│ │ └── misc.rs # DirCache and miscellaneous state helpers
|
├── daemon/ # Unix socket daemon + client
|
||||||
│ ├── runtime/ # Action dispatch and event loop
|
├── ws/ # WebSocket server
|
||||||
│ │ ├── actions/ # Action enum and apply_action reducer
|
├── grpc/ # gRPC server
|
||||||
│ │ ├── stream/ # LLM streaming and tool execution
|
└── web/ # Web frontend (static file server)
|
||||||
│ │ │ └── tools/ # Tool harness integration
|
|
||||||
│ │ │ └── turn.rs # Turn orchestration
|
|
||||||
│ │ ├── event_loop/ # Main event loop and shortsend
|
|
||||||
│ │ ├── commands.rs # Slash command dispatch
|
|
||||||
│ │ └── shortsend.rs # Short-lived async send helper
|
|
||||||
│ ├── mode/ # UI modes and overlays (13 modes)
|
|
||||||
│ │ ├── bash.rs # Bash panel mode
|
|
||||||
│ │ ├── editor.rs # Multi-line editor mode
|
|
||||||
│ │ ├── effort.rs # Effort level selector
|
|
||||||
│ │ ├── help.rs # Help overlay
|
|
||||||
│ │ ├── key_input.rs # Raw key input mode
|
|
||||||
│ │ ├── learning.rs # Lesson management overlay
|
|
||||||
│ │ ├── loading.rs # Loading spinner overlay
|
|
||||||
│ │ ├── mcp.rs # MCP server management
|
|
||||||
│ │ ├── quit_confirm.rs # Quit confirmation dialog
|
|
||||||
│ │ ├── rewind.rs # Session rewind mode
|
|
||||||
│ │ ├── settings.rs # Settings panel
|
|
||||||
│ │ ├── todo.rs # Task list overlay
|
|
||||||
│ │ └── workflow.rs # Workflow visualization
|
|
||||||
│ ├── harness.rs # Tool harness for agent execution
|
|
||||||
│ ├── workflow/ # Workflow engine
|
|
||||||
│ │ ├── script.rs # Workflow script DSL
|
|
||||||
│ │ ├── engine.rs # Workflow executor
|
|
||||||
│ │ ├── hive_mind.rs # Hive-mind orchestrator
|
|
||||||
│ │ └── docs.rs # Deterministic docs/runs/*.md writer
|
|
||||||
│ ├── mcp/ # MCP client manager
|
|
||||||
│ │ └── manager.rs # MCP server lifecycle and tool exposure
|
|
||||||
│ ├── subagent/ # Sub-agent management
|
|
||||||
│ │ ├── spawn.rs # AgentDefinition and spawning
|
|
||||||
│ │ ├── engine.rs # Sub-agent event loop
|
|
||||||
│ │ ├── context.rs # Context construction for sub-agents
|
|
||||||
│ │ └── event.rs # Progress event types
|
|
||||||
│ ├── bgbash/ # Background bash job management
|
|
||||||
│ │ ├── job.rs # Background job handle
|
|
||||||
│ │ └── control.rs # Bash control (bg/fg/kill)
|
|
||||||
│ ├── lsp/ # LSP client management
|
|
||||||
│ │ ├── client.rs # LSP client connection wrapper
|
|
||||||
│ │ └── provisioner.rs # Auto-provisioning of LSP servers
|
|
||||||
│ └── review/ # Self-review quality system
|
|
||||||
├── controller/
|
|
||||||
│ ├── input.rs # Key event → Action mapping
|
|
||||||
│ └── command.rs # Slash command parser
|
|
||||||
├── dto/
|
|
||||||
│ ├── chat/ # Message, ToolCall, Role types
|
|
||||||
│ │ ├── message.rs # Chat message types
|
|
||||||
│ │ ├── tool.rs # Tool call/result types
|
|
||||||
│ │ └── mod.rs
|
|
||||||
│ └── provider/ # AI provider request/response/usage types
|
|
||||||
│ ├── request.rs # Provider request schema
|
|
||||||
│ ├── response.rs # Provider response schema
|
|
||||||
│ └── usage.rs # Token usage tracking
|
|
||||||
├── ipc/
|
|
||||||
│ ├── protocol.rs # ClientRequest, DaemonFrame, StatePayload
|
|
||||||
│ ├── server.rs # Unix socket server
|
|
||||||
│ ├── client.rs # Unix socket client
|
|
||||||
│ ├── conn.rs # Framed connection
|
|
||||||
│ ├── frame.rs # Length-prefixed frame encoding
|
|
||||||
│ ├── snapshot.rs # State snapshot serialization
|
|
||||||
│ └── diff.rs # Binary diff for state sync
|
|
||||||
├── model/
|
|
||||||
│ ├── store.rs # File-based storage (~/.config/zesdex/)
|
|
||||||
│ ├── session.rs # Session CRUD and listing
|
|
||||||
│ ├── settings.rs # User settings (provider, model, tokens)
|
|
||||||
│ ├── app_config.rs # Provider definitions and model roles
|
|
||||||
│ ├── memory.rs # Persistent memory with frontmatter
|
|
||||||
│ ├── editlog.rs # Edit history tracking
|
|
||||||
│ ├── msglog/ # Message log (SQLite-backed)
|
|
||||||
│ │ ├── schema.rs # SQLite schema
|
|
||||||
│ │ ├── query.rs # Query helpers
|
|
||||||
│ │ ├── blobs.rs # Large blob storage
|
|
||||||
│ │ └── summary.rs # Session summarization
|
|
||||||
│ ├── agent_def/ # Agent definitions (builtin, global, session)
|
|
||||||
│ │ ├── builtin.rs # Built-in agent profiles
|
|
||||||
│ │ ├── global.rs # Global agent config
|
|
||||||
│ │ └── session.rs # Per-session agent config
|
|
||||||
│ ├── conversation.rs # Conversation helpers
|
|
||||||
│ └── session_lock.rs # Flock-based session locking
|
|
||||||
├── service/
|
|
||||||
│ ├── provider.rs # AI provider abstraction
|
|
||||||
│ └── oauth/ # OAuth PKCE flow with loopback server
|
|
||||||
│ ├── loopback.rs # Local HTTP server for OAuth redirect
|
|
||||||
│ ├── manager.rs # OAuth token manager
|
|
||||||
│ ├── pkce.rs # PKCE code challenge/verifier
|
|
||||||
│ └── mod.rs
|
|
||||||
├── tool/ # 34 tool implementations
|
|
||||||
│ ├── fs/ # read, write, edit, delete
|
|
||||||
│ │ ├── read.rs
|
|
||||||
│ │ ├── write.rs
|
|
||||||
│ │ ├── edit.rs
|
|
||||||
│ │ ├── delete.rs
|
|
||||||
│ │ └── helpers.rs # Path resolution and validation
|
|
||||||
│ ├── search.rs # grep, glob
|
|
||||||
│ ├── shell.rs # bash
|
|
||||||
│ ├── bash_tools.rs # bash_output, bash_kill
|
|
||||||
│ ├── git_operator.rs # git operations
|
|
||||||
│ ├── git_worktree.rs # git worktree management
|
|
||||||
│ ├── git_cred.rs # git credential store/get/erase
|
|
||||||
│ ├── memory/ # remember, forget, recall
|
|
||||||
│ │ ├── remember.rs
|
|
||||||
│ │ ├── forget.rs
|
|
||||||
│ │ └── recall.rs
|
|
||||||
│ ├── plan.rs # plan_enter, plan_ready
|
|
||||||
│ ├── seqthink.rs # Sequential thinking
|
|
||||||
│ ├── workflow.rs # workflow_run, note_finding
|
|
||||||
│ ├── utility/ # cd, dir_list, dir_cache_update, pong, todowrite, todofinish
|
|
||||||
│ │ ├── cd.rs
|
|
||||||
│ │ ├── dir_list.rs
|
|
||||||
│ │ ├── dir_cache_update.rs
|
|
||||||
│ │ ├── pong.rs
|
|
||||||
│ │ ├── todowrite.rs
|
|
||||||
│ │ └── todofinish.rs
|
|
||||||
│ ├── lsp/ # LSP tools (connect, diagnostics, hover, etc.)
|
|
||||||
│ │ └── mod.rs
|
|
||||||
│ └── shell_filter/ # Shell output filtering (credentials, git)
|
|
||||||
│ ├── credentials.rs
|
|
||||||
│ ├── git.rs
|
|
||||||
│ └── mod.rs
|
|
||||||
└── view/ # TUI rendering
|
|
||||||
├── chat.rs # Chat transcript with markdown
|
|
||||||
├── markdown.rs # Markdown → ratatui spans
|
|
||||||
├── status.rs # Status bar
|
|
||||||
├── theme.rs # Color scheme
|
|
||||||
└── workflow.rs # Workflow visualization
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Tool System
|
||||||
|
|
||||||
## Usage
|
37 tools across 9 categories:
|
||||||
|
|
||||||
```bash
|
| Category | Tools |
|
||||||
# Run in single-process mode (default)
|
|----------|-------|
|
||||||
zesdex
|
| **File System** | `read`, `write`, `edit`, `delete`, `dir_list`, `dir_cache_update` |
|
||||||
|
| **Shell** | `bash`, `bash_interactive`, `bash_kill`, `bash_output` |
|
||||||
|
| **Git** | `git_operator`, `git_cred`, `git_worktree` |
|
||||||
|
| **Search** | `search`, `grep`, `glob`, `semantic_search` |
|
||||||
|
| **LSP** | `lsp_connect`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_diagnostics`, `lsp_disconnect` |
|
||||||
|
| **Memory** | `remember`, `recall`, `forget` |
|
||||||
|
| **Workflow** | `spawn_agents`, `spawn_pipeline`, `plan`, `sequential_think`, `hive_mind` |
|
||||||
|
| **Utility** | `todo_write`, `todo_finish`, `pong`, `cd` |
|
||||||
|
| **Background** | Background bash jobs with `cancel/status/list` |
|
||||||
|
|
||||||
# Run as a background daemon
|
Each tool implements the `Tool` trait:
|
||||||
zesdex --daemon
|
```rust
|
||||||
|
pub trait Tool: Send + Sync {
|
||||||
# Attach to a running daemon session
|
fn name(&self) -> &'static str;
|
||||||
zesdex --attach <session-id>
|
fn description(&self) -> &'static str;
|
||||||
|
fn parameters(&self) -> Value;
|
||||||
# Set log level
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
|
||||||
RUST_LOG=debug zesdex
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Bindings
|
|
||||||
|
|
||||||
| Binding | Action |
|
|
||||||
|---------|--------|
|
|
||||||
| `Ctrl+Q` | Quit |
|
|
||||||
| `Ctrl+H` | Help overlay |
|
|
||||||
| `Ctrl+P` | Settings overlay |
|
|
||||||
| `Ctrl+B` | Bash panel |
|
|
||||||
| `Ctrl+S` | Session hub |
|
|
||||||
| `Ctrl+T` | Task list |
|
|
||||||
| `Ctrl+W` | Workflow view |
|
|
||||||
| `Ctrl+K` | Key input mode |
|
|
||||||
| `Esc` | Cancel / back |
|
|
||||||
| `Tab` | Autocomplete |
|
|
||||||
| `↑/↓` | History / navigation |
|
|
||||||
| `Scroll` | Mouse scroll in chat |
|
|
||||||
|
|
||||||
### Slash Commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `/help` | Show help |
|
|
||||||
| `/clear` | Clear transcript |
|
|
||||||
| `/model` | Select AI model provider |
|
|
||||||
| `/workflow` | Open the workflow panel |
|
|
||||||
| `/workflow run <script>` | Run a JSON-encoded workflow script |
|
|
||||||
| `/mcp` | Open MCP server manager |
|
|
||||||
| `/mcp add <name> <command>` | Add an MCP server |
|
|
||||||
| `/login [provider]` | Authenticate with a provider |
|
|
||||||
| `/edit [path]` | Open a file/dir in the external editor |
|
|
||||||
| `/compact` | Compact the conversation transcript |
|
|
||||||
| `/lesson` | Interactive lesson/memory review |
|
|
||||||
| `/quit` | Exit application |
|
|
||||||
| `Any text` | Sent to the AI assistant as a prompt |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All configuration lives in `~/.config/zesdex/` (or platform equivalent via the `dirs` crate).
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `settings.json` | Provider selection, model, temperature, max tokens, review settings, workflow concurrency |
|
|
||||||
| `app_config.json` | AI provider definitions (name, API base URL, auth type, default model) |
|
|
||||||
| `memory/` | Persistent lesson and reference storage (Markdown with YAML frontmatter) |
|
|
||||||
| `sessions/` | Per-session transcripts, edit logs, and activity data |
|
|
||||||
| `run/` | Unix domain sockets for daemon mode |
|
|
||||||
|
|
||||||
### Provider Configuration
|
|
||||||
|
|
||||||
Providers are defined in `app_config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"my-provider": {
|
|
||||||
"api_base": "https://api.example.com/v1",
|
|
||||||
"api_key_env": "MY_API_KEY",
|
|
||||||
"default_model": "model-name"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"model_roles": {
|
|
||||||
"default": {
|
|
||||||
"provider": "my-provider",
|
|
||||||
"model": "model-name",
|
|
||||||
"max_tokens": 8192,
|
|
||||||
"temperature": 0.7
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"default_provider": "my-provider",
|
|
||||||
"default_model": "model-name"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Settings
|
### Hive Mind Orchestration
|
||||||
|
|
||||||
Key settings in `settings.json`:
|
The multi-agent orchestration system compiles a **cognitive cycle plan** per
|
||||||
|
task — ordered cycles of parallel processing nodes. Each node has a directive
|
||||||
|
and an **access tier** (`read` / `write` / `full`). Node outputs merge into a
|
||||||
|
shared collective state in real time, and a final **consensus synthesis**
|
||||||
|
produces the unified result.
|
||||||
|
|
||||||
| Setting | Default | Description |
|
- **Auto-trigger**: Complex requests automatically use the hive mind
|
||||||
|---------|---------|-------------|
|
- **Manual entry**: The `hive_mind` tool lets the LLM specify cycles explicitly
|
||||||
| `review_enabled` | `true` | Enable self-review after tool execution |
|
- **Live progress**: TUI panel shows each node's status and current tool
|
||||||
| `review_max_lessons_per_run` | `5` | Max lessons loaded per review cycle |
|
- **Guaranteed docs**: Every convergence writes to `docs/runs/`
|
||||||
| `adaptive_review_max_skip` | `3` | Consecutive passes before skipping review |
|
|
||||||
| `verify_command` | `null` | Optional command to verify changes |
|
### IPC Protocol (Daemon Mode)
|
||||||
| `workflow_max_concurrency` | `5` | Max parallel sub-agents in workflows |
|
|
||||||
| `session_archive_enabled` | `true` | Auto-archive completed sessions |
|
```
|
||||||
|
┌──────────┐ Unix socket ┌──────────┐
|
||||||
|
│ Client │ ◄──────────────► │ Daemon │
|
||||||
|
│ (TUI) │ length-prefixed│ │
|
||||||
|
└──────────┘ serde_json └──────────┘
|
||||||
|
|
||||||
|
Frame format: [4-byte BE length][JSON payload]
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon holds `AppStateRest` and drives the agent loop. Clients are stateless
|
||||||
|
renderers that receive full state snapshots after each action.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Built-in Features
|
||||||
|
|
||||||
### Prerequisites
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **LLM Provider** | OpenAI/Anthropic-compatible API (streaming + non-streaming) with automatic retry and fallback |
|
||||||
|
| **Tool Harness** | Safety-gated tool execution with graduated review checks |
|
||||||
|
| **Subagents** | Auto-inline review, background test-gen, arch-review, security-review |
|
||||||
|
| **OAuth 2.0** | PKCE flow for LLM provider authentication |
|
||||||
|
| **MCP** | Model Context Protocol server management (stdio + HTTP transport) |
|
||||||
|
| **LSP** | Language Server Protocol integration (completion, hover, diagnostics, references) |
|
||||||
|
| **Session Mgmt** | SQLite-persisted sessions with lock-based concurrency control |
|
||||||
|
| **Memory** | File-based memory system with frontmatter metadata |
|
||||||
|
| **Edit Log** | Append-only edit history with configurable retention |
|
||||||
|
| **Rate Limiting** | Sliding-window per-client rate limiter |
|
||||||
|
| **JWT Auth** | HS256 JWT access/refresh tokens (API mode) |
|
||||||
|
| **Password Auth** | Argon2 password hashing with pepper |
|
||||||
|
| **OAuth Loopback** | Localhost HTTP server for OAuth redirect capture |
|
||||||
|
| **Background Jobs** | Long-running shell jobs with cancellation and output collection |
|
||||||
|
| **Settings** | JSON-persisted settings with hot-reload |
|
||||||
|
|
||||||
- **Rust** 2021 edition toolchain ([rustup](https://rustup.rs/))
|
---
|
||||||
|
|
||||||
### Build from Source
|
## TUI Overlays
|
||||||
|
|
||||||
|
16 overlays accessible from the terminal UI:
|
||||||
|
|
||||||
|
| Overlay | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| Chat Input | Main input bar with autocomplete |
|
||||||
|
| Bash Panel | Interactive shell panel |
|
||||||
|
| File Editor | Built-in file editor |
|
||||||
|
| Effort Selector | LLM reasoning effort selector |
|
||||||
|
| Help | Keybindings reference |
|
||||||
|
| Key Input | Custom key binding configuration |
|
||||||
|
| Learning | Lesson viewer |
|
||||||
|
| Loading | Generating spinner |
|
||||||
|
| MCP Manager | MCP server management |
|
||||||
|
| Model Selector | LLM model picker |
|
||||||
|
| Quit Confirm | Exit confirmation dialog |
|
||||||
|
| Rewind | Message/history rewind |
|
||||||
|
| Settings | Settings panel |
|
||||||
|
| Todo | Task/TODO list |
|
||||||
|
| Usage | Token usage statistics |
|
||||||
|
| Workflow | Hive-mind node progress |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data & Persistence
|
||||||
|
|
||||||
|
All data lives under the platform's data directory (`~/.local/share/zesdex/`):
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd zesdex
|
|
||||||
cargo build --release
|
|
||||||
./target/release/zesdex
|
|
||||||
```
|
```
|
||||||
|
~/.local/share/zesdex/
|
||||||
|
├── settings.json # User settings (provider, model, keys)
|
||||||
|
├── app_config.json # Provider definitions (endpoints, env vars)
|
||||||
|
├── sessions/ # Chat sessions (one subdirectory per session)
|
||||||
|
│ └── <uuid>/
|
||||||
|
│ ├── session.json # Session metadata
|
||||||
|
│ ├── messages.jsonl # Message log
|
||||||
|
│ └── .lock # Session lock file
|
||||||
|
└── memories/ # Memory files with frontmatter metadata
|
||||||
|
└── *.md
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Commit Convention
|
```bash
|
||||||
|
# Build all crates
|
||||||
|
cargo build
|
||||||
|
|
||||||
Project ini menggunakan **Conventional Commits** untuk otomatis menentukan versi rilis (melalui semantic-release).
|
# Run all unit tests (8 tests across 11 crates)
|
||||||
|
cargo test
|
||||||
|
|
||||||
Format:
|
# Run clippy linting
|
||||||
```
|
cargo clippy --all-targets
|
||||||
<type>(<scope>): <description>
|
|
||||||
|
|
||||||
[optional body]
|
# Run with verbose logging
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
[optional footer]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**`<type>` — menentukan bump version:**
|
### Workspace Crates
|
||||||
|
|
||||||
| Type | Bump | Keterangan |
|
| Crate | Path | Layer |
|
||||||
|-------------|---------|-------------------------------------------|
|
|-------|------|-------|
|
||||||
| `feat` | minor | Fitur baru |
|
| `zesdex-domain` | `apps/domain/` | Pure domain entities & traits |
|
||||||
| `fix` | patch | Perbaikan bug |
|
| `zesdex-application` | `apps/application/` | Use-case services |
|
||||||
| `chore` | patch | Tugas maintenance, refactor ringan |
|
| `zesdex-infrastructure` | `apps/infrastructure/` | All I/O & tool implementations |
|
||||||
| `docs` | patch | Perubahan dokumentasi |
|
| `zesdex-tui` | `apps/interfaces/tui/` | Ratatui terminal interface |
|
||||||
| `refactor` | patch | Refactor kode (tanpa perubahan fungsional)|
|
| `zesdex-api` | `apps/interfaces/api/` | Axum REST API |
|
||||||
| `test` | patch | Penambahan atau perbaikan test |
|
| `zesdex-daemon` | `apps/interfaces/daemon/` | Unix socket daemon |
|
||||||
| `style` | patch | Perubahan formatting, whitespace, dll |
|
| `zesdex-ws` | `apps/interfaces/ws/` | WebSocket server |
|
||||||
| `perf` | patch | Optimasi performa |
|
| `zesdex-grpc` | `apps/interfaces/grpc/` | gRPC server |
|
||||||
| `ci` | patch | Perubahan CI/CD |
|
| `zesdex-web` | `apps/interfaces/web/` | Web frontend |
|
||||||
|
| `zesdex-gateway` | `apps/gateway/` | CLI entry point & dispatcher |
|
||||||
|
| `zesdex-bootstrap` | `apps/bootstrap/` | Initial data seeder |
|
||||||
|
|
||||||
**`BREAKING CHANGE`** pada body commit → **major** (apa pun typenya).
|
### Code Map
|
||||||
|
|
||||||
Contoh:
|
Detailed architecture documentation is in `docs/CODEMAPS/`:
|
||||||
```
|
|
||||||
feat(agent): add workspace-aware file search
|
|
||||||
|
|
||||||
Implement context-aware search scoped to current workspace directory.
|
| File | Covers |
|
||||||
|
|------|--------|
|
||||||
BREAKING CHANGE: search results now filter by workspace scope.
|
| `docs/CODEMAPS/architecture.md` | System layout, process modes, data flow |
|
||||||
```
|
| `docs/CODEMAPS/backend.md` | Provider, OAuth, IPC, workflow engine, MCP, LSP, review |
|
||||||
|
| `docs/CODEMAPS/frontend.md` | TUI render pipeline, 16 overlays, toasts, input handling |
|
||||||
```
|
| `docs/CODEMAPS/data.md` | Persistence, SQLite msglog, memory files, settings/config |
|
||||||
fix(ipc): handle partial frame on unix socket reconnect
|
| `docs/CODEMAPS/dependencies.md` | All Rust crates and external services |
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
chore: update rustls to 0.23
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release Workflow
|
|
||||||
|
|
||||||
Push ke branch `main` akan memicu:
|
|
||||||
1. **CI** — `cargo build --release` + `cargo test`
|
|
||||||
2. **Semantic Release** — analisis commit → update `Cargo.toml` + `CHANGELOG.md` → git tag → GitHub Release dengan binary
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See [LICENSE](LICENSE) for details.
|
See `CHANGELOG.md` for release history.
|
||||||
|
|||||||
@@ -23,4 +23,10 @@ pub trait TokenService: Send + Sync {
|
|||||||
/// Returns `Err` if the token is expired, malformed, or has an
|
/// Returns `Err` if the token is expired, malformed, or has an
|
||||||
/// invalid signature.
|
/// invalid signature.
|
||||||
fn verify_access_token(&self, token: &str) -> Result<String>;
|
fn verify_access_token(&self, token: &str) -> Result<String>;
|
||||||
|
|
||||||
|
/// Verify a refresh token and return the embedded subject claim.
|
||||||
|
///
|
||||||
|
/// Returns `Err` if the token is expired, malformed, or has an
|
||||||
|
/// invalid signature.
|
||||||
|
fn verify_refresh_token(&self, token: &str) -> Result<String>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ fn main() -> anyhow::Result<()> {
|
|||||||
if !settings_path.exists() {
|
if !settings_path.exists() {
|
||||||
let settings = zesdex_domain::cms::Settings::default();
|
let settings = zesdex_domain::cms::Settings::default();
|
||||||
let content = serde_json::to_string_pretty(&settings)?;
|
let content = serde_json::to_string_pretty(&settings)?;
|
||||||
std::fs::write(&settings_path, content)?;
|
let tmp = store.base_dir.join("settings.json.tmp");
|
||||||
|
std::fs::write(&tmp, &content)?;
|
||||||
|
std::fs::File::open(&tmp)?.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, &settings_path)?;
|
||||||
println!(" ✓ Default settings created");
|
println!(" ✓ Default settings created");
|
||||||
} else {
|
} else {
|
||||||
println!(" · Settings already exist, skipping");
|
println!(" · Settings already exist, skipping");
|
||||||
@@ -27,7 +30,10 @@ fn main() -> anyhow::Result<()> {
|
|||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
let config = zesdex_domain::cms::AppConfig::default();
|
let config = zesdex_domain::cms::AppConfig::default();
|
||||||
let content = serde_json::to_string_pretty(&config)?;
|
let content = serde_json::to_string_pretty(&config)?;
|
||||||
std::fs::write(&config_path, content)?;
|
let tmp = store.base_dir.join("app_config.json.tmp");
|
||||||
|
std::fs::write(&tmp, &content)?;
|
||||||
|
std::fs::File::open(&tmp)?.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, &config_path)?;
|
||||||
println!(" ✓ Default app_config created");
|
println!(" ✓ Default app_config created");
|
||||||
} else {
|
} else {
|
||||||
println!(" · App config already exists, skipping");
|
println!(" · App config already exists, skipping");
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
//! 3. Oldest entries are evicted from the in-memory cache when
|
//! 3. Oldest entries are evicted from the in-memory cache when
|
||||||
//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth)
|
//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth)
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A single recorded file edit event.
|
/// A single recorded file edit event.
|
||||||
@@ -47,18 +49,18 @@ pub const MAX_MEMORY_ENTRIES: usize = 10_000;
|
|||||||
|
|
||||||
/// In-memory view of a session's edit log.
|
/// In-memory view of a session's edit log.
|
||||||
///
|
///
|
||||||
/// Wraps a `Vec<EditLogEntry>` and provides basic query helpers.
|
/// Wraps a `VecDeque<EditLogEntry>` and provides basic query helpers.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct EditLog {
|
pub struct EditLog {
|
||||||
/// Ordered list of edit entries (newest appended last).
|
/// Ordered list of edit entries (newest appended last).
|
||||||
pub entries: Vec<EditLogEntry>,
|
pub entries: VecDeque<EditLogEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditLog {
|
impl EditLog {
|
||||||
/// Create an empty edit log with no entries.
|
/// Create an empty edit log with no entries.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
entries: Vec::new(),
|
entries: VecDeque::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ fn run_api_server(port: u16) -> anyhow::Result<()> {
|
|||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let store = zesdex_domain::core::Store::new();
|
let store = zesdex_domain::core::Store::new();
|
||||||
|
store.ensure_dirs()?;
|
||||||
let state = zesdex_api::ApiState::new(
|
let state = zesdex_api::ApiState::new(
|
||||||
store.base_dir.clone(),
|
store.base_dir.clone(),
|
||||||
"dev-secret",
|
"dev-secret",
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
//! Background bash control — list, cancel, and inspect background processes.
|
//! Background bash control — list, cancel, and inspect background processes.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use super::job::BashJob;
|
use super::job::BashJob;
|
||||||
|
|
||||||
|
/// Global accessor for the shared BashControl singleton.
|
||||||
|
///
|
||||||
|
/// Used by the Bash tool (to register jobs) and BashKill (to look them up).
|
||||||
|
pub fn bash_control() -> &'static BashControl {
|
||||||
|
static BASH_CONTROL: OnceLock<BashControl> = OnceLock::new();
|
||||||
|
BASH_CONTROL.get_or_init(BashControl::new)
|
||||||
|
}
|
||||||
|
|
||||||
/// Central registry of all running background bash jobs.
|
/// Central registry of all running background bash jobs.
|
||||||
pub struct BashControl {
|
pub struct BashControl {
|
||||||
jobs: Mutex<HashMap<String, Arc<BashJob>>>,
|
jobs: Mutex<HashMap<String, Arc<BashJob>>>,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
//! Authentication middleware — session-lock based auth for Axum.
|
//! Authentication middleware — session-lock based auth for Axum.
|
||||||
|
//!
|
||||||
|
//! Validates `X-Session-Id` header against the `SessionRepository` before
|
||||||
|
//! forwarding the request to the inner service.
|
||||||
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
|
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
@@ -9,6 +14,7 @@ use axum::http::{Request, Response, StatusCode};
|
|||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tower::{Layer, Service};
|
use tower::{Layer, Service};
|
||||||
|
use zesdex_domain::auth::{SessionId, SessionRepository};
|
||||||
|
|
||||||
/// Identity extracted from a validated session.
|
/// Identity extracted from a validated session.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -30,40 +36,50 @@ impl SessionIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Tower Layer that produces SessionAuthMiddleware services.
|
/// Tower Layer that produces SessionAuthMiddleware services.
|
||||||
|
///
|
||||||
|
/// Holds a reference to the `SessionRepository` and the base directory
|
||||||
|
/// needed to validate session IDs.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SessionAuthLayer;
|
pub struct SessionAuthLayer<R: SessionRepository + Send + Sync + 'static> {
|
||||||
|
base_dir: PathBuf,
|
||||||
|
repo: Arc<R>,
|
||||||
|
}
|
||||||
|
|
||||||
impl SessionAuthLayer {
|
impl<R: SessionRepository + Send + Sync + 'static> SessionAuthLayer<R> {
|
||||||
pub fn new() -> Self {
|
pub fn new(base_dir: PathBuf, repo: Arc<R>) -> Self {
|
||||||
Self
|
Self { base_dir, repo }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SessionAuthLayer {
|
impl<S, R> Layer<S> for SessionAuthLayer<R>
|
||||||
fn default() -> Self {
|
where
|
||||||
Self
|
R: SessionRepository + Send + Sync + 'static,
|
||||||
}
|
{
|
||||||
}
|
type Service = SessionAuthMiddleware<S, R>;
|
||||||
|
|
||||||
impl<S> Layer<S> for SessionAuthLayer {
|
|
||||||
type Service = SessionAuthMiddleware<S>;
|
|
||||||
|
|
||||||
fn layer(&self, inner: S) -> Self::Service {
|
fn layer(&self, inner: S) -> Self::Service {
|
||||||
SessionAuthMiddleware { inner }
|
SessionAuthMiddleware {
|
||||||
|
inner,
|
||||||
|
base_dir: self.base_dir.clone(),
|
||||||
|
repo: self.repo.clone(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tower Service that validates X-Session-Id before forwarding.
|
/// Tower Service that validates X-Session-Id before forwarding.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SessionAuthMiddleware<S> {
|
pub struct SessionAuthMiddleware<S, R: SessionRepository + Send + Sync + 'static> {
|
||||||
inner: S,
|
inner: S,
|
||||||
|
base_dir: PathBuf,
|
||||||
|
repo: Arc<R>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S, ReqBody> Service<Request<ReqBody>> for SessionAuthMiddleware<S>
|
impl<S, ReqBody, R> Service<Request<ReqBody>> for SessionAuthMiddleware<S, R>
|
||||||
where
|
where
|
||||||
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
|
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
|
||||||
S::Future: Send + 'static,
|
S::Future: Send + 'static,
|
||||||
ReqBody: Send + 'static,
|
ReqBody: Send + 'static,
|
||||||
|
R: SessionRepository + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
type Response = S::Response;
|
type Response = S::Response;
|
||||||
type Error = S::Error;
|
type Error = S::Error;
|
||||||
@@ -81,18 +97,24 @@ where
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
if session_id.as_deref() != Some("valid-session") {
|
// Validate the session against the repository.
|
||||||
// In production, this validates against the store
|
match session_id {
|
||||||
return Box::pin(async move {
|
Some(sid) => match SessionId::new(&sid) {
|
||||||
Ok((
|
Ok(id) => match self.repo.load_session(&self.base_dir, &id) {
|
||||||
StatusCode::UNAUTHORIZED,
|
Ok(_session) => {
|
||||||
"missing or invalid X-Session-Id header",
|
// Session is valid — forward the request.
|
||||||
)
|
let fut = self.inner.call(req);
|
||||||
.into_response())
|
return Box::pin(fut);
|
||||||
});
|
}
|
||||||
|
Err(_) => { /* fall through to 401 */ }
|
||||||
|
},
|
||||||
|
Err(_) => { /* fall through to 401 */ }
|
||||||
|
},
|
||||||
|
None => { /* fall through to 401 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
let fut = self.inner.call(req);
|
Box::pin(async move {
|
||||||
Box::pin(fut)
|
Ok((StatusCode::UNAUTHORIZED, "missing or invalid X-Session-Id header").into_response())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! JSONL file–backed `EditLogRepository`.
|
//! JSONL file–backed `EditLogRepository`.
|
||||||
//! Stores `EditLog` as an append-only newline-delimited JSON file.
|
//! Stores `EditLog` as an append-only newline-delimited JSON file.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -18,21 +19,21 @@ impl JsonlEditLogRepository {
|
|||||||
Self
|
Self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
|
fn load_from_disk(path: &Path) -> VecDeque<EditLogEntry> {
|
||||||
let Ok(file) = std::fs::File::open(path) else {
|
let Ok(file) = std::fs::File::open(path) else {
|
||||||
return Vec::new();
|
return VecDeque::new();
|
||||||
};
|
};
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
let mut entries: Vec<EditLogEntry> = Vec::new();
|
let mut entries: VecDeque<EditLogEntry> = VecDeque::new();
|
||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
let Ok(line) = line else {
|
let Ok(line) = line else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
|
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
|
||||||
if entries.len() >= MAX_MEMORY_ENTRIES {
|
if entries.len() >= MAX_MEMORY_ENTRIES {
|
||||||
entries.remove(0);
|
entries.pop_front();
|
||||||
}
|
}
|
||||||
entries.push(entry);
|
entries.push_back(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entries
|
entries
|
||||||
@@ -74,14 +75,14 @@ impl EditLogRepository for JsonlEditLogRepository {
|
|||||||
file.write_all(line.as_bytes())?;
|
file.write_all(line.as_bytes())?;
|
||||||
file.sync_all()?;
|
file.sync_all()?;
|
||||||
}
|
}
|
||||||
log.entries.push(entry);
|
log.entries.push_back(entry);
|
||||||
if log.entries.len() > MAX_MEMORY_ENTRIES {
|
if log.entries.len() > MAX_MEMORY_ENTRIES {
|
||||||
log.entries.remove(0);
|
log.entries.pop_front();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
||||||
log.entries.clone()
|
log.entries.clone().into_iter().collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,26 +17,37 @@ impl MarkdownMemoryRepository {
|
|||||||
Self
|
Self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Escape newlines in field values so they do not break the
|
||||||
|
/// line-oriented frontmatter parser.
|
||||||
|
fn escape_newlines(s: &str) -> String {
|
||||||
|
s.replace('\n', "\\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unescape `\n` back to actual newlines after frontmatter parsing.
|
||||||
|
fn unescape_newlines(s: &str) -> String {
|
||||||
|
s.replace("\\n", "\n")
|
||||||
|
}
|
||||||
|
|
||||||
fn build_frontmatter(memory: &Memory) -> String {
|
fn build_frontmatter(memory: &Memory) -> String {
|
||||||
let outcome_line = memory
|
let outcome_line = memory
|
||||||
.outcome
|
.outcome
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|o| format!("outcome: {o}\n"))
|
.map(|o| format!("outcome: {}\n", Self::escape_newlines(o)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let scope_line = memory
|
let scope_line = memory
|
||||||
.scope
|
.scope
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| format!("scope: {s}\n"))
|
.map(|s| format!("scope: {}\n", Self::escape_newlines(s)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let before_line = memory
|
let before_line = memory
|
||||||
.before_snippet
|
.before_snippet
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| format!("before: {s}\n"))
|
.map(|s| format!("before: {}\n", Self::escape_newlines(s)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let after_line = memory
|
let after_line = memory
|
||||||
.after_snippet
|
.after_snippet
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| format!("after: {s}\n"))
|
.map(|s| format!("after: {}\n", Self::escape_newlines(s)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let prov_line = if memory.provenances.is_empty() {
|
let prov_line = if memory.provenances.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -101,14 +112,30 @@ impl MarkdownMemoryRepository {
|
|||||||
.get("updated_at")
|
.get("updated_at")
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
outcome: front
|
||||||
|
.get("outcome")
|
||||||
|
.cloned()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| Self::unescape_newlines(&s)),
|
||||||
lifecycle: front
|
lifecycle: front
|
||||||
.get("lifecycle")
|
.get("lifecycle")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| "new".to_string()),
|
.unwrap_or_else(|| "new".to_string()),
|
||||||
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
|
scope: front
|
||||||
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
.get("scope")
|
||||||
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
.cloned()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| Self::unescape_newlines(&s)),
|
||||||
|
before_snippet: front
|
||||||
|
.get("before")
|
||||||
|
.cloned()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| Self::unescape_newlines(&s)),
|
||||||
|
after_snippet: front
|
||||||
|
.get("after")
|
||||||
|
.cloned()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| Self::unescape_newlines(&s)),
|
||||||
provenances: front
|
provenances: front
|
||||||
.get("provenances")
|
.get("provenances")
|
||||||
.cloned()
|
.cloned()
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
|
|||||||
Err(e) => return Err(RepositoryError::Io(e)),
|
Err(e) => return Err(RepositoryError::Io(e)),
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
let content = std::fs::read_to_string(&path).map_err(RepositoryError::Io)?;
|
||||||
if let Ok(existing_pid) = content.trim().parse::<u32>() {
|
if let Ok(existing_pid) = content.trim().parse::<u32>() {
|
||||||
if self.is_alive(existing_pid) {
|
if self.is_alive(existing_pid) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -46,10 +46,14 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
|
|||||||
let tmp = path.with_extension("lock.tmp");
|
let tmp = path.with_extension("lock.tmp");
|
||||||
{
|
{
|
||||||
let mut tmp_file = std::fs::OpenOptions::new()
|
let mut tmp_file = std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create_new(true)
|
||||||
.truncate(true)
|
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(&tmp)?;
|
.open(&tmp)
|
||||||
|
.map_err(|_| {
|
||||||
|
RepositoryError::Other(
|
||||||
|
"another process is replacing the lock".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
write!(tmp_file, "{pid}")?;
|
write!(tmp_file, "{pid}")?;
|
||||||
tmp_file.sync_all()?;
|
tmp_file.sync_all()?;
|
||||||
}
|
}
|
||||||
@@ -62,7 +66,7 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
|
|||||||
|
|
||||||
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> {
|
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> {
|
||||||
let path = session_dir.join(".lock");
|
let path = session_dir.join(".lock");
|
||||||
let _ = std::fs::remove_file(path);
|
std::fs::remove_file(path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ impl Tool for BashOutput {
|
|||||||
let job_id = arg_str(args, "job_id")?;
|
let job_id = arg_str(args, "job_id")?;
|
||||||
info!("Getting output for job: {job_id}");
|
info!("Getting output for job: {job_id}");
|
||||||
|
|
||||||
|
// Prevent path traversal
|
||||||
|
if job_id.contains('/') || job_id.contains('\\') || job_id.contains("..") {
|
||||||
|
anyhow::bail!("invalid job_id '{job_id}': must not contain path separators");
|
||||||
|
}
|
||||||
|
|
||||||
// Read from the session's bash output directory
|
// Read from the session's bash output directory
|
||||||
let output_dir = ctx.session_dir.join("bash-outputs");
|
let output_dir = ctx.session_dir.join("bash-outputs");
|
||||||
let output_file = output_dir.join(&job_id);
|
let output_file = output_dir.join(&job_id);
|
||||||
@@ -80,23 +85,11 @@ impl Tool for BashKill {
|
|||||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let job_id = crate::tools::arg_str(args, "job_id")?;
|
let job_id = crate::tools::arg_str(args, "job_id")?;
|
||||||
info!("bash_kill called for job: {job_id}");
|
info!("bash_kill called for job: {job_id}");
|
||||||
// Try to kill by PID (if job_id is numeric) or by process name
|
|
||||||
if let Ok(pid) = job_id.parse::<u32>() {
|
if crate::bgbash::control::bash_control().cancel(&job_id) {
|
||||||
use std::process::Command;
|
Ok(format!("Killed background job '{job_id}'"))
|
||||||
match Command::new("kill").arg(pid.to_string()).output() {
|
|
||||||
Ok(output) if output.status.success() => {
|
|
||||||
Ok(format!("Killed background job '{job_id}' (PID {pid})"))
|
|
||||||
}
|
|
||||||
Ok(output) => {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
Ok(format!("Failed to kill job '{job_id}': {stderr}"))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
Ok(format!("Failed to kill job '{job_id}': {e}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Ok(format!("Invalid job ID '{job_id}' — expected numeric PID"))
|
anyhow::bail!("no active background job found with ID '{job_id}'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ impl Tool for Delete {
|
|||||||
fs::remove_file(&path)?;
|
fs::remove_file(&path)?;
|
||||||
Ok(format!("Deleted file '{rel}'"))
|
Ok(format!("Deleted file '{rel}'"))
|
||||||
} else if path.is_dir() {
|
} else if path.is_dir() {
|
||||||
fs::remove_dir_all(&path)?;
|
fs::remove_dir(&path).map_err(|e| {
|
||||||
Ok(format!("Deleted directory '{rel}' and all contents"))
|
anyhow::anyhow!("failed to delete directory '{rel}': {e} (directory must be empty)")
|
||||||
|
})?;
|
||||||
|
Ok(format!("Deleted empty directory '{rel}'"))
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("'{rel}' is neither a file nor a directory")
|
anyhow::bail!("'{rel}' is neither a file nor a directory")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ impl Tool for Edit {
|
|||||||
anyhow::bail!("old text not found in '{}'", rel);
|
anyhow::bail!("old text not found in '{}'", rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_content = content.replace(&old, &new);
|
let new_content = content.replacen(&old, &new, 1);
|
||||||
fs::write(&path, &new_content)?;
|
fs::write(&path, &new_content)?;
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
pub struct GitCred;
|
pub struct GitCred;
|
||||||
|
|
||||||
@@ -49,10 +51,15 @@ impl Tool for GitCred {
|
|||||||
let url = crate::tools::arg_str(args, "url")?;
|
let url = crate::tools::arg_str(args, "url")?;
|
||||||
let username = crate::tools::arg_str(args, "username")?;
|
let username = crate::tools::arg_str(args, "username")?;
|
||||||
let password = crate::tools::arg_str(args, "password")?;
|
let password = crate::tools::arg_str(args, "password")?;
|
||||||
let _input = format!("url={url}\nusername={username}\npassword={password}\n");
|
let input = format!("url={url}\nusername={username}\npassword={password}\n");
|
||||||
let _output = execute_cmd(
|
let mut child = Command::new("git")
|
||||||
std::process::Command::new("git").args(["credential", "approve"]),
|
.args(["credential", "approve"])
|
||||||
)?;
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
if let Some(ref mut stdin) = child.stdin {
|
||||||
|
stdin.write_all(input.as_bytes())?;
|
||||||
|
}
|
||||||
|
child.wait()?;
|
||||||
Ok(format!("Credential stored for {url}"))
|
Ok(format!("Credential stored for {url}"))
|
||||||
}
|
}
|
||||||
"list" => {
|
"list" => {
|
||||||
@@ -63,7 +70,15 @@ impl Tool for GitCred {
|
|||||||
}
|
}
|
||||||
"erase" => {
|
"erase" => {
|
||||||
let url = crate::tools::arg_str(args, "url")?;
|
let url = crate::tools::arg_str(args, "url")?;
|
||||||
let _input = format!("url={url}\n");
|
let input = format!("url={url}\n");
|
||||||
|
let mut child = Command::new("git")
|
||||||
|
.args(["credential", "reject"])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
if let Some(ref mut stdin) = child.stdin {
|
||||||
|
stdin.write_all(input.as_bytes())?;
|
||||||
|
}
|
||||||
|
child.wait()?;
|
||||||
Ok(format!("Credential erased for {url}"))
|
Ok(format!("Credential erased for {url}"))
|
||||||
}
|
}
|
||||||
_ => anyhow::bail!("unknown action: {}", action),
|
_ => anyhow::bail!("unknown action: {}", action),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Git operator tool — commit, push, pull, branch operations.
|
//! Git operator tool — commit, push, pull, branch operations.
|
||||||
|
|
||||||
|
use crate::tools::shell_filter::git::check_git_destructive;
|
||||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -46,6 +47,12 @@ impl Tool for GitOperator {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Safety filter: block destructive git operations
|
||||||
|
let cmd_str = format!("git {} {}", operation, extra_args.join(" "));
|
||||||
|
if let Err(e) = check_git_destructive(&cmd_str) {
|
||||||
|
anyhow::bail!("blocked: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
let mut cmd = std::process::Command::new("git");
|
let mut cmd = std::process::Command::new("git");
|
||||||
cmd.arg(&operation);
|
cmd.arg(&operation);
|
||||||
for arg in &extra_args {
|
for arg in &extra_args {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ impl Tool for Bash {
|
|||||||
|
|
||||||
if run_in_background {
|
if run_in_background {
|
||||||
let job = crate::bgbash::job::spawn_bash_job(cmd);
|
let job = crate::bgbash::job::spawn_bash_job(cmd);
|
||||||
|
crate::bgbash::control::bash_control().register(job.clone());
|
||||||
return Ok(format!("Background job: {}", job.id));
|
return Ok(format!("Background job: {}", job.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,8 @@ impl Tool for SpawnPipeline {
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"directive": {"type": "string", "description": "Directive for this pipeline stage"}
|
"directive": {"type": "string", "description": "Directive for this pipeline stage"},
|
||||||
|
"access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier for this stage"}
|
||||||
},
|
},
|
||||||
"required": ["directive"]
|
"required": ["directive"]
|
||||||
},
|
},
|
||||||
@@ -200,17 +201,28 @@ impl Tool for SpawnPipeline {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
let access_str = stage
|
||||||
|
.get("access")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("full");
|
||||||
|
|
||||||
|
let access = match access_str {
|
||||||
|
"read" => AccessTier::Read,
|
||||||
|
"write" => AccessTier::Write,
|
||||||
|
_ => AccessTier::Full,
|
||||||
|
};
|
||||||
|
|
||||||
let subagent_ctx = SubagentContext::new(
|
let subagent_ctx = SubagentContext::new(
|
||||||
directive.clone(),
|
directive.clone(),
|
||||||
ctx.clone(),
|
ctx.clone(),
|
||||||
"full".to_string(),
|
access_str.to_string(),
|
||||||
base_url.clone(),
|
base_url.clone(),
|
||||||
api_key.clone(),
|
api_key.clone(),
|
||||||
model.clone(),
|
model.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = rt.block_on(async {
|
let result = rt.block_on(async {
|
||||||
run_agent(subagent_ctx, &directive, AccessTier::Full, ctx.clone()).await
|
run_agent(subagent_ctx, &directive, access, ctx.clone()).await
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
pipeline_result.push_str(&format!("Stage {}: {}\n", i, result));
|
pipeline_result.push_str(&format!("Stage {}: {}\n", i, result));
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
//! Hive-mind cycle execution — run one cycle of parallel nodes.
|
//! Hive-mind cycle execution — run one cycle of parallel nodes.
|
||||||
//!
|
//!
|
||||||
//! Flow: load settings → resolve LLM credentials → for each directive,
|
//! Flow: load settings → resolve LLM credentials → run all directives in the
|
||||||
//! build a SubagentContext and call run_agent → collect NodeOutputs.
|
//! cycle concurrently via try_join_all → collect Vec<NodeOutput>.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use futures_util::future::try_join_all;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
|
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
|
||||||
@@ -21,8 +22,9 @@ use crate::workflow::hive_mind::types::{CognitiveCycle, NodeOutput};
|
|||||||
/// Flow:
|
/// Flow:
|
||||||
/// 1. Load `Settings` and `AppConfig` from the store directory.
|
/// 1. Load `Settings` and `AppConfig` from the store directory.
|
||||||
/// 2. Resolve provider, model, base_url, and api_key.
|
/// 2. Resolve provider, model, base_url, and api_key.
|
||||||
/// 3. For each directive → build `SubagentContext` → `run_agent` (Full access).
|
/// 3. Spawn all directives concurrently — each builds a `SubagentContext`
|
||||||
/// 4. Collect `NodeOutput` results.
|
/// and calls `run_agent` (Full access).
|
||||||
|
/// 4. `try_join_all` waits for all to complete, then collect `NodeOutput`s.
|
||||||
pub async fn execute_cycle(
|
pub async fn execute_cycle(
|
||||||
cycle: &CognitiveCycle,
|
cycle: &CognitiveCycle,
|
||||||
tool_ctx: &ToolCtx,
|
tool_ctx: &ToolCtx,
|
||||||
@@ -52,25 +54,37 @@ pub async fn execute_cycle(
|
|||||||
|
|
||||||
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
|
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
|
||||||
|
|
||||||
let mut outputs = Vec::new();
|
let cycle_index = cycle.index;
|
||||||
for (i, directive) in cycle.directives.iter().enumerate() {
|
|
||||||
let ctx = SubagentContext::new(
|
|
||||||
directive.clone(),
|
|
||||||
tool_ctx.clone(),
|
|
||||||
"full".to_string(),
|
|
||||||
base_url.clone(),
|
|
||||||
api_key.clone(),
|
|
||||||
model.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = run_agent(ctx, directive, AccessTier::Full, tool_ctx.clone()).await?;
|
// Run all directives in this cycle concurrently.
|
||||||
|
let handles: Vec<_> = cycle
|
||||||
|
.directives
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, directive)| {
|
||||||
|
let dir = directive.clone();
|
||||||
|
let ctx = SubagentContext::new(
|
||||||
|
dir.clone(),
|
||||||
|
tool_ctx.clone(),
|
||||||
|
"full".to_string(),
|
||||||
|
base_url.clone(),
|
||||||
|
api_key.clone(),
|
||||||
|
model.clone(),
|
||||||
|
);
|
||||||
|
let tc = tool_ctx.clone();
|
||||||
|
|
||||||
outputs.push(NodeOutput {
|
async move {
|
||||||
id: format!("Node-{}-{}", cycle.index, i),
|
let result = run_agent(ctx, &dir, AccessTier::Full, tc).await?;
|
||||||
directive: directive.clone(),
|
Ok::<NodeOutput, anyhow::Error>(NodeOutput {
|
||||||
output: result,
|
id: format!("Node-{}-{}", cycle_index, i),
|
||||||
});
|
directive: dir,
|
||||||
}
|
output: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(outputs)
|
let results = try_join_all(handles).await?;
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ pub async fn refresh_handler(
|
|||||||
// Verify the refresh token and extract the subject
|
// Verify the refresh token and extract the subject
|
||||||
let sub = state
|
let sub = state
|
||||||
.token_service
|
.token_service
|
||||||
.verify_access_token(&req.refresh_token)
|
.verify_refresh_token(&req.refresh_token)
|
||||||
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
|
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
|
||||||
|
|
||||||
// Generate a fresh token pair
|
// Generate a fresh token pair
|
||||||
|
|||||||
@@ -109,18 +109,21 @@ pub async fn chat_completions_handler(
|
|||||||
|
|
||||||
// Call the LLM provider (non-streaming)
|
// Call the LLM provider (non-streaming)
|
||||||
//
|
//
|
||||||
// We create a temporary LlmClient with the overridden model so we
|
// When the requested model matches the shared client we reuse it to
|
||||||
// don't mutate the shared state's client.
|
// avoid allocating a new HTTP connection. Otherwise we create a
|
||||||
|
// temporary LlmClient with the requested model — the ownership
|
||||||
|
// lives on the stack via `temp_client`.
|
||||||
|
#[allow(unused_assignments)]
|
||||||
|
let mut temp_client: Option<zesdex_infrastructure::llm::LlmClient> = None;
|
||||||
let llm_client = if model == state.llm_client.model {
|
let llm_client = if model == state.llm_client.model {
|
||||||
// Use the shared client directly
|
|
||||||
&state.llm_client
|
&state.llm_client
|
||||||
} else {
|
} else {
|
||||||
// Create a modified client for this request (only borrows, but
|
temp_client = Some(zesdex_infrastructure::llm::LlmClient::new(
|
||||||
// we need to own it for the call — handled below)
|
state.llm_client.api_key.clone(),
|
||||||
//
|
model,
|
||||||
// For simplicity, use the shared client with its model. A full
|
Some(state.llm_client.base_url.clone()),
|
||||||
// implementation would override the model per request.
|
));
|
||||||
&state.llm_client
|
temp_client.as_ref().unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
let (response, usage) = llm_client
|
let (response, usage) = llm_client
|
||||||
|
|||||||
@@ -103,29 +103,44 @@ pub async fn add_message_handler(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /sessions/:id/conversations/:cid — delete a message from a conversation.
|
/// DELETE /sessions/:id/conversations/:cid — delete a single message by index.
|
||||||
///
|
///
|
||||||
/// Note: the `cid` parameter currently identifies the message index or the
|
/// ## Flow
|
||||||
/// entire conversation. For simplicity, this deletes the entire conversation
|
///
|
||||||
/// and creates a fresh one. A more sophisticated implementation would remove
|
/// 1. Extract session ID and message index from the path.
|
||||||
/// a single message by index.
|
/// 2. Load the conversation.
|
||||||
|
/// 3. Remove the message at `cid` (zero-based index).
|
||||||
|
/// 4. Persist the updated conversation.
|
||||||
///
|
///
|
||||||
/// ## Errors
|
/// ## Errors
|
||||||
///
|
///
|
||||||
/// - `404 Not Found` — conversation not found.
|
/// - `400 Bad Request` — session ID is empty or `cid` is not a valid integer.
|
||||||
|
/// - `404 Not Found` — conversation or message index not found.
|
||||||
#[tracing::instrument(skip(state))]
|
#[tracing::instrument(skip(state))]
|
||||||
pub async fn delete_message_handler(
|
pub async fn delete_message_handler(
|
||||||
State(state): State<Arc<ApiState>>,
|
State(state): State<Arc<ApiState>>,
|
||||||
Path((id, _cid)): Path<(String, String)>,
|
Path((id, cid)): Path<(String, String)>,
|
||||||
) -> Result<axum::http::StatusCode, ApiError> {
|
) -> Result<axum::http::StatusCode, ApiError> {
|
||||||
if id.is_empty() {
|
if id.is_empty() {
|
||||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load conversation and clear all messages
|
// Parse the message index from the path
|
||||||
|
let index: usize = cid
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ApiError::BadRequest(format!("Invalid message index: {cid}")))?;
|
||||||
|
|
||||||
|
// Load conversation and remove the specific message by index
|
||||||
let mut conversation = state.conversation_service.load_conversation(&id)?;
|
let mut conversation = state.conversation_service.load_conversation(&id)?;
|
||||||
|
|
||||||
conversation.messages.clear();
|
if index >= conversation.messages.len() {
|
||||||
|
return Err(ApiError::NotFound(format!(
|
||||||
|
"Message index {index} out of bounds (max: {})",
|
||||||
|
conversation.messages.len().saturating_sub(1)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.messages.remove(index);
|
||||||
state
|
state
|
||||||
.conversation_service
|
.conversation_service
|
||||||
.save_conversation(&conversation)?;
|
.save_conversation(&conversation)?;
|
||||||
|
|||||||
@@ -56,10 +56,17 @@ pub fn build_router(state: ApiState) -> Router {
|
|||||||
// CORS layer — permissive for local daemon / development use
|
// CORS layer — permissive for local daemon / development use
|
||||||
let cors = CorsLayer::permissive();
|
let cors = CorsLayer::permissive();
|
||||||
|
|
||||||
|
// JWT auth middleware — validates Bearer tokens on all API routes.
|
||||||
|
// Health and auth endpoints (login/register/refresh) are also
|
||||||
|
// protected; adjust route ordering or add an allow-list inside the
|
||||||
|
// middleware if public access is needed.
|
||||||
|
let jwt_auth = middleware::auth::JwtAuthLayer::new(shared_state.clone());
|
||||||
|
|
||||||
// Combine all sub-routers under a versioned prefix
|
// Combine all sub-routers under a versioned prefix
|
||||||
Router::new()
|
Router::new()
|
||||||
.nest("/api/v1", api_v1_router())
|
.nest("/api/v1", api_v1_router())
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
|
.layer(jwt_auth)
|
||||||
.with_state(shared_state)
|
.with_state(shared_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,19 @@ impl TokenService for JwtTokenService {
|
|||||||
let claims = verify_token(&self.secret, token)?;
|
let claims = verify_token(&self.secret, token)?;
|
||||||
Ok(claims.sub)
|
Ok(claims.sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify a refresh token and return the subject claim.
|
||||||
|
///
|
||||||
|
/// Delegates to the same JWT verification function as access tokens;
|
||||||
|
/// the signature algorithm and secret are shared. Expiry validation
|
||||||
|
/// is handled by the JWT library against the `exp` claim embedded
|
||||||
|
/// in the token payload.
|
||||||
|
fn verify_refresh_token(&self, token: &str) -> anyhow::Result<String> {
|
||||||
|
use zesdex_infrastructure::auth::jwt::verify_token;
|
||||||
|
|
||||||
|
let claims = verify_token(&self.secret, token)?;
|
||||||
|
Ok(claims.sub)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -230,8 +230,8 @@ fn handle_tick(state: &mut AppStateRest) {
|
|||||||
}
|
}
|
||||||
TurnEvent::Usage { tokens_in, tokens_out } => {
|
TurnEvent::Usage { tokens_in, tokens_out } => {
|
||||||
if let Some(ref mut rt) = state.session_runtime {
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
rt.usage.tokens_in += tokens_in;
|
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
||||||
rt.usage.tokens_out += tokens_out;
|
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TurnEvent::ReviewUsage {
|
TurnEvent::ReviewUsage {
|
||||||
@@ -239,8 +239,8 @@ fn handle_tick(state: &mut AppStateRest) {
|
|||||||
tokens_out,
|
tokens_out,
|
||||||
} => {
|
} => {
|
||||||
if let Some(ref mut rt) = state.session_runtime {
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
rt.usage.tokens_in += tokens_in;
|
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
||||||
rt.usage.tokens_out += tokens_out;
|
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TurnEvent::Done => {
|
TurnEvent::Done => {
|
||||||
@@ -524,11 +524,8 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
// ── Normal mode ───────────────────────────────────────────────────────
|
// ── Normal mode ───────────────────────────────────────────────────────
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
if state.misc.overlay.is_active() {
|
// Always show quit-confirm, regardless of overlay state.
|
||||||
vec![Action::QuitConfirm]
|
vec![Action::QuitConfirm]
|
||||||
} else {
|
|
||||||
vec![Action::ForceQuit]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
vec![Action::CloseOverlay]
|
vec![Action::CloseOverlay]
|
||||||
@@ -552,7 +549,14 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if state.misc.overlay.is_active() {
|
if state.misc.overlay.is_active() {
|
||||||
vec![Action::CloseOverlay]
|
match state.misc.overlay {
|
||||||
|
Overlay::QuitConfirm => vec![Action::ForceQuit],
|
||||||
|
Overlay::ClearConfirm => vec![Action::SystemNote {
|
||||||
|
kind: "clear".to_string(),
|
||||||
|
message: "cleared".to_string(),
|
||||||
|
}],
|
||||||
|
_ => vec![Action::CloseOverlay],
|
||||||
|
}
|
||||||
} else if state.input.autocomplete_visible {
|
} else if state.input.autocomplete_visible {
|
||||||
// Select the current autocomplete candidate
|
// Select the current autocomplete candidate
|
||||||
if !state.input.autocomplete_candidates.is_empty() {
|
if !state.input.autocomplete_candidates.is_empty() {
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
|||||||
zesdex_infrastructure::TurnEvent::Error(msg) => {
|
zesdex_infrastructure::TurnEvent::Error(msg) => {
|
||||||
state.toast_error(msg);
|
state.toast_error(msg);
|
||||||
}
|
}
|
||||||
|
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
rt.messages = msgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
zesdex_infrastructure::TurnEvent::Done => {
|
zesdex_infrastructure::TurnEvent::Done => {
|
||||||
if let Ok(mut flag) = state.turn_in_flight_flag.lock() {
|
if let Ok(mut flag) = state.turn_in_flight_flag.lock() {
|
||||||
*flag = false;
|
*flag = false;
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ fn run_turn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propagate accumulated messages back to session_runtime so the next
|
||||||
|
// turn starts with the full history (assistant replies + tool results).
|
||||||
|
// TurnEvent::Compacted already exists on the enum and is handled in
|
||||||
|
// action.rs to write back to state.session_runtime.messages.
|
||||||
|
push_event(turn_events, TurnEvent::Compacted(messages.clone()));
|
||||||
push_event(turn_events, TurnEvent::Done);
|
push_event(turn_events, TurnEvent::Done);
|
||||||
mark_done(in_flight);
|
mark_done(in_flight);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user