Add desktop distribution pipeline: rolling releases, auto-updates, code signing

- Rewrite .github/workflows/desktop.yml: 2-job pipeline (validate + build-and-release)
  with rolling desktop-latest pre-release on push to main and stable desktop-v* releases
- Add UpdateChecker component: checks for updates on startup + every 30 min,
  background download with progress bar, one-click relaunch
- Configure Tauri updater: endpoints pointing to desktop-latest release, pubkey placeholder
- Add tauri-plugin-process for relaunch support (Cargo.toml, lib.rs, package.json)
- Add macOS Entitlements.plist for notarization (network + file access, no sandbox)
- Add scripts/bump-desktop-version.sh for atomic version bumps across 3 config files
- Add desktop/README.md with dev setup, auto-update architecture, signing docs
- Update .gitignore for desktop/node_modules, dist, target
- Configure macOS minimumSystemVersion, Windows timestampUrl
- Include all Phase 14-21 work: agent hardening, RBAC, taint tracking, workflows,
  skills, knowledge graph, sessions, A2A, MCP templates, WASM sandbox, TUI dashboard,
  production tools, CLI expansion, API expansion, learning productionization,
  Tauri desktop app, and 10 new channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-02-27 19:14:05 +00:00
co-authored by Claude Opus 4.6
parent 24972e3e52
commit a4c4081ff4
197 changed files with 42212 additions and 232 deletions
+160
View File
@@ -0,0 +1,160 @@
name: Desktop Build & Release
on:
push:
branches: [main]
paths:
- 'desktop/**'
- '.github/workflows/desktop.yml'
tags:
- 'desktop-v*'
pull_request:
branches: [main]
paths:
- 'desktop/**'
- '.github/workflows/desktop.yml'
workflow_dispatch:
concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
validate:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install frontend dependencies
working-directory: desktop
run: npm install
- name: TypeScript type-check
working-directory: desktop
run: npx tsc --noEmit
- name: Vite build
working-directory: desktop
run: npx vite build
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: 'desktop/src-tauri -> target'
- name: Cargo check
working-directory: desktop/src-tauri
run: cargo check
build-and-release:
needs: [validate]
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-22.04
args: ''
- platform: macos-latest
args: '--target aarch64-apple-darwin'
- platform: macos-13
args: '--target x86_64-apple-darwin'
- platform: windows-latest
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install system dependencies (Linux)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: 'desktop/src-tauri -> target'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install frontend dependencies
working-directory: desktop
run: npm install
- name: Determine release info
id: release-info
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/desktop-v* ]]; then
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "name=Desktop ${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "tag=desktop-latest" >> "$GITHUB_OUTPUT"
echo "name=Desktop (Latest Build)" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
fi
- name: Build and release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
projectPath: desktop
tauriScript: npx tauri
tagName: ${{ steps.release-info.outputs.tag }}
releaseName: ${{ steps.release-info.outputs.name }}
releaseBody: 'Desktop application built from ${{ github.sha }}'
releaseDraft: false
prerelease: ${{ steps.release-info.outputs.prerelease }}
includeUpdaterJson: true
args: ${{ matrix.args }}
+5
View File
@@ -51,3 +51,8 @@ site/
frontend/node_modules/
frontend/dist/
src/openjarvis/server/static/
# Desktop (Tauri)
desktop/node_modules/
desktop/dist/
desktop/src-tauri/target/
+110 -148
View File
@@ -4,13 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
OpenJarvis is a research framework for studying on-device AI systems. Phase 13 (Install, Hosting, Cross-Hardware, Eval) complete. Five composable pillars: Intelligence, Engine, Agents, Tools (with storage + MCP), and Learning — with trace-driven learning as a cross-cutting concern. Python SDK (`Jarvis` class), composition layer (`SystemBuilder`/`JarvisSystem`), OpenClaw agent infrastructure, benchmarking framework, Docker deployment all ready. Agent hierarchy refactored: `BaseAgent` (with shared helpers) → `ToolUsingAgent` (tool-using agents), `accepts_tools` introspection, real OpenHands SDK integration. NanoClaw functionality subsumed: `ClaudeCodeAgent` (Claude Agent SDK via Node.js), `WhatsAppBaileysChannel` (Baileys protocol), `ContainerRunner`/`SandboxedAgent` (Docker sandbox), `TaskScheduler` (cron/interval/once with SQLite + MCP tools). ~2244 tests pass (37 skipped for optional deps). Eval framework with 4 benchmarks (SuperGPQA, GAIA, FRAMES, WildChat) and LLM-as-judge scoring via `gpt-5-mini-2025-08-07`. Energy measurement upgraded: `EnergyMonitor` ABC with multi-vendor support (NVIDIA hw counters, AMD amdsmi, Apple Silicon zeus-ml, CPU RAPL sysfs), batch-level energy-per-token accounting (`EnergyBatch`), steady-state detection (`SteadyStateDetector`), `EnergyBenchmark` with warmup phase. Tool system enriched with shared `build_tool_descriptions()` builder; engine tool_calls normalized across OpenAI, Anthropic, Google, and LiteLLM. Phase 13: `jarvis doctor` diagnostic command, `jarvis init` post-setup guidance, MLX engine backend (Apple Silicon → `mlx` recommendation), AMD VRAM/multi-GPU detection, PyTorch MPS device selection, PWA support for browser/desktop hosting, ROCm Docker support.
OpenJarvis is a research framework for studying on-device AI systems. Phase 21 complete. Five composable pillars: Intelligence, Engine, Agents, Tools (with storage + MCP), and Learning — with trace-driven learning as a cross-cutting concern. ~2940 tests pass (~51 skipped for optional deps). Python SDK (`Jarvis` class), composition layer (`SystemBuilder`/`JarvisSystem`), benchmarking framework, Docker deployment, Tauri desktop app, 40+ tools, 20+ CLI commands, 40+ API endpoints all ready.
## Build & Development Commands
```bash
uv sync --extra dev # Install deps + dev tools
uv run pytest tests/ -v # Run ~2244 tests (37 skipped if optional deps missing)
uv run pytest tests/ -v # Run ~2997 tests (~42 skipped if optional deps missing)
uv run ruff check src/ tests/ # Lint
uv run jarvis --version # 1.0.0
uv run jarvis ask "Hello" # Query via discovered engine (direct mode)
@@ -35,19 +35,30 @@ uv run jarvis telemetry clear --yes # Delete all telemetry records
uv run jarvis channel list # List available messaging channels
uv run jarvis channel send slack "Hello" # Send a message to a channel
uv run jarvis channel status # Show channel bridge connection status
uv run jarvis scheduler create "Check weather" --type cron --value "0 9 * * *" # Schedule a task
uv run jarvis scheduler create "Check weather" --type cron --value "0 9 * * *"
uv run jarvis scheduler list # List scheduled tasks
uv run jarvis scheduler pause TASK_ID # Pause a scheduled task
uv run jarvis scheduler cancel TASK_ID # Cancel a scheduled task
uv run jarvis scheduler logs TASK_ID # Show task run history
uv run jarvis scheduler start # Start scheduler daemon (foreground)
uv run jarvis bench run # Run all benchmarks against engine
uv run jarvis bench run -n 20 --json # Run with 20 samples, JSON output
uv run jarvis bench run -b latency -o results.jsonl # Specific benchmark to file
uv run jarvis bench run -b energy -w 5 -n 20 --json # Energy benchmark with warmup
uv run jarvis serve --port 8000 # OpenAI-compatible API server (requires openjarvis[server])
uv run jarvis doctor # Run diagnostic checks (config, engines, models, deps)
uv run jarvis doctor --json # Machine-readable diagnostics
uv run jarvis start # Start server as background daemon
uv run jarvis stop # Stop background daemon
uv run jarvis restart # Restart background daemon
uv run jarvis status # Show daemon status (PID, uptime)
uv run jarvis chat # Interactive REPL (/quit, /clear, /model, /help, /history)
uv run jarvis chat --agent orchestrator --tools calculator # REPL with agent
uv run jarvis agent list # List registered agents
uv run jarvis agent info native_react # Show agent details
uv run jarvis workflow list # List available workflows
uv run jarvis workflow run my_workflow # Execute a workflow
uv run jarvis skill list # List installed skills
uv run jarvis skill install path/to/skill.toml # Install a skill
uv run jarvis vault set MY_KEY # Store encrypted credential
uv run jarvis vault get MY_KEY # Retrieve credential
uv run jarvis vault list # List stored keys
uv run jarvis add github # Quick-add MCP server (github, slack, postgres, etc.)
uv run jarvis --help # Show all subcommands
uv run jarvis init --force # Detect hardware, write ~/.openjarvis/config.toml
# Eval framework
@@ -87,8 +98,8 @@ j.close() # Release resources
```
- **Package manager:** `uv` with `hatchling` build backend
- **Config:** `pyproject.toml` with extras for optional backends (e.g., `openjarvis[inference-vllm]`, `openjarvis[inference-mlx]`, `openjarvis[memory-colbert]`, `openjarvis[server]`, `openjarvis[openclaw]`, `openjarvis[energy-amd]`, `openjarvis[energy-apple]`, `openjarvis[energy-all]`)
- **CLI entry point:** `jarvis` (Click-based) — subcommands: `init`, `ask`, `serve`, `model`, `memory`, `telemetry`, `bench`, `channel`, `scheduler`, `doctor`
- **Config:** `pyproject.toml` with extras for optional backends (e.g., `openjarvis[inference-vllm]`, `openjarvis[inference-mlx]`, `openjarvis[memory-colbert]`, `openjarvis[server]`, `openjarvis[openclaw]`, `openjarvis[energy-amd]`, `openjarvis[energy-apple]`, `openjarvis[energy-all]`, `openjarvis[security-signing]`, `openjarvis[sandbox-wasm]`, `openjarvis[dashboard]`, `openjarvis[browser]`, `openjarvis[media]`, `openjarvis[pdf]`, `openjarvis[channel-line]`, `openjarvis[channel-viber]`, `openjarvis[channel-reddit]`, `openjarvis[channel-mastodon]`, `openjarvis[channel-xmpp]`, `openjarvis[channel-rocketchat]`, `openjarvis[channel-zulip]`, `openjarvis[channel-twitch]`, `openjarvis[channel-nostr]`)
- **CLI entry point:** `jarvis` (Click-based) — subcommands: `init`, `ask`, `serve`, `start`, `stop`, `restart`, `status`, `chat`, `model`, `memory`, `telemetry`, `bench`, `channel`, `scheduler`, `doctor`, `agent`, `workflow`, `skill`, `vault`, `add`
- **Python:** 3.10+ required
- **Node.js:** 22+ required only for OpenClaw agent
@@ -98,170 +109,121 @@ OpenJarvis is a research framework for on-device AI organized around **five comp
### Five Pillars
1. **Intelligence** (`src/openjarvis/intelligence/`) — The model definition, catalog, and generation defaults. `ModelRegistry` maps model keys to `ModelSpec`. `IntelligenceConfig` holds model identity (default/fallback model, model_path, checkpoint_path, quantization, preferred_engine, provider) and generation defaults (temperature, max_tokens, top_p, top_k, repetition_penalty, stop_sequences). Model catalog (`model_catalog.py`) maintains `BUILTIN_MODELS` list with auto-discovery via `merge_discovered_models()`. Backward-compat shims in `intelligence/_stubs.py` and `intelligence/router.py` re-export from `learning/` for old import paths.
2. **Engine** (`src/openjarvis/engine/`) — The inference runtime. Backends: vLLM, SGLang, Ollama, llama.cpp, MLX. All implement `InferenceEngine` ABC with `generate()`, `stream()`, `list_models()`, `health()`. Engines extract and pass through `tool_calls` in OpenAI format.
3. **Agents** (`src/openjarvis/agents/`) — Pluggable logic for handling queries, making tool/API calls, managing memory. Class hierarchy: `BaseAgent` ABC (with concrete helpers: `_emit_turn_start/end`, `_build_messages`, `_generate`, `_max_turns_result`, `_strip_think_tags`) → `ToolUsingAgent` (adds `tools`, `ToolExecutor`, `max_turns`). Agents: `SimpleAgent` (single-turn, no tools, extends `BaseAgent`), `OrchestratorAgent` (multi-turn tool-calling loop, extends `ToolUsingAgent`), `NativeReActAgent` (Thought-Action-Observation loop, extends `ToolUsingAgent`, registry key `"native_react"`, alias `"react"`), `NativeOpenHandsAgent` (CodeAct-style code execution, extends `ToolUsingAgent`, registry key `"native_openhands"`), `RLMAgent` (recursive LM with code gen, extends `ToolUsingAgent`), `OpenHandsAgent` (wraps real `openhands-sdk`, extends `BaseAgent`, registry key `"openhands"`, requires `openjarvis[openhands]` + Python 3.12+), `OpenClawAgent` (HTTP/subprocess transport), `ClaudeCodeAgent` (wraps Claude Agent SDK via Node.js subprocess, registry key `"claude_code"`, requires Node.js 22+, `accepts_tools=False`), `SandboxedAgent` (transparent Docker wrapper for any agent, registry key `"sandboxed"`, `accepts_tools=False`). `accepts_tools` class attribute enables CLI/SDK to auto-detect tool-using agents. Backward-compat: `from openjarvis.agents.react import ReActAgent` still works (shim). Agents call `engine.generate()` directly — telemetry is handled by the `InstrumentedEngine` wrapper when enabled.
1. **Intelligence** (`src/openjarvis/intelligence/`) — Model definition, catalog, and generation defaults. `ModelRegistry` maps model keys to `ModelSpec`. `IntelligenceConfig` holds model identity (default/fallback model, model_path, checkpoint_path, quantization, preferred_engine, provider) and generation defaults (temperature, max_tokens, top_p, top_k, repetition_penalty, stop_sequences). Model catalog maintains `BUILTIN_MODELS` with auto-discovery via `merge_discovered_models()`. Backward-compat shims re-export from `learning/` for old import paths.
2. **Engine** (`src/openjarvis/engine/`) — The inference runtime. Backends: vLLM, SGLang, Ollama, llama.cpp, MLX, LM Studio. All implement `InferenceEngine` ABC with `generate()`, `stream()`, `list_models()`, `health()`. Engines extract and pass through `tool_calls` in OpenAI format.
3. **Agents** (`src/openjarvis/agents/`) — Pluggable logic for queries, tool/API calls, memory. Hierarchy: `BaseAgent` ABC (helpers: `_emit_turn_start/end`, `_build_messages`, `_generate`, `_max_turns_result`, `_strip_think_tags`, `_check_continuation`) → `ToolUsingAgent` (adds `tools`, `ToolExecutor`, `max_turns`). Agents: `SimpleAgent` (single-turn), `OrchestratorAgent` (multi-turn tool loop), `NativeReActAgent` (Thought-Action-Observation, key `"native_react"`, alias `"react"`), `NativeOpenHandsAgent` (CodeAct, key `"native_openhands"`), `RLMAgent` (recursive LM), `OpenHandsAgent` (real `openhands-sdk`, key `"openhands"`, requires Python 3.12+), `OpenClawAgent` (HTTP/subprocess transport), `ClaudeCodeAgent` (Claude Agent SDK via Node.js, key `"claude_code"`), `SandboxedAgent` (Docker wrapper, key `"sandboxed"`). `accepts_tools` class attribute for CLI/SDK auto-detection. Agents call `engine.generate()` directly — telemetry handled by `InstrumentedEngine` wrapper.
4. **Tools** (`src/openjarvis/tools/`) — All tools managed via MCP (Model Context Protocol).
- **API tools**: `CalculatorTool`, `ThinkTool`, `FileReadTool`, `WebSearchTool`, `CodeInterpreterTool` — all implement `BaseTool` ABC
- **Storage tools** (`tools/storage_tools.py`): `MemoryStoreTool`, `MemoryRetrieveTool`, `MemorySearchTool`, `MemoryIndexTool` — wrap `MemoryBackend` operations as MCP-discoverable tools
- **LM tool** (`tools/llm_tool.py`): Sub-model calls via engine
- **Storage backends** (`tools/storage/`): SQLite/FTS5 (default), FAISS, ColBERTv2, BM25, Hybrid (RRF fusion). All implement `MemoryBackend` ABC with `store()`, `retrieve()`, `delete()`, `clear()`. Canonical import: `from openjarvis.tools.storage.sqlite import SQLiteMemory`. Backward-compat shims in `memory/` still work.
- **Scheduler tools** (`scheduler/tools.py`): `ScheduleTaskTool`, `ListScheduledTasksTool`, `PauseScheduledTaskTool`, `ResumeScheduledTaskTool`, `CancelScheduledTaskTool` — MCP-discoverable tools for task scheduling, injected with `_scheduler` dependency
- **MCP adapter** (`tools/mcp_adapter.py`): `MCPToolAdapter` wraps external MCP server tools as native `BaseTool` instances. `MCPToolProvider` discovers tools from an MCP server.
- **MCP server** (`mcp/server.py`): Exposes all built-in tools via JSON-RPC `tools/list` + `tools/call` (MCP spec 2025-11-25). Any MCP client (Claude, GPT, etc.) can discover and use OpenJarvis tools.
5. **Learning** (`src/openjarvis/learning/`) — Structured learning system with nested per-pillar sub-policies. `LearningConfig` has nested sections: `routing` (policy selection: heuristic/learned/grpo), `intelligence` (model updates: none/sft), `agent` (agent logic: none/agent_advisor/icl_updater), `metrics` (reward weights: accuracy/latency/cost/efficiency). `RouterPolicy` ABC and `QueryAnalyzer` ABC defined in `learning/_stubs.py`. `HeuristicRouter` and `build_routing_context()` in `learning/router.py`. `LearningPolicy` ABC taxonomy: `IntelligenceLearningPolicy` (updates model routing), `AgentLearningPolicy` (updates agent logic). Implementations: `SFTRouterPolicy` (learns query→model mapping from traces; backward-compat alias `SFTPolicy`), `AgentAdvisorPolicy` (LM-guided agent restructuring), `ICLUpdaterPolicy` (in-context example + skill discovery, registered as `AgentLearningPolicy`). Router policies: `HeuristicRouter` (registered as "heuristic"), `TraceDrivenPolicy` (registered as "learned"), `GRPORouterPolicy` (stub, registered as "grpo"). `HeuristicRewardFunction` scores inference results on latency/cost/efficiency. Orchestrator training subpackage (`learning/orchestrator/`) provides SFT and GRPO pipelines for structured-mode OrchestratorAgent training.
- **API tools**: `CalculatorTool`, `ThinkTool`, `FileReadTool`, `FileWriteTool`, `WebSearchTool`, `CodeInterpreterTool`, `LLMTool`, `ShellExecTool`, `ApplyPatchTool`, `HttpRequestTool`, `DatabaseQueryTool`, `PDFExtractTool`, `ImageGenerateTool`, `AudioTranscribeTool` — all implement `BaseTool` ABC
- **Git tools** (`git_tool.py`): `GitStatusTool`, `GitDiffTool`, `GitCommitTool`, `GitLogTool`
- **Browser tools** (`browser.py`): `BrowserNavigateTool`, `BrowserClickTool`, `BrowserTypeTool`, `BrowserScreenshotTool`, `BrowserExtractTool` (Playwright, optional `[browser]`)
- **Agent tools** (`agent_tools.py`): `AgentSpawnTool`, `AgentSendTool`, `AgentListTool`, `AgentKillTool`
- **Storage tools** (`storage_tools.py`): `MemoryStoreTool`, `MemoryRetrieveTool`, `MemorySearchTool`, `MemoryIndexTool`
- **Storage backends** (`tools/storage/`): SQLite/FTS5 (default), FAISS, ColBERTv2, BM25, Hybrid (RRF fusion), KnowledgeGraph. All implement `MemoryBackend` ABC. Canonical import: `from openjarvis.tools.storage.sqlite import SQLiteMemory`. Backward-compat shims in `memory/` still work.
- **Scheduler tools** (`scheduler/tools.py`): 5 MCP tools for task scheduling
- **Knowledge graph tools** (`knowledge_tools.py`): `KGAddEntityTool`, `KGAddRelationTool`, `KGQueryTool`, `KGNeighborsTool`
- **MCP adapter** (`mcp_adapter.py`): `MCPToolAdapter` wraps external MCP tools as native `BaseTool`; `MCPToolProvider` discovers from server
- **MCP server** (`mcp/server.py`): Exposes all built-in tools via JSON-RPC `tools/list` + `tools/call` (MCP spec 2025-11-25)
- **MCP templates** (`tools/templates/`): `ToolTemplate` dynamically constructs tools from TOML specs. 10 builtin templates. `discover_templates()` auto-discovers.
- **`ToolExecutor`**: dispatch with RBAC check + taint check, `timeout_seconds` on `ToolSpec` (default 30s via `ThreadPoolExecutor`), event bus integration
- All registered via `@ToolRegistry.register("name")` decorator
5. **Learning** (`src/openjarvis/learning/`) — Structured learning with nested per-pillar sub-policies. `LearningConfig` sections: `routing` (heuristic/learned/grpo/bandit), `intelligence` (none/sft), `agent` (none/agent_advisor/icl_updater), `metrics` (accuracy/latency/cost/efficiency weights). Policies: `SFTRouterPolicy` (query→model from traces), `AgentAdvisorPolicy` (LM-guided), `ICLUpdaterPolicy` (in-context with example DB, versioning, rollback, quality gates), `GRPORouterPolicy` (softmax sampling, group relative advantage, per-query-class weights), `BanditRouterPolicy` (Thompson Sampling / UCB1, per-arm stats). `SkillDiscovery` mines tool subsequences from traces to auto-generate skill manifests. Router policies: `HeuristicRouter`, `TraceDrivenPolicy`. Orchestrator training subpackage provides SFT and GRPO pipelines.
### Cross-cutting: Traces
### Cross-cutting Systems
- **Traces** (`src/openjarvis/traces/`) — Full interaction-level recording. Every agent interaction produces a `Trace` capturing the sequence of `TraceStep`s (route, retrieve, generate, tool_call, respond) with timing, inputs, outputs, and outcomes. `TraceStore` persists to SQLite. `TraceCollector` wraps any `BaseAgent` to record traces automatically. `TraceAnalyzer` provides aggregated stats for the learning system.
- **Traces** (`src/openjarvis/traces/`) — Full interaction recording. `Trace` captures `TraceStep`s (route, retrieve, generate, tool_call, respond) with timing. `TraceStore` (SQLite), `TraceCollector` (auto-wraps agents), `TraceAnalyzer` (stats for learning).
- **Telemetry** (`src/openjarvis/telemetry/`) — `InstrumentedEngine` wraps any engine, publishing events to SQLite via `TelemetryStore`. `TelemetryAggregator` for read-only queries. `EnergyMonitor` ABC with vendor-specific implementations: `NvidiaEnergyMonitor` (hw counters/polling), `AmdEnergyMonitor` (amdsmi), `AppleEnergyMonitor` (zeus-ml), `RaplEnergyMonitor` (sysfs). `EnergyBatch` for batch-level energy-per-token. `SteadyStateDetector` for thermal equilibrium (CV-based).
- **Security** (`src/openjarvis/security/`) — `SecretScanner` + `PIIScanner` (implement `BaseScanner` ABC). `GuardrailsEngine` wraps engines with input/output scanning (WARN/REDACT/BLOCK modes). `AuditLogger` with Merkle hash chain (SHA-256 tamper-evidence). `CapabilityPolicy` RBAC (10 capabilities with glob matching, enforced in `ToolExecutor`). `TaintLabel`/`TaintSet` information flow control with `SINK_POLICY`. Ed25519 signing via `cryptography` (optional `[security-signing]`). `file_policy.py` for sensitive file detection. `InjectionScanner` (11 regex patterns: prompt override, identity override, code/shell injection, exfiltration, jailbreak, delimiter injection). `check_ssrf()` SSRF protection (RFC 1918, loopback, link-local, cloud metadata blocking). `RateLimiter` with `TokenBucket` (thread-safe, per-key). `run_sandboxed()` subprocess isolation (`os.setsid`, process group kill, env clearing). Security HTTP middleware (7 headers: CSP, HSTS, X-Frame-Options, etc.).
### Composition Layer (`src/openjarvis/system.py`)
### Composition & Infrastructure
- `SystemBuilder`: Config-driven fluent builder `.engine()`, `.model()`, `.agent()`, `.tools()`, `.telemetry()`, `.traces()`, `.build()``JarvisSystem`
- `JarvisSystem`: Fully wired system with `ask()`, `close()`. Single source of truth for pillar wiring.
- Consolidates duplicated engine/model/tool/agent resolution logic from CLI, SDK, and server.
### Python SDK (`src/openjarvis/sdk.py`)
- `Jarvis` class: High-level sync API wrapping CLI code paths
- `MemoryHandle`: Lazy memory backend proxy on `j.memory`
- `ask()` / `ask_full()`: Direct engine or agent mode, with router policy selection
- Lazy engine initialization, telemetry recording, resource cleanup via `close()`
- Also exports `JarvisSystem` and `SystemBuilder` from `system.py` for config-driven composition
### Tool System (`src/openjarvis/tools/`)
- `_stubs.py``ToolSpec` dataclass, `BaseTool` ABC (abstract `spec`, `execute()`), `ToolExecutor` (dispatch with event bus integration, JSON argument parsing, latency tracking)
- Built-in API tools: `CalculatorTool` (ast-based safe eval), `ThinkTool` (reasoning scratchpad), `RetrievalTool` (memory search), `LLMTool` (sub-model calls), `FileReadTool` (safe file reading with path validation), `WebSearchTool`, `CodeInterpreterTool`
- Storage MCP tools (`storage_tools.py`): `MemoryStoreTool`, `MemoryRetrieveTool`, `MemorySearchTool`, `MemoryIndexTool` — expose memory operations as MCP-discoverable tools
- MCP adapter (`mcp_adapter.py`): `MCPToolAdapter` wraps external MCP tools as native `BaseTool` instances; `MCPToolProvider` discovers tools from an `MCPClient`
- Storage backends (`tools/storage/`): `MemoryBackend` ABC, `SQLiteMemory`, `BM25Memory`, `FAISSMemory`, `ColBERTMemory`, `HybridMemory`, plus chunking, context injection, embeddings, ingest. Canonical imports from `openjarvis.tools.storage.*`; backward-compat shims in `openjarvis.memory.*`
- All registered via `@ToolRegistry.register("name")` decorator
### Benchmarking Framework (`src/openjarvis/bench/`)
- `_stubs.py``BenchmarkResult` dataclass (includes `warmup_samples`, `steady_state_samples`, `steady_state_reached`, `total_energy_joules`, `energy_per_token_joules`, `energy_method`), `BaseBenchmark` ABC, `BenchmarkSuite` runner
- `latency.py``LatencyBenchmark`: measures per-call latency (mean, p50, p95, min, max). Supports `warmup_samples` parameter.
- `throughput.py``ThroughputBenchmark`: measures tokens/second throughput. Supports `warmup_samples` parameter.
- `energy.py``EnergyBenchmark`: measures energy per token at thermal equilibrium. Uses `SteadyStateDetector` + `EnergyBatch` with optional `EnergyMonitor`. Warmup phase excluded from metrics.
- All registered via `BenchmarkRegistry` with `ensure_registered()` pattern
- CLI: `jarvis bench run` with options for model, engine, samples, benchmark selection, warmup (`-w`), JSON/JSONL output
### OpenClaw Infrastructure (`src/openjarvis/agents/openclaw*.py`)
- `openclaw_protocol.py``MessageType` enum, `ProtocolMessage` dataclass, JSON-line `serialize()`/`deserialize()`
- `openclaw_transport.py``OpenClawTransport` ABC, `HttpTransport` (HTTP POST to OpenClaw server), `SubprocessTransport` (Node.js stdin/stdout)
- `openclaw.py``OpenClawAgent`: transport-based agent with tool-call loop, event bus integration
- `openclaw_plugin.py``ProviderPlugin` (wraps engine for OpenClaw), `MemorySearchManager` (wraps memory for OpenClaw)
### API Server (`src/openjarvis/server/`)
- OpenAI-compatible server via `jarvis serve` (FastAPI + uvicorn, optional `[server]` extra)
- `POST /v1/chat/completions` — non-streaming through agent/engine, streaming via SSE
- `GET /v1/models` — list available models
- `GET /health` — health check
- Pydantic request/response models matching OpenAI API format
### Trace System (`src/openjarvis/traces/`)
- `store.py``TraceStore` writes complete `Trace` objects (with `TraceStep` lists) to SQLite. Supports filtering by agent, model, outcome, time range. Subscribes to `TRACE_COMPLETE` events on EventBus.
- `collector.py``TraceCollector` wraps any `BaseAgent` and records a `Trace` for every `run()`. Subscribes to EventBus events (inference, tool, memory) during agent execution, converting them to `TraceStep` objects. Automatically persists traces and publishes `TRACE_COMPLETE`.
- `analyzer.py``TraceAnalyzer` read-only query layer: `summary()`, `per_route_stats()`, `per_tool_stats()`, `traces_for_query_type()`, `export_traces()`. Time-range filtering. Provides inputs for the learning system.
- Dataclasses: `RouteStats`, `ToolStats`, `TraceSummary`
### Telemetry (`src/openjarvis/telemetry/`)
- `store.py``TelemetryStore` writes records to SQLite via EventBus subscription (append-only). Schema includes `energy_method`, `energy_vendor`, `batch_id`, `is_warmup`, `cpu_energy_joules`, `gpu_energy_joules`, `dram_energy_joules` columns.
- `aggregator.py``TelemetryAggregator` read-only query layer: `per_model_stats()`, `per_engine_stats()`, `per_batch_stats()`, `top_models()`, `summary()`, `export_records()`, `clear()`. Time-range filtering via `since`/`until`.
- `instrumented_engine.py``InstrumentedEngine` wraps any `InferenceEngine` transparently, publishing `INFERENCE_START/END` and `TELEMETRY_RECORD` events. Prefers `EnergyMonitor` over legacy `GpuMonitor` when available. Agents call `engine.generate()` normally; telemetry is opt-in via this wrapper (applied by `SystemBuilder` when `config.telemetry.enabled`).
- `energy_monitor.py``EnergyMonitor` ABC: `available()`, `vendor()`, `energy_method()`, `sample()` context manager, `close()`. `EnergySample` dataclass (superset of `GpuSample`): total + per-component (CPU, GPU, DRAM, ANE) energy, vendor/device info, measurement method. `EnergyVendor` enum: NVIDIA, AMD, APPLE, CPU_RAPL. `create_energy_monitor()` factory with auto-detection order: NVIDIA > AMD > Apple > CPU RAPL.
- `energy_nvidia.py``NvidiaEnergyMonitor`: hardware counters via `nvmlDeviceGetTotalEnergyConsumption()` on Volta+, trapezoidal polling fallback on pre-Volta. `energy_method` = "hw_counter" or "polling".
- `energy_amd.py``AmdEnergyMonitor`: hardware counters via `amdsmi_get_energy_count()` (ROCm 6.1+). `energy_method` = "hw_counter".
- `energy_apple.py``AppleEnergyMonitor`: wraps `zeus-ml[apple]` `AppleSiliconMonitor`. Per-component breakdown (CPU, GPU, DRAM, ANE). `energy_method` = "zeus".
- `energy_rapl.py``RaplEnergyMonitor`: reads Intel RAPL counters from `/sys/class/powercap/intel-rapl/` (no deps). Handles counter wrap-around. `energy_method` = "rapl".
- `batch.py``EnergyBatch`: wraps `EnergyMonitor.sample()` around grouped requests. `BatchMetrics` dataclass with energy-per-token/per-request accounting. `BATCH_START`/`BATCH_END` events.
- `steady_state.py``SteadyStateDetector`: CV-based sliding window to detect thermal equilibrium. `SteadyStateConfig` (warmup_samples, window_size, cv_threshold). Used by `EnergyBenchmark`.
- `wrapper.py` — Legacy `instrumented_generate()` function (still used by some CLI/SDK code paths)
- Dataclasses: `ModelStats`, `EngineStats`, `AggregatedStats`, `EnergySample`, `BatchMetrics`, `SteadyStateResult`
### Security (`src/openjarvis/security/`)
- `_stubs.py``BaseScanner` ABC (abstract `scan()`, `redact()`)
- `types.py``ThreatLevel`, `RedactionMode`, `ScanFinding`, `ScanResult`, `SecurityEvent`, `SecurityEventType`
- `scanner.py``SecretScanner` (API keys, tokens, passwords, connection strings, private keys), `PIIScanner` (email, SSN, credit cards, phone, IP)
- `guardrails.py``GuardrailsEngine` wraps any `InferenceEngine` with input/output scanning. Modes: WARN (pass-through + event), REDACT (replace sensitive content), BLOCK (raise `SecurityBlockError`). `stream()` does post-hoc scan of accumulated output.
- `file_policy.py``is_sensitive_file()` checks filename against `DEFAULT_SENSITIVE_PATTERNS` (`.env`, `*.pem`, `*.key`, `credentials.*`, etc.). `filter_sensitive_paths()` filters path lists.
- `audit.py``AuditLogger` persists security events to SQLite for compliance/forensics.
### Channels (`src/openjarvis/channels/`)
- `_stubs.py``BaseChannel` ABC (`connect()`, `disconnect()`, `send()`, `status()`, `list_channels()`, `on_message()`), `ChannelMessage` dataclass, `ChannelStatus` enum, `ChannelHandler` type
- `openclaw_bridge.py``OpenClawChannelBridge`: WebSocket/HTTP bridge to OpenClaw gateway. Background listener thread, reconnect logic, handler registration, event bus integration. Registered as `"openclaw"` in `ChannelRegistry`.
- `whatsapp_baileys.py``WhatsAppBaileysChannel`: Bidirectional WhatsApp messaging via Baileys protocol. Spawns Node.js bridge subprocess, JSON-line stdio protocol, QR code auth, background reader thread. Registered as `"whatsapp_baileys"` in `ChannelRegistry`. Bundled bridge in `whatsapp_baileys_bridge/`.
### Sandbox (`src/openjarvis/sandbox/`)
- `runner.py``ContainerRunner`: manages Docker/Podman container lifecycle for sandboxed agent execution. Sentinel-wrapped JSON I/O, timeout enforcement, mount validation, orphan cleanup. `SandboxedAgent(BaseAgent)`: transparent wrapper that runs any agent inside a container (follows `GuardrailsEngine` wrapper pattern).
- `mount_security.py``MountAllowlist`, `AllowedRoot` dataclasses, `DEFAULT_BLOCKED_PATTERNS` (`.ssh`, `.gnupg`, `.env`, `*.pem`, `*.key`, etc.), `validate_mount()`, `validate_mounts()`, path traversal prevention.
- `Dockerfile.sandbox` — Container image for sandboxed execution (Python 3.12-slim + Node.js 22).
### Scheduler (`src/openjarvis/scheduler/`)
- `scheduler.py``ScheduledTask` dataclass, `TaskScheduler` with cron/interval/once scheduling, background polling thread, event bus integration (`SCHEDULER_TASK_START/END`).
- `store.py``SchedulerStore`: SQLite CRUD for `scheduled_tasks` and `task_run_logs` tables.
- `tools.py` — 5 MCP tools: `ScheduleTaskTool`, `ListScheduledTasksTool`, `PauseScheduledTaskTool`, `ResumeScheduledTaskTool`, `CancelScheduledTaskTool`.
- CLI: `jarvis scheduler create|list|pause|resume|cancel|logs|start` (Click command group in `cli/scheduler_cmd.py`).
- **Composition Layer** (`system.py`) — `SystemBuilder` fluent builder `JarvisSystem` with `ask()`, `close()`. Wires engine, model, agent, tools, telemetry, traces, workflow, sessions, capability policy.
- **SDK** (`sdk.py`) — `Jarvis` class: high-level sync API with `ask()`/`ask_full()`, `MemoryHandle`, lazy init, telemetry. Also exports `JarvisSystem`/`SystemBuilder`.
- **Benchmarks** (`bench/`) — `LatencyBenchmark`, `ThroughputBenchmark`, `EnergyBenchmark`. All registered via `BenchmarkRegistry`. CLI: `jarvis bench run`.
- **OpenClaw** (`agents/openclaw*.py`) — `OpenClawAgent` with `HttpTransport`/`SubprocessTransport`, JSON-line protocol, `ProviderPlugin`, `MemorySearchManager`.
- **API Server** (`server/`) — OpenAI-compatible via `jarvis serve` (FastAPI + uvicorn). Endpoints: `POST /v1/chat/completions`, `GET /v1/models`, `GET /health`, channel endpoints. SSE streaming.
- **Channels** (`channels/`) — `BaseChannel` ABC. `OpenClawChannelBridge` (WebSocket/HTTP to OpenClaw gateway). `WhatsAppBaileysChannel` (Baileys protocol, Node.js bridge, QR auth). Phase 21 channels: `LINEChannel`, `ViberChannel`, `MessengerChannel`, `RedditChannel`, `MastodonChannel`, `XMPPChannel`, `RocketChatChannel`, `ZulipChannel`, `TwitchChannel`, `NostrChannel`. All follow `BaseChannel` ABC with env var fallbacks, `@ChannelRegistry.register()`, `EventBus` integration.
- **Sandbox** (`sandbox/`) — `ContainerRunner` (Docker/Podman lifecycle, mount validation). `WasmRunner` (wasmtime-py, fuel/memory limits, optional `[sandbox-wasm]`). `SandboxedAgent` transparent wrapper. `create_sandbox_runner()` factory. `MountAllowlist` with path traversal prevention.
- **Scheduler** (`scheduler/`) — `TaskScheduler` with cron/interval/once scheduling, SQLite persistence, 5 MCP tools, event bus. CLI: `jarvis scheduler create|list|pause|resume|cancel|logs|start`.
- **Agent Hardening** (`agents/loop_guard.py`) — `LoopGuard`: SHA-256 hash tracking (identical calls), ping-pong detection (A-B-A-B patterns), poll-tool budget, context overflow recovery. `BaseAgent._check_continuation()` auto-resumes on `finish_reason=length`.
- **Workflow Engine** (`workflow/`) — DAG-based `WorkflowGraph` (cycle detection, topological sort, parallel stages via `ThreadPoolExecutor`). `WorkflowBuilder` fluent API. `WorkflowEngine` executes against `JarvisSystem`. TOML loader. Node types: agent, tool, condition, parallel, loop, transform.
- **Skills** (`skills/`) — `SkillManifest`/`SkillExecutor` (sequential tool steps with template rendering). Ed25519 signature verification. `SkillTool` adapter wraps skills as invocable tools. TOML loader.
- **Knowledge Graph** (`tools/storage/knowledge_graph.py`) — `KnowledgeGraphMemory(MemoryBackend)`: SQLite entity-relation store. `add_entity()`, `add_relation()`, `neighbors()`, `query_pattern()`. Registered as `"knowledge_graph"`.
- **Sessions** (`sessions/`) — `SessionStore` (SQLite): cross-channel persistent sessions. `SessionIdentity` canonical user across channels. `consolidate()` summarizes old messages, `decay()` removes expired.
- **A2A Protocol** (`a2a/`) — Google Agent-to-Agent spec (JSON-RPC 2.0). `A2AServer` (tasks/send, tasks/get, tasks/cancel, `/.well-known/agent.json`). `A2AClient`. `A2AAgentTool` adapter.
- **TUI Dashboard** (`cli/dashboard.py`)`textual`-based terminal dashboard (optional `[dashboard]`). Panels: system status, event stream, telemetry, agent activity, sessions.
- **Desktop App** (`desktop/`) — Tauri 2.0 native desktop application. 5 dashboard panels: EnergyDashboard (real-time power monitoring with recharts), TraceDebugger (timeline inspection with step-type color coding), LearningCurve (policy visualization, GRPO/bandit stats), MemoryBrowser (search + stats), AdminPanel (health, agents, server control). Tauri commands proxy to OpenJarvis REST API. Plugins: notification, shell, global-shortcut, autostart, updater, single-instance. CI: `.github/workflows/desktop.yml` (Linux/macOS/Windows).
- **Vault** (`cli/vault_cmd.py`) — Fernet-encrypted credential store at `~/.openjarvis/vault.enc` with auto-generated key (`0o600` permissions).
- **MCP Quick-Add** (`cli/add_cmd.py`) `jarvis add <server>` with 8 templates (github, filesystem, slack, postgres, brave-search, memory, puppeteer, google-maps). Saves JSON config to `~/.openjarvis/mcp/`.
### Core Module (`src/openjarvis/core/`)
- `registry.py``RegistryBase[T]` generic base class adapted from IPW. Typed subclasses: `ModelRegistry`, `EngineRegistry`, `MemoryRegistry`, `AgentRegistry`, `ToolRegistry`, `RouterPolicyRegistry`, `BenchmarkRegistry`, `ChannelRegistry`, `LearningRegistry`.
- `registry.py``RegistryBase[T]` generic base. Subclasses: `ModelRegistry`, `EngineRegistry`, `MemoryRegistry`, `AgentRegistry`, `ToolRegistry`, `RouterPolicyRegistry`, `BenchmarkRegistry`, `ChannelRegistry`, `LearningRegistry`, `SkillRegistry`.
- `types.py``Message`, `Conversation`, `ModelSpec`, `ToolResult`, `TelemetryRecord`, `StepType`, `TraceStep`, `Trace`, `RoutingContext`.
- `config.py``JarvisConfig` dataclass hierarchy with TOML loader. Config classes: `EngineConfig` (nested `OllamaEngineConfig`, `VLLMEngineConfig`, `SGLangEngineConfig`, `LlamaCppEngineConfig`, `MLXEngineConfig`), `IntelligenceConfig` (model identity + generation defaults: temperature, max_tokens, top_p, top_k, repetition_penalty, stop_sequences), `AgentConfig` (default_agent, tools, objective, system_prompt, system_prompt_path, context_from_memory), `ToolsConfig` (nests `StorageConfig` + `MCPConfig`), `LearningConfig` (nested `RoutingLearningConfig`, `IntelligenceLearningConfig`, `AgentLearningConfig`, `MetricsConfig`), `TracesConfig`, `TelemetryConfig`, `ServerConfig`, `ChannelConfig` (nests `WhatsAppBaileysChannelConfig`), `SecurityConfig`, `SandboxConfig`, `SchedulerConfig`. Backward-compat properties: `engine.ollama_host``engine.ollama.host`, `agent.default_tools``agent.tools`, `learning.default_policy``learning.routing.policy`. TOML migration layer handles cross-section moves (`agent.temperature``intelligence.temperature`, `memory.context_injection``agent.context_from_memory`). User config lives at `~/.openjarvis/config.toml`. TOML sections: `[engine]`, `[engine.ollama]`, `[engine.vllm]`, `[engine.sglang]`, `[engine.llamacpp]`, `[engine.mlx]`, `[intelligence]`, `[agent]`, `[tools.storage]`, `[tools.mcp]`, `[learning]`, `[learning.routing]`, `[learning.intelligence]`, `[learning.agent]`, `[learning.metrics]`, `[memory]` (backward-compat), `[server]`, `[telemetry]`, `[traces]`, `[channel]`, `[channel.whatsapp_baileys]`, `[security]`, `[sandbox]`, `[scheduler]`.
- `events.py` — Pub/sub event bus for inter-pillar telemetry (synchronous dispatch). EventType values: INFERENCE_START/END, TOOL_CALL_START/END, MEMORY_STORE/RETRIEVE, AGENT_TURN_START/END, TELEMETRY_RECORD, TRACE_STEP/COMPLETE, CHANNEL_MESSAGE_RECEIVED/SENT, SECURITY_SCAN/ALERT/BLOCK, SCHEDULER_TASK_START/END.
- `config.py``JarvisConfig` dataclass hierarchy with TOML loader. Config classes for each pillar/subsystem. TOML sections: `[engine]` (+ nested `[engine.ollama]`, `[engine.vllm]`, `[engine.sglang]`, `[engine.llamacpp]`, `[engine.mlx]`, `[engine.lmstudio]`), `[intelligence]`, `[agent]`, `[tools.storage]`, `[tools.mcp]`, `[tools.browser]`, `[learning]` (+ nested routing/intelligence/agent/metrics), `[server]`, `[telemetry]`, `[traces]`, `[channel]`, `[security]` (+ `[security.capabilities]`, `ssrf_protection`, `rate_limit_*`), `[sandbox]`, `[scheduler]`, `[workflow]`, `[sessions]`, `[a2a]`. Backward-compat: `engine.ollama_host``engine.ollama.host`, `agent.default_tools``agent.tools`, TOML migration for cross-section moves.
- `events.py` — Pub/sub event bus (synchronous dispatch). ~30 EventType values covering inference, tools, memory, agents, telemetry, traces, channels, security, scheduler, workflow, skills, sessions, A2A.
### Docker & Deployment
- `Dockerfile` — Multi-stage build: Python 3.12-slim, installs `.[server]`, entrypoint `jarvis serve`
- `Dockerfile.gpu` — NVIDIA CUDA 12.4 runtime variant
- `Dockerfile.gpu.rocm` — AMD ROCm 6.2 runtime variant (multi-stage build)
- `docker-compose.yml` Services: `jarvis` (port 8000) + `ollama` (port 11434)
- `docker-compose.gpu.rocm.yml` — ROCm override file (use with `-f docker-compose.yml -f docker-compose.gpu.rocm.yml`)
- `deploy/systemd/openjarvis.service` — systemd unit file
- `deploy/launchd/com.openjarvis.plist` — macOS launchd plist
- `Dockerfile` — Multi-stage: Python 3.12-slim, `.[server]`, entrypoint `jarvis serve`
- `Dockerfile.gpu` — NVIDIA CUDA 12.4 variant
- `Dockerfile.gpu.rocm` — AMD ROCm 6.2 variant
- `docker-compose.yml``jarvis` (8000) + `ollama` (11434). ROCm override: `docker-compose.gpu.rocm.yml`
- `deploy/systemd/openjarvis.service`, `deploy/launchd/com.openjarvis.plist`
### Query Flow
User query &rarr; Security scanning (input) &rarr; Intelligence resolves model (default_model → preferred_engine → fallback chain) &rarr; Agentic Logic (determine tools/memory needs) &rarr; Memory retrieval &rarr; Context injection with source attribution &rarr; Inference Engine generates response &rarr; Security scanning (output) &rarr; Trace recorded to SQLite (full interaction sequence) &rarr; Telemetry recorded &rarr; Learning policies update from accumulated traces.
User query Security scanning (input) Intelligence resolves model Agentic Logic (tools/memory) → Memory retrieval Context injection Engine generates Security scanning (output) Trace recorded Telemetry recorded Learning policies update.
### API Surface
OpenAI-compatible server via `jarvis serve`: `POST /v1/chat/completions`, `GET /v1/models`, `GET /v1/channels`, `POST /v1/channels/send`, `GET /v1/channels/status` with SSE streaming.
OpenAI-compatible server via `jarvis serve`:
- **Core**: `POST /v1/chat/completions`, `GET /v1/models`, `GET /health`
- **Channels**: `GET /v1/channels`, `POST /v1/channels/send`, `GET /v1/channels/status`
- **Agents**: `GET /v1/agents`, `POST /v1/agents`, `DELETE /v1/agents/{id}`, `POST /v1/agents/{id}/message`
- **Memory**: `POST /v1/memory/store`, `POST /v1/memory/search`, `GET /v1/memory/stats`
- **Traces**: `GET /v1/traces`, `GET /v1/traces/{id}`
- **Telemetry**: `GET /v1/telemetry/stats`, `GET /v1/telemetry/energy`
- **Learning**: `GET /v1/learning/stats`, `GET /v1/learning/policy`
- **Skills**: `GET /v1/skills`, `POST /v1/skills`, `DELETE /v1/skills/{name}`
- **Sessions**: `GET /v1/sessions`, `GET /v1/sessions/{id}`
- **Budget**: `GET /v1/budget`, `PUT /v1/budget/limits`
- **Metrics**: `GET /metrics` (Prometheus-compatible)
- **WebSocket**: `WS /v1/chat/stream` (JSON chunked streaming)
- SSE streaming on `/v1/chat/completions` with `stream=true`
## Key Design Patterns
- **Registry pattern:** All extensible components use `@XRegistry.register("name")` decorator for registration and runtime discovery. New implementations are added by decorating a class — no factory modifications needed.
- **Registry pattern:** All extensible components use `@XRegistry.register("name")` decorator for registration and runtime discovery.
- **ABC interfaces:** Each pillar defines an ABC. Implement the ABC + register via decorator to add a new backend.
- **Offline-first:** Cloud APIs are optional. All core functionality works without network.
- **Hardware-aware:** Auto-detect GPU vendor/model/VRAM via `nvidia-smi`, `rocm-smi`, `system_profiler`, `/proc/cpuinfo`. Recommend engine accordingly.
- **Telemetry opt-in:** When enabled, `InstrumentedEngine` wraps the inference engine to transparently record timing, tokens, energy, cost to SQLite via event bus. Agents call `engine.generate()` without awareness of telemetry. `TelemetryAggregator` provides read-only query/aggregation over stored records.
- **Backward-compat shims:** `memory/` re-exports from `tools/storage/`, `intelligence/_stubs.py` re-exports `RouterPolicy`/`QueryAnalyzer` from `learning/_stubs.py`, `intelligence/router.py` re-exports `HeuristicRouter`/`build_routing_context`/`DefaultQueryAnalyzer` from `learning/router.py`, `agents/react.py` re-exports `NativeReActAgent` as `ReActAgent` (old import path still works), registry alias `"react"``NativeReActAgent`. Old import paths continue to work. Config backward-compat: `engine.ollama_host``engine.ollama.host`, `agent.default_tools``agent.tools`, `learning.default_policy``learning.routing.policy`, etc. TOML migration: `agent.temperature``intelligence.temperature`, `memory.context_injection``agent.context_from_memory`.
- **`ensure_registered()` pattern:** Benchmark and learning modules use lazy registration via `ensure_registered()` to survive registry clearing in tests.
- **Telemetry opt-in:** `InstrumentedEngine` wraps inference transparently. Agents unaware of telemetry.
- **Backward-compat shims:** `memory/` re-exports from `tools/storage/`, `intelligence/` re-exports from `learning/`, `agents/react.py` re-exports as `ReActAgent`, registry alias `"react"``NativeReActAgent`. Old import paths and config keys continue to work.
- **`ensure_registered()` pattern:** Benchmark and learning modules use lazy registration to survive registry clearing in tests.
## Development Phases
| Version | Phase | Delivers |
|---------|-------|----------|
| v0.1 | Phase 0 | Scaffolding, registries, core types, config, CLI skeleton |
| v0.2 | Phase 1 | Intelligence + Inference — `jarvis ask` works end-to-end |
| v0.3 | Phase 2 | Memory backends, document indexing, context injection |
| v0.4 | Phase 3 | Agents, tool system, OpenAI-compatible API server |
| v0.5 | Phase 4 | Learning implementations, telemetry aggregation, `--router` CLI, `jarvis telemetry` |
| v1.0 | Phase 5 | SDK, OpenClaw infrastructure, benchmarks, Docker, documentation |
| v1.1 | Phase 6 | Trace system, trace-driven learning, pluggable agent architectures |
| v1.2 | Phase 7 | 5-pillar restructuring: Intelligence ABCs, memory→tools/storage, MCP tool management, composition layer (SystemBuilder/JarvisSystem), InstrumentedEngine, structured learning (SFT/AgentAdvisor/ICL), config schema update |
| v1.3 | Phase 8 | Intelligence = "The Model": routing moved to Learning, enriched IntelligenceConfig (model_path, checkpoint_path, quantization, preferred_engine, provider), intelligence-driven engine selection, backward-compat shims |
| v1.4 | Phase 9 | Pillar-aligned config: generation params in Intelligence, nested engine/learning configs, agent objective/system_prompt/context_from_memory, structured learning sub-policies (routing/intelligence/agent/metrics), TOML migration layer |
| v1.5 | Phase 10 | Agent restructuring: BaseAgent helpers (`_emit_turn_start/end`, `_build_messages`, `_generate`, `_max_turns_result`), ToolUsingAgent intermediate base, `accepts_tools` introspection, NativeReActAgent/NativeOpenHandsAgent renames, real OpenHands SDK integration, CLI/SDK tool-passing bug fix, backward-compat shims |
| v1.6 | Phase 11 | NanoClaw subsumption: `ClaudeCodeAgent` (Claude Agent SDK via Node.js subprocess), `WhatsAppBaileysChannel` (Baileys protocol), `ContainerRunner`/`SandboxedAgent` (Docker sandbox with mount security), `TaskScheduler` (cron/interval/once + SQLite + MCP tools + CLI), `SandboxConfig`/`SchedulerConfig`/`WhatsAppBaileysChannelConfig`, SystemBuilder `.sandbox()`/`.scheduler()` |
| v1.7 | Phase 12 | Energy Measurement Upgrade: `EnergyMonitor` ABC with multi-vendor support (NVIDIA hw counters, AMD amdsmi, Apple zeus-ml, CPU RAPL sysfs), `create_energy_monitor()` factory with auto-detection, `EnergySample` superset of `GpuSample`, `EnergyBatch` batch-level energy-per-token accounting, `SteadyStateDetector` CV-based thermal equilibrium detection, `EnergyBenchmark` with warmup phase, `InstrumentedEngine` prefers `EnergyMonitor` over legacy `GpuMonitor`, expanded `GPU_SPECS` (B200, MI300X, MI250X, M4 Max, M2 Ultra), `TelemetryRecord`/store schema extended with energy_method/vendor/batch_id/is_warmup/per-component fields, eval runner warmup phase, `--warmup` CLI option, new extras `energy-amd`/`energy-apple`/`energy-all` |
| v1.8 | Phase 13 | Install, Hosting, Cross-Hardware, Eval: `jarvis doctor` diagnostic command (8 checks with Rich output + `--json`), `jarvis init` post-setup guidance (engine-specific next steps), README Quick Start section, MLX engine backend (`MLXEngine`, `MLXEngineConfig`, Apple Silicon → `mlx` recommendation), AMD VRAM/multi-GPU detection via `rocm-smi --showmeminfo`/`--showallinfo`, PyTorch MPS device selection in orchestrator trainers, PWA support (vite-plugin-pwa, service worker, manifest, icons), server static file serving fix for PWA files, `Dockerfile.gpu.rocm` + `docker-compose.gpu.rocm.yml` for ROCm, `inference-mlx` extra |
| v0.1 | 0 | Scaffolding, registries, core types, config, CLI skeleton |
| v0.2 | 1 | Intelligence + Inference — `jarvis ask` end-to-end |
| v0.3 | 2 | Memory backends, document indexing, context injection |
| v0.4 | 3 | Agents, tool system, OpenAI-compatible API server |
| v0.5 | 4 | Learning, telemetry aggregation, `--router` CLI |
| v1.0 | 5 | SDK, OpenClaw infra, benchmarks, Docker |
| v1.1 | 6 | Trace system, trace-driven learning, pluggable agents |
| v1.2 | 7 | 5-pillar restructuring, composition layer, MCP, structured learning |
| v1.3 | 8 | Intelligence = "The Model", routing Learning, engine selection |
| v1.4 | 9 | Pillar-aligned config, nested configs, TOML migration |
| v1.5 | 10 | Agent restructuring, BaseAgent/ToolUsingAgent, `accepts_tools`, OpenHands SDK |
| v1.6 | 11 | NanoClaw subsumption: ClaudeCodeAgent, WhatsApp Baileys, Docker sandbox, TaskScheduler |
| v1.7 | 12 | EnergyMonitor ABC (NVIDIA/AMD/Apple/RAPL), EnergyBatch, SteadyStateDetector |
| v1.8 | 13 | `jarvis doctor`/`init`, MLX engine, AMD multi-GPU, PWA, ROCm Docker |
| v1.9 | 14 | Agent hardening: LoopGuard, RBAC CapabilityPolicy, taint tracking, Merkle audit, Ed25519 |
| v2.0 | 15 | WorkflowEngine (DAG), SkillSystem, KnowledgeGraphMemory, SessionStore |
| v2.1 | 16 | A2A protocol, MCP templates, WasmRunner, TUI dashboard |
| v2.2 | 17 | Production tool parity: FileWrite, ApplyPatch, ShellExec, Git, HTTP, DB, Browser, Agent, Media, PDF tools. SSRF protection, injection scanner, rate limiter, subprocess sandbox, security middleware |
| v2.3 | 18 | CLI expansion (20 commands): daemon, chat REPL, agent, workflow, skill, vault, add. API expansion (40+ endpoints): agents, memory, traces, telemetry, learning, skills, sessions, budget, metrics, WebSocket streaming |
| v2.4 | 19 | Learning productionization: GRPO (softmax/advantage), BanditRouter (Thompson/UCB1), SkillDiscovery (trace mining), ICL updates (versioning/rollback/quality gates) |
| v2.5 | 20 | Tauri 2.0 desktop app: energy dashboard, trace debugger, learning curve visualization, memory browser, admin panel. CI for Linux/macOS/Windows |
| v2.6 | 21 | 10 new channels: LINE, Viber, Messenger, Reddit, Mastodon, XMPP, Rocket.Chat, Zulip, Twitch, Nostr |
+108
View File
@@ -0,0 +1,108 @@
# OpenJarvis Desktop
Tauri 2.0 native desktop application for OpenJarvis with auto-updates, energy monitoring, trace debugging, and learning visualization.
## Development Setup
```bash
# Prerequisites: Node.js 22+, Rust stable, system deps (see below)
cd desktop
npm install
cargo tauri dev # Hot-reload development mode
cargo tauri build # Production build
```
### Linux System Dependencies
```bash
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev \
librsvg2-dev patchelf libxdo-dev
```
## Auto-Update Architecture
Every push to `main` (touching `desktop/` or the workflow) triggers a CI pipeline that:
1. Validates TypeScript + Rust (`validate` job)
2. Builds for Linux, macOS (ARM + Intel), and Windows (`build-and-release` job)
3. Creates/updates a `desktop-latest` pre-release on GitHub Releases
4. Uploads platform installers and a signed `latest.json` manifest
The desktop app checks `latest.json` on startup and every 30 minutes. When a newer version is found, it shows a banner prompting the user to download and relaunch.
```
Push to main -> CI builds -> desktop-latest release -> latest.json
|
Desktop app checks periodically <-------------------------+
-> "Update available" banner
-> Download in background
-> "Relaunch now" prompt
```
## Releases
### Rolling (Nightly)
Automatic on every push to `main`. Users on the desktop app receive updates seamlessly.
### Stable (Versioned)
```bash
# Bump version in all 3 config files
./scripts/bump-desktop-version.sh 1.0.1
# Commit and tag
git add desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml
git commit -m "chore(desktop): bump version to 1.0.1"
git tag desktop-v1.0.1
git push origin main --tags
```
CI creates a versioned GitHub Release (e.g., `desktop-v1.0.1`) with full installers.
## Code Signing
### Update Signing (Required for Auto-Updates)
Generate a key pair for signing update manifests:
```bash
cargo tauri signer generate -w ~/.tauri/openjarvis.key
```
Set the public key in `src-tauri/tauri.conf.json` under `plugins.updater.pubkey`, then add these GitHub Secrets:
| Secret | Description |
|--------|-------------|
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the `.key` file |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password used during generation |
### macOS Notarization (Optional)
| Secret | Description |
|--------|-------------|
| `APPLE_CERTIFICATE` | Base64-encoded `.p12` certificate |
| `APPLE_CERTIFICATE_PASSWORD` | Certificate password |
| `APPLE_SIGNING_IDENTITY` | e.g., `Developer ID Application: Name (TEAMID)` |
| `APPLE_ID` | Apple ID email |
| `APPLE_PASSWORD` | App-specific password |
| `APPLE_TEAM_ID` | 10-character team ID |
### Windows Authenticode (Optional)
| Secret | Description |
|--------|-------------|
| `WINDOWS_CERTIFICATE` | Base64-encoded `.pfx` certificate |
| `WINDOWS_CERTIFICATE_PASSWORD` | Certificate password |
All signing is optional — unsigned builds work without any secrets configured.
## Dashboard Panels
- **Energy** — Real-time power monitoring (recharts)
- **Traces** — Timeline inspection with step-type color coding
- **Learning** — Policy visualization (GRPO/bandit stats)
- **Memory** — Search and stats for memory backends
- **Admin** — Health checks, agent management, server control
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenJarvis Desktop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2273
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "openjarvis-desktop",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-updater": "^2",
"@tauri-apps/plugin-process": "^2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.0",
"vite": "^6.0.0"
}
}
+6184
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "openjarvis-desktop"
version = "1.0.0"
description = "OpenJarvis Desktop — Native AI assistant with energy monitoring, trace debugging, and learning visualization"
edition = "2021"
license = "MIT"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-notification = "2"
tauri-plugin-shell = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-autostart = "2"
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

+208
View File
@@ -0,0 +1,208 @@
use serde::Serialize;
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
/// Fetch health status from the OpenJarvis API server.
#[tauri::command]
async fn check_health(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/health", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch energy monitoring data from the API.
#[tauri::command]
async fn fetch_energy(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/telemetry/energy", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch telemetry statistics from the API.
#[tauri::command]
async fn fetch_telemetry(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/telemetry/stats", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch recent traces from the API.
#[tauri::command]
async fn fetch_traces(api_url: String, limit: u32) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/traces?limit={}", api_url, limit);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch a single trace by ID.
#[tauri::command]
async fn fetch_trace(api_url: String, trace_id: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/traces/{}", api_url, trace_id);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch learning system statistics.
#[tauri::command]
async fn fetch_learning_stats(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/learning/stats", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch learning policy configuration.
#[tauri::command]
async fn fetch_learning_policy(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/learning/policy", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch memory backend statistics.
#[tauri::command]
async fn fetch_memory_stats(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/memory/stats", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Search memory for relevant chunks.
#[tauri::command]
async fn search_memory(
api_url: String,
query: String,
top_k: u32,
) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/memory/search", api_url);
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({"query": query, "top_k": top_k}))
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch list of available agents.
#[tauri::command]
async fn fetch_agents(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/agents", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Launch the `jarvis` CLI command via shell.
#[tauri::command]
async fn run_jarvis_command(args: Vec<String>) -> Result<String, String> {
let output = tokio::process::Command::new("jarvis")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
Some(vec!["--hidden"]),
))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// Focus the main window if another instance is launched
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}))
.setup(|app| {
// Set up system tray menu
let _tray = app.tray_by_id("main");
Ok(())
})
.invoke_handler(tauri::generate_handler![
check_health,
fetch_energy,
fetch_telemetry,
fetch_traces,
fetch_trace,
fetch_learning_stats,
fetch_learning_policy,
fetch_memory_stats,
search_memory,
fetch_agents,
run_jarvis_command,
])
.run(tauri::generate_context!())
.expect("error while running OpenJarvis Desktop");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
openjarvis_desktop::run();
}
+71
View File
@@ -0,0 +1,71 @@
{
"$schema": "https://raw.githubusercontent.com/nicknisi/tauri-v2-json-schema/main/tauri-v2-schema.json",
"productName": "OpenJarvis",
"version": "1.0.0",
"identifier": "com.openjarvis.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "OpenJarvis",
"width": 1280,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false
}
],
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true,
"tooltip": "OpenJarvis"
},
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:*; img-src 'self' data: blob:"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png"
],
"category": "Utility",
"shortDescription": "On-device AI assistant with energy monitoring and trace debugging",
"longDescription": "OpenJarvis Desktop wraps the OpenJarvis research framework in a native desktop application with real-time energy monitoring, trace debugging, learning curve visualization, and memory browsing.",
"macOS": {
"entitlements": "Entitlements.plist",
"minimumSystemVersion": "10.15",
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
}
},
"plugins": {
"updater": {
"pubkey": "REPLACE_WITH_OUTPUT_OF_CARGO_TAURI_SIGNER_GENERATE",
"endpoints": [
"https://github.com/jonsf/OpenJarvis/releases/download/desktop-latest/latest.json"
]
},
"notification": {
"permissionState": "prompt"
}
}
}
+107
View File
@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { UpdateChecker } from './components/UpdateChecker';
import { EnergyDashboard } from './components/EnergyDashboard';
import { TraceDebugger } from './components/TraceDebugger';
import { LearningCurve } from './components/LearningCurve';
import { MemoryBrowser } from './components/MemoryBrowser';
import { AdminPanel } from './components/AdminPanel';
type TabId = 'energy' | 'traces' | 'learning' | 'memory' | 'admin';
interface Tab {
id: TabId;
label: string;
}
const TABS: Tab[] = [
{ id: 'energy', label: 'Energy' },
{ id: 'traces', label: 'Traces' },
{ id: 'learning', label: 'Learning' },
{ id: 'memory', label: 'Memory' },
{ id: 'admin', label: 'Admin' },
];
const API_URL = 'http://localhost:8000';
export function App() {
const [activeTab, setActiveTab] = useState<TabId>('energy');
return (
<div style={styles.container}>
<header style={styles.header}>
<h1 style={styles.title}>OpenJarvis Desktop</h1>
<nav style={styles.nav}>
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
...styles.tabButton,
...(activeTab === tab.id ? styles.activeTab : {}),
}}
>
{tab.label}
</button>
))}
</nav>
</header>
<UpdateChecker />
<main style={styles.main}>
{activeTab === 'energy' && <EnergyDashboard apiUrl={API_URL} />}
{activeTab === 'traces' && <TraceDebugger apiUrl={API_URL} />}
{activeTab === 'learning' && <LearningCurve apiUrl={API_URL} />}
{activeTab === 'memory' && <MemoryBrowser apiUrl={API_URL} />}
{activeTab === 'admin' && <AdminPanel apiUrl={API_URL} />}
</main>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
minHeight: '100vh',
backgroundColor: '#1e1e2e',
color: '#cdd6f4',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 24px',
borderBottom: '1px solid #313244',
backgroundColor: '#181825',
},
title: {
fontSize: '18px',
fontWeight: 600,
margin: 0,
color: '#89b4fa',
},
nav: {
display: 'flex',
gap: '4px',
},
tabButton: {
padding: '8px 16px',
border: 'none',
borderRadius: '6px',
backgroundColor: 'transparent',
color: '#a6adc8',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.15s ease',
},
activeTab: {
backgroundColor: '#313244',
color: '#cdd6f4',
},
main: {
padding: '24px',
height: 'calc(100vh - 60px)',
overflow: 'auto',
},
};
+454
View File
@@ -0,0 +1,454 @@
import { useState, useEffect, useCallback } from 'react';
import type React from 'react';
import { invoke } from '@tauri-apps/api/core';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface HealthStatus {
status: string;
}
interface AgentInfo {
name: string;
key: string;
accepts_tools: boolean;
description: string;
}
interface AgentsResponse {
agents: AgentInfo[];
}
interface ServerInfo {
model: string;
agent: string;
engine: string;
version: string;
uptime_seconds: number;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles: Record<string, React.CSSProperties> = {
container: {
backgroundColor: '#1e1e2e',
color: '#cdd6f4',
padding: 24,
borderRadius: 12,
fontFamily: 'system-ui, -apple-system, sans-serif',
minHeight: 400,
},
header: {
fontSize: 20,
fontWeight: 700,
marginBottom: 20,
color: '#cdd6f4',
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: 16,
marginBottom: 24,
},
card: {
backgroundColor: '#313244',
borderRadius: 8,
padding: 16,
},
cardTitle: {
fontSize: 13,
fontWeight: 600,
textTransform: 'uppercase' as const,
letterSpacing: '0.05em',
color: '#89b4fa',
marginBottom: 12,
},
row: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '6px 0',
},
label: {
fontSize: 13,
color: '#a6adc8',
},
value: {
fontSize: 13,
fontWeight: 500,
color: '#cdd6f4',
},
healthDot: {
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
marginRight: 8,
verticalAlign: 'middle',
},
healthDotHealthy: {
backgroundColor: '#a6e3a1',
boxShadow: '0 0 6px #a6e3a166',
},
healthDotUnhealthy: {
backgroundColor: '#f38588',
boxShadow: '0 0 6px #f3858866',
},
healthDotUnknown: {
backgroundColor: '#a6adc8',
},
badge: {
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
fontWeight: 600,
},
badgeTrue: {
backgroundColor: '#a6e3a133',
color: '#a6e3a1',
},
badgeFalse: {
backgroundColor: '#45475a',
color: '#a6adc8',
},
agentTable: {
width: '100%',
borderCollapse: 'collapse' as const,
fontSize: 13,
},
th: {
textAlign: 'left' as const,
padding: '8px 10px',
borderBottom: '1px solid #45475a',
color: '#89b4fa',
fontWeight: 600,
fontSize: 12,
textTransform: 'uppercase' as const,
},
td: {
padding: '8px 10px',
borderBottom: '1px solid #313244',
color: '#cdd6f4',
},
buttonGroup: {
display: 'flex',
gap: 10,
marginTop: 16,
},
button: {
padding: '10px 20px',
fontSize: 14,
fontWeight: 600,
border: 'none',
borderRadius: 8,
cursor: 'pointer',
},
startButton: {
backgroundColor: '#a6e3a1',
color: '#1e1e2e',
},
stopButton: {
backgroundColor: '#f38588',
color: '#1e1e2e',
},
buttonDisabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
error: {
color: '#f38588',
padding: 12,
backgroundColor: '#f3858811',
borderRadius: 8,
fontSize: 13,
marginBottom: 16,
},
commandOutput: {
marginTop: 12,
padding: 12,
backgroundColor: '#181825',
borderRadius: 6,
fontSize: 12,
fontFamily: 'monospace',
color: '#a6adc8',
maxHeight: 120,
overflow: 'auto',
whiteSpace: 'pre-wrap' as const,
wordBreak: 'break-word' as const,
},
loading: {
color: '#a6adc8',
textAlign: 'center' as const,
padding: 40,
},
healthStatus: {
display: 'flex',
alignItems: 'center',
fontSize: 16,
fontWeight: 600,
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function AdminPanel({ apiUrl }: { apiUrl: string }) {
const [healthy, setHealthy] = useState<boolean | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [error, setError] = useState<string | null>(null);
const [commandRunning, setCommandRunning] = useState(false);
const [commandOutput, setCommandOutput] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
// Check health
const healthResult = await invoke<HealthStatus>('check_health', { apiUrl });
setHealthy(healthResult.status === 'ok');
// Fetch agents
try {
const agentsResult = await invoke<AgentsResponse>('fetch_agents', { apiUrl });
setAgents(agentsResult.agents ?? []);
} catch {
// Agent list may not be available; keep previous state
}
// Fetch server info (model, engine, uptime)
try {
const infoResult = await invoke<ServerInfo>('check_health', { apiUrl });
// If server exposes extra fields, merge them
setServerInfo((prev) => ({
model: prev?.model ?? '',
agent: prev?.agent ?? '',
engine: prev?.engine ?? '',
version: prev?.version ?? '',
uptime_seconds: prev?.uptime_seconds ?? 0,
...infoResult as unknown as Partial<ServerInfo>,
}));
} catch {
// Non-critical
}
setError(null);
} catch (err) {
setHealthy(false);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [apiUrl]);
useEffect(() => {
refresh();
const timer = setInterval(refresh, 15_000);
return () => clearInterval(timer);
}, [refresh]);
const handleStart = useCallback(async () => {
setCommandRunning(true);
setCommandOutput(null);
try {
const output = await invoke<string>('run_jarvis_command', {
args: ['serve', '--port', '8000'],
});
setCommandOutput(output);
// Wait a moment then refresh health
setTimeout(refresh, 2000);
} catch (err) {
setCommandOutput(err instanceof Error ? err.message : String(err));
} finally {
setCommandRunning(false);
}
}, [refresh]);
const handleStop = useCallback(async () => {
setCommandRunning(true);
setCommandOutput(null);
try {
const output = await invoke<string>('run_jarvis_command', {
args: ['stop'],
});
setCommandOutput(output);
setTimeout(refresh, 2000);
} catch (err) {
setCommandOutput(err instanceof Error ? err.message : String(err));
} finally {
setCommandRunning(false);
}
}, [refresh]);
if (loading) {
return (
<div style={styles.container}>
<div style={styles.loading}>Loading system status...</div>
</div>
);
}
const healthDotStyle =
healthy === null
? styles.healthDotUnknown
: healthy
? styles.healthDotHealthy
: styles.healthDotUnhealthy;
const healthLabel =
healthy === null ? 'Unknown' : healthy ? 'Healthy' : 'Unhealthy';
return (
<div style={styles.container}>
<div style={styles.header}>Admin Panel</div>
{error && <div style={styles.error}>{error}</div>}
<div style={styles.grid}>
{/* Health & Engine */}
<div style={styles.card}>
<div style={styles.cardTitle}>System Health</div>
<div style={{ ...styles.row, marginBottom: 8 }}>
<div style={styles.healthStatus}>
<span style={{ ...styles.healthDot, ...healthDotStyle }} />
{healthLabel}
</div>
</div>
<div style={styles.row}>
<span style={styles.label}>Engine</span>
<span style={styles.value}>{serverInfo?.engine || 'N/A'}</span>
</div>
<div style={styles.row}>
<span style={styles.label}>Model</span>
<span style={styles.value}>{serverInfo?.model || 'N/A'}</span>
</div>
<div style={styles.row}>
<span style={styles.label}>Agent</span>
<span style={styles.value}>{serverInfo?.agent || 'N/A'}</span>
</div>
</div>
{/* System Info */}
<div style={styles.card}>
<div style={styles.cardTitle}>System Info</div>
<div style={styles.row}>
<span style={styles.label}>Version</span>
<span style={styles.value}>{serverInfo?.version || '1.0.0'}</span>
</div>
<div style={styles.row}>
<span style={styles.label}>Uptime</span>
<span style={styles.value}>
{serverInfo?.uptime_seconds !== undefined
? formatUptime(serverInfo.uptime_seconds)
: 'N/A'}
</span>
</div>
<div style={styles.row}>
<span style={styles.label}>API URL</span>
<span style={styles.value}>{apiUrl}</span>
</div>
{/* Server controls */}
<div style={styles.buttonGroup}>
<button
style={{
...styles.button,
...styles.startButton,
...(commandRunning ? styles.buttonDisabled : {}),
}}
onClick={handleStart}
disabled={commandRunning}
>
Start Server
</button>
<button
style={{
...styles.button,
...styles.stopButton,
...(commandRunning ? styles.buttonDisabled : {}),
}}
onClick={handleStop}
disabled={commandRunning}
>
Stop Server
</button>
</div>
{commandOutput && (
<div style={styles.commandOutput}>{commandOutput}</div>
)}
</div>
</div>
{/* Agent Registry */}
{agents.length > 0 && (
<div style={styles.card}>
<div style={styles.cardTitle}>Agent Registry ({agents.length})</div>
<table style={styles.agentTable}>
<thead>
<tr>
<th style={styles.th}>Name</th>
<th style={styles.th}>Key</th>
<th style={styles.th}>Tools</th>
<th style={styles.th}>Description</th>
</tr>
</thead>
<tbody>
{agents.map((agent) => (
<tr key={agent.key}>
<td style={styles.td}>{agent.name}</td>
<td style={{ ...styles.td, fontFamily: 'monospace', fontSize: 12 }}>
{agent.key}
</td>
<td style={styles.td}>
<span
style={{
...styles.badge,
...(agent.accepts_tools ? styles.badgeTrue : styles.badgeFalse),
}}
>
{agent.accepts_tools ? 'Yes' : 'No'}
</span>
</td>
<td style={{ ...styles.td, color: '#a6adc8', fontSize: 12 }}>
{agent.description || '--'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{agents.length === 0 && !loading && (
<div style={styles.card}>
<div style={styles.cardTitle}>Agent Registry</div>
<div style={{ color: '#a6adc8', fontSize: 13, padding: '8px 0' }}>
No agents registered or server not reachable.
</div>
</div>
)}
</div>
);
}
+406
View File
@@ -0,0 +1,406 @@
import { useState, useEffect, useCallback } from 'react';
import type React from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface EnergySample {
timestamp: string;
power_w: number;
energy_j: number;
}
interface EnergyData {
total_energy_j?: number;
energy_per_token_j?: number;
avg_power_w?: number;
samples?: EnergySample[];
}
interface TelemetryStats {
total_requests?: number;
avg_latency_ms?: number;
total_tokens?: number;
}
interface ChartPoint {
time: string;
power: number;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const colors = {
bg: '#1e1e2e',
surface: '#282840',
surfaceHover: '#313150',
text: '#cdd6f4',
textMuted: '#a6adc8',
accent: '#89b4fa',
green: '#a6e3a1',
yellow: '#f9e2af',
red: '#f38ba8',
border: '#45475a',
} as const;
const styles: Record<string, React.CSSProperties> = {
container: {
background: colors.bg,
color: colors.text,
padding: 24,
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
height: '100%',
overflowY: 'auto',
boxSizing: 'border-box',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
},
title: {
fontSize: 22,
fontWeight: 600,
margin: 0,
color: colors.text,
},
liveBadge: {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: colors.green,
background: 'rgba(166,227,161,0.1)',
padding: '4px 10px',
borderRadius: 12,
fontWeight: 500,
},
liveDot: {
width: 6,
height: 6,
borderRadius: '50%',
background: colors.green,
animation: 'pulse 2s infinite',
},
statsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 16,
marginBottom: 24,
},
statCard: {
background: colors.surface,
borderRadius: 10,
padding: 16,
border: `1px solid ${colors.border}`,
},
statLabel: {
fontSize: 12,
color: colors.textMuted,
marginBottom: 6,
textTransform: 'uppercase' as const,
letterSpacing: '0.05em',
},
statValue: {
fontSize: 26,
fontWeight: 700,
color: colors.accent,
lineHeight: 1.1,
},
statUnit: {
fontSize: 13,
fontWeight: 400,
color: colors.textMuted,
marginLeft: 4,
},
chartContainer: {
background: colors.surface,
borderRadius: 10,
padding: 20,
border: `1px solid ${colors.border}`,
marginBottom: 24,
},
chartTitle: {
fontSize: 14,
fontWeight: 600,
marginBottom: 16,
color: colors.text,
},
emptyState: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
padding: 64,
color: colors.textMuted,
gap: 12,
},
emptyIcon: {
fontSize: 40,
opacity: 0.4,
},
emptyText: {
fontSize: 15,
textAlign: 'center' as const,
},
errorBanner: {
background: 'rgba(243,139,168,0.1)',
border: `1px solid ${colors.red}`,
borderRadius: 8,
padding: '10px 16px',
marginBottom: 16,
fontSize: 13,
color: colors.red,
},
thermalStatus: {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
fontWeight: 600,
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatEnergy(joules: number): string {
if (joules >= 1000) {
return `${(joules / 1000).toFixed(2)} kJ`;
}
return `${joules.toFixed(2)} J`;
}
function formatTimestamp(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return ts;
}
}
function thermalIndicator(avgPower: number): { label: string; color: string } {
if (avgPower < 50) return { label: 'Cool', color: colors.green };
if (avgPower < 150) return { label: 'Warm', color: colors.yellow };
return { label: 'Hot', color: colors.red };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const REFRESH_INTERVAL_MS = 5000;
export function EnergyDashboard({ apiUrl }: { apiUrl: string }) {
const [energyData, setEnergyData] = useState<EnergyData | null>(null);
const [telemetry, setTelemetry] = useState<TelemetryStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
try {
const [energy, telem] = await Promise.allSettled([
invoke<EnergyData>('fetch_energy', { apiUrl }),
invoke<TelemetryStats>('fetch_telemetry', { apiUrl }),
]);
if (energy.status === 'fulfilled') {
setEnergyData(energy.value);
setError(null);
} else {
setEnergyData(null);
setError(String(energy.reason));
}
if (telem.status === 'fulfilled') {
setTelemetry(telem.value);
} else {
setTelemetry(null);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
} finally {
setLoading(false);
}
}, [apiUrl]);
useEffect(() => {
fetchData();
const timer = setInterval(fetchData, REFRESH_INTERVAL_MS);
return () => clearInterval(timer);
}, [fetchData]);
// Build chart data from samples
const chartData: ChartPoint[] = (energyData?.samples ?? []).map((s) => ({
time: formatTimestamp(s.timestamp),
power: s.power_w,
}));
const hasEnergyData =
energyData !== null &&
(energyData.total_energy_j !== undefined ||
(energyData.samples !== undefined && energyData.samples.length > 0));
// --- Empty / error states ---
if (!loading && !hasEnergyData && !error) {
return (
<div style={styles.container}>
<div style={styles.header}>
<h2 style={styles.title}>Energy Monitor</h2>
</div>
<div style={styles.emptyState}>
<div style={styles.emptyIcon}>&#x26A1;</div>
<div style={styles.emptyText}>
No energy data available.<br />
Ensure an energy monitor backend (NVIDIA, AMD, Apple, or RAPL) is configured.
</div>
</div>
</div>
);
}
const thermal = thermalIndicator(energyData?.avg_power_w ?? 0);
return (
<div style={styles.container}>
{/* Pulse animation injected once */}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
`}</style>
{/* Header */}
<div style={styles.header}>
<h2 style={styles.title}>Energy Monitor</h2>
<span style={styles.liveBadge}>
<span style={styles.liveDot} />
Live - {REFRESH_INTERVAL_MS / 1000}s
</span>
</div>
{/* Error banner */}
{error && <div style={styles.errorBanner}>{error}</div>}
{/* Summary cards */}
<div style={styles.statsGrid}>
<div style={styles.statCard}>
<div style={styles.statLabel}>Total Energy</div>
<div style={styles.statValue}>
{energyData?.total_energy_j !== undefined
? formatEnergy(energyData.total_energy_j)
: '--'}
</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>Energy per Token</div>
<div style={styles.statValue}>
{energyData?.energy_per_token_j !== undefined ? (
<>
{(energyData.energy_per_token_j * 1000).toFixed(3)}
<span style={styles.statUnit}>mJ</span>
</>
) : (
'--'
)}
</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>Avg Power Draw</div>
<div style={styles.statValue}>
{energyData?.avg_power_w !== undefined ? (
<>
{energyData.avg_power_w.toFixed(1)}
<span style={styles.statUnit}>W</span>
</>
) : (
'--'
)}
</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>Thermal Status</div>
<div style={{ ...styles.thermalStatus, color: thermal.color }}>
{thermal.label}
</div>
</div>
{telemetry?.total_requests !== undefined && (
<div style={styles.statCard}>
<div style={styles.statLabel}>Total Requests</div>
<div style={styles.statValue}>{telemetry.total_requests.toLocaleString()}</div>
</div>
)}
{telemetry?.total_tokens !== undefined && (
<div style={styles.statCard}>
<div style={styles.statLabel}>Total Tokens</div>
<div style={styles.statValue}>{telemetry.total_tokens.toLocaleString()}</div>
</div>
)}
</div>
{/* Power chart */}
{chartData.length > 0 && (
<div style={styles.chartContainer}>
<div style={styles.chartTitle}>Power Draw Over Time (W)</div>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData} margin={{ top: 4, right: 20, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={colors.border} />
<XAxis
dataKey="time"
stroke={colors.textMuted}
tick={{ fill: colors.textMuted, fontSize: 11 }}
/>
<YAxis
stroke={colors.textMuted}
tick={{ fill: colors.textMuted, fontSize: 11 }}
unit=" W"
/>
<Tooltip
contentStyle={{
background: colors.surface,
border: `1px solid ${colors.border}`,
borderRadius: 6,
color: colors.text,
fontSize: 13,
}}
labelStyle={{ color: colors.textMuted }}
/>
<Line
type="monotone"
dataKey="power"
stroke={colors.accent}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: colors.accent }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
+436
View File
@@ -0,0 +1,436 @@
import { useState, useEffect, useCallback } from 'react';
import type React from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PolicyConfig {
name: string;
enabled: boolean;
update_interval: number;
}
interface RoutingWeight {
query_class: string;
model: string;
weight: number;
}
interface BanditArm {
model: string;
pulls: number;
reward_mean: number;
ucb: number;
}
interface LearningStatsPoint {
timestamp: string;
accuracy: number;
latency_ms: number;
cost: number;
}
interface LearningStats {
history: LearningStatsPoint[];
icl_example_count: number;
discovered_skills_count: number;
total_traces: number;
total_updates: number;
}
interface LearningPolicy {
config: PolicyConfig;
routing_weights: RoutingWeight[];
bandit_arms: BanditArm[];
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles: Record<string, React.CSSProperties> = {
container: {
backgroundColor: '#1e1e2e',
color: '#cdd6f4',
padding: 24,
borderRadius: 12,
fontFamily: 'system-ui, -apple-system, sans-serif',
minHeight: 400,
},
header: {
fontSize: 20,
fontWeight: 700,
marginBottom: 20,
color: '#cdd6f4',
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: 16,
marginBottom: 24,
},
card: {
backgroundColor: '#313244',
borderRadius: 8,
padding: 16,
},
cardTitle: {
fontSize: 13,
fontWeight: 600,
textTransform: 'uppercase' as const,
letterSpacing: '0.05em',
color: '#89b4fa',
marginBottom: 12,
},
row: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '4px 0',
},
label: {
fontSize: 13,
color: '#a6adc8',
},
value: {
fontSize: 13,
fontWeight: 500,
color: '#cdd6f4',
},
badge: {
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
fontWeight: 600,
},
badgeEnabled: {
backgroundColor: '#a6e3a133',
color: '#a6e3a1',
},
badgeDisabled: {
backgroundColor: '#f3858833',
color: '#f38588',
},
chartContainer: {
backgroundColor: '#313244',
borderRadius: 8,
padding: 16,
marginBottom: 24,
},
table: {
width: '100%',
borderCollapse: 'collapse' as const,
fontSize: 13,
},
th: {
textAlign: 'left' as const,
padding: '6px 8px',
borderBottom: '1px solid #45475a',
color: '#89b4fa',
fontWeight: 600,
fontSize: 12,
textTransform: 'uppercase' as const,
},
td: {
padding: '6px 8px',
borderBottom: '1px solid #313244',
color: '#cdd6f4',
},
weightBar: {
height: 6,
borderRadius: 3,
backgroundColor: '#45475a',
overflow: 'hidden' as const,
marginTop: 4,
},
weightFill: {
height: '100%',
borderRadius: 3,
backgroundColor: '#89b4fa',
},
error: {
color: '#f38588',
padding: 12,
backgroundColor: '#f3858811',
borderRadius: 8,
fontSize: 13,
},
loading: {
color: '#a6adc8',
textAlign: 'center' as const,
padding: 40,
},
statNumber: {
fontSize: 28,
fontWeight: 700,
color: '#89b4fa',
lineHeight: 1.2,
},
statLabel: {
fontSize: 12,
color: '#a6adc8',
marginTop: 4,
},
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function LearningCurve({ apiUrl }: { apiUrl: string }) {
const [stats, setStats] = useState<LearningStats | null>(null);
const [policy, setPolicy] = useState<LearningPolicy | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const [statsResult, policyResult] = await Promise.all([
invoke<LearningStats>('fetch_learning_stats', { apiUrl }),
invoke<LearningPolicy>('fetch_learning_policy', { apiUrl }),
]);
setStats(statsResult);
setPolicy(policyResult);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [apiUrl]);
useEffect(() => {
refresh();
const timer = setInterval(refresh, 10_000);
return () => clearInterval(timer);
}, [refresh]);
if (loading && !stats) {
return (
<div style={styles.container}>
<div style={styles.loading}>Loading learning data...</div>
</div>
);
}
if (error && !stats) {
return (
<div style={styles.container}>
<div style={styles.header}>Learning Curve</div>
<div style={styles.error}>{error}</div>
</div>
);
}
const chartData = (stats?.history ?? []).map((point) => ({
time: point.timestamp,
accuracy: Math.round(point.accuracy * 1000) / 10,
latency: Math.round(point.latency_ms),
cost: Math.round(point.cost * 10000) / 10000,
}));
const policyConfig = policy?.config;
const isGrpo = policyConfig?.name?.toLowerCase().includes('grpo');
const isBandit = policyConfig?.name?.toLowerCase().includes('bandit');
return (
<div style={styles.container}>
<div style={styles.header}>Learning Curve</div>
{error && <div style={styles.error}>{error}</div>}
{/* Policy config + counters */}
<div style={styles.grid}>
<div style={styles.card}>
<div style={styles.cardTitle}>Policy Config</div>
<div style={styles.row}>
<span style={styles.label}>Policy</span>
<span style={styles.value}>{policyConfig?.name ?? 'unknown'}</span>
</div>
<div style={styles.row}>
<span style={styles.label}>Status</span>
<span
style={{
...styles.badge,
...(policyConfig?.enabled ? styles.badgeEnabled : styles.badgeDisabled),
}}
>
{policyConfig?.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div style={styles.row}>
<span style={styles.label}>Update interval</span>
<span style={styles.value}>{policyConfig?.update_interval ?? 0}s</span>
</div>
</div>
<div style={styles.card}>
<div style={styles.cardTitle}>Counters</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={styles.statNumber}>{stats?.total_traces ?? 0}</div>
<div style={styles.statLabel}>Total traces</div>
</div>
<div>
<div style={styles.statNumber}>{stats?.total_updates ?? 0}</div>
<div style={styles.statLabel}>Policy updates</div>
</div>
<div>
<div style={styles.statNumber}>{stats?.icl_example_count ?? 0}</div>
<div style={styles.statLabel}>ICL examples</div>
</div>
<div>
<div style={styles.statNumber}>{stats?.discovered_skills_count ?? 0}</div>
<div style={styles.statLabel}>Discovered skills</div>
</div>
</div>
</div>
</div>
{/* Accuracy / latency chart */}
{chartData.length > 0 && (
<div style={styles.chartContainer}>
<div style={styles.cardTitle}>Routing Accuracy Over Time</div>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#45475a" />
<XAxis
dataKey="time"
tick={{ fill: '#a6adc8', fontSize: 11 }}
stroke="#45475a"
/>
<YAxis
yAxisId="left"
tick={{ fill: '#a6adc8', fontSize: 11 }}
stroke="#45475a"
label={{
value: 'Accuracy %',
angle: -90,
position: 'insideLeft',
style: { fill: '#a6adc8', fontSize: 11 },
}}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: '#a6adc8', fontSize: 11 }}
stroke="#45475a"
label={{
value: 'Latency (ms)',
angle: 90,
position: 'insideRight',
style: { fill: '#a6adc8', fontSize: 11 },
}}
/>
<Tooltip
contentStyle={{
backgroundColor: '#313244',
border: '1px solid #45475a',
borderRadius: 6,
color: '#cdd6f4',
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ color: '#cdd6f4', fontSize: 12 }} />
<Line
yAxisId="left"
type="monotone"
dataKey="accuracy"
name="Accuracy %"
stroke="#89b4fa"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="latency"
name="Latency (ms)"
stroke="#f9e2af"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* GRPO routing weights */}
{isGrpo && (policy?.routing_weights ?? []).length > 0 && (
<div style={styles.card}>
<div style={styles.cardTitle}>GRPO Routing Weights</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Query Class</th>
<th style={styles.th}>Model</th>
<th style={styles.th}>Weight</th>
</tr>
</thead>
<tbody>
{policy!.routing_weights.map((rw, i) => (
<tr key={i}>
<td style={styles.td}>{rw.query_class}</td>
<td style={styles.td}>{rw.model}</td>
<td style={styles.td}>
<span>{(rw.weight * 100).toFixed(1)}%</span>
<div style={styles.weightBar}>
<div
style={{
...styles.weightFill,
width: `${Math.min(rw.weight * 100, 100)}%`,
}}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Bandit arm stats */}
{isBandit && (policy?.bandit_arms ?? []).length > 0 && (
<div style={{ ...styles.card, marginTop: 16 }}>
<div style={styles.cardTitle}>Bandit Arm Statistics</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Model</th>
<th style={styles.th}>Pulls</th>
<th style={styles.th}>Mean Reward</th>
<th style={styles.th}>UCB</th>
</tr>
</thead>
<tbody>
{policy!.bandit_arms.map((arm, i) => (
<tr key={i}>
<td style={styles.td}>{arm.model}</td>
<td style={styles.td}>{arm.pulls}</td>
<td style={styles.td}>{arm.reward_mean.toFixed(4)}</td>
<td style={styles.td}>{arm.ucb.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+369
View File
@@ -0,0 +1,369 @@
import { useState, useEffect, useCallback } from 'react';
import type React from 'react';
import { invoke } from '@tauri-apps/api/core';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface MemoryChunk {
content: string;
score: number;
metadata: Record<string, string | number | boolean>;
}
interface SearchResponse {
results: MemoryChunk[];
query: string;
total: number;
}
interface MemoryStats {
backend: string;
total_documents: number;
total_chunks: number;
index_size_bytes: number;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles: Record<string, React.CSSProperties> = {
container: {
backgroundColor: '#1e1e2e',
color: '#cdd6f4',
padding: 24,
borderRadius: 12,
fontFamily: 'system-ui, -apple-system, sans-serif',
minHeight: 400,
},
header: {
fontSize: 20,
fontWeight: 700,
marginBottom: 20,
color: '#cdd6f4',
},
searchBar: {
display: 'flex',
gap: 8,
marginBottom: 20,
},
input: {
flex: 1,
padding: '10px 14px',
fontSize: 14,
backgroundColor: '#313244',
border: '1px solid #45475a',
borderRadius: 8,
color: '#cdd6f4',
outline: 'none',
},
button: {
padding: '10px 20px',
fontSize: 14,
fontWeight: 600,
backgroundColor: '#89b4fa',
color: '#1e1e2e',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
whiteSpace: 'nowrap' as const,
},
buttonDisabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
statsPanel: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 12,
marginBottom: 20,
},
statCard: {
backgroundColor: '#313244',
borderRadius: 8,
padding: 14,
textAlign: 'center' as const,
},
statValue: {
fontSize: 22,
fontWeight: 700,
color: '#89b4fa',
lineHeight: 1.2,
},
statLabel: {
fontSize: 12,
color: '#a6adc8',
marginTop: 4,
textTransform: 'uppercase' as const,
letterSpacing: '0.04em',
},
resultsList: {
display: 'flex',
flexDirection: 'column' as const,
gap: 12,
},
resultCard: {
backgroundColor: '#313244',
borderRadius: 8,
padding: 16,
borderLeft: '3px solid #89b4fa',
},
resultContent: {
fontSize: 14,
lineHeight: 1.6,
color: '#cdd6f4',
marginBottom: 10,
wordBreak: 'break-word' as const,
},
scoreContainer: {
marginBottom: 8,
},
scoreHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
scoreLabel: {
fontSize: 12,
color: '#a6adc8',
},
scoreValue: {
fontSize: 12,
fontWeight: 600,
color: '#89b4fa',
},
scoreBar: {
height: 6,
borderRadius: 3,
backgroundColor: '#45475a',
overflow: 'hidden' as const,
},
scoreFill: {
height: '100%',
borderRadius: 3,
backgroundColor: '#89b4fa',
transition: 'width 0.3s ease',
},
metadataRow: {
display: 'flex',
flexWrap: 'wrap' as const,
gap: 6,
marginTop: 8,
},
metaTag: {
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
backgroundColor: '#45475a',
color: '#a6adc8',
},
emptyState: {
textAlign: 'center' as const,
padding: 40,
color: '#a6adc8',
},
error: {
color: '#f38588',
padding: 12,
backgroundColor: '#f3858811',
borderRadius: 8,
fontSize: 13,
marginBottom: 16,
},
resultCount: {
fontSize: 13,
color: '#a6adc8',
marginBottom: 12,
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(1) + ' GB';
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max) + '...';
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function MemoryBrowser({ apiUrl }: { apiUrl: string }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<MemoryChunk[]>([]);
const [resultTotal, setResultTotal] = useState(0);
const [hasSearched, setHasSearched] = useState(false);
const [searching, setSearching] = useState(false);
const [stats, setStats] = useState<MemoryStats | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch stats on mount
const loadStats = useCallback(async () => {
try {
const result = await invoke<MemoryStats>('fetch_memory_stats', { apiUrl });
setStats(result);
} catch (err) {
// Stats are non-critical; silently ignore
}
}, [apiUrl]);
useEffect(() => {
loadStats();
}, [loadStats]);
const handleSearch = useCallback(async () => {
if (!query.trim()) return;
setSearching(true);
setError(null);
try {
const response = await invoke<SearchResponse>('search_memory', {
apiUrl,
query: query.trim(),
topK: 10,
});
setResults(response.results ?? []);
setResultTotal(response.total ?? (response.results ?? []).length);
setHasSearched(true);
// Refresh stats after search
loadStats();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setResults([]);
setHasSearched(true);
} finally {
setSearching(false);
}
}, [apiUrl, query, loadStats]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch],
);
return (
<div style={styles.container}>
<div style={styles.header}>Memory Browser</div>
{/* Stats panel */}
{stats && (
<div style={styles.statsPanel}>
<div style={styles.statCard}>
<div style={styles.statValue}>{stats.backend}</div>
<div style={styles.statLabel}>Backend</div>
</div>
<div style={styles.statCard}>
<div style={styles.statValue}>{stats.total_documents.toLocaleString()}</div>
<div style={styles.statLabel}>Documents</div>
</div>
<div style={styles.statCard}>
<div style={styles.statValue}>{stats.total_chunks.toLocaleString()}</div>
<div style={styles.statLabel}>Chunks</div>
</div>
<div style={styles.statCard}>
<div style={styles.statValue}>{formatBytes(stats.index_size_bytes)}</div>
<div style={styles.statLabel}>Index Size</div>
</div>
</div>
)}
{/* Search bar */}
<div style={styles.searchBar}>
<input
style={styles.input}
type="text"
placeholder="Search memory..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
<button
style={{
...styles.button,
...(searching ? styles.buttonDisabled : {}),
}}
onClick={handleSearch}
disabled={searching || !query.trim()}
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
{error && <div style={styles.error}>{error}</div>}
{/* Results */}
{hasSearched && results.length === 0 && !error && (
<div style={styles.emptyState}>No results found for "{query}"</div>
)}
{results.length > 0 && (
<>
<div style={styles.resultCount}>
Showing {results.length} of {resultTotal} results
</div>
<div style={styles.resultsList}>
{results.map((chunk, i) => {
const scorePercent = Math.round(chunk.score * 100);
return (
<div key={i} style={styles.resultCard}>
{/* Score bar */}
<div style={styles.scoreContainer}>
<div style={styles.scoreHeader}>
<span style={styles.scoreLabel}>Relevance</span>
<span style={styles.scoreValue}>{scorePercent}%</span>
</div>
<div style={styles.scoreBar}>
<div
style={{
...styles.scoreFill,
width: `${Math.min(scorePercent, 100)}%`,
}}
/>
</div>
</div>
{/* Content preview */}
<div style={styles.resultContent}>
{truncate(chunk.content, 200)}
</div>
{/* Metadata tags */}
{Object.keys(chunk.metadata).length > 0 && (
<div style={styles.metadataRow}>
{Object.entries(chunk.metadata).map(([key, val]) => (
<span key={key} style={styles.metaTag}>
{key}: {String(val)}
</span>
))}
</div>
)}
</div>
);
})}
</div>
</>
)}
{!hasSearched && !stats && (
<div style={styles.emptyState}>Enter a query to search memory</div>
)}
</div>
);
}
+607
View File
@@ -0,0 +1,607 @@
import { useState, useEffect, useCallback } from 'react';
import type React from 'react';
import { invoke } from '@tauri-apps/api/core';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TraceStepData {
model?: string;
tokens?: number;
tool?: string;
input?: string;
output?: string;
backend?: string;
results?: number;
policy?: string;
[key: string]: unknown;
}
interface TraceStep {
step_type: string;
duration_ms: number;
data: TraceStepData;
}
interface TraceSummary {
id: string;
query: string;
steps: TraceStep[];
created_at: string;
}
interface TraceListResponse {
traces: TraceSummary[];
}
interface TraceDetail {
id: string;
query: string;
steps: TraceStep[];
created_at?: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const STEP_COLORS: Record<string, string> = {
route: '#89b4fa',
retrieve: '#a6e3a1',
generate: '#f9e2af',
tool_call: '#cba6f7',
respond: '#f38ba8',
};
const DEFAULT_STEP_COLOR = '#9399b2';
const colors = {
bg: '#1e1e2e',
surface: '#282840',
surfaceHover: '#313150',
text: '#cdd6f4',
textMuted: '#a6adc8',
accent: '#89b4fa',
border: '#45475a',
red: '#f38ba8',
} as const;
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles: Record<string, React.CSSProperties> = {
container: {
background: colors.bg,
color: colors.text,
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
display: 'flex',
height: '100%',
boxSizing: 'border-box',
},
// Left panel - trace list
listPanel: {
width: 320,
minWidth: 280,
borderRight: `1px solid ${colors.border}`,
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
},
listHeader: {
padding: '20px 16px 12px',
borderBottom: `1px solid ${colors.border}`,
flexShrink: 0,
},
listTitle: {
fontSize: 18,
fontWeight: 600,
margin: 0,
marginBottom: 4,
color: colors.text,
},
listSubtitle: {
fontSize: 12,
color: colors.textMuted,
margin: 0,
},
listScroll: {
flex: 1,
overflowY: 'auto',
padding: '8px 0',
},
traceItem: {
padding: '10px 16px',
cursor: 'pointer',
borderBottom: `1px solid ${colors.border}`,
transition: 'background 0.15s',
},
traceItemSelected: {
background: colors.surfaceHover,
},
traceItemId: {
fontSize: 13,
fontWeight: 600,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
color: colors.accent,
marginBottom: 3,
},
traceItemQuery: {
fontSize: 12,
color: colors.text,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
marginBottom: 3,
maxWidth: '100%',
},
traceItemMeta: {
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: colors.textMuted,
},
// Right panel - detail
detailPanel: {
flex: 1,
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
},
detailHeader: {
padding: '20px 24px 16px',
borderBottom: `1px solid ${colors.border}`,
flexShrink: 0,
},
detailTitle: {
fontSize: 16,
fontWeight: 600,
margin: 0,
marginBottom: 4,
color: colors.text,
},
detailQuery: {
fontSize: 13,
color: colors.textMuted,
margin: 0,
marginBottom: 8,
lineHeight: 1.4,
},
detailStats: {
display: 'flex',
gap: 16,
fontSize: 12,
color: colors.textMuted,
},
detailStatValue: {
fontWeight: 600,
color: colors.accent,
},
detailScroll: {
flex: 1,
overflowY: 'auto',
padding: 24,
},
timelineContainer: {
position: 'relative',
paddingLeft: 24,
},
timelineLine: {
position: 'absolute',
left: 7,
top: 0,
bottom: 0,
width: 2,
background: colors.border,
},
// Timeline step
stepContainer: {
position: 'relative',
marginBottom: 16,
},
stepDot: {
position: 'absolute',
left: -20,
top: 8,
width: 12,
height: 12,
borderRadius: '50%',
border: `2px solid ${colors.bg}`,
},
stepCard: {
background: colors.surface,
borderRadius: 8,
padding: 14,
border: `1px solid ${colors.border}`,
},
stepHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
stepBadge: {
display: 'inline-block',
padding: '2px 10px',
borderRadius: 10,
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase' as const,
letterSpacing: '0.04em',
},
stepDuration: {
fontSize: 12,
color: colors.textMuted,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
},
stepDetails: {
fontSize: 12,
color: colors.textMuted,
lineHeight: 1.6,
},
stepDetailRow: {
display: 'flex',
gap: 8,
},
stepDetailKey: {
color: colors.textMuted,
minWidth: 60,
flexShrink: 0,
},
stepDetailValue: {
color: colors.text,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: 11,
wordBreak: 'break-all',
},
expandButton: {
background: 'none',
border: 'none',
color: colors.accent,
fontSize: 11,
cursor: 'pointer',
padding: '4px 0',
textAlign: 'left',
},
// Empty state
emptyState: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: colors.textMuted,
gap: 12,
padding: 32,
},
emptyIcon: {
fontSize: 40,
opacity: 0.4,
},
emptyText: {
fontSize: 15,
textAlign: 'center',
},
errorBanner: {
background: 'rgba(243,139,168,0.1)',
border: `1px solid ${colors.red}`,
borderRadius: 8,
padding: '10px 16px',
margin: 16,
fontSize: 13,
color: colors.red,
},
placeholder: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: colors.textMuted,
fontSize: 14,
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function truncateId(id: string, len: number = 12): string {
if (id.length <= len) return id;
return id.slice(0, len) + '...';
}
function formatTimestamp(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return ts;
}
}
function formatDuration(ms: number): string {
if (ms < 1) return '<1ms';
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function stepColor(stepType: string): string {
return STEP_COLORS[stepType] ?? DEFAULT_STEP_COLOR;
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function StepDataView({ data }: { data: TraceStepData }) {
const [expanded, setExpanded] = useState(false);
const entries = Object.entries(data).filter(
([, v]) => v !== undefined && v !== null && v !== '',
);
if (entries.length === 0) {
return null;
}
// Show up to 3 entries by default; expand to show all
const displayEntries = expanded ? entries : entries.slice(0, 3);
const hasMore = entries.length > 3;
return (
<div style={styles.stepDetails}>
{displayEntries.map(([key, value]) => (
<div key={key} style={styles.stepDetailRow}>
<span style={styles.stepDetailKey}>{key}:</span>
<span style={styles.stepDetailValue}>
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
{hasMore && (
<button
style={styles.expandButton}
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'Show less' : `Show ${entries.length - 3} more fields...`}
</button>
)}
</div>
);
}
interface TimelineStepProps {
step: TraceStep;
}
function TimelineStep({ step }: TimelineStepProps) {
const color = stepColor(step.step_type);
return (
<div style={styles.stepContainer}>
<div style={{ ...styles.stepDot, background: color }} />
<div style={styles.stepCard}>
<div style={styles.stepHeader}>
<span
style={{
...styles.stepBadge,
background: `${color}22`,
color,
}}
>
{step.step_type}
</span>
<span style={styles.stepDuration}>{formatDuration(step.duration_ms)}</span>
</div>
{step.data && <StepDataView data={step.data} />}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export function TraceDebugger({ apiUrl }: { apiUrl: string }) {
const [traces, setTraces] = useState<TraceSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [traceDetail, setTraceDetail] = useState<TraceDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [listLoading, setListLoading] = useState(true);
// Fetch trace list
const fetchTraces = useCallback(async () => {
try {
const response = await invoke<TraceListResponse>('fetch_traces', {
apiUrl,
limit: 50,
});
setTraces(response.traces ?? []);
setError(null);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
setTraces([]);
} finally {
setListLoading(false);
}
}, [apiUrl]);
useEffect(() => {
fetchTraces();
}, [fetchTraces]);
// Fetch trace detail when selection changes
const fetchDetail = useCallback(
async (traceId: string) => {
setDetailLoading(true);
try {
const detail = await invoke<TraceDetail>('fetch_trace', {
apiUrl,
traceId,
});
setTraceDetail(detail);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
setTraceDetail(null);
} finally {
setDetailLoading(false);
}
},
[apiUrl],
);
const handleSelectTrace = useCallback(
(traceId: string) => {
setSelectedId(traceId);
fetchDetail(traceId);
},
[fetchDetail],
);
// Compute totals for detail header
const totalDuration =
traceDetail?.steps.reduce((sum, s) => sum + s.duration_ms, 0) ?? 0;
// --- Empty state ---
if (!listLoading && traces.length === 0 && !error) {
return (
<div style={styles.container}>
<div style={styles.emptyState}>
<div style={styles.emptyIcon}>&#x1F50D;</div>
<div style={styles.emptyText}>
No traces available.<br />
Traces are recorded when queries are processed through the system.
</div>
</div>
</div>
);
}
return (
<div style={styles.container}>
{/* Left panel - trace list */}
<div style={styles.listPanel}>
<div style={styles.listHeader}>
<h2 style={styles.listTitle}>Traces</h2>
<p style={styles.listSubtitle}>{traces.length} recent traces</p>
</div>
{error && <div style={styles.errorBanner}>{error}</div>}
<div style={styles.listScroll}>
{traces.map((trace) => {
const isSelected = trace.id === selectedId;
return (
<div
key={trace.id}
style={{
...styles.traceItem,
...(isSelected ? styles.traceItemSelected : {}),
}}
onClick={() => handleSelectTrace(trace.id)}
onMouseEnter={(e) => {
if (!isSelected) {
(e.currentTarget as HTMLDivElement).style.background =
colors.surfaceHover;
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
(e.currentTarget as HTMLDivElement).style.background = '';
}
}}
>
<div style={styles.traceItemId}>{truncateId(trace.id)}</div>
<div style={styles.traceItemQuery}>{trace.query}</div>
<div style={styles.traceItemMeta}>
<span>{trace.steps.length} steps</span>
<span>{formatTimestamp(trace.created_at)}</span>
</div>
</div>
);
})}
</div>
</div>
{/* Right panel - trace detail */}
<div style={styles.detailPanel}>
{!selectedId && (
<div style={styles.placeholder}>
Select a trace from the list to inspect its steps.
</div>
)}
{selectedId && detailLoading && (
<div style={styles.placeholder}>Loading trace...</div>
)}
{selectedId && !detailLoading && traceDetail && (
<>
<div style={styles.detailHeader}>
<h3 style={styles.detailTitle}>
Trace {truncateId(traceDetail.id)}
</h3>
<p style={styles.detailQuery}>
Query: &quot;{traceDetail.query}&quot;
</p>
<div style={styles.detailStats}>
<span>
Steps:{' '}
<span style={styles.detailStatValue}>
{traceDetail.steps.length}
</span>
</span>
<span>
Total:{' '}
<span style={styles.detailStatValue}>
{formatDuration(totalDuration)}
</span>
</span>
{traceDetail.created_at && (
<span>
Created:{' '}
<span style={styles.detailStatValue}>
{formatTimestamp(traceDetail.created_at)}
</span>
</span>
)}
</div>
</div>
<div style={styles.detailScroll}>
{traceDetail.steps.length === 0 ? (
<div style={styles.placeholder}>
This trace contains no steps.
</div>
) : (
<div style={styles.timelineContainer}>
<div style={styles.timelineLine} />
{traceDetail.steps.map((step, idx) => (
<TimelineStep key={idx} step={step} />
))}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
type UpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
const CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
export function UpdateChecker() {
const [state, setState] = useState<UpdateState>('idle');
const [version, setVersion] = useState('');
const [progress, setProgress] = useState(0);
const [errorMsg, setErrorMsg] = useState('');
const [dismissed, setDismissed] = useState(false);
const updateRef = useRef<any>(null);
const checkForUpdate = useCallback(async () => {
try {
const { check } = await import('@tauri-apps/plugin-updater');
const update = await check();
if (update) {
updateRef.current = update;
setVersion(update.version);
setState('available');
setDismissed(false);
}
} catch {
// Silently ignore — likely running in browser or no update available
}
}, []);
useEffect(() => {
// Check if we're in a Tauri environment
if (typeof window === 'undefined' || !(window as any).__TAURI_INTERNALS__) {
return;
}
checkForUpdate();
const interval = setInterval(checkForUpdate, CHECK_INTERVAL_MS);
return () => clearInterval(interval);
}, [checkForUpdate]);
const handleDownload = useCallback(async () => {
const update = updateRef.current;
if (!update) return;
setState('downloading');
setProgress(0);
try {
let downloaded = 0;
const contentLength = update.contentLength ?? 0;
await update.downloadAndInstall((event: any) => {
if (event.event === 'Started' && event.data?.contentLength) {
// Content length received
} else if (event.event === 'Progress') {
downloaded += event.data?.chunkLength ?? 0;
if (contentLength > 0) {
setProgress(Math.min(100, Math.round((downloaded / contentLength) * 100)));
}
} else if (event.event === 'Finished') {
setProgress(100);
}
});
setState('ready');
} catch (e: any) {
setErrorMsg(e?.message || 'Download failed');
setState('error');
setTimeout(() => setState('idle'), 5000);
}
}, []);
const handleRelaunch = useCallback(async () => {
try {
const { relaunch } = await import('@tauri-apps/plugin-process');
await relaunch();
} catch {
// Fallback: inform user to restart manually
setErrorMsg('Please restart the application manually');
setState('error');
setTimeout(() => setState('idle'), 5000);
}
}, []);
if (state === 'idle' || dismissed) return null;
return (
<div style={styles.banner}>
{state === 'available' && (
<div style={styles.row}>
<span>Update available: <strong>v{version}</strong></span>
<div style={styles.actions}>
<button style={styles.primaryBtn} onClick={handleDownload}>Download</button>
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Dismiss</button>
</div>
</div>
)}
{state === 'downloading' && (
<div style={styles.row}>
<span>Downloading update... {progress}%</span>
<div style={styles.progressBar}>
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
</div>
</div>
)}
{state === 'ready' && (
<div style={styles.row}>
<span style={{ color: '#a6e3a1' }}>Update installed.</span>
<div style={styles.actions}>
<button style={styles.successBtn} onClick={handleRelaunch}>Relaunch now</button>
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Later</button>
</div>
</div>
)}
{state === 'error' && (
<div style={styles.row}>
<span style={{ color: '#f38ba8' }}>Update error: {errorMsg}</span>
</div>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
banner: {
padding: '10px 24px',
backgroundColor: '#181825',
borderBottom: '1px solid #313244',
},
row: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
fontSize: '13px',
},
actions: {
display: 'flex',
gap: '8px',
},
primaryBtn: {
padding: '4px 14px',
border: 'none',
borderRadius: '4px',
backgroundColor: '#89b4fa',
color: '#1e1e2e',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
},
successBtn: {
padding: '4px 14px',
border: 'none',
borderRadius: '4px',
backgroundColor: '#a6e3a1',
color: '#1e1e2e',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
},
secondaryBtn: {
padding: '4px 14px',
border: '1px solid #45475a',
borderRadius: '4px',
backgroundColor: 'transparent',
color: '#a6adc8',
fontSize: '12px',
cursor: 'pointer',
},
progressBar: {
flex: 1,
maxWidth: '300px',
height: '6px',
backgroundColor: '#313244',
borderRadius: '3px',
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: '#89b4fa',
borderRadius: '3px',
transition: 'width 0.3s ease',
},
};
+87
View File
@@ -0,0 +1,87 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Detect whether we're running inside Tauri or in a browser.
* In Tauri, `window.__TAURI_INTERNALS__` is set.
*/
export function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
/**
* Invoke a Tauri command if in Tauri, otherwise fall back to fetch.
*/
export async function tauriInvoke<T>(
command: string,
args: Record<string, unknown> = {},
): Promise<T> {
if (isTauri()) {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<T>(command, args);
}
// Browser fallback: map command to REST API
return browserFallback<T>(command, args);
}
async function browserFallback<T>(
command: string,
args: Record<string, unknown>,
): Promise<T> {
const apiUrl = (args.apiUrl as string) || 'http://localhost:8000';
const urlMap: Record<string, string> = {
check_health: '/health',
fetch_energy: '/v1/telemetry/energy',
fetch_telemetry: '/v1/telemetry/stats',
fetch_traces: `/v1/traces?limit=${args.limit || 20}`,
fetch_trace: `/v1/traces/${args.traceId}`,
fetch_learning_stats: '/v1/learning/stats',
fetch_learning_policy: '/v1/learning/policy',
fetch_memory_stats: '/v1/memory/stats',
fetch_agents: '/v1/agents',
};
const path = urlMap[command];
if (!path) {
throw new Error(`No browser fallback for command: ${command}`);
}
const resp = await fetch(`${apiUrl}${path}`);
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
}
return resp.json() as Promise<T>;
}
/**
* Hook for polling a Tauri command at a regular interval.
*/
export function usePolling<T>(
command: string,
args: Record<string, unknown>,
intervalMs: number,
): { data: T | null; error: string | null; loading: boolean; refresh: () => void } {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
setLoading(true);
const result = await tauriInvoke<T>(command, args);
setData(result);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [command, JSON.stringify(args)]);
useEffect(() => {
refresh();
const timer = setInterval(refresh, intervalMs);
return () => clearInterval(timer);
}, [refresh, intervalMs]);
return { data, error, loading, refresh };
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AdminPanel.tsx","./src/components/EnergyDashboard.tsx","./src/components/LearningCurve.tsx","./src/components/MemoryBrowser.tsx","./src/components/TraceDebugger.tsx","./src/hooks/useTauriApi.ts"],"version":"5.7.3"}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
strictPort: true,
proxy: {
'/v1': 'http://localhost:8000',
'/health': 'http://localhost:8000',
},
},
clearScreen: false,
envPrefix: ['VITE_', 'TAURI_'],
});
+115
View File
@@ -0,0 +1,115 @@
# OpenJarvis Roadmap Progress
Last updated: 2026-02-27
## Phase 17: Production Tool Parity
### Core Tools
- [x] `FileWriteTool``src/openjarvis/tools/file_write.py` + `tests/tools/test_file_write.py` (16 tests)
- [x] `ApplyPatchTool``src/openjarvis/tools/apply_patch.py` + `tests/tools/test_apply_patch.py` (13 tests)
- [x] `ShellExecTool``src/openjarvis/tools/shell_exec.py` + `tests/tools/test_shell_exec.py` (19 tests)
- [x] `GitTool` (4 ops) — `src/openjarvis/tools/git_tool.py` + `tests/tools/test_git_tool.py` (41 tests)
- [x] `HttpRequestTool``src/openjarvis/tools/http_request.py` + `tests/tools/test_http_request.py` (21 tests)
- [x] `DatabaseQueryTool``src/openjarvis/tools/db_query.py` + `tests/tools/test_db_query.py` (26 tests)
### Inter-Agent Tools
- [x] `AgentSpawnTool``src/openjarvis/tools/agent_tools.py` + `tests/tools/test_agent_tools.py`
- [x] `AgentSendTool` — (same file)
- [x] `AgentListTool` — (same file)
- [x] `AgentKillTool` — (same file, 22 tests total)
### Browser Automation
- [x] `BrowserNavigateTool``src/openjarvis/tools/browser.py` + `tests/tools/test_browser.py`
- [x] `BrowserClickTool` — (same file)
- [x] `BrowserTypeTool` — (same file)
- [x] `BrowserScreenshotTool` — (same file)
- [x] `BrowserExtractTool` — (same file, 71 tests total)
### Media Tools
- [x] `ImageGenerateTool``src/openjarvis/tools/image_tool.py` + `tests/tools/test_image_tool.py` (12 tests)
- [x] `AudioTranscribeTool``src/openjarvis/tools/audio_tool.py` + `tests/tools/test_audio_tool.py` (17 tests)
- [x] `PDFExtractTool``src/openjarvis/tools/pdf_tool.py` + `tests/tools/test_pdf_tool.py` (19 tests)
### Security Hardening
- [x] SSRF protection — `src/openjarvis/security/ssrf.py` + `tests/security/test_ssrf.py` (18 tests)
- [x] Subprocess sandbox — `src/openjarvis/security/subprocess_sandbox.py` + `tests/security/test_subprocess_sandbox.py` (11 tests)
- [x] Prompt injection scanner — `src/openjarvis/security/injection_scanner.py` + `tests/security/test_injection_scanner.py` (10 tests)
- [x] Rate limiting — `src/openjarvis/security/rate_limiter.py` + `tests/security/test_rate_limiter.py` (12 tests)
- [x] Security headers middleware — `src/openjarvis/server/middleware.py` + `tests/server/test_middleware.py` (4 tests)
### Config & Integration
- [x] New extras in `pyproject.toml`: `browser`, `media`, `pdf`, channel extras for Phase 21
- [x] `ToolsConfig.browser` section added to `config.py` (`BrowserConfig`: headless, timeout_ms, viewport)
- [x] `SecurityConfig.ssrf_protection`, `rate_limit_enabled`, `rate_limit_rpm`, `rate_limit_burst` fields
## Phase 18: CLI & API Expansion
### CLI Commands
- [x] `jarvis start/stop/restart/status``src/openjarvis/cli/daemon_cmd.py` + `tests/cli/test_daemon_cmd.py` (7 tests)
- [x] `jarvis chat``src/openjarvis/cli/chat_cmd.py` + `tests/cli/test_chat_cmd.py` (6 tests)
- [x] `jarvis agent``src/openjarvis/cli/agent_cmd.py` + `tests/cli/test_agent_cmd.py` (3 tests)
- [x] `jarvis workflow``src/openjarvis/cli/workflow_cmd.py` + `tests/cli/test_workflow_cmd.py` (4 tests)
- [x] `jarvis skill``src/openjarvis/cli/skill_cmd.py` + `tests/cli/test_skill_cmd.py` (4 tests)
- [x] `jarvis vault``src/openjarvis/cli/vault_cmd.py` + `tests/cli/test_vault_cmd.py` (6 tests)
- [x] `jarvis add``src/openjarvis/cli/add_cmd.py` + `tests/cli/test_add_cmd.py` (5 tests)
### API Endpoints
- [x] Agent API endpoints — `src/openjarvis/server/api_routes.py`
- [x] Workflow API endpoints — (same file)
- [x] Memory API endpoints — (same file)
- [x] Traces API endpoints — (same file)
- [x] Telemetry API endpoints — (same file)
- [x] Skills API endpoints — (same file)
- [x] Sessions API endpoints — (same file)
- [x] Budget API endpoints — (same file)
- [x] Prometheus metrics — (same file)
- [x] Security headers middleware wired into app — `src/openjarvis/server/app.py`
- [x] All routes tested — `tests/server/test_api_routes.py` (11 tests)
- [x] WebSocket streaming endpoint — `WS /v1/chat/stream` + `tests/server/test_websocket.py` (9 tests)
## Phase 19: Learning System Productionization
- [x] GRPO Router — `src/openjarvis/learning/grpo_policy.py` (replaced stub) + `tests/learning/test_grpo_policy.py` (13 tests)
- [x] Multi-Armed Bandit Router — `src/openjarvis/learning/bandit_router.py` + `tests/learning/test_bandit_router.py` (13 tests)
- [x] Closed-Loop Skill Discovery — `src/openjarvis/learning/skill_discovery.py` + `tests/learning/test_skill_discovery.py` (10 tests)
- [x] Auto-Apply ICL Updates — modified `src/openjarvis/learning/icl_updater.py` + `tests/learning/test_icl_updates.py` (13 tests)
- [x] Learning Dashboard API — `GET /v1/learning/stats`, `GET /v1/learning/policy` + `tests/learning/test_learning_api.py` (8 tests)
## Phase 20: Desktop App (Tauri 2.0)
- [x] Tauri scaffold in `desktop/``src-tauri/`, `package.json`, `vite.config.ts`, `tsconfig.json`
- [x] Tauri Rust backend — `src-tauri/src/lib.rs` with 11 commands (health, energy, telemetry, traces, learning, memory, agents, jarvis CLI)
- [x] Tauri plugins — notification, shell, global-shortcut, autostart, updater, single-instance
- [x] Energy dashboard — `src/components/EnergyDashboard.tsx` (recharts line chart, auto-refresh)
- [x] Trace debugger — `src/components/TraceDebugger.tsx` (dual-panel, color-coded step timeline)
- [x] Learning curve visualization — `src/components/LearningCurve.tsx` (GRPO/bandit/ICL stats)
- [x] Memory browser — `src/components/MemoryBrowser.tsx` (search + stats)
- [x] Admin panel — `src/components/AdminPanel.tsx` (health, agents, server control)
- [x] Build successful — `.deb`, `.rpm`, `.AppImage` bundles produced
- [x] CI workflow — `.github/workflows/desktop.yml` (Linux/macOS/Windows matrix)
## Phase 21: Channels
- [x] LINE — `src/openjarvis/channels/line_channel.py`
- [x] Viber — `src/openjarvis/channels/viber_channel.py`
- [x] Facebook Messenger — `src/openjarvis/channels/messenger_channel.py`
- [x] Reddit — `src/openjarvis/channels/reddit_channel.py`
- [x] Mastodon — `src/openjarvis/channels/mastodon_channel.py`
- [x] XMPP — `src/openjarvis/channels/xmpp_channel.py`
- [x] Rocket.Chat — `src/openjarvis/channels/rocketchat_channel.py`
- [x] Zulip — `src/openjarvis/channels/zulip_channel.py`
- [x] Twitch — `src/openjarvis/channels/twitch_channel.py`
- [x] Nostr — `src/openjarvis/channels/nostr_channel.py`
- [x] All channels tested — `tests/channels/test_channels_phase21.py` (103 tests)
## Test Summary
| Phase | New Tests | Cumulative |
|-------|-----------|------------|
| Pre-existing | ~2,447 | 2,447 |
| Phase 17 | ~300 | ~2,747 |
| Phase 18 | ~56 | ~2,803 |
| Phase 19 | ~49 | ~2,852 |
| Phase 21 | ~103 | ~2,923 |
| WebSocket + Learning API | ~17 | ~2,940 |
| **Verified total** | | **2,997 passed, 42 skipped** |
## CLAUDE.md
- [x] Updated with all new tools, CLI commands, API endpoints, channels, learning policies, desktop app, config fields, and phase table
@@ -0,0 +1,43 @@
# GLM-4.7-FP8 eval with NativeOpenHandsAgent on GAIA only.
# Machine: 8x A100-80GB, engine: vLLM, TP=8
[meta]
name = "glm-4.7-fp8-openhands-gaia"
description = "Evaluate GLM-4.7-FP8 via NativeOpenHandsAgent on GAIA"
[defaults]
temperature = 0.0
max_tokens = 2048
[judge]
model = "gpt-5-mini-2025-08-07"
temperature = 0.0
max_tokens = 1024
[run]
max_workers = 4
output_dir = "results/glm-4.7-fp8-openhands-gaia/"
seed = 42
telemetry = true
gpu_metrics = true
# --- Model Under Test ---
[[models]]
name = "zai-org/GLM-4.7-FP8"
engine = "vllm"
param_count_b = 30.0 # 30B total MoE params
active_params_b = 3.0 # ~3B active params per token
gpu_peak_tflops = 312.0 # A100 SXM FP16 peak TFLOPS
gpu_peak_bandwidth_gb_s = 2039.0 # A100 SXM memory bandwidth
num_gpus = 8 # TP=8
# --- Benchmarks ---
# Agentic: GAIA — requires file reading, web search, multi-step reasoning
[[benchmarks]]
name = "gaia"
backend = "jarvis-agent"
agent = "native_openhands"
tools = ["code_interpreter", "web_search", "file_read", "calculator", "think"]
max_samples = 50
+4399 -1
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -18,6 +18,6 @@
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vite-plugin-pwa": "^0.21"
"vite-plugin-pwa": "^0.21.2"
}
}
+14 -2
View File
@@ -523,12 +523,24 @@ body {
/* === Streaming Indicator === */
.streaming-indicator {
display: flex;
align-items: center;
gap: 12px;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
margin: 0 24px;
}
.streaming-tool-calls {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.streaming-progress-row {
display: flex;
align-items: center;
gap: 12px;
}
.streaming-bar-container {
flex: 1;
height: 4px;
@@ -18,12 +18,6 @@ export function MessageBubble({ message }: MessageBubbleProps) {
return (
<div className={`message-bubble ${message.role}`}>
<div className="message-content">
{message.content || (message.role === 'assistant' ? '\u200B' : '')}
{message.role === 'assistant' && message.content && (
<CopyButton text={message.content} />
)}
</div>
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="tool-calls">
{message.toolCalls.map((tc) => (
@@ -31,6 +25,12 @@ export function MessageBubble({ message }: MessageBubbleProps) {
))}
</div>
)}
<div className="message-content">
{message.content || (message.role === 'assistant' ? '\u200B' : '')}
{message.role === 'assistant' && message.content && (
<CopyButton text={message.content} />
)}
</div>
<div className="message-meta">
<span className="message-time">{formatTime(message.timestamp)}</span>
{usage && (
@@ -20,11 +20,20 @@ export function StreamingIndicator({
}: StreamingIndicatorProps) {
return (
<div className="streaming-indicator">
<div className="streaming-bar-container">
<div className="streaming-bar" />
{toolCalls.length > 0 && (
<div className="streaming-tool-calls">
{toolCalls.map((tc) => (
<ToolCallIndicator key={tc.id} toolCall={tc} />
))}
</div>
)}
<div className="streaming-progress-row">
<div className="streaming-bar-container">
<div className="streaming-bar" />
</div>
<span className="streaming-phase">{phase}</span>
<span className="streaming-elapsed">{formatElapsed(elapsedMs)}</span>
</div>
<span className="streaming-phase">{phase}</span>
<span className="streaming-elapsed">{formatElapsed(elapsedMs)}</span>
</div>
);
}
+24
View File
@@ -128,6 +128,18 @@ export function useChat(conversationId: string | null, model: string) {
phase: `Running ${data.tool}...`,
activeToolCalls: [...toolCalls],
}));
// Update message with live tool call progress
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last && last.role === 'assistant') {
updated[updated.length - 1] = {
...last,
toolCalls: [...toolCalls],
};
}
return updated;
});
} catch {}
} else if (eventName === 'tool_call_end') {
try {
@@ -143,6 +155,18 @@ export function useChat(conversationId: string | null, model: string) {
phase: 'Generating response...',
activeToolCalls: [...toolCalls],
}));
// Update message with completed tool call
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last && last.role === 'assistant') {
updated[updated.length - 1] = {
...last,
toolCalls: [...toolCalls],
};
}
return updated;
});
} catch {}
} else {
// Content chunk (no event name or event: content)
+16
View File
@@ -80,7 +80,23 @@ channel-mattermost = []
channel-feishu = []
channel-bluebubbles = []
channel-whatsapp-baileys = []
channel-line = ["line-bot-sdk>=3.0"]
channel-viber = ["viberbot>=1.0"]
channel-messenger = []
channel-reddit = ["praw>=7.0"]
channel-mastodon = ["Mastodon.py>=1.8"]
channel-xmpp = ["slixmpp>=1.8"]
channel-rocketchat = ["rocketchat-API>=1.30"]
channel-zulip = ["zulip>=0.9"]
channel-twitch = ["twitchio>=2.6"]
channel-nostr = ["pynostr>=0.6"]
browser = ["playwright>=1.40"]
media = ["openai>=1.30"]
pdf = ["pdfplumber>=0.10"]
scheduler = ["croniter>=2.0"]
security-signing = ["cryptography>=43"]
sandbox-wasm = ["wasmtime>=25"]
dashboard = ["textual>=0.80"]
docs = [
"mkdocs>=1.6",
"mkdocs-material>=9.5",
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
# Bump the desktop app version across all 3 config files.
# Usage: ./scripts/bump-desktop-version.sh <semver>
# Example: ./scripts/bump-desktop-version.sh 1.0.1
if [ $# -ne 1 ]; then
echo "Usage: $0 <version>"
echo "Example: $0 1.0.1"
exit 1
fi
VERSION="$1"
# Validate semver (major.minor.patch, optional pre-release)
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
echo "Error: '$VERSION' is not a valid semver (expected X.Y.Z or X.Y.Z-pre)"
exit 1
fi
DESKTOP_DIR="$(cd "$(dirname "$0")/../desktop" && pwd)"
# 1. package.json
node -e "
const fs = require('fs');
const path = '${DESKTOP_DIR}/package.json';
const pkg = JSON.parse(fs.readFileSync(path, 'utf8'));
pkg.version = '${VERSION}';
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
"
echo "Updated desktop/package.json -> ${VERSION}"
# 2. tauri.conf.json
node -e "
const fs = require('fs');
const path = '${DESKTOP_DIR}/src-tauri/tauri.conf.json';
const conf = JSON.parse(fs.readFileSync(path, 'utf8'));
conf.version = '${VERSION}';
fs.writeFileSync(path, JSON.stringify(conf, null, 2) + '\n');
"
echo "Updated desktop/src-tauri/tauri.conf.json -> ${VERSION}"
# 3. Cargo.toml
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" "${DESKTOP_DIR}/src-tauri/Cargo.toml"
rm -f "${DESKTOP_DIR}/src-tauri/Cargo.toml.bak"
echo "Updated desktop/src-tauri/Cargo.toml -> ${VERSION}"
echo ""
echo "Version bumped to ${VERSION} in all 3 files."
echo ""
echo "Next steps:"
echo " git add desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml"
echo " git commit -m \"chore(desktop): bump version to ${VERSION}\""
echo " git tag desktop-v${VERSION}"
echo " git push origin main --tags"
+10
View File
@@ -0,0 +1,10 @@
"""Agent-to-Agent protocol — Google A2A spec implementation."""
from openjarvis.a2a.client import A2AClient
from openjarvis.a2a.protocol import A2ARequest, A2AResponse, A2ATask, AgentCard
from openjarvis.a2a.server import A2AServer
from openjarvis.a2a.tool import A2AAgentTool
__all__ = [
"A2AAgentTool", "A2AClient", "A2ARequest", "A2AResponse",
"A2AServer", "A2ATask", "AgentCard",
]
+111
View File
@@ -0,0 +1,111 @@
"""A2A client — discover and call external A2A agents."""
from __future__ import annotations
from typing import Any, Optional
from openjarvis.a2a.protocol import A2ARequest, A2ATask, AgentCard
class A2AClient:
"""Client for calling external A2A-compatible agents.
Discovers agent capabilities via /.well-known/agent.json and
sends tasks via /a2a/tasks.
"""
def __init__(self, base_url: str, *, timeout: float = 30.0) -> None:
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._card: Optional[AgentCard] = None
def discover(self) -> AgentCard:
"""Fetch the agent card from /.well-known/agent.json."""
import httpx
resp = httpx.get(
f"{self._base_url}/.well-known/agent.json",
timeout=self._timeout,
)
resp.raise_for_status()
data = resp.json()
self._card = AgentCard(
name=data.get("name", ""),
description=data.get("description", ""),
url=data.get("url", self._base_url),
version=data.get("version", ""),
capabilities=data.get("capabilities", []),
skills=data.get("skills", []),
)
return self._card
def send_task(self, input_text: str, **kwargs: Any) -> A2ATask:
"""Send a task to the remote agent and return the result."""
import httpx
request = A2ARequest(
method="tasks/send",
params={
"message": {
"role": "user",
"parts": [{"text": input_text}],
},
},
)
resp = httpx.post(
f"{self._base_url}/a2a/tasks",
json=request.to_dict(),
timeout=self._timeout,
)
resp.raise_for_status()
data = resp.json()
result = data.get("result", {})
return A2ATask(
task_id=result.get("id", ""),
state=result.get("state", "unknown"),
input_text=result.get("input", input_text),
output_text=result.get("output", ""),
history=result.get("history", []),
)
def get_task(self, task_id: str) -> A2ATask:
"""Get the status of a previously submitted task."""
import httpx
request = A2ARequest(
method="tasks/get",
params={"id": task_id},
)
resp = httpx.post(
f"{self._base_url}/a2a/tasks",
json=request.to_dict(),
timeout=self._timeout,
)
resp.raise_for_status()
data = resp.json()
result = data.get("result", {})
return A2ATask(
task_id=result.get("id", task_id),
state=result.get("state", "unknown"),
output_text=result.get("output", ""),
)
def cancel_task(self, task_id: str) -> A2ATask:
"""Cancel a running task."""
import httpx
request = A2ARequest(
method="tasks/cancel",
params={"id": task_id},
)
resp = httpx.post(
f"{self._base_url}/a2a/tasks",
json=request.to_dict(),
timeout=self._timeout,
)
resp.raise_for_status()
data = resp.json()
result = data.get("result", {})
return A2ATask(
task_id=result.get("id", task_id),
state=result.get("state", "canceled"),
)
__all__ = ["A2AClient"]
+112
View File
@@ -0,0 +1,112 @@
"""A2A protocol types — Google A2A spec (JSON-RPC 2.0)."""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional
class TaskState(str, Enum):
SUBMITTED = "submitted"
WORKING = "working"
INPUT_REQUIRED = "input-required"
COMPLETED = "completed"
CANCELED = "canceled"
FAILED = "failed"
@dataclass(slots=True)
class AgentCard:
"""Agent discovery card served at /.well-known/agent.json."""
name: str
description: str = ""
url: str = ""
version: str = "1.0.0"
capabilities: List[str] = field(default_factory=list)
skills: List[str] = field(default_factory=list)
authentication: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"url": self.url,
"version": self.version,
"capabilities": self.capabilities,
"skills": self.skills,
"authentication": self.authentication,
}
@dataclass
class A2ATask:
"""An A2A task with state machine."""
task_id: str = field(default_factory=lambda: uuid.uuid4().hex[:16])
state: TaskState = TaskState.SUBMITTED
input_text: str = ""
output_text: str = ""
history: List[Dict[str, str]] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.task_id,
"state": self.state.value,
"input": self.input_text,
"output": self.output_text,
"history": self.history,
"metadata": self.metadata,
}
@dataclass(slots=True)
class A2ARequest:
"""JSON-RPC 2.0 request for A2A."""
method: str
params: Dict[str, Any] = field(default_factory=dict)
request_id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
def to_dict(self) -> Dict[str, Any]:
return {
"jsonrpc": "2.0",
"method": self.method,
"params": self.params,
"id": self.request_id,
}
def to_json(self) -> str:
return json.dumps(self.to_dict())
@dataclass(slots=True)
class A2AResponse:
"""JSON-RPC 2.0 response for A2A."""
result: Any = None
error: Optional[Dict[str, Any]] = None
request_id: str = ""
def to_dict(self) -> Dict[str, Any]:
resp: Dict[str, Any] = {"jsonrpc": "2.0", "id": self.request_id}
if self.error:
resp["error"] = self.error
else:
resp["result"] = self.result
return resp
def to_json(self) -> str:
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, data: str) -> A2AResponse:
parsed = json.loads(data)
return cls(
result=parsed.get("result"),
error=parsed.get("error"),
request_id=parsed.get("id", ""),
)
__all__ = ["A2ARequest", "A2AResponse", "A2ATask", "AgentCard", "TaskState"]
+130
View File
@@ -0,0 +1,130 @@
"""A2A server — exposes agents via /.well-known/agent.json and /a2a/tasks."""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from openjarvis.a2a.protocol import (
A2AResponse,
A2ATask,
AgentCard,
TaskState,
)
from openjarvis.core.events import EventBus, EventType
class A2AServer:
"""A2A server that processes incoming tasks via agent execution.
Can be mounted as routes in the FastAPI server.
"""
def __init__(
self,
agent_card: AgentCard,
*,
handler: Optional[Callable[[str], str]] = None,
bus: Optional[EventBus] = None,
) -> None:
self._card = agent_card
self._handler = handler
self._bus = bus
self._tasks: Dict[str, A2ATask] = {}
@property
def agent_card(self) -> AgentCard:
return self._card
def handle_request(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""Process a JSON-RPC 2.0 A2A request."""
method = request_data.get("method", "")
params = request_data.get("params", {})
req_id = request_data.get("id", "")
if method == "tasks/send":
return self._handle_task_send(params, req_id)
elif method == "tasks/get":
return self._handle_task_get(params, req_id)
elif method == "tasks/cancel":
return self._handle_task_cancel(params, req_id)
else:
return A2AResponse(
error={"code": -32601, "message": f"Method not found: {method}"},
request_id=req_id,
).to_dict()
def _handle_task_send(self, params: Dict[str, Any], req_id: str) -> Dict[str, Any]:
"""Handle tasks/send — create and execute a task."""
input_text = params.get("message", {}).get("parts", [{}])[0].get("text", "")
if not input_text:
input_text = params.get("input", "")
task = A2ATask(input_text=input_text)
self._tasks[task.task_id] = task
if self._bus:
self._bus.publish(
EventType.A2A_TASK_RECEIVED,
{"task_id": task.task_id, "input": input_text},
)
# Execute
task.state = TaskState.WORKING
try:
if self._handler:
result = self._handler(input_text)
else:
result = f"No handler configured for A2A task: {input_text}"
task.output_text = result
task.state = TaskState.COMPLETED
task.history.append({"role": "agent", "content": result})
except Exception as exc:
task.output_text = str(exc)
task.state = TaskState.FAILED
if self._bus:
self._bus.publish(
EventType.A2A_TASK_COMPLETED,
{"task_id": task.task_id, "state": task.state.value},
)
return A2AResponse(result=task.to_dict(), request_id=req_id).to_dict()
def _handle_task_get(self, params: Dict[str, Any], req_id: str) -> Dict[str, Any]:
"""Handle tasks/get — retrieve task status."""
task_id = params.get("id", "")
task = self._tasks.get(task_id)
if not task:
return A2AResponse(
error={"code": -32602, "message": f"Task not found: {task_id}"},
request_id=req_id,
).to_dict()
return A2AResponse(result=task.to_dict(), request_id=req_id).to_dict()
def _handle_task_cancel(
self, params: Dict[str, Any], req_id: str,
) -> Dict[str, Any]:
"""Handle tasks/cancel — cancel a running task."""
task_id = params.get("id", "")
task = self._tasks.get(task_id)
if not task:
return A2AResponse(
error={"code": -32602, "message": f"Task not found: {task_id}"},
request_id=req_id,
).to_dict()
task.state = TaskState.CANCELED
return A2AResponse(result=task.to_dict(), request_id=req_id).to_dict()
def get_routes(self) -> List[Dict[str, Any]]:
"""Return route definitions for mounting in a web framework."""
return [
{
"path": "/.well-known/agent.json",
"method": "GET",
"handler": "agent_card",
},
{"path": "/a2a/tasks", "method": "POST", "handler": "handle_request"},
]
__all__ = ["A2AServer"]
+77
View File
@@ -0,0 +1,77 @@
"""A2AAgentTool — wraps an external A2A agent as an invocable tool."""
from __future__ import annotations
from typing import Any
from openjarvis.a2a.client import A2AClient
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
class A2AAgentTool(BaseTool):
"""Wraps an external A2A agent as a BaseTool.
Follows the MCPToolAdapter pattern for external tool integration.
"""
tool_id: str
def __init__(self, client: A2AClient, *, name: str = "") -> None:
self._client = client
self._name = name or "a2a_agent"
self.tool_id = self._name
# Try to discover agent info
try:
card = client.discover()
if not name:
self._name = f"a2a_{card.name.lower().replace(' ', '_')}"
self.tool_id = self._name
self._description = card.description or f"External A2A agent: {card.name}"
except Exception:
self._description = "External A2A agent"
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name=self._name,
description=self._description,
parameters={
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "Input text to send to the remote agent.",
},
},
"required": ["input"],
},
category="a2a",
required_capabilities=["network:fetch"],
)
def execute(self, **params: Any) -> ToolResult:
input_text = params.get("input", "")
if not input_text:
return ToolResult(
tool_name=self._name,
content="No input provided.",
success=False,
)
try:
task = self._client.send_task(input_text)
return ToolResult(
tool_name=self._name,
content=task.output_text,
success=task.state in ("completed", "working"),
metadata={"task_id": task.task_id, "state": str(task.state)},
)
except Exception as exc:
return ToolResult(
tool_name=self._name,
content=f"A2A call failed: {exc}",
success=False,
)
__all__ = ["A2AAgentTool"]
+60 -3
View File
@@ -139,6 +139,41 @@ class BaseAgent(ABC):
metadata={"max_turns_exceeded": True},
)
def _check_continuation(
self,
result: dict,
messages: list,
*,
max_continuations: int = 2,
) -> str:
"""Re-prompt on ``finish_reason == "length"`` to get complete output.
Returns the concatenated content after up to *max_continuations*
follow-up generate calls.
"""
content = result.get("content", "")
finish_reason = result.get("finish_reason", "")
for _ in range(max_continuations):
if finish_reason != "length":
break
# Append what we have so far and ask the model to continue
from openjarvis.core.types import Message, Role
messages.append(Message(role=Role.ASSISTANT, content=content))
messages.append(
Message(
role=Role.USER,
content="Continue from where you left off.",
),
)
cont = self._generate(messages)
continuation = cont.get("content", "")
content += continuation
finish_reason = cont.get("finish_reason", "")
return content
@staticmethod
def _strip_think_tags(text: str) -> str:
"""Remove ``<think>...</think>`` blocks from model output.
@@ -148,9 +183,9 @@ class BaseAgent(ABC):
begins directly with reasoning text followed by ``</think>``.
"""
# Full <think>...</think> blocks
text = re.sub(r"<think>.*?</think>\s*", "", text, flags=re.DOTALL)
text = re.sub(r"<think>.*?</think>\s*", "", text, flags=re.DOTALL | re.IGNORECASE)
# Leading content before a bare </think> (no opening tag)
text = re.sub(r"^.*?</think>\s*", "", text, flags=re.DOTALL)
text = re.sub(r"^.*?</think>\s*", "", text, flags=re.DOTALL | re.IGNORECASE)
return text.strip()
@abstractmethod
@@ -182,6 +217,9 @@ class ToolUsingAgent(BaseAgent):
max_turns: int = 10,
temperature: float = 0.7,
max_tokens: int = 1024,
loop_guard_config: Optional[Any] = None,
capability_policy: Optional[Any] = None,
agent_id: Optional[str] = None,
) -> None:
super().__init__(
engine, model, bus=bus,
@@ -190,8 +228,27 @@ class ToolUsingAgent(BaseAgent):
from openjarvis.tools._stubs import ToolExecutor
self._tools = tools or []
self._executor = ToolExecutor(self._tools, bus=bus)
_aid = agent_id or getattr(self, "agent_id", "")
self._executor = ToolExecutor(
self._tools, bus=bus,
capability_policy=capability_policy,
agent_id=_aid,
)
self._max_turns = max_turns
# Loop guard
self._loop_guard = None
try:
from openjarvis.agents.loop_guard import LoopGuard, LoopGuardConfig
if loop_guard_config is None:
loop_guard_config = LoopGuardConfig()
elif isinstance(loop_guard_config, dict):
loop_guard_config = LoopGuardConfig(**loop_guard_config)
if loop_guard_config.enabled:
self._loop_guard = LoopGuard(loop_guard_config, bus=bus)
except ImportError:
pass
__all__ = ["AgentContext", "AgentResult", "BaseAgent", "ToolUsingAgent"]
+193
View File
@@ -0,0 +1,193 @@
"""Agent loop guard — detect and prevent degenerate tool-calling loops."""
from __future__ import annotations
import hashlib
from collections import deque
from dataclasses import dataclass
from typing import Optional
from openjarvis.core.events import EventBus, EventType
@dataclass(slots=True)
class LoopGuardConfig:
"""Configuration for the loop guard."""
enabled: bool = True
max_identical_calls: int = 3 # SHA-256 of (tool_name, arguments)
ping_pong_window: int = 6 # detect A-B-A-B cycling
poll_tool_budget: int = 5 # max calls to same polling tool
max_context_messages: int = 100 # context overflow threshold
@dataclass(slots=True)
class LoopVerdict:
"""Result of a loop guard check."""
blocked: bool = False
reason: str = ""
class LoopGuard:
"""Detect and prevent degenerate agent loops.
Features:
1. Hash tracking: SHA-256 of (tool_name, args) blocks after max_identical_calls
2. Ping-pong detection: Sliding window detects A-B-A-B or A-B-C-A-B-C patterns
3. Poll-tool awareness: Tools with spec.metadata["polling"] = True
get relaxed budget
4. Context overflow recovery: 4-stage compression of message history
"""
def __init__(self, config: LoopGuardConfig, *, bus: Optional[EventBus] = None):
self._config = config
self._bus = bus
# Track call hashes and their counts
self._call_counts: dict[str, int] = {}
# Track tool name sequence for pattern detection
self._tool_sequence: deque[str] = deque(maxlen=config.ping_pong_window * 2)
# Track per-tool call counts (for polling budget)
self._per_tool_counts: dict[str, int] = {}
def check_call(self, tool_name: str, arguments: str) -> LoopVerdict:
"""Check whether a tool call should proceed or be blocked."""
# 1. Hash tracking — identical calls
call_hash = hashlib.sha256(
f"{tool_name}:{arguments}".encode()
).hexdigest()[:16]
self._call_counts[call_hash] = self._call_counts.get(call_hash, 0) + 1
if self._call_counts[call_hash] > self._config.max_identical_calls:
self._emit_triggered("identical_call", tool_name)
return LoopVerdict(
blocked=True,
reason=(
f"Identical call to '{tool_name}' repeated "
f"{self._call_counts[call_hash]} times "
f"(max {self._config.max_identical_calls})."
),
)
# 2. Per-tool budget (polling tools)
self._per_tool_counts[tool_name] = self._per_tool_counts.get(tool_name, 0) + 1
if self._per_tool_counts[tool_name] > self._config.poll_tool_budget:
self._emit_triggered("poll_budget", tool_name)
return LoopVerdict(
blocked=True,
reason=(
f"Tool '{tool_name}' exceeded poll budget "
f"({self._config.poll_tool_budget})."
),
)
# 3. Ping-pong detection
self._tool_sequence.append(tool_name)
if len(self._tool_sequence) >= self._config.ping_pong_window:
if self._detect_ping_pong():
self._emit_triggered("ping_pong", tool_name)
return LoopVerdict(
blocked=True,
reason="Repetitive tool-calling pattern detected (ping-pong).",
)
return LoopVerdict()
def check_response(self, content: str) -> LoopVerdict:
"""Check whether an agent response indicates a loop. Reserved for future use."""
return LoopVerdict()
def compress_context(self, messages: list) -> list:
"""Apply 4-stage context overflow recovery to message list.
Stages:
1. Summarize old tool results (replace content with "[Tool result truncated]")
2. Sliding window — keep only recent messages
3. Drop tool call/result pairs from the middle
4. Truncate to system + last 2 exchanges
"""
if len(messages) <= self._config.max_context_messages:
return messages
# Stage 1: Truncate old tool result messages
threshold = len(messages) // 2
compressed = []
for i, msg in enumerate(messages):
if (
i < threshold
and hasattr(msg, 'role')
and str(getattr(msg, 'role', '')) == 'tool'
):
# Replace with truncated version
from openjarvis.core.types import Message, Role
compressed.append(Message(
role=Role.TOOL,
content="[Tool result truncated]",
tool_call_id=getattr(msg, 'tool_call_id', None),
name=getattr(msg, 'name', None),
))
else:
compressed.append(msg)
if len(compressed) <= self._config.max_context_messages:
return compressed
# Stage 2: Sliding window — keep system messages + recent window
system_msgs = [
m for m in compressed
if hasattr(m, 'role')
and str(getattr(m, 'role', '')) == 'system'
]
non_system = [
m for m in compressed
if not (
hasattr(m, 'role')
and str(getattr(m, 'role', '')) == 'system'
)
]
window_size = self._config.max_context_messages - len(system_msgs)
if len(non_system) > window_size:
non_system = non_system[-window_size:]
compressed = system_msgs + non_system
if len(compressed) <= self._config.max_context_messages:
return compressed
# Stage 3: Drop tool call/result pairs from middle
# Keep first 10% and last 50%
keep_start = max(len(system_msgs), len(compressed) // 10)
keep_end = len(compressed) // 2
compressed = compressed[:keep_start] + compressed[-keep_end:]
if len(compressed) <= self._config.max_context_messages:
return compressed
# Stage 4: Extreme — system + last 2 exchanges (4 messages)
return system_msgs + non_system[-4:]
def reset(self) -> None:
"""Reset all tracking state."""
self._call_counts.clear()
self._tool_sequence.clear()
self._per_tool_counts.clear()
def _detect_ping_pong(self) -> bool:
"""Detect repeating patterns in tool call sequence."""
seq = list(self._tool_sequence)
n = len(seq)
# Check for period-2 pattern (A-B-A-B)
for period in (2, 3):
if n >= period * 2:
tail = seq[-period * 2:]
pattern = tail[:period]
if all(tail[i] == pattern[i % period] for i in range(len(tail))):
return True
return False
def _emit_triggered(self, reason_type: str, tool_name: str) -> None:
"""Publish a LOOP_GUARD_TRIGGERED event."""
if self._bus:
self._bus.publish(
EventType.LOOP_GUARD_TRIGGERED,
{"reason_type": reason_type, "tool": tool_name},
)
__all__ = ["LoopGuard", "LoopGuardConfig", "LoopVerdict"]
+44 -3
View File
@@ -126,6 +126,18 @@ class NativeOpenHandsAgent(ToolUsingAgent):
break
return messages
@staticmethod
def _strip_tool_call_text(text: str) -> str:
"""Remove raw tool call artifacts from final output."""
# Remove Action: ... Action Input: ... blocks
text = re.sub(
r"Action:\s*.+?(?:Action Input:\s*.+?)?(?=\n\n|\Z)",
"", text, flags=re.DOTALL | re.IGNORECASE,
)
# Remove <tool_call>...</tool_call> or </tool_name> blocks
text = re.sub(r"<tool_call>.*?</\w+>", "", text, flags=re.DOTALL)
return text.strip()
def _extract_code(self, text: str) -> str | None:
"""Extract Python code from markdown code blocks."""
match = re.search(r"```python\n(.*?)```", text, re.DOTALL)
@@ -174,6 +186,16 @@ class NativeOpenHandsAgent(ToolUsingAgent):
params[key] = int(val)
except ValueError:
params[key] = val
# key: value format (common in GLM models)
if not params:
for m in re.finditer(
r"(\w+)\s*:\s*(.+?)(?=\n\w+\s*:|$)", raw_params, re.DOTALL
):
key, val = m.group(1), m.group(2).strip().strip("\"'")
try:
params[key] = int(val)
except ValueError:
params[key] = val
if params:
return (tool_name, _json.dumps(params))
return (tool_name, "{}")
@@ -214,8 +236,16 @@ class NativeOpenHandsAgent(ToolUsingAgent):
try:
result = self._generate(direct_messages)
content = self._strip_think_tags(result.get("content", ""))
usage = result.get("usage", {})
self._emit_turn_end(turns=1)
return AgentResult(content=content, tool_results=[], turns=1)
return AgentResult(
content=content, tool_results=[], turns=1,
metadata={
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
},
)
except Exception as exc:
error_str = str(exc)
if "400" in error_str:
@@ -243,6 +273,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
all_tool_results: list[ToolResult] = []
turns = 0
last_content = ""
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
for _turn in range(self._max_turns):
turns += 1
@@ -269,6 +300,11 @@ class NativeOpenHandsAgent(ToolUsingAgent):
metadata={"error": True},
)
# Accumulate usage from this generate call
usage = result.get("usage", {})
for k in total_usage:
total_usage[k] += usage.get(k, 0)
content = result.get("content", "")
# Strip think tags so they don't interfere with parsing
content = self._strip_think_tags(content)
@@ -316,14 +352,19 @@ class NativeOpenHandsAgent(ToolUsingAgent):
# No code or tool call -- this is the final answer
content = self._strip_think_tags(content)
content = self._strip_tool_call_text(content)
self._emit_turn_end(turns=turns)
return AgentResult(
content=content, tool_results=all_tool_results, turns=turns
content=content, tool_results=all_tool_results, turns=turns,
metadata=total_usage,
)
# Max turns
final = self._strip_think_tags(last_content) or "Maximum turns reached."
return self._max_turns_result(all_tool_results, turns, content=final)
final = self._strip_tool_call_text(final)
result = self._max_turns_result(all_tool_results, turns, content=final)
result.metadata.update(total_usage)
return result
__all__ = ["NativeOpenHandsAgent"]
+17
View File
@@ -139,6 +139,23 @@ class NativeReActAgent(ToolUsingAgent):
name=parsed["action"],
arguments=parsed["action_input"] or "{}",
)
# Loop guard check before execution
if self._loop_guard:
verdict = self._loop_guard.check_call(
tool_call.name, tool_call.arguments,
)
if verdict.blocked:
tool_result = ToolResult(
tool_name=tool_call.name,
content=f"Loop guard: {verdict.reason}",
success=False,
)
all_tool_results.append(tool_result)
observation = f"Observation: {tool_result.content}"
messages.append(Message(role=Role.USER, content=observation))
continue
tool_result = self._executor.execute(tool_call)
all_tool_results.append(tool_result)
+23 -2
View File
@@ -225,8 +225,9 @@ class OrchestratorAgent(ToolUsingAgent):
content = result.get("content", "")
raw_tool_calls = result.get("tool_calls", [])
# No tool calls -> final answer
# No tool calls -> check continuation, then final answer
if not raw_tool_calls:
content = self._check_continuation(result, messages)
self._emit_turn_end(turns=turns, content_length=len(content))
return AgentResult(
content=content,
@@ -251,8 +252,28 @@ class OrchestratorAgent(ToolUsingAgent):
tool_calls=tool_calls,
))
# Execute each tool and append results
# Execute each tool (with loop guard check) and append results
for tc in tool_calls:
# Loop guard check before execution
if self._loop_guard:
verdict = self._loop_guard.check_call(
tc.name, tc.arguments,
)
if verdict.blocked:
tool_result = ToolResult(
tool_name=tc.name,
content=f"Loop guard: {verdict.reason}",
success=False,
)
all_tool_results.append(tool_result)
messages.append(Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
))
continue
tool_result = self._executor.execute(tc)
all_tool_results.append(tool_result)
+50
View File
@@ -88,6 +88,56 @@ try:
except ImportError:
pass
try:
import openjarvis.channels.line_channel # noqa: F401
except ImportError:
pass
try:
import openjarvis.channels.viber_channel # noqa: F401
except ImportError:
pass
try:
import openjarvis.channels.messenger_channel # noqa: F401
except ImportError:
pass
try:
import openjarvis.channels.reddit_channel # noqa: F401
except ImportError:
pass
try:
import openjarvis.channels.mastodon_channel # noqa: F401
except ImportError:
pass
try:
import openjarvis.channels.xmpp_channel # noqa: F401
except ImportError:
pass
try:
import openjarvis.channels.rocketchat_channel # noqa: F401
except ImportError:
pass
try:
import openjarvis.channels.zulip_channel # noqa: F401
except ImportError:
pass
try:
import openjarvis.channels.twitch_channel # noqa: F401
except ImportError:
pass
try:
import openjarvis.channels.nostr_channel # noqa: F401
except ImportError:
pass
__all__ = [
"BaseChannel",
"ChannelHandler",
+1
View File
@@ -26,6 +26,7 @@ class ChannelMessage:
content: str
message_id: str = ""
conversation_id: str = ""
session_id: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
+157
View File
@@ -0,0 +1,157 @@
"""LineChannel — LINE Messaging API adapter via line-bot-sdk."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("line")
class LineChannel(BaseChannel):
"""LINE messaging channel adapter.
Uses the LINE Messaging API via ``line-bot-sdk``.
Parameters
----------
channel_access_token:
LINE channel access token. Falls back to ``LINE_CHANNEL_ACCESS_TOKEN``
env var.
channel_secret:
LINE channel secret. Falls back to ``LINE_CHANNEL_SECRET`` env var.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "line"
def __init__(
self,
channel_access_token: str = "",
*,
channel_secret: str = "",
bus: Optional[EventBus] = None,
) -> None:
self._channel_access_token = channel_access_token or os.environ.get(
"LINE_CHANNEL_ACCESS_TOKEN", ""
)
self._channel_secret = channel_secret or os.environ.get(
"LINE_CHANNEL_SECRET", ""
)
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
if not self._channel_access_token or not self._channel_secret:
logger.warning("No LINE channel_access_token or channel_secret configured")
self._status = ChannelStatus.ERROR
return
try:
import linebot # noqa: F401
except ImportError:
raise ImportError(
"line-bot-sdk not installed. Install with: "
"pip install 'openjarvis[channel-line]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a push message to a LINE user or group.
Parameters
----------
channel:
LINE user ID or group ID to send to.
content:
Text message content.
"""
if not self._channel_access_token:
logger.warning("Cannot send: no LINE credentials configured")
return False
try:
from linebot.v3.messaging import (
ApiClient,
Configuration,
MessagingApi,
PushMessageRequest,
TextMessage,
)
configuration = Configuration(
access_token=self._channel_access_token,
)
with ApiClient(configuration) as api_client:
api = MessagingApi(api_client)
api.push_message(
PushMessageRequest(
to=channel,
messages=[TextMessage(text=content)],
)
)
self._publish_sent(channel, content, conversation_id)
return True
except ImportError:
logger.debug("line-bot-sdk not installed")
return False
except Exception:
logger.debug("LINE send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["line"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["LineChannel"]
+161
View File
@@ -0,0 +1,161 @@
"""MastodonChannel — Mastodon adapter via Mastodon.py."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("mastodon")
class MastodonChannel(BaseChannel):
"""Mastodon messaging channel adapter.
Uses the Mastodon API via ``Mastodon.py``.
Parameters
----------
api_base_url:
Mastodon instance URL (e.g. ``https://mastodon.social``). Falls back
to ``MASTODON_API_BASE_URL`` env var.
access_token:
Mastodon access token. Falls back to ``MASTODON_ACCESS_TOKEN`` env var.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "mastodon"
def __init__(
self,
api_base_url: str = "",
*,
access_token: str = "",
bus: Optional[EventBus] = None,
) -> None:
self._api_base_url = api_base_url or os.environ.get(
"MASTODON_API_BASE_URL", ""
)
self._access_token = access_token or os.environ.get(
"MASTODON_ACCESS_TOKEN", ""
)
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
if not self._api_base_url or not self._access_token:
logger.warning("No Mastodon api_base_url or access_token configured")
self._status = ChannelStatus.ERROR
return
try:
import mastodon # noqa: F401
except ImportError:
raise ImportError(
"Mastodon.py not installed. Install with: "
"pip install 'openjarvis[channel-mastodon]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Post a status or send a direct message on Mastodon.
Parameters
----------
channel:
Visibility level (``public``, ``unlisted``, ``private``,
``direct``) or a username to DM (prefix with ``@``).
content:
Toot / message content.
"""
if not self._api_base_url or not self._access_token:
logger.warning("Cannot send: no Mastodon credentials configured")
return False
try:
from mastodon import Mastodon
client = Mastodon(
access_token=self._access_token,
api_base_url=self._api_base_url,
)
visibility = "public"
if channel in ("public", "unlisted", "private", "direct"):
visibility = channel
elif channel.startswith("@"):
# Direct message — prepend mention
visibility = "direct"
if not content.startswith(channel):
content = f"{channel} {content}"
in_reply_to_id = conversation_id or None
client.status_post(
content,
visibility=visibility,
in_reply_to_id=in_reply_to_id,
)
self._publish_sent(channel, content, conversation_id)
return True
except ImportError:
logger.debug("Mastodon.py not installed")
return False
except Exception:
logger.debug("Mastodon send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["mastodon"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["MastodonChannel"]
@@ -0,0 +1,136 @@
"""MessengerChannel — Facebook Messenger adapter via pymessenger."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("messenger")
class MessengerChannel(BaseChannel):
"""Facebook Messenger channel adapter.
Uses the Messenger Platform Send API via ``pymessenger``.
Parameters
----------
access_token:
Facebook page access token. Falls back to ``MESSENGER_ACCESS_TOKEN``
env var.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "messenger"
def __init__(
self,
access_token: str = "",
*,
bus: Optional[EventBus] = None,
) -> None:
self._access_token = access_token or os.environ.get(
"MESSENGER_ACCESS_TOKEN", ""
)
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
if not self._access_token:
logger.warning("No Messenger access_token configured")
self._status = ChannelStatus.ERROR
return
try:
import pymessenger # noqa: F401
except ImportError:
raise ImportError(
"pymessenger not installed. Install with: "
"pip install 'openjarvis[channel-messenger]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a message to a Messenger user.
Parameters
----------
channel:
Facebook user PSID to send to.
content:
Text message content.
"""
if not self._access_token:
logger.warning("Cannot send: no Messenger credentials configured")
return False
try:
from pymessenger import Bot
bot = Bot(self._access_token)
bot.send_text_message(channel, content)
self._publish_sent(channel, content, conversation_id)
return True
except ImportError:
logger.debug("pymessenger not installed")
return False
except Exception:
logger.debug("Messenger send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["messenger"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["MessengerChannel"]
+167
View File
@@ -0,0 +1,167 @@
"""NostrChannel — Nostr protocol adapter via pynostr."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("nostr")
class NostrChannel(BaseChannel):
"""Nostr decentralized messaging channel adapter.
Uses the Nostr protocol via ``pynostr``.
Parameters
----------
private_key:
Nostr private key (hex or nsec format). Falls back to
``NOSTR_PRIVATE_KEY`` env var.
relays:
Comma-separated list of relay URLs. Falls back to ``NOSTR_RELAYS``
env var. Default: ``wss://relay.damus.io``.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "nostr"
def __init__(
self,
private_key: str = "",
*,
relays: str = "",
bus: Optional[EventBus] = None,
) -> None:
self._private_key = private_key or os.environ.get("NOSTR_PRIVATE_KEY", "")
relays_str = relays or os.environ.get(
"NOSTR_RELAYS", "wss://relay.damus.io"
)
self._relays = [r.strip() for r in relays_str.split(",") if r.strip()]
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
if not self._private_key:
logger.warning("No Nostr private_key configured")
self._status = ChannelStatus.ERROR
return
try:
import pynostr # noqa: F401
except ImportError:
raise ImportError(
"pynostr not installed. Install with: "
"pip install 'openjarvis[channel-nostr]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Publish a Nostr event (kind 1 note or kind 4 DM).
Parameters
----------
channel:
For public notes, this is ignored or used as a tag. For
encrypted DMs, this is the recipient public key (hex or npub).
content:
Note or message content.
"""
if not self._private_key:
logger.warning("Cannot send: no Nostr private_key configured")
return False
try:
from pynostr.event import Event
from pynostr.key import PrivateKey
from pynostr.relay_manager import RelayManager
if self._private_key.startswith("nsec"):
pk = PrivateKey.from_nsec(self._private_key)
else:
pk = PrivateKey(bytes.fromhex(self._private_key))
meta = metadata or {}
kind = meta.get("kind", 1)
event = Event(
content=content,
kind=kind,
pub_key=pk.public_key.hex(),
)
if channel and kind == 4:
# Encrypted DM tag
event.add_tag("p", channel)
pk.sign_event(event)
relay_manager = RelayManager()
for relay_url in self._relays:
relay_manager.add_relay(relay_url)
relay_manager.open_connections()
relay_manager.publish_event(event)
relay_manager.close_connections()
self._publish_sent(channel, content, conversation_id)
return True
except ImportError:
logger.debug("pynostr not installed")
return False
except Exception:
logger.debug("Nostr send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["nostr"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["NostrChannel"]
+172
View File
@@ -0,0 +1,172 @@
"""RedditChannel — Reddit adapter via praw."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("reddit")
class RedditChannel(BaseChannel):
"""Reddit messaging channel adapter.
Uses the Reddit API via ``praw`` (Python Reddit API Wrapper).
Parameters
----------
client_id:
Reddit app client ID. Falls back to ``REDDIT_CLIENT_ID`` env var.
client_secret:
Reddit app client secret. Falls back to ``REDDIT_CLIENT_SECRET`` env var.
username:
Reddit username. Falls back to ``REDDIT_USERNAME`` env var.
password:
Reddit password. Falls back to ``REDDIT_PASSWORD`` env var.
user_agent:
User-agent string for API requests. Falls back to
``REDDIT_USER_AGENT`` env var.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "reddit"
def __init__(
self,
client_id: str = "",
*,
client_secret: str = "",
username: str = "",
password: str = "",
user_agent: str = "",
bus: Optional[EventBus] = None,
) -> None:
self._client_id = client_id or os.environ.get("REDDIT_CLIENT_ID", "")
self._client_secret = client_secret or os.environ.get(
"REDDIT_CLIENT_SECRET", ""
)
self._username = username or os.environ.get("REDDIT_USERNAME", "")
self._password = password or os.environ.get("REDDIT_PASSWORD", "")
self._user_agent = user_agent or os.environ.get(
"REDDIT_USER_AGENT", "openjarvis:v1.0"
)
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
self._reddit: Any = None
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
if not self._client_id or not self._client_secret:
logger.warning("No Reddit client_id or client_secret configured")
self._status = ChannelStatus.ERROR
return
try:
import praw # noqa: F401
except ImportError:
raise ImportError(
"praw not installed. Install with: "
"pip install 'openjarvis[channel-reddit]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._reddit = None
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a message or comment on Reddit.
Parameters
----------
channel:
Subreddit name (without ``r/`` prefix) or a Reddit thing ID to
reply to.
content:
Text content for the submission or comment.
"""
if not self._client_id or not self._client_secret:
logger.warning("Cannot send: no Reddit credentials configured")
return False
try:
import praw
reddit = praw.Reddit(
client_id=self._client_id,
client_secret=self._client_secret,
username=self._username,
password=self._password,
user_agent=self._user_agent,
)
if conversation_id:
# Reply to an existing comment/submission
comment = reddit.comment(conversation_id)
comment.reply(content)
else:
# Submit as a new text post to the subreddit
subreddit = reddit.subreddit(channel)
title = (metadata or {}).get("title", "OpenJarvis Message")
subreddit.submit(title=title, selftext=content)
self._publish_sent(channel, content, conversation_id)
return True
except ImportError:
logger.debug("praw not installed")
return False
except Exception:
logger.debug("Reddit send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["reddit"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["RedditChannel"]
@@ -0,0 +1,169 @@
"""RocketChatChannel — Rocket.Chat adapter via rocketchat_API."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("rocketchat")
class RocketChatChannel(BaseChannel):
"""Rocket.Chat messaging channel adapter.
Uses the Rocket.Chat REST API via ``rocketchat_API``.
Parameters
----------
url:
Rocket.Chat server URL. Falls back to ``ROCKETCHAT_URL`` env var.
user:
Rocket.Chat username. Falls back to ``ROCKETCHAT_USER`` env var.
password:
Rocket.Chat password. Falls back to ``ROCKETCHAT_PASSWORD`` env var.
auth_token:
Rocket.Chat auth token (alternative to password). Falls back to
``ROCKETCHAT_AUTH_TOKEN`` env var.
user_id:
Rocket.Chat user ID (used with auth_token). Falls back to
``ROCKETCHAT_USER_ID`` env var.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "rocketchat"
def __init__(
self,
url: str = "",
*,
user: str = "",
password: str = "",
auth_token: str = "",
user_id: str = "",
bus: Optional[EventBus] = None,
) -> None:
self._url = url or os.environ.get("ROCKETCHAT_URL", "")
self._user = user or os.environ.get("ROCKETCHAT_USER", "")
self._password = password or os.environ.get("ROCKETCHAT_PASSWORD", "")
self._auth_token = auth_token or os.environ.get("ROCKETCHAT_AUTH_TOKEN", "")
self._user_id = user_id or os.environ.get("ROCKETCHAT_USER_ID", "")
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
if not self._url:
logger.warning("No Rocket.Chat URL configured")
self._status = ChannelStatus.ERROR
return
has_password_auth = self._user and self._password
has_token_auth = self._auth_token and self._user_id
if not has_password_auth and not has_token_auth:
logger.warning("No Rocket.Chat credentials configured")
self._status = ChannelStatus.ERROR
return
try:
import rocketchat_API # noqa: F401
except ImportError:
raise ImportError(
"rocketchat_API not installed. Install with: "
"pip install 'openjarvis[channel-rocketchat]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a message to a Rocket.Chat channel or DM.
Parameters
----------
channel:
Rocket.Chat channel name or room ID.
content:
Text message content.
"""
if not self._url:
logger.warning("Cannot send: no Rocket.Chat URL configured")
return False
try:
from rocketchat_API import RocketChat
if self._auth_token and self._user_id:
rocket = RocketChat(
server_url=self._url,
auth_token=self._auth_token,
user_id=self._user_id,
)
else:
rocket = RocketChat(
user=self._user,
password=self._password,
server_url=self._url,
)
rocket.chat_post_message(content, channel=channel)
self._publish_sent(channel, content, conversation_id)
return True
except ImportError:
logger.debug("rocketchat_API not installed")
return False
except Exception:
logger.debug("Rocket.Chat send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["rocketchat"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["RocketChatChannel"]
+167
View File
@@ -0,0 +1,167 @@
"""TwitchChannel — Twitch chat adapter via twitchio."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("twitch")
class TwitchChannel(BaseChannel):
"""Twitch chat messaging channel adapter.
Uses the Twitch IRC/EventSub API via ``twitchio``.
Parameters
----------
access_token:
Twitch OAuth access token. Falls back to ``TWITCH_ACCESS_TOKEN``
env var.
client_id:
Twitch application client ID. Falls back to ``TWITCH_CLIENT_ID``
env var.
nick:
Bot nickname for IRC. Falls back to ``TWITCH_NICK`` env var.
initial_channels:
Comma-separated list of channels to join. Falls back to
``TWITCH_CHANNELS`` env var.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "twitch"
def __init__(
self,
access_token: str = "",
*,
client_id: str = "",
nick: str = "",
initial_channels: str = "",
bus: Optional[EventBus] = None,
) -> None:
self._access_token = access_token or os.environ.get(
"TWITCH_ACCESS_TOKEN", ""
)
self._client_id = client_id or os.environ.get("TWITCH_CLIENT_ID", "")
self._nick = nick or os.environ.get("TWITCH_NICK", "")
self._initial_channels = initial_channels or os.environ.get(
"TWITCH_CHANNELS", ""
)
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
if not self._access_token:
logger.warning("No Twitch access_token configured")
self._status = ChannelStatus.ERROR
return
try:
import twitchio # noqa: F401
except ImportError:
raise ImportError(
"twitchio not installed. Install with: "
"pip install 'openjarvis[channel-twitch]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a chat message to a Twitch channel.
Parameters
----------
channel:
Twitch channel name (without ``#`` prefix).
content:
Chat message content.
Note
----
For send-only usage this uses the Twitch Helix API to send a chat
message. A full interactive bot would use the twitchio event loop.
"""
if not self._access_token:
logger.warning("Cannot send: no Twitch credentials configured")
return False
try:
import httpx
url = "https://api.twitch.tv/helix/chat/messages"
headers = {
"Authorization": f"Bearer {self._access_token}",
"Client-Id": self._client_id,
"Content-Type": "application/json",
}
payload: Dict[str, Any] = {
"broadcaster_id": channel,
"sender_id": self._nick,
"message": content,
}
resp = httpx.post(url, json=payload, headers=headers, timeout=10.0)
if resp.status_code < 300:
self._publish_sent(channel, content, conversation_id)
return True
logger.warning("Twitch API returned status %d", resp.status_code)
return False
except Exception:
logger.debug("Twitch send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["twitch"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["TwitchChannel"]
+147
View File
@@ -0,0 +1,147 @@
"""ViberChannel — Viber adapter via viberbot."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("viber")
class ViberChannel(BaseChannel):
"""Viber messaging channel adapter.
Uses the Viber Bot API via ``viberbot``.
Parameters
----------
auth_token:
Viber bot auth token. Falls back to ``VIBER_AUTH_TOKEN`` env var.
name:
Bot display name. Falls back to ``VIBER_BOT_NAME`` env var.
avatar:
Bot avatar URL. Falls back to ``VIBER_BOT_AVATAR`` env var.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "viber"
def __init__(
self,
auth_token: str = "",
*,
name: str = "",
avatar: str = "",
bus: Optional[EventBus] = None,
) -> None:
self._auth_token = auth_token or os.environ.get("VIBER_AUTH_TOKEN", "")
self._name = name or os.environ.get("VIBER_BOT_NAME", "OpenJarvis")
self._avatar = avatar or os.environ.get("VIBER_BOT_AVATAR", "")
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
if not self._auth_token:
logger.warning("No Viber auth_token configured")
self._status = ChannelStatus.ERROR
return
try:
import viberbot # noqa: F401
except ImportError:
raise ImportError(
"viberbot not installed. Install with: "
"pip install 'openjarvis[channel-viber]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a message to a Viber user.
Parameters
----------
channel:
Viber user ID to send to.
content:
Text message content.
"""
if not self._auth_token:
logger.warning("Cannot send: no Viber credentials configured")
return False
try:
from viberbot import Api, BotConfiguration
from viberbot.api.messages.text_message import TextMessage
config = BotConfiguration(
name=self._name,
avatar=self._avatar,
auth_token=self._auth_token,
)
api = Api(config)
api.send_messages(channel, [TextMessage(text=content)])
self._publish_sent(channel, content, conversation_id)
return True
except ImportError:
logger.debug("viberbot not installed")
return False
except Exception:
logger.debug("Viber send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["viber"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["ViberChannel"]
+154
View File
@@ -0,0 +1,154 @@
"""XMPPChannel — XMPP/Jabber adapter via slixmpp."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("xmpp")
class XMPPChannel(BaseChannel):
"""XMPP (Jabber) messaging channel adapter.
Uses the XMPP protocol via ``slixmpp``.
Parameters
----------
jid:
XMPP JID (e.g. ``bot@example.com``). Falls back to ``XMPP_JID``
env var.
password:
XMPP account password. Falls back to ``XMPP_PASSWORD`` env var.
server:
Optional XMPP server hostname override. Falls back to
``XMPP_SERVER`` env var.
port:
XMPP server port (default 5222). Falls back to ``XMPP_PORT`` env var.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "xmpp"
def __init__(
self,
jid: str = "",
*,
password: str = "",
server: str = "",
port: int = 5222,
bus: Optional[EventBus] = None,
) -> None:
self._jid = jid or os.environ.get("XMPP_JID", "")
self._password = password or os.environ.get("XMPP_PASSWORD", "")
self._server = server or os.environ.get("XMPP_SERVER", "")
self._port = int(os.environ.get("XMPP_PORT", str(port)))
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
if not self._jid or not self._password:
logger.warning("No XMPP jid or password configured")
self._status = ChannelStatus.ERROR
return
try:
import slixmpp # noqa: F401
except ImportError:
raise ImportError(
"slixmpp not installed. Install with: "
"pip install 'openjarvis[channel-xmpp]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send an XMPP message.
Parameters
----------
channel:
Recipient JID (user or MUC room).
content:
Text message content.
"""
if not self._jid or not self._password:
logger.warning("Cannot send: no XMPP credentials configured")
return False
try:
import slixmpp
xmpp = slixmpp.ClientXMPP(self._jid, self._password)
msg_type = (metadata or {}).get("type", "chat")
msg = xmpp.make_message(
mto=channel,
mbody=content,
mtype=msg_type,
)
msg.send()
self._publish_sent(channel, content, conversation_id)
return True
except ImportError:
logger.debug("slixmpp not installed")
return False
except Exception:
logger.debug("XMPP send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["xmpp"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["XMPPChannel"]
+170
View File
@@ -0,0 +1,170 @@
"""ZulipChannel — Zulip adapter via zulip Python bindings."""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("zulip")
class ZulipChannel(BaseChannel):
"""Zulip messaging channel adapter.
Uses the Zulip API via the official ``zulip`` Python package.
Parameters
----------
email:
Zulip bot email address. Falls back to ``ZULIP_EMAIL`` env var.
api_key:
Zulip bot API key. Falls back to ``ZULIP_API_KEY`` env var.
site:
Zulip server URL (e.g. ``https://yourorg.zulipchat.com``).
Falls back to ``ZULIP_SITE`` env var.
zuliprc:
Path to a ``~/.zuliprc`` config file. Falls back to ``ZULIP_RC``
env var. If provided, ``email``/``api_key``/``site`` are ignored.
bus:
Optional event bus for publishing channel events.
"""
channel_id = "zulip"
def __init__(
self,
email: str = "",
*,
api_key: str = "",
site: str = "",
zuliprc: str = "",
bus: Optional[EventBus] = None,
) -> None:
self._email = email or os.environ.get("ZULIP_EMAIL", "")
self._api_key = api_key or os.environ.get("ZULIP_API_KEY", "")
self._site = site or os.environ.get("ZULIP_SITE", "")
self._zuliprc = zuliprc or os.environ.get("ZULIP_RC", "")
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Validate credentials and mark as connected."""
has_explicit_creds = self._email and self._api_key and self._site
if not has_explicit_creds and not self._zuliprc:
logger.warning("No Zulip credentials or zuliprc configured")
self._status = ChannelStatus.ERROR
return
try:
import zulip # noqa: F401
except ImportError:
raise ImportError(
"zulip not installed. Install with: "
"pip install 'openjarvis[channel-zulip]'"
)
self._status = ChannelStatus.CONNECTED
def disconnect(self) -> None:
"""Mark as disconnected."""
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a message to a Zulip stream or user.
Parameters
----------
channel:
Stream name for stream messages, or email address for direct
messages.
content:
Message content (Markdown supported).
"""
try:
import zulip
if self._zuliprc:
client = zulip.Client(config_file=self._zuliprc)
else:
client = zulip.Client(
email=self._email,
api_key=self._api_key,
site=self._site,
)
meta = metadata or {}
msg_type = meta.get("type", "stream")
topic = meta.get("topic", "OpenJarvis")
request: Dict[str, Any] = {
"type": msg_type,
"content": content,
}
if msg_type == "stream":
request["to"] = channel
request["topic"] = topic
else:
# Direct / private message
request["to"] = [channel]
result = client.send_message(request)
if result.get("result") == "success":
self._publish_sent(channel, content, conversation_id)
return True
logger.warning("Zulip API returned: %s", result.get("msg", ""))
return False
except ImportError:
logger.debug("zulip not installed")
return False
except Exception:
logger.debug("Zulip send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["zulip"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["ZulipChannel"]
+17
View File
@@ -5,16 +5,23 @@ from __future__ import annotations
import click
import openjarvis
from openjarvis.cli.add_cmd import add
from openjarvis.cli.agent_cmd import agent
from openjarvis.cli.ask import ask
from openjarvis.cli.bench_cmd import bench
from openjarvis.cli.channel_cmd import channel
from openjarvis.cli.chat_cmd import chat
from openjarvis.cli.daemon_cmd import restart, start, status, stop
from openjarvis.cli.doctor_cmd import doctor
from openjarvis.cli.init_cmd import init
from openjarvis.cli.memory_cmd import memory
from openjarvis.cli.model import model
from openjarvis.cli.scheduler_cmd import scheduler
from openjarvis.cli.serve import serve
from openjarvis.cli.skill_cmd import skill
from openjarvis.cli.telemetry_cmd import telemetry
from openjarvis.cli.vault_cmd import vault
from openjarvis.cli.workflow_cmd import workflow
@click.group(help="OpenJarvis — modular AI assistant backend")
@@ -25,6 +32,7 @@ def cli() -> None:
cli.add_command(init, "init")
cli.add_command(ask, "ask")
cli.add_command(chat, "chat")
cli.add_command(serve, "serve")
cli.add_command(model, "model")
cli.add_command(memory, "memory")
@@ -33,6 +41,15 @@ cli.add_command(bench, "bench")
cli.add_command(channel, "channel")
cli.add_command(scheduler, "scheduler")
cli.add_command(doctor, "doctor")
cli.add_command(agent, "agent")
cli.add_command(workflow, "workflow")
cli.add_command(skill, "skill")
cli.add_command(start, "start")
cli.add_command(stop, "stop")
cli.add_command(restart, "restart")
cli.add_command(status, "status")
cli.add_command(vault, "vault")
cli.add_command(add, "add")
def main() -> None:
+136
View File
@@ -0,0 +1,136 @@
"""``jarvis add`` — quick MCP server setup."""
from __future__ import annotations
import json
import sys
import click
from rich.console import Console
from openjarvis.core.config import DEFAULT_CONFIG_DIR
_MCP_CONFIG_DIR = DEFAULT_CONFIG_DIR / "mcp"
# Known MCP server templates
_MCP_TEMPLATES = {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env_key": "GITHUB_PERSONAL_ACCESS_TOKEN",
"description": "GitHub API (repos, issues, PRs)",
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env_key": None,
"description": "Local filesystem operations",
},
"slack": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env_key": "SLACK_BOT_TOKEN",
"description": "Slack workspace integration",
},
"postgres": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"env_key": "POSTGRES_CONNECTION_STRING",
"description": "PostgreSQL database",
},
"brave-search": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env_key": "BRAVE_API_KEY",
"description": "Brave web search",
},
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"env_key": None,
"description": "Knowledge graph memory",
},
"puppeteer": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
"env_key": None,
"description": "Browser automation via Puppeteer",
},
"google-maps": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-google-maps"],
"env_key": "GOOGLE_MAPS_API_KEY",
"description": "Google Maps API",
},
}
@click.command()
@click.argument("server_name")
@click.option("--key", default=None, help="API key or token for the server.")
@click.option(
"--args", "extra_args", default=None,
help="Additional arguments (comma-separated).",
)
def add(server_name: str, key: str | None, extra_args: str | None) -> None:
"""Add an MCP server configuration.
Quick setup for common MCP servers:
jarvis add github --key TOKEN
jarvis add filesystem
jarvis add slack --key TOKEN
Known servers: github, filesystem, slack, postgres, brave-search,
memory, puppeteer, google-maps
"""
console = Console(stderr=True)
template = _MCP_TEMPLATES.get(server_name)
if template is None:
console.print(f"[red]Unknown MCP server: {server_name}[/red]")
console.print("[dim]Known servers:[/dim]")
for name, tmpl in _MCP_TEMPLATES.items():
console.print(f" [cyan]{name}[/cyan] — {tmpl['description']}")
sys.exit(1)
# Build server config
config = {
"command": template["command"],
"args": list(template["args"]),
}
# Add extra args
if extra_args:
config["args"].extend(a.strip() for a in extra_args.split(","))
# Handle API key
env = {}
env_key = template["env_key"]
if env_key:
if key:
env[env_key] = key
else:
console.print(
f"[yellow]Tip: Pass --key to set {env_key},"
" or set it as an environment variable.[/yellow]"
)
if env:
config["env"] = env
# Save to MCP config dir
_MCP_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file = _MCP_CONFIG_DIR / f"{server_name}.json"
config_file.write_text(json.dumps(config, indent=2))
console.print(
f"[green]Added MCP server: {server_name}[/green]\n"
f" Config: {config_file}\n"
f" Description: {template['description']}"
)
if env_key and not key:
console.print(f" [dim]Set {env_key} env var or re-run with --key[/dim]")
__all__ = ["add"]
+78
View File
@@ -0,0 +1,78 @@
"""``jarvis agent`` — agent lifecycle management."""
from __future__ import annotations
import click
from rich.console import Console
from rich.table import Table
@click.group()
def agent() -> None:
"""Manage agents — list, spawn, kill, info."""
@agent.command("list")
def list_agents() -> None:
"""List available agent types and running agents."""
console = Console(stderr=True)
# List registered agent types
try:
import openjarvis.agents # noqa: F401 -- trigger registration
from openjarvis.core.registry import AgentRegistry
table = Table(title="Registered Agent Types")
table.add_column("Key", style="cyan")
table.add_column("Class", style="green")
table.add_column("Accepts Tools", style="yellow")
for key in sorted(AgentRegistry.keys()):
cls = AgentRegistry.get(key)
accepts = "Yes" if getattr(cls, "accepts_tools", False) else "No"
table.add_row(key, cls.__name__, accepts)
console.print(table)
except Exception as exc:
console.print(f"[red]Error listing agents: {exc}[/red]")
# List running agents from agent_tools if available
try:
from openjarvis.tools.agent_tools import _SPAWNED_AGENTS
if _SPAWNED_AGENTS:
table2 = Table(title="Running Agents")
table2.add_column("ID", style="cyan")
table2.add_column("Type", style="green")
table2.add_column("Status", style="yellow")
for aid, info in _SPAWNED_AGENTS.items():
table2.add_row(aid, info.get("agent_type", ""), info.get("status", ""))
console.print(table2)
except ImportError:
pass
@agent.command()
@click.argument("agent_key")
def info(agent_key: str) -> None:
"""Show detailed info about an agent type."""
console = Console(stderr=True)
try:
import openjarvis.agents # noqa: F401 -- trigger registration
from openjarvis.core.registry import AgentRegistry
if not AgentRegistry.contains(agent_key):
console.print(f"[red]Unknown agent: {agent_key}[/red]")
return
cls = AgentRegistry.get(agent_key)
console.print(f"[bold]{agent_key}[/bold] — {cls.__name__}")
console.print(f" Module: {cls.__module__}")
console.print(f" Accepts tools: {getattr(cls, 'accepts_tools', False)}")
if cls.__doc__:
console.print(f" Description: {cls.__doc__.strip().splitlines()[0]}")
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
__all__ = ["agent"]
+205
View File
@@ -0,0 +1,205 @@
"""``jarvis chat`` — interactive multi-turn chat REPL."""
from __future__ import annotations
import sys
from typing import List, Optional
import click
from rich.console import Console
from rich.markdown import Markdown
from openjarvis.core.config import load_config
from openjarvis.core.types import Message, Role
def _read_input(prompt: str = "You> ") -> Optional[str]:
"""Read user input with graceful EOF handling."""
try:
return input(prompt)
except (EOFError, KeyboardInterrupt):
return None
@click.command()
@click.option("-e", "--engine", "engine_key", default=None, help="Engine backend.")
@click.option("-m", "--model", "model_name", default=None, help="Model to use.")
@click.option("-a", "--agent", "agent_name", default=None, help="Agent type.")
@click.option("--tools", default=None, help="Comma-separated tool names.")
@click.option("--system", "system_prompt", default=None, help="Custom system prompt.")
def chat(
engine_key: str | None,
model_name: str | None,
agent_name: str | None,
tools: str | None,
system_prompt: str | None,
) -> None:
"""Start an interactive multi-turn chat session.
Commands during chat:
/quit, /exit — end session
/clear — clear conversation history
/model — show current model
/help — show available commands
/history — show conversation history
"""
console = Console(stderr=True)
config = load_config()
# Resolve engine
from openjarvis.engine import get_engine
from openjarvis.intelligence import register_builtin_models
register_builtin_models()
resolved = get_engine(config, engine_key)
if resolved is None:
console.print("[red]No inference engine available.[/red]")
sys.exit(1)
engine_name, engine = resolved
model = model_name or config.intelligence.default_model
if not model:
from openjarvis.engine import discover_engines, discover_models
all_engines = discover_engines(config)
all_models = discover_models(all_engines)
engine_models = all_models.get(engine_name, [])
if engine_models:
model = engine_models[0]
else:
console.print("[red]No model available.[/red]")
sys.exit(1)
# Resolve agent (optional)
agent = None
agent_key = agent_name or config.agent.default_agent
if agent_key and agent_key != "none":
try:
import openjarvis.agents # noqa: F401 — trigger registration
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
if AgentRegistry.contains(agent_key):
agent_cls = AgentRegistry.get(agent_key)
kwargs: dict = {"bus": EventBus()}
if getattr(agent_cls, "accepts_tools", False) and tools:
import openjarvis.tools # noqa: F401 — trigger registration
from openjarvis.core.registry import ToolRegistry
from openjarvis.tools._stubs import BaseTool
tool_instances = []
for tname in tools.split(","):
tname = tname.strip()
if ToolRegistry.contains(tname):
tcls = ToolRegistry.get(tname)
if isinstance(tcls, type) and issubclass(
tcls, BaseTool
):
tool_instances.append(tcls())
elif isinstance(tcls, BaseTool):
tool_instances.append(tcls)
if tool_instances:
kwargs["tools"] = tool_instances
kwargs["max_turns"] = config.agent.max_turns
agent = agent_cls(engine, model, **kwargs)
except Exception as exc:
console.print(f"[yellow]Agent '{agent_key}' failed: {exc}[/yellow]")
# Print banner
console.print(
f"[green bold]OpenJarvis Chat[/green bold]\n"
f" Engine: [cyan]{engine_name}[/cyan] Model: [cyan]{model}[/cyan]"
f" Agent: [cyan]{agent_key or 'direct'}[/cyan]\n"
f" Type /help for commands, /quit to exit.\n"
)
# Conversation state
history: List[Message] = []
if system_prompt:
history.append(Message(role=Role.SYSTEM, content=system_prompt))
# REPL loop
while True:
user_input = _read_input()
if user_input is None:
console.print("\n[dim]Goodbye![/dim]")
break
user_input = user_input.strip()
if not user_input:
continue
# Handle slash commands
cmd = user_input.lower()
if cmd in ("/quit", "/exit", "/q"):
console.print("[dim]Goodbye![/dim]")
break
elif cmd == "/clear":
history = []
if system_prompt:
history.append(Message(role=Role.SYSTEM, content=system_prompt))
console.print("[dim]History cleared.[/dim]")
continue
elif cmd == "/model":
console.print(
f"Model: [cyan]{model}[/cyan] "
f"Engine: [cyan]{engine_name}[/cyan]"
)
continue
elif cmd == "/help":
console.print(
"[bold]Commands:[/bold]\n"
" /quit, /exit — end session\n"
" /clear — clear conversation\n"
" /model — show model info\n"
" /history — show conversation\n"
" /help — this message"
)
continue
elif cmd == "/history":
if not history:
console.print("[dim]No history yet.[/dim]")
else:
for msg in history:
role_str = msg.role if isinstance(msg.role, str) else msg.role.value
role = role_str.upper()
console.print(
f"[bold]{role}:[/bold] {msg.content[:200]}"
)
continue
# Add user message
history.append(Message(role=Role.USER, content=user_input))
# Generate response
try:
if agent is not None:
response = agent.run(user_input)
content = (
response.content
if hasattr(response, "content")
else str(response)
)
else:
result = engine.generate(history, model=model)
content = (
result.get("content", "")
if isinstance(result, dict)
else str(result)
)
history.append(Message(role=Role.ASSISTANT, content=content))
console.print()
console.print(Markdown(content))
console.print()
except KeyboardInterrupt:
console.print("\n[dim]Generation interrupted.[/dim]")
except Exception as exc:
console.print(f"\n[red]Error: {exc}[/red]\n")
__all__ = ["chat"]
+175
View File
@@ -0,0 +1,175 @@
"""``jarvis start|stop|restart|status`` — daemon management commands."""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
import click
from rich.console import Console
from openjarvis.core.config import DEFAULT_CONFIG_DIR, load_config
_PID_FILE = DEFAULT_CONFIG_DIR / "server.pid"
_LOG_FILE = DEFAULT_CONFIG_DIR / "server.log"
def _read_pid() -> int | None:
"""Read PID from pid file, return None if not found or stale."""
if not _PID_FILE.exists():
return None
try:
pid = int(_PID_FILE.read_text().strip())
# Check if process is still running
os.kill(pid, 0)
return pid
except (ValueError, OSError):
_PID_FILE.unlink(missing_ok=True)
return None
def _write_pid(pid: int) -> None:
"""Write PID to pid file."""
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_PID_FILE.write_text(str(pid))
@click.group()
def daemon() -> None:
"""Manage the OpenJarvis server daemon."""
@daemon.command()
@click.option("--host", default=None, help="Bind address.")
@click.option("--port", default=None, type=int, help="Port number.")
@click.option("-e", "--engine", "engine_key", default=None, help="Engine backend.")
@click.option("-m", "--model", "model_name", default=None, help="Default model.")
@click.option("-a", "--agent", "agent_name", default=None, help="Agent type.")
def start(
host: str | None,
port: int | None,
engine_key: str | None,
model_name: str | None,
agent_name: str | None,
) -> None:
"""Start the OpenJarvis server as a background daemon."""
console = Console(stderr=True)
existing = _read_pid()
if existing is not None:
console.print(f"[yellow]Server already running (PID {existing}).[/yellow]")
console.print("Use 'jarvis stop' to stop it first, or 'jarvis restart'.")
sys.exit(1)
config = load_config()
bind_host = host or config.server.host
bind_port = port or config.server.port
# Build command to run jarvis serve
cmd = [sys.executable, "-m", "openjarvis.cli", "serve"]
if host:
cmd.extend(["--host", host])
if port:
cmd.extend(["--port", str(port)])
if engine_key:
cmd.extend(["--engine", engine_key])
if model_name:
cmd.extend(["--model", model_name])
if agent_name:
cmd.extend(["--agent", agent_name])
# Start as background process
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
log_fh = open(_LOG_FILE, "a") # noqa: SIM115
proc = subprocess.Popen(
cmd,
stdout=log_fh,
stderr=log_fh,
start_new_session=True,
)
_write_pid(proc.pid)
console.print(
f"[green]OpenJarvis server started[/green] (PID {proc.pid})\n"
f" URL: http://{bind_host}:{bind_port}\n"
f" Log: {_LOG_FILE}"
)
@daemon.command()
def stop() -> None:
"""Stop the running OpenJarvis server daemon."""
console = Console(stderr=True)
pid = _read_pid()
if pid is None:
console.print("[yellow]No running server found.[/yellow]")
sys.exit(1)
try:
os.kill(pid, signal.SIGTERM)
# Wait up to 10 seconds for graceful shutdown
for _ in range(20):
time.sleep(0.5)
try:
os.kill(pid, 0)
except OSError:
break
else:
# Force kill if still running
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
except OSError:
pass
_PID_FILE.unlink(missing_ok=True)
console.print(f"[green]Server stopped[/green] (PID {pid}).")
@daemon.command()
@click.pass_context
def restart(ctx: click.Context) -> None:
"""Restart the OpenJarvis server daemon."""
console = Console(stderr=True)
pid = _read_pid()
if pid is not None:
console.print(f"Stopping server (PID {pid})...")
ctx.invoke(stop)
ctx.invoke(start)
@daemon.command()
def status() -> None:
"""Show status of the OpenJarvis server daemon."""
console = Console(stderr=True)
pid = _read_pid()
if pid is None:
console.print("[yellow]Server is not running.[/yellow]")
return
# Get process info
uptime_info = ""
try:
import psutil
proc = psutil.Process(pid)
uptime = time.time() - proc.create_time()
hours, remainder = divmod(int(uptime), 3600)
minutes, seconds = divmod(remainder, 60)
uptime_info = f"\n Uptime: {hours}h {minutes}m {seconds}s"
except (ImportError, Exception):
pass
config = load_config()
console.print(
f"[green]Server is running[/green] (PID {pid}){uptime_info}\n"
f" URL: http://{config.server.host}:{config.server.port}\n"
f" Log: {_LOG_FILE}"
)
__all__ = ["daemon", "start", "stop", "restart", "status"]
+181
View File
@@ -0,0 +1,181 @@
"""TUI dashboard — terminal-based system monitoring via textual."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
class DashboardApp:
"""Terminal dashboard for OpenJarvis monitoring.
Panels:
- System status (engine health, model, memory backend)
- Live EventBus event stream
- Telemetry metrics (throughput, latency, energy)
- Agent activity (current runs, tool calls)
- Session list (active sessions, last activity)
Requires: ``pip install openjarvis[dashboard]``
"""
def __init__(self, config: Optional[Any] = None) -> None:
self._config = config
self._events: List[Dict[str, Any]] = []
@staticmethod
def available() -> bool:
"""Check if textual is available."""
try:
import textual # noqa: F401
return True
except ImportError:
return False
def run(self) -> None:
"""Launch the TUI dashboard."""
try:
from textual.app import App, ComposeResult # noqa: F401
from textual.containers import Container, Horizontal, Vertical # noqa: F401
from textual.reactive import reactive # noqa: F401
from textual.widgets import ( # noqa: F401
DataTable,
Footer,
Header,
Log,
Static,
)
except ImportError:
raise ImportError(
"TUI dashboard requires 'textual'. "
"Install with: pip install openjarvis[dashboard]"
)
class JarvisDashboard(App):
"""OpenJarvis TUI Dashboard."""
TITLE = "OpenJarvis Dashboard"
CSS_PATH = None
CSS = """
Screen {
layout: grid;
grid-size: 2 2;
grid-gutter: 1;
}
.panel {
border: solid green;
padding: 1;
}
#status-panel { row-span: 1; }
#events-panel { row-span: 1; }
#telemetry-panel { row-span: 1; }
#agent-panel { row-span: 1; }
"""
def compose(self) -> ComposeResult:
yield Header()
yield Static(
"System Status\n"
"─────────────\n"
"Engine: checking...\n"
"Model: checking...\n"
"Memory: checking...",
id="status-panel",
classes="panel",
)
yield Log(
id="events-panel", classes="panel",
)
yield Static(
"Telemetry\n"
"─────────\n"
"Throughput: --\n"
"Latency: --\n"
"Energy: --",
id="telemetry-panel",
classes="panel",
)
yield Static(
"Agent Activity\n"
"──────────────\n"
"No active agents.",
id="agent-panel",
classes="panel",
)
yield Footer()
def on_mount(self) -> None:
events_log = self.query_one("#events-panel", Log)
events_log.write_line("Event stream started...")
# Try to connect to event bus
try:
from openjarvis.core.events import get_event_bus
bus = get_event_bus()
def _on_event(event: Any) -> None:
try:
events_log.write_line(
f"[{event.event_type.value}] {event.data}"
)
except Exception:
pass
from openjarvis.core.events import EventType
for et in EventType:
bus.subscribe(et, _on_event)
except Exception:
events_log.write_line("Could not connect to event bus.")
# Update status
self._update_status()
def _update_status(self) -> None:
status = self.query_one("#status-panel", Static)
lines = ["System Status", "─────────────"]
try:
from openjarvis.core.config import load_config
config = load_config()
lines.append(
f"Engine: {config.engine.default}"
)
model = (
config.intelligence.default_model
or 'auto'
)
lines.append(f"Model: {model}")
backend = (
config.tools.storage.default_backend
)
lines.append(f"Memory: {backend}")
sec = (
'enabled'
if config.security.enabled
else 'disabled'
)
lines.append(f"Security: {sec}")
tel = (
'enabled'
if config.telemetry.enabled
else 'disabled'
)
lines.append(f"Telemetry: {tel}")
except Exception:
lines.append("Config: not loaded")
status.update("\n".join(lines))
app = JarvisDashboard()
app.run()
def launch_dashboard(config: Optional[Any] = None) -> None:
"""Convenience function to launch the dashboard."""
app = DashboardApp(config=config)
if not app.available():
raise ImportError(
"TUI dashboard requires 'textual'. "
"Install with: pip install openjarvis[dashboard]"
)
app.run()
__all__ = ["DashboardApp", "launch_dashboard"]
+13
View File
@@ -92,6 +92,19 @@ def _next_steps_text(engine: str) -> str:
"\n"
" Run `jarvis doctor` to verify your setup."
),
"lmstudio": (
"Next steps:\n"
"\n"
" 1. Download LM Studio:\n"
" https://lmstudio.ai\n"
"\n"
" 2. Load a model and start the local server (port 1234)\n"
"\n"
" 3. Try it out:\n"
" jarvis ask \"Hello\"\n"
"\n"
" Run `jarvis doctor` to verify your setup."
),
}
return steps.get(engine, steps["ollama"])
+2 -3
View File
@@ -117,8 +117,7 @@ def serve(
agent_kwargs = {"bus": bus}
# Load tools for agents that support them
tools_agents = ("orchestrator", "react", "openhands")
if agent_key in tools_agents:
if getattr(agent_cls, "accepts_tools", False):
import openjarvis.tools # noqa: F401 # trigger registration
from openjarvis.core.registry import ToolRegistry
from openjarvis.tools._stubs import BaseTool
@@ -133,7 +132,7 @@ def serve(
if tools:
agent_kwargs["tools"] = tools
if agent_key in ("orchestrator", "react", "openhands"):
if getattr(agent_cls, "accepts_tools", False):
agent_kwargs["max_turns"] = config.agent.max_turns
agent = agent_cls(engine, model_name, **agent_kwargs)
+75
View File
@@ -0,0 +1,75 @@
"""``jarvis skill`` — skill management commands."""
from __future__ import annotations
import click
from rich.console import Console
from rich.table import Table
@click.group()
def skill() -> None:
"""Manage skills — list, install, remove."""
@skill.command("list")
def list_skills() -> None:
"""List installed skills."""
console = Console(stderr=True)
try:
from openjarvis.core.registry import SkillRegistry
keys = sorted(SkillRegistry.keys())
if not keys:
console.print("[dim]No skills installed.[/dim]")
return
table = Table(title="Installed Skills")
table.add_column("Name", style="cyan")
table.add_column("Description", style="green")
for key in keys:
skill_cls = SkillRegistry.get(key)
desc = ""
if hasattr(skill_cls, "manifest"):
m = skill_cls.manifest if not callable(skill_cls.manifest) else None
if m and hasattr(m, "description"):
desc = m.description[:60]
table.add_row(key, desc)
console.print(table)
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
@skill.command()
@click.argument("skill_name")
def install(skill_name: str) -> None:
"""Install a skill from the bundled library."""
console = Console(stderr=True)
console.print(f"[yellow]Installing skill: {skill_name}[/yellow]")
# Skills are discovered from TOML files — point user to the right place
console.print(
f"[dim]Place skill TOML file in ~/.openjarvis/skills/{skill_name}.toml[/dim]"
)
@skill.command()
@click.argument("skill_name")
def remove(skill_name: str) -> None:
"""Remove an installed skill."""
console = Console(stderr=True)
console.print(f"[yellow]Removing skill: {skill_name}[/yellow]")
console.print("[dim]Skill removal not yet implemented.[/dim]")
@skill.command()
@click.argument("query", default="")
def search(query: str) -> None:
"""Search for available skills."""
console = Console(stderr=True)
if not query:
console.print("[dim]Provide a search query.[/dim]")
return
console.print(f"[dim]Searching for skills matching '{query}'...[/dim]")
console.print("[dim]Skill search not yet implemented.[/dim]")
__all__ = ["skill"]
+139
View File
@@ -0,0 +1,139 @@
"""``jarvis vault`` — encrypted credential store."""
from __future__ import annotations
import json
import sys
import click
from rich.console import Console
from rich.table import Table
from openjarvis.core.config import DEFAULT_CONFIG_DIR
_VAULT_FILE = DEFAULT_CONFIG_DIR / "vault.enc"
_VAULT_KEY_FILE = DEFAULT_CONFIG_DIR / ".vault_key"
def _get_or_create_key() -> bytes:
"""Get or create a Fernet encryption key."""
try:
from cryptography.fernet import Fernet
except ImportError:
raise ImportError(
"cryptography not installed. Install with: "
"pip install 'openjarvis[security-signing]'"
)
if _VAULT_KEY_FILE.exists():
return _VAULT_KEY_FILE.read_bytes().strip()
key = Fernet.generate_key()
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_VAULT_KEY_FILE.write_bytes(key)
_VAULT_KEY_FILE.chmod(0o600)
return key
def _load_vault() -> dict:
"""Load and decrypt vault contents."""
if not _VAULT_FILE.exists():
return {}
try:
from cryptography.fernet import Fernet
key = _get_or_create_key()
f = Fernet(key)
encrypted = _VAULT_FILE.read_bytes()
decrypted = f.decrypt(encrypted)
return json.loads(decrypted.decode())
except Exception:
return {}
def _save_vault(data: dict) -> None:
"""Encrypt and save vault contents."""
from cryptography.fernet import Fernet
key = _get_or_create_key()
f = Fernet(key)
plaintext = json.dumps(data).encode()
encrypted = f.encrypt(plaintext)
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_VAULT_FILE.write_bytes(encrypted)
_VAULT_FILE.chmod(0o600)
@click.group()
def vault() -> None:
"""Manage encrypted credentials."""
@vault.command("set")
@click.argument("key")
@click.argument("value")
def vault_set(key: str, value: str) -> None:
"""Store a credential in the vault."""
console = Console(stderr=True)
try:
data = _load_vault()
data[key] = value
_save_vault(data)
console.print(f"[green]Stored credential: {key}[/green]")
except ImportError as exc:
console.print(f"[red]{exc}[/red]")
sys.exit(1)
@vault.command("get")
@click.argument("key")
def vault_get(key: str) -> None:
"""Retrieve a credential from the vault."""
console = Console(stderr=True)
try:
data = _load_vault()
if key in data:
console.print(data[key])
else:
console.print(f"[yellow]Key not found: {key}[/yellow]")
except ImportError as exc:
console.print(f"[red]{exc}[/red]")
sys.exit(1)
@vault.command("list")
def vault_list() -> None:
"""List all stored credential keys."""
console = Console(stderr=True)
try:
data = _load_vault()
if not data:
console.print("[dim]Vault is empty.[/dim]")
return
table = Table(title="Vault Keys")
table.add_column("Key", style="cyan")
table.add_column("Value Preview", style="dim")
for k, v in sorted(data.items()):
preview = v[:4] + "****" if len(v) > 4 else "****"
table.add_row(k, preview)
console.print(table)
except ImportError as exc:
console.print(f"[red]{exc}[/red]")
@vault.command("remove")
@click.argument("key")
def vault_remove(key: str) -> None:
"""Remove a credential from the vault."""
console = Console(stderr=True)
try:
data = _load_vault()
if key in data:
del data[key]
_save_vault(data)
console.print(f"[green]Removed: {key}[/green]")
else:
console.print(f"[yellow]Key not found: {key}[/yellow]")
except ImportError as exc:
console.print(f"[red]{exc}[/red]")
__all__ = ["vault"]
+71
View File
@@ -0,0 +1,71 @@
"""``jarvis workflow`` — workflow management commands."""
from __future__ import annotations
import click
from rich.console import Console
from rich.table import Table
@click.group()
def workflow() -> None:
"""Manage workflows — list, run, status."""
@workflow.command("list")
def list_workflows() -> None:
"""List available workflow definitions."""
console = Console(stderr=True)
try:
from openjarvis.workflow.loader import discover_workflows
workflows = discover_workflows()
if not workflows:
console.print("[dim]No workflows found.[/dim]")
return
table = Table(title="Workflows")
table.add_column("Name", style="cyan")
table.add_column("Nodes", style="green")
for name, wf in workflows.items():
table.add_row(name, str(len(wf.nodes) if hasattr(wf, "nodes") else "?"))
console.print(table)
except ImportError:
console.print("[dim]No workflows found. Define workflows in TOML files.[/dim]")
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
@workflow.command()
@click.argument("workflow_name")
@click.option("--input", "input_text", default=None, help="Input text for workflow.")
def run(workflow_name: str, input_text: str | None) -> None:
"""Run a workflow by name."""
console = Console(stderr=True)
console.print(f"[yellow]Running workflow: {workflow_name}[/yellow]")
try:
from openjarvis.workflow.loader import discover_workflows
workflows = discover_workflows()
if workflow_name not in workflows:
console.print(f"[red]Workflow '{workflow_name}' not found.[/red]")
return
console.print(f"[green]Workflow '{workflow_name}' started.[/green]")
# Full execution would need a JarvisSystem — just report for now
console.print(
"[dim]Note: Full workflow execution"
" requires a running system.[/dim]"
)
except ImportError:
console.print("[red]Workflow system not available.[/red]")
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
@workflow.command()
def status() -> None:
"""Show status of running workflows."""
console = Console(stderr=True)
console.print("[dim]No workflows currently running.[/dim]")
__all__ = ["workflow"]
+100 -2
View File
@@ -250,6 +250,13 @@ class MLXEngineConfig:
host: str = "http://localhost:8080"
@dataclass(slots=True)
class LMStudioEngineConfig:
"""Per-engine config for LM Studio."""
host: str = "http://localhost:1234"
@dataclass
class EngineConfig:
"""Inference engine settings with nested per-engine configs."""
@@ -260,6 +267,7 @@ class EngineConfig:
sglang: SGLangEngineConfig = field(default_factory=SGLangEngineConfig)
llamacpp: LlamaCppEngineConfig = field(default_factory=LlamaCppEngineConfig)
mlx: MLXEngineConfig = field(default_factory=MLXEngineConfig)
lmstudio: LMStudioEngineConfig = field(default_factory=LMStudioEngineConfig)
# Backward-compat properties for old flat attribute names
@property
@@ -316,6 +324,15 @@ class EngineConfig:
def mlx_host(self, value: str) -> None:
self.mlx.host = value
@property
def lmstudio_host(self) -> str:
"""Deprecated: use ``engine.lmstudio.host``."""
return self.lmstudio.host
@lmstudio_host.setter
def lmstudio_host(self, value: str) -> None:
self.lmstudio.host = value
@dataclass(slots=True)
class IntelligenceConfig:
@@ -473,12 +490,23 @@ class MCPConfig:
servers: str = "" # JSON list of MCP server configs
@dataclass(slots=True)
class BrowserConfig:
"""Browser automation settings (Playwright)."""
headless: bool = True
timeout_ms: int = 30000
viewport_width: int = 1280
viewport_height: int = 720
@dataclass(slots=True)
class ToolsConfig:
"""Tools pillar settings — wraps storage and MCP configuration."""
storage: StorageConfig = field(default_factory=StorageConfig)
mcp: MCPConfig = field(default_factory=MCPConfig)
browser: BrowserConfig = field(default_factory=BrowserConfig)
enabled: str = "" # comma-separated default tools
@@ -689,19 +717,31 @@ class ChannelConfig:
email: EmailChannelConfig = field(default_factory=EmailChannelConfig)
whatsapp: WhatsAppChannelConfig = field(default_factory=WhatsAppChannelConfig)
signal: SignalChannelConfig = field(default_factory=SignalChannelConfig)
google_chat: GoogleChatChannelConfig = field(default_factory=GoogleChatChannelConfig)
google_chat: GoogleChatChannelConfig = field(
default_factory=GoogleChatChannelConfig,
)
irc: IRCChannelConfig = field(default_factory=IRCChannelConfig)
webchat: WebChatChannelConfig = field(default_factory=WebChatChannelConfig)
teams: TeamsChannelConfig = field(default_factory=TeamsChannelConfig)
matrix: MatrixChannelConfig = field(default_factory=MatrixChannelConfig)
mattermost: MattermostChannelConfig = field(default_factory=MattermostChannelConfig)
feishu: FeishuChannelConfig = field(default_factory=FeishuChannelConfig)
bluebubbles: BlueBubblesChannelConfig = field(default_factory=BlueBubblesChannelConfig)
bluebubbles: BlueBubblesChannelConfig = field(
default_factory=BlueBubblesChannelConfig,
)
whatsapp_baileys: WhatsAppBaileysChannelConfig = field(
default_factory=WhatsAppBaileysChannelConfig,
)
@dataclass(slots=True)
class CapabilitiesConfig:
"""RBAC capability system settings."""
enabled: bool = False
policy_path: str = ""
@dataclass(slots=True)
class SecurityConfig:
"""Security guardrails settings."""
@@ -714,6 +754,13 @@ class SecurityConfig:
pii_scanner: bool = True
audit_log_path: str = str(DEFAULT_CONFIG_DIR / "audit.db")
enforce_tool_confirmation: bool = True
merkle_audit: bool = True
signing_key_path: str = ""
ssrf_protection: bool = True
rate_limit_enabled: bool = False
rate_limit_rpm: int = 60
rate_limit_burst: int = 10
capabilities: CapabilitiesConfig = field(default_factory=CapabilitiesConfig)
@dataclass(slots=True)
@@ -727,6 +774,8 @@ class SandboxConfig:
mount_allowlist_path: str = ""
max_concurrent: int = 5
runtime: str = "docker"
wasm_fuel_limit: int = 1_000_000
wasm_memory_limit_mb: int = 256
@dataclass(slots=True)
@@ -738,6 +787,32 @@ class SchedulerConfig:
db_path: str = "" # Defaults to ~/.openjarvis/scheduler.db
@dataclass(slots=True)
class WorkflowConfig:
"""Workflow engine settings."""
enabled: bool = False
max_parallel: int = 4
default_node_timeout: int = 300
@dataclass(slots=True)
class SessionConfig:
"""Cross-channel session settings."""
enabled: bool = False
max_age_hours: float = 24.0
consolidation_threshold: int = 100
db_path: str = str(DEFAULT_CONFIG_DIR / "sessions.db")
@dataclass(slots=True)
class A2AConfig:
"""Agent-to-Agent protocol settings."""
enabled: bool = False
@dataclass
class JarvisConfig:
"""Top-level configuration for OpenJarvis."""
@@ -755,6 +830,9 @@ class JarvisConfig:
security: SecurityConfig = field(default_factory=SecurityConfig)
sandbox: SandboxConfig = field(default_factory=SandboxConfig)
scheduler: SchedulerConfig = field(default_factory=SchedulerConfig)
workflow: WorkflowConfig = field(default_factory=WorkflowConfig)
sessions: SessionConfig = field(default_factory=SessionConfig)
a2a: A2AConfig = field(default_factory=A2AConfig)
@property
def memory(self) -> StorageConfig:
@@ -848,6 +926,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
"engine", "intelligence", "learning", "agent",
"server", "telemetry", "traces", "security",
"channel", "tools", "sandbox", "scheduler",
"workflow", "sessions", "a2a",
)
for section_name in top_sections:
if section_name in data:
@@ -900,6 +979,9 @@ host = "http://localhost:30000"
[engine.mlx]
host = "http://localhost:8080"
# [engine.lmstudio]
# host = "http://localhost:1234"
[intelligence]
default_model = ""
fallback_model = ""
@@ -930,6 +1012,12 @@ default_backend = "sqlite"
[tools.mcp]
enabled = true
# [tools.browser]
# headless = true
# timeout_ms = 30000
# viewport_width = 1280
# viewport_height = 720
[server]
host = "0.0.0.0"
port = 8000
@@ -1025,6 +1113,10 @@ scan_output = true
secret_scanner = true
pii_scanner = true
enforce_tool_confirmation = true
ssrf_protection = true
# rate_limit_enabled = false
# rate_limit_rpm = 60
# rate_limit_burst = 10
# [sandbox]
# enabled = false
@@ -1046,9 +1138,12 @@ enforce_tool_confirmation = true
__all__ = [
"A2AConfig",
"AgentConfig",
"AgentLearningConfig",
"BlueBubblesChannelConfig",
"BrowserConfig",
"CapabilitiesConfig",
"ChannelConfig",
"DEFAULT_CONFIG_DIR",
"DEFAULT_CONFIG_PATH",
@@ -1064,6 +1159,7 @@ __all__ = [
"IntelligenceLearningConfig",
"JarvisConfig",
"LearningConfig",
"LMStudioEngineConfig",
"LlamaCppEngineConfig",
"MCPConfig",
"MLXEngineConfig",
@@ -1078,6 +1174,7 @@ __all__ = [
"SchedulerConfig",
"SecurityConfig",
"ServerConfig",
"SessionConfig",
"SignalChannelConfig",
"SlackChannelConfig",
"StorageConfig",
@@ -1091,6 +1188,7 @@ __all__ = [
"WebhookChannelConfig",
"WhatsAppBaileysChannelConfig",
"WhatsAppChannelConfig",
"WorkflowConfig",
"detect_hardware",
"generate_default_toml",
"load_config",
+17
View File
@@ -41,6 +41,23 @@ class EventType(str, Enum):
SCHEDULER_TASK_END = "scheduler_task_end"
BATCH_START = "batch_start"
BATCH_END = "batch_end"
# Phase 14 — Agent Hardening & Security
TOOL_TIMEOUT = "tool_timeout"
LOOP_GUARD_TRIGGERED = "loop_guard_triggered"
CAPABILITY_DENIED = "capability_denied"
TAINT_VIOLATION = "taint_violation"
# Phase 15 — Workflow, Skills, Sessions
WORKFLOW_START = "workflow_start"
WORKFLOW_NODE_START = "workflow_node_start"
WORKFLOW_NODE_END = "workflow_node_end"
WORKFLOW_END = "workflow_end"
SKILL_EXECUTE_START = "skill_execute_start"
SKILL_EXECUTE_END = "skill_execute_end"
SESSION_START = "session_start"
SESSION_END = "session_end"
# Phase 16 — A2A Protocol
A2A_TASK_RECEIVED = "a2a_task_received"
A2A_TASK_COMPLETED = "a2a_task_completed"
@dataclass(slots=True)
+5
View File
@@ -133,6 +133,10 @@ class LearningRegistry(RegistryBase[Any]):
"""Registry for learning policies."""
class SkillRegistry(RegistryBase[Any]):
"""Registry for skill manifests."""
__all__ = [
"AgentRegistry",
"BenchmarkRegistry",
@@ -143,5 +147,6 @@ __all__ = [
"ModelRegistry",
"RegistryBase",
"RouterPolicyRegistry",
"SkillRegistry",
"ToolRegistry",
]
+1
View File
@@ -15,6 +15,7 @@ _HOST_MAP: Dict[str, str | None] = {
"llamacpp": "llamacpp_host",
"sglang": "sglang_host",
"mlx": "mlx_host",
"lmstudio": "lmstudio_host",
"cloud": None,
"litellm": None,
}
+17
View File
@@ -0,0 +1,17 @@
"""LM Studio inference engine backend (OpenAI-compatible API)."""
from __future__ import annotations
from openjarvis.core.registry import EngineRegistry
from openjarvis.engine._openai_compat import _OpenAICompatibleEngine
@EngineRegistry.register("lmstudio")
class LMStudioEngine(_OpenAICompatibleEngine):
"""LM Studio backend — thin wrapper over the shared OpenAI-compatible base."""
engine_id = "lmstudio"
_default_host = "http://localhost:1234"
__all__ = ["LMStudioEngine"]
+9
View File
@@ -35,6 +35,15 @@ def ensure_registered() -> None:
except ImportError:
pass
try:
from openjarvis.learning.bandit_router import (
ensure_registered as _reg_bandit,
)
_reg_bandit()
except ImportError:
pass
from openjarvis.learning.trace_policy import (
ensure_registered as _reg_trace,
)
+174
View File
@@ -0,0 +1,174 @@
"""Bandit router — Thompson Sampling / UCB for query→model selection."""
from __future__ import annotations
import math
import random
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional
from openjarvis.core.registry import RouterPolicyRegistry
from openjarvis.core.types import RoutingContext
def _derive_query_class(context: RoutingContext) -> str:
"""Derive a query class string from RoutingContext fields."""
if context.has_code:
return "code"
if context.has_math:
return "math"
if context.query_length < 50:
return "short"
if context.query_length > 500:
return "long"
return "general"
@dataclass(slots=True)
class ArmStats:
"""Statistics for a single arm (model)."""
successes: int = 0 # alpha for Beta distribution
failures: int = 0 # beta for Beta distribution
total_reward: float = 0.0
pulls: int = 0
@property
def mean_reward(self) -> float:
return self.total_reward / self.pulls if self.pulls > 0 else 0.0
class BanditRouterPolicy:
"""Multi-armed bandit router using Thompson Sampling or UCB.
Each (query_class, model) pair is an arm. Rewards come from
trace outcomes.
"""
def __init__(
self,
*,
strategy: Literal["thompson", "ucb"] = "thompson",
exploration_factor: float = 2.0, # UCB exploration constant
min_pulls: int = 3, # minimum pulls before trusting estimates
reward_threshold: float = 0.5, # reward above this = success
) -> None:
self._strategy = strategy
self._exploration = exploration_factor
self._min_pulls = min_pulls
self._reward_threshold = reward_threshold
# query_class -> model -> ArmStats
self._arms: Dict[str, Dict[str, ArmStats]] = defaultdict(
lambda: defaultdict(ArmStats)
)
self._total_pulls = 0
def route(self, context: RoutingContext, models: List[str]) -> str:
"""Select model using the configured bandit strategy."""
if not models:
raise ValueError("No models available")
query_class = _derive_query_class(context)
arms = self._arms[query_class]
# Ensure all models have arms
for m in models:
if m not in arms:
arms[m] = ArmStats()
# Check minimum pulls — explore uniformly first
under_explored = [m for m in models if arms[m].pulls < self._min_pulls]
if under_explored:
return random.choice(under_explored)
if self._strategy == "thompson":
return self._thompson_select(models, arms)
else:
return self._ucb_select(models, arms)
def _thompson_select(self, models: List[str], arms: Dict[str, ArmStats]) -> str:
"""Thompson Sampling: sample from Beta(alpha, beta) per arm."""
best_model = models[0]
best_sample = -1.0
for m in models:
stats = arms[m]
alpha = stats.successes + 1 # Prior: Beta(1,1)
beta = stats.failures + 1
sample = random.betavariate(alpha, beta)
if sample > best_sample:
best_sample = sample
best_model = m
return best_model
def _ucb_select(self, models: List[str], arms: Dict[str, ArmStats]) -> str:
"""UCB1: select arm with highest upper confidence bound."""
best_model = models[0]
best_ucb = -1.0
total = max(self._total_pulls, 1)
for m in models:
stats = arms[m]
if stats.pulls == 0:
return m # Unexplored arm gets priority
mean = stats.mean_reward
exploration = self._exploration * math.sqrt(
math.log(total) / stats.pulls
)
ucb_value = mean + exploration
if ucb_value > best_ucb:
best_ucb = ucb_value
best_model = m
return best_model
def update(self, query_class: str, model: str, reward: float) -> None:
"""Update arm statistics with observed reward."""
stats = self._arms[query_class][model]
stats.pulls += 1
stats.total_reward += reward
if reward >= self._reward_threshold:
stats.successes += 1
else:
stats.failures += 1
self._total_pulls += 1
def get_stats(self, query_class: Optional[str] = None) -> Dict[str, Any]:
"""Get arm statistics."""
if query_class:
arms = self._arms.get(query_class, {})
return {
m: {
"pulls": s.pulls,
"mean_reward": s.mean_reward,
"successes": s.successes,
"failures": s.failures,
}
for m, s in arms.items()
}
return {
qc: {
m: {"pulls": s.pulls, "mean_reward": s.mean_reward}
for m, s in arms.items()
}
for qc, arms in self._arms.items()
}
def reset(self) -> None:
"""Reset all state."""
self._arms.clear()
self._total_pulls = 0
def ensure_registered() -> None:
"""Register BanditRouterPolicy if not already present."""
if not RouterPolicyRegistry.contains("bandit"):
RouterPolicyRegistry.register_value("bandit", BanditRouterPolicy)
ensure_registered()
__all__ = ["ArmStats", "BanditRouterPolicy"]
+159 -13
View File
@@ -1,29 +1,175 @@
"""GRPO-based router policy — stub for Phase 5."""
"""GRPO router — Group Relative Policy Optimization for query→model routing."""
from __future__ import annotations
from typing import Any
import math
import random
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List
from openjarvis.core.registry import RouterPolicyRegistry
from openjarvis.core.types import RoutingContext
from openjarvis.learning._stubs import RouterPolicy
class GRPORouterPolicy(RouterPolicy):
"""Placeholder for GRPO-trained router policy (Phase 5).
def _derive_query_class(context: RoutingContext) -> str:
"""Derive a query class string from RoutingContext fields."""
if context.has_code:
return "code"
if context.has_math:
return "math"
if context.query_length < 50:
return "short"
if context.query_length > 500:
return "long"
return "general"
Raises ``NotImplementedError`` until training infrastructure is ready.
@dataclass(slots=True)
class GRPOSample:
"""A single sample in a GRPO group."""
query_class: str
model: str
reward: float
@dataclass
class GRPOState:
"""Persistent state for GRPO policy weights."""
# model -> query_class -> weight (log probability)
weights: Dict[str, Dict[str, float]] = field(
default_factory=lambda: defaultdict(
lambda: defaultdict(float)
),
)
# Track sample counts for min_samples threshold
sample_counts: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
total_updates: int = 0
class GRPORouterPolicy:
"""Group Relative Policy Optimization for routing queries to models.
Groups samples by query_class, computes relative advantage within each
group (reward - mean_reward) / std, and updates policy weights via
softmax gradient.
Falls back to random selection when insufficient samples exist.
"""
def __init__(self, **kwargs: Any) -> None:
self._kwargs = kwargs
def __init__(
self,
*,
learning_rate: float = 0.1,
min_samples: int = 5,
group_size: int = 4,
temperature: float = 1.0,
) -> None:
self._lr = learning_rate
self._min_samples = min_samples
self._group_size = group_size
self._temperature = temperature
self._state = GRPOState()
self._sample_buffer: List[GRPOSample] = []
def select_model(self, context: RoutingContext) -> str:
raise NotImplementedError(
"GRPORouterPolicy is not yet implemented. "
"GRPO training will be available in Phase 5."
def route(self, context: RoutingContext, models: List[str]) -> str:
"""Select the best model for the given routing context."""
if not models:
raise ValueError("No models available for routing")
query_class = _derive_query_class(context)
# Check if we have enough samples
if self._state.sample_counts.get(query_class, 0) < self._min_samples:
return random.choice(models)
# Compute softmax probabilities from weights
scores = []
for m in models:
w = self._state.weights.get(m, {}).get(query_class, 0.0)
scores.append(w / self._temperature)
# Softmax
max_score = max(scores)
exp_scores = [math.exp(s - max_score) for s in scores]
total = sum(exp_scores)
probs = [e / total for e in exp_scores]
# Sample from distribution
r = random.random()
cumulative = 0.0
for i, p in enumerate(probs):
cumulative += p
if r <= cumulative:
return models[i]
return models[-1]
def add_sample(self, query_class: str, model: str, reward: float) -> None:
"""Add a training sample to the buffer."""
self._sample_buffer.append(GRPOSample(
query_class=query_class, model=model, reward=reward,
))
self._state.sample_counts[query_class] = (
self._state.sample_counts.get(query_class, 0) + 1
)
def update(self) -> Dict[str, Any]:
"""Run GRPO update on accumulated samples.
Groups samples by query_class, computes relative advantages,
and updates policy weights.
Returns stats about the update.
"""
if not self._sample_buffer:
return {"updated": False, "reason": "no samples"}
# Group by query_class
groups: Dict[str, List[GRPOSample]] = defaultdict(list)
for sample in self._sample_buffer:
groups[sample.query_class].append(sample)
updates_applied = 0
for qc, samples in groups.items():
if len(samples) < 2:
continue # Need at least 2 for relative comparison
# Compute group statistics
rewards = [s.reward for s in samples]
mean_r = sum(rewards) / len(rewards)
var_r = sum((r - mean_r) ** 2 for r in rewards) / len(rewards)
std_r = math.sqrt(var_r) if var_r > 0 else 1.0
# Compute advantages and update weights
for sample in samples:
advantage = (sample.reward - mean_r) / std_r
self._state.weights[sample.model][qc] += (
self._lr * advantage
)
updates_applied += 1
self._state.total_updates += 1
processed = len(self._sample_buffer)
self._sample_buffer.clear()
return {
"updated": True,
"samples_processed": processed,
"groups": len(groups),
"updates_applied": updates_applied,
"total_updates": self._state.total_updates,
}
@property
def state(self) -> GRPOState:
"""Access the current policy state."""
return self._state
def reset(self) -> None:
"""Reset all state."""
self._state = GRPOState()
self._sample_buffer.clear()
def ensure_registered() -> None:
"""Register GRPORouterPolicy if not already present."""
@@ -33,4 +179,4 @@ def ensure_registered() -> None:
ensure_registered()
__all__ = ["GRPORouterPolicy"]
__all__ = ["GRPORouterPolicy", "GRPOSample", "GRPOState"]
+105 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from openjarvis.core.registry import LearningRegistry
from openjarvis.learning._stubs import AgentLearningPolicy
@@ -24,12 +24,17 @@ class ICLUpdaterPolicy(AgentLearningPolicy):
min_score: float = 0.7,
max_examples: int = 20,
min_skill_occurrences: int = 3,
auto_apply: bool = False,
) -> None:
self._min_score = min_score
self._max_examples = max_examples
self._min_skill_occurrences = min_skill_occurrences
self._auto_apply = auto_apply
self._examples: List[Dict[str, Any]] = []
self._skills: List[Dict[str, Any]] = []
# Versioned example database for add_example / rollback
self._example_db: List[Dict[str, Any]] = []
self._version: int = 0
def update(self, trace_store: Any, **kwargs: object) -> Dict[str, Any]:
"""Analyze traces and extract ICL examples + skills."""
@@ -116,6 +121,105 @@ class ICLUpdaterPolicy(AgentLearningPolicy):
skills.sort(key=lambda x: x["occurrences"], reverse=True)
return skills
# -- Versioned example database methods ------------------------------------
def add_example(
self,
query: str,
response: str,
outcome: float,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Add an ICL example if it meets the quality threshold.
Parameters
----------
query:
The user query that produced this example.
response:
The agent/model response.
outcome:
Quality score in [0, 1].
metadata:
Optional metadata dict attached to the example.
Returns
-------
True if the example was accepted, False if rejected (below threshold).
"""
if outcome < self._min_score:
return False
self._version += 1
entry: Dict[str, Any] = {
"query": query,
"response": response,
"outcome": outcome,
"metadata": metadata or {},
"version": self._version,
}
self._example_db.append(entry)
# Trim to max_examples (remove oldest first)
if len(self._example_db) > self._max_examples:
self._example_db = self._example_db[-self._max_examples:]
return True
def rollback(self, version: int) -> None:
"""Remove all examples added after the given version.
Parameters
----------
version:
The version checkpoint to rollback to. All examples with
``version > checkpoint`` are removed.
"""
self._example_db = [
ex for ex in self._example_db if ex["version"] <= version
]
self._version = version
def get_examples(
self,
query_class: str = "",
top_k: int = 5,
) -> List[Dict[str, Any]]:
"""Retrieve the best examples, optionally filtered by query class.
Parameters
----------
query_class:
If non-empty, only return examples whose query contains this
substring (case-insensitive).
top_k:
Maximum number of examples to return.
Returns
-------
Up to *top_k* examples sorted by outcome (descending).
"""
pool = self._example_db
if query_class:
lc = query_class.lower()
pool = [ex for ex in pool if lc in ex["query"].lower()]
# Sort by outcome descending, take top_k
ranked = sorted(pool, key=lambda ex: ex["outcome"], reverse=True)
return ranked[:top_k]
@property
def version(self) -> int:
"""Current version counter."""
return self._version
@property
def example_db(self) -> List[Dict[str, Any]]:
"""Return a copy of the versioned example database."""
return list(self._example_db)
# -- Original property accessors ------------------------------------------
@property
def examples(self) -> List[Dict[str, Any]]:
return list(self._examples)
+168
View File
@@ -0,0 +1,168 @@
"""Skill discovery -- mine recurring tool sequences from traces."""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Tuple
@dataclass(slots=True)
class DiscoveredSkill:
"""A skill discovered from trace analysis."""
name: str
description: str
tool_sequence: List[str] # ordered tool names
frequency: int # how often this sequence appeared
avg_outcome: float # average outcome score
example_inputs: List[str] = field(default_factory=list)
class SkillDiscovery:
"""Mine recurring tool sequences from trace data to auto-generate skills.
Analyzes TraceStore data for patterns like:
- "web_search -> file_write" (research-then-save)
- "file_read -> calculator -> file_write" (read-compute-save)
When a sequence appears >= min_frequency times with positive outcomes,
it's surfaced as a DiscoveredSkill that can be registered.
"""
def __init__(
self,
*,
min_frequency: int = 3,
min_sequence_length: int = 2,
max_sequence_length: int = 4,
min_outcome: float = 0.5,
) -> None:
self._min_freq = min_frequency
self._min_len = min_sequence_length
self._max_len = max_sequence_length
self._min_outcome = min_outcome
self._discovered: List[DiscoveredSkill] = []
def analyze_traces(self, traces: List[Any]) -> List[DiscoveredSkill]:
"""Analyze a list of traces for recurring tool sequences.
Parameters
----------
traces:
List of Trace objects (or dicts with 'steps' and 'outcome' keys).
Each trace should have steps with 'step_type' and 'tool_name'.
Returns
-------
List of DiscoveredSkill objects meeting frequency and outcome thresholds.
"""
# Extract tool sequences from traces
sequence_data: Dict[Tuple[str, ...], List[float]] = defaultdict(list)
sequence_inputs: Dict[Tuple[str, ...], List[str]] = defaultdict(list)
for trace in traces:
tool_calls = self._extract_tool_sequence(trace)
outcome = self._extract_outcome(trace)
query = self._extract_query(trace)
if len(tool_calls) < self._min_len:
continue
# Generate all subsequences of valid length
upper = min(self._max_len + 1, len(tool_calls) + 1)
for length in range(self._min_len, upper):
for start in range(len(tool_calls) - length + 1):
seq = tuple(tool_calls[start:start + length])
sequence_data[seq].append(outcome)
if query and len(sequence_inputs[seq]) < 3:
sequence_inputs[seq].append(query)
# Filter by frequency and outcome
discovered = []
for seq, outcomes in sequence_data.items():
freq = len(outcomes)
avg_outcome = sum(outcomes) / len(outcomes) if outcomes else 0.0
if freq >= self._min_freq and avg_outcome >= self._min_outcome:
name = "_".join(seq)
desc = f"Auto-discovered skill: {' -> '.join(seq)} (seen {freq} times)"
discovered.append(DiscoveredSkill(
name=name,
description=desc,
tool_sequence=list(seq),
frequency=freq,
avg_outcome=avg_outcome,
example_inputs=sequence_inputs.get(seq, []),
))
# Sort by frequency * outcome (quality score)
discovered.sort(key=lambda s: s.frequency * s.avg_outcome, reverse=True)
self._discovered = discovered
return discovered
def _extract_tool_sequence(self, trace: Any) -> List[str]:
"""Extract ordered list of tool names from a trace."""
if isinstance(trace, dict):
steps = trace.get("steps", [])
elif hasattr(trace, "steps"):
steps = trace.steps
else:
return []
tools = []
for step in steps:
if isinstance(step, dict):
if step.get("step_type") == "tool_call":
name = step.get("tool_name", step.get("name", ""))
if name:
tools.append(name)
elif hasattr(step, "step_type"):
st = step.step_type
is_tool = str(st) == "tool_call" or (
hasattr(st, "value") and st.value == "tool_call"
)
if is_tool:
name = getattr(
step, "tool_name", getattr(step, "name", ""),
)
if name:
tools.append(name)
return tools
def _extract_outcome(self, trace: Any) -> float:
"""Extract outcome score from a trace."""
if isinstance(trace, dict):
return float(trace.get("outcome", 0.0))
return float(getattr(trace, "outcome", 0.0))
def _extract_query(self, trace: Any) -> str:
"""Extract the original query from a trace."""
if isinstance(trace, dict):
return trace.get("query", "")
return getattr(trace, "query", "")
@property
def discovered_skills(self) -> List[DiscoveredSkill]:
"""Return the most recently discovered skills."""
return list(self._discovered)
def to_skill_manifests(self) -> List[Dict[str, Any]]:
"""Convert discovered skills to TOML-compatible manifest dicts."""
manifests = []
for skill in self._discovered:
manifests.append({
"name": skill.name,
"description": skill.description,
"steps": [
{"tool": tool, "params": {}} for tool in skill.tool_sequence
],
"metadata": {
"auto_discovered": True,
"frequency": skill.frequency,
"avg_outcome": skill.avg_outcome,
},
})
return manifests
__all__ = ["DiscoveredSkill", "SkillDiscovery"]
+156
View File
@@ -0,0 +1,156 @@
"""WASM sandbox — lightweight isolation via Wasmtime."""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional
@dataclass(slots=True)
class WasmResult:
"""Result from a WASM execution."""
success: bool = True
output: str = ""
duration_seconds: float = 0.0
fuel_consumed: int = 0
memory_used_bytes: int = 0
class WasmRunner:
"""Execute WASM modules with resource limits.
Uses wasmtime-py for sub-100ms isolation. Supplements Docker-based
ContainerRunner for lightweight, fast sandboxing.
Requires: ``pip install openjarvis[sandbox-wasm]``
"""
def __init__(
self,
*,
fuel_limit: int = 1_000_000,
memory_limit_mb: int = 256,
timeout: float = 30.0,
) -> None:
self._fuel_limit = fuel_limit
self._memory_limit_mb = memory_limit_mb
self._timeout = timeout
@staticmethod
def available() -> bool:
"""Check if wasmtime is available."""
try:
import wasmtime # noqa: F401
return True
except ImportError:
return False
def run(
self,
wasm_bytes: bytes,
input_data: Optional[Dict[str, Any]] = None,
) -> WasmResult:
"""Execute a WASM module with input data.
The module is expected to export a ``run`` function that takes
a pointer and length and returns a pointer and length.
For simpler modules, we attempt to call ``_start`` (WASI).
"""
try:
import wasmtime
except ImportError:
return WasmResult(
success=False,
output=(
"wasmtime not installed. Install with: "
"pip install openjarvis[sandbox-wasm]"
),
)
t0 = time.time()
try:
# Configure engine with fuel metering
config = wasmtime.Config()
config.consume_fuel = True
engine = wasmtime.Engine(config)
# Create store with fuel limit
store = wasmtime.Store(engine)
store.set_fuel(self._fuel_limit)
# Compile module
module = wasmtime.Module(engine, wasm_bytes)
# Set up WASI if needed
wasi_config = wasmtime.WasiConfig()
wasi_config.inherit_stdout()
wasi_config.inherit_stderr()
store.set_wasi(wasi_config)
# Create linker and link WASI
linker = wasmtime.Linker(engine)
linker.define_wasi()
# Instantiate
instance = linker.instantiate(store, module)
# Try to call _start (WASI entry point)
start_func = instance.exports(store).get("_start")
if start_func and isinstance(start_func, wasmtime.Func):
start_func(store)
fuel_remaining = store.get_fuel()
fuel_consumed = self._fuel_limit - fuel_remaining
duration = time.time() - t0
return WasmResult(
success=True,
output="WASM module executed successfully.",
duration_seconds=duration,
fuel_consumed=fuel_consumed,
)
except Exception as exc:
duration = time.time() - t0
return WasmResult(
success=False,
output=f"WASM execution error: {exc}",
duration_seconds=duration,
)
def validate(self, wasm_bytes: bytes) -> bool:
"""Validate that bytes represent a valid WASM module."""
try:
import wasmtime
config = wasmtime.Config()
engine = wasmtime.Engine(config)
wasmtime.Module.validate(engine, wasm_bytes)
return True
except Exception:
return False
def create_sandbox_runner(config: Any = None) -> Any:
"""Factory: select Docker or WASM runner based on config/availability."""
if config and getattr(config, "runtime", "") == "wasm":
runner = WasmRunner(
fuel_limit=getattr(config, "wasm_fuel_limit", 1_000_000),
memory_limit_mb=getattr(config, "wasm_memory_limit_mb", 256),
timeout=getattr(config, "timeout", 30),
)
if runner.available():
return runner
# Fall back to Docker ContainerRunner
try:
from openjarvis.sandbox.runner import ContainerRunner
return ContainerRunner(
image=getattr(config, "image", "openjarvis-sandbox:latest"),
timeout=getattr(config, "timeout", 300),
)
except ImportError:
return None
__all__ = ["WasmResult", "WasmRunner", "create_sandbox_runner"]
+4 -1
View File
@@ -1,4 +1,4 @@
"""Security guardrails — scanners, engine wrapper, and audit logging."""
"""Security guardrails — scanners, engine wrapper, audit, SSRF."""
from openjarvis.security._stubs import BaseScanner
from openjarvis.security.audit import AuditLogger
@@ -9,6 +9,7 @@ from openjarvis.security.file_policy import (
)
from openjarvis.security.guardrails import GuardrailsEngine, SecurityBlockError
from openjarvis.security.scanner import PIIScanner, SecretScanner
from openjarvis.security.ssrf import check_ssrf, is_private_ip
from openjarvis.security.types import (
RedactionMode,
ScanFinding,
@@ -32,6 +33,8 @@ __all__ = [
"SecurityEvent",
"SecurityEventType",
"ThreatLevel",
"check_ssrf",
"filter_sensitive_paths",
"is_private_ip",
"is_sensitive_file",
]
+83 -6
View File
@@ -1,11 +1,12 @@
"""Audit logger — persist security events to SQLite."""
"""Audit logger — persist security events to SQLite with Merkle hash chain."""
from __future__ import annotations
import hashlib
import json
import sqlite3
from pathlib import Path
from typing import List, Optional, Union
from typing import List, Optional, Tuple, Union
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.core.events import Event, EventBus, EventType
@@ -45,21 +46,42 @@ class AuditLogger:
event_type TEXT,
findings_json TEXT,
content_preview TEXT,
action_taken TEXT
action_taken TEXT,
row_hash TEXT DEFAULT '',
prev_hash TEXT DEFAULT ''
)
"""
)
self._conn.commit()
self._migrate_schema()
if bus is not None:
bus.subscribe(EventType.SECURITY_SCAN, self._on_event)
bus.subscribe(EventType.SECURITY_ALERT, self._on_event)
bus.subscribe(EventType.SECURITY_BLOCK, self._on_event)
def _migrate_schema(self) -> None:
"""Add row_hash/prev_hash columns if missing (schema migration)."""
columns = {
row[1]
for row in self._conn.execute(
"PRAGMA table_info(security_events)"
).fetchall()
}
if "row_hash" not in columns:
self._conn.execute(
"ALTER TABLE security_events ADD COLUMN row_hash TEXT DEFAULT ''"
)
if "prev_hash" not in columns:
self._conn.execute(
"ALTER TABLE security_events ADD COLUMN prev_hash TEXT DEFAULT ''"
)
self._conn.commit()
# -- public API ----------------------------------------------------------
def log(self, event: SecurityEvent) -> None:
"""Insert a security event into the audit log."""
"""Insert a security event into the audit log with Merkle hash chain."""
findings_json = json.dumps([
{
"pattern_name": f.pattern_name,
@@ -71,11 +93,21 @@ class AuditLogger:
}
for f in event.findings
])
# Compute hash chain
prev_hash = self.tail_hash()
hash_input = (
f"{prev_hash}|{event.timestamp}|{event.event_type.value}"
f"|{findings_json}|{event.content_preview}|{event.action_taken}"
)
row_hash = hashlib.sha256(hash_input.encode()).hexdigest()
self._conn.execute(
"""
INSERT INTO security_events
(timestamp, event_type, findings_json, content_preview, action_taken)
VALUES (?, ?, ?, ?, ?)
(timestamp, event_type, findings_json, content_preview,
action_taken, row_hash, prev_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
event.timestamp,
@@ -83,6 +115,8 @@ class AuditLogger:
findings_json,
event.content_preview,
event.action_taken,
row_hash,
prev_hash,
),
)
self._conn.commit()
@@ -139,6 +173,49 @@ class AuditLogger:
)
return events
def tail_hash(self) -> str:
"""Return the hash of the last row in the chain, or empty string."""
row = self._conn.execute(
"SELECT row_hash FROM security_events ORDER BY id DESC LIMIT 1"
).fetchone()
return row[0] if row and row[0] else ""
def verify_chain(self) -> Tuple[bool, Optional[int]]:
"""Verify the Merkle hash chain integrity.
Returns
-------
tuple
``(True, None)`` if the chain is valid, or
``(False, row_id)`` where *row_id* is the first broken link.
"""
rows = self._conn.execute(
"SELECT id, timestamp, event_type, findings_json,"
" content_preview, action_taken, row_hash, prev_hash"
" FROM security_events ORDER BY id"
).fetchall()
expected_prev = ""
for row in rows:
rid, ts, etype, fj, preview, action, stored_hash, stored_prev = row
# Skip rows that predate the Merkle upgrade
if not stored_hash:
continue
# Verify prev_hash link
if stored_prev != expected_prev:
return False, rid
# Verify row_hash
hash_input = (
f"{stored_prev}|{ts}|{etype}"
f"|{fj}|{preview}|{action}"
)
computed = hashlib.sha256(hash_input.encode()).hexdigest()
if computed != stored_hash:
return False, rid
expected_prev = stored_hash
return True, None
def count(self) -> int:
"""Return the total number of logged security events."""
row = self._conn.execute(
+168
View File
@@ -0,0 +1,168 @@
"""RBAC capability system — fine-grained permission model for tool dispatch."""
from __future__ import annotations
import fnmatch
import json
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
class Capability(str, Enum):
"""Fine-grained capability labels."""
FILE_READ = "file:read"
FILE_WRITE = "file:write"
NETWORK_FETCH = "network:fetch"
CODE_EXECUTE = "code:execute"
MEMORY_READ = "memory:read"
MEMORY_WRITE = "memory:write"
CHANNEL_SEND = "channel:send"
TOOL_INVOKE = "tool:invoke"
SCHEDULE_CREATE = "schedule:create"
SYSTEM_ADMIN = "system:admin"
@dataclass(slots=True)
class CapabilityGrant:
"""A single capability grant for an agent."""
capability: str # Capability value or glob pattern
pattern: str = "*" # resource glob pattern
@dataclass(slots=True)
class AgentPolicy:
"""Policy for a specific agent."""
agent_id: str
grants: List[CapabilityGrant] = field(default_factory=list)
deny: List[str] = field(default_factory=list) # explicit denials
class CapabilityPolicy:
"""RBAC capability policy for tool dispatch.
Checks whether an agent has the required capability to invoke a tool.
Policy can be loaded from a JSON file or configured programmatically.
Default policy: if no explicit policy exists for an agent, all
capabilities are granted (open by default). Set ``default_deny=True``
to flip to deny-by-default.
"""
def __init__(
self,
*,
policy_path: Optional[str] = None,
default_deny: bool = False,
) -> None:
self._policies: Dict[str, AgentPolicy] = {}
self._default_deny = default_deny
if policy_path:
self._load_file(Path(policy_path))
def grant(self, agent_id: str, capability: str, pattern: str = "*") -> None:
"""Grant a capability to an agent."""
policy = self._policies.setdefault(
agent_id, AgentPolicy(agent_id=agent_id),
)
policy.grants.append(CapabilityGrant(capability=capability, pattern=pattern))
def deny(self, agent_id: str, capability: str) -> None:
"""Explicitly deny a capability to an agent."""
policy = self._policies.setdefault(
agent_id, AgentPolicy(agent_id=agent_id),
)
policy.deny.append(capability)
def check(self, agent_id: str, capability: str, resource: str = "") -> bool:
"""Check whether *agent_id* has *capability* for *resource*.
Returns True if allowed, False if denied.
"""
policy = self._policies.get(agent_id)
if policy is None:
# No explicit policy — use default
return not self._default_deny
# Explicit denials take precedence
for denied in policy.deny:
if fnmatch.fnmatch(capability, denied):
return False
# Check grants
for grant in policy.grants:
if fnmatch.fnmatch(capability, grant.capability):
if resource and grant.pattern != "*":
if fnmatch.fnmatch(resource, grant.pattern):
return True
else:
return True
# No matching grant found
return not self._default_deny
def list_grants(self, agent_id: str) -> List[CapabilityGrant]:
"""List all grants for an agent."""
policy = self._policies.get(agent_id)
return list(policy.grants) if policy else []
def list_agents(self) -> List[str]:
"""List all agents with explicit policies."""
return list(self._policies.keys())
def _load_file(self, path: Path) -> None:
"""Load policy from a JSON file."""
if not path.exists():
return
try:
data = json.loads(path.read_text())
for agent_data in data.get("agents", []):
agent_id = agent_data["agent_id"]
for grant_data in agent_data.get("grants", []):
self.grant(
agent_id,
grant_data["capability"],
grant_data.get("pattern", "*"),
)
for denied in agent_data.get("deny", []):
self.deny(agent_id, denied)
except (json.JSONDecodeError, KeyError, TypeError):
pass
def save(self, path: Path) -> None:
"""Save policy to a JSON file."""
agents = []
for agent_id, policy in self._policies.items():
agents.append({
"agent_id": agent_id,
"grants": [
{"capability": g.capability, "pattern": g.pattern}
for g in policy.grants
],
"deny": policy.deny,
})
path.write_text(json.dumps({"agents": agents}, indent=2))
# Default capability requirements for built-in tools
DEFAULT_TOOL_CAPABILITIES: Dict[str, List[str]] = {
"file_read": [Capability.FILE_READ],
"web_search": [Capability.NETWORK_FETCH],
"code_interpreter": [Capability.CODE_EXECUTE],
"memory_store": [Capability.MEMORY_WRITE],
"memory_retrieve": [Capability.MEMORY_READ],
"memory_search": [Capability.MEMORY_READ],
"memory_index": [Capability.MEMORY_WRITE],
"schedule_task": [Capability.SCHEDULE_CREATE],
"channel_send": [Capability.CHANNEL_SEND],
}
__all__ = [
"AgentPolicy",
"Capability",
"CapabilityGrant",
"CapabilityPolicy",
"DEFAULT_TOOL_CAPABILITIES",
]
@@ -0,0 +1,154 @@
"""Prompt injection scanner — detect malicious patterns in text."""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import List
from openjarvis.security.types import ScanFinding, ThreatLevel
# Threat level ordering for comparison
_THREAT_ORDER = [
ThreatLevel.LOW,
ThreatLevel.MEDIUM,
ThreatLevel.HIGH,
ThreatLevel.CRITICAL,
]
# Injection patterns: (regex, name, threat_level, description)
_INJECTION_PATTERNS = [
# System prompt override attempts
(
r"(?i)ignore\s+(all\s+)?(previous|prior|above)"
r"\s+(instructions?|prompts?|rules?)",
"prompt_override",
ThreatLevel.HIGH,
"Attempt to override system instructions",
),
(
r"(?i)you\s+are\s+now\s+(?:a\s+)?(?:different|new|my)",
"identity_override",
ThreatLevel.HIGH,
"Attempt to change AI identity",
),
(
r"(?i)disregard\s+(?:all\s+)?(?:previous|prior|your)"
r"\s+(?:instructions?|programming|rules?)",
"prompt_override",
ThreatLevel.HIGH,
"Attempt to disregard instructions",
),
# Shell/code injection via prompt
(
r"(?i)(?:execute|run|eval)\s*\(\s*['\"]",
"code_injection",
ThreatLevel.HIGH,
"Code execution attempt in prompt",
),
(
r"(?:;|\||&&)\s*(?:rm|curl|wget|nc|ncat"
r"|bash|sh|python|perl)\s",
"shell_injection",
ThreatLevel.HIGH,
"Shell command injection",
),
# Data exfiltration
(
r"(?i)(?:send|post|upload|exfiltrate|transmit)"
r"\s+(?:(?:to|data|all|everything)\s+)*"
r"(?:to\s+)?(?:https?://|my\s+server)",
"exfiltration",
ThreatLevel.HIGH,
"Data exfiltration attempt",
),
(
r"(?i)base64\s+encode\s+(?:and\s+)?"
r"(?:send|include|append)",
"exfiltration",
ThreatLevel.MEDIUM,
"Encoded exfiltration attempt",
),
# Jailbreak patterns
(
r"(?i)(?:DAN|do\s+anything\s+now)"
r"\s+(?:mode|prompt|jailbreak)",
"jailbreak",
ThreatLevel.HIGH,
"DAN jailbreak attempt",
),
(
r"(?i)pretend\s+(?:you\s+)?(?:have\s+)?no"
r"\s+(?:restrictions?|limitations?|rules?|filters?)",
"jailbreak",
ThreatLevel.MEDIUM,
"Restriction bypass attempt",
),
# Delimiter injection
(
r"```(?:system|assistant)\b",
"delimiter_injection",
ThreatLevel.MEDIUM,
"Role delimiter injection",
),
(
r"<\|(?:im_start|im_end|system|assistant)\|>",
"delimiter_injection",
ThreatLevel.HIGH,
"Chat template injection",
),
]
@dataclass(slots=True)
class InjectionScanResult:
"""Result of an injection scan."""
is_clean: bool
findings: List[ScanFinding]
threat_level: ThreatLevel # highest threat found
class InjectionScanner:
"""Scan text for prompt injection patterns.
Implements pattern-based detection for common injection techniques:
- System prompt overrides
- Shell/code injection
- Data exfiltration attempts
- Jailbreak patterns
- Delimiter injection
"""
def __init__(self) -> None:
self._patterns = [
(re.compile(pat), name, level, desc)
for pat, name, level, desc in _INJECTION_PATTERNS
]
def scan(self, text: str) -> InjectionScanResult:
"""Scan text for injection patterns."""
findings: List[ScanFinding] = []
max_threat = ThreatLevel.LOW
for pattern, name, level, desc in self._patterns:
for match in pattern.finditer(text):
findings.append(ScanFinding(
pattern_name=name,
matched_text=match.group(0)[:100],
threat_level=level,
start=match.start(),
end=match.end(),
description=desc,
))
idx = _THREAT_ORDER.index(level)
if idx > _THREAT_ORDER.index(max_threat):
max_threat = level
return InjectionScanResult(
is_clean=len(findings) == 0,
findings=findings,
threat_level=max_threat if findings else ThreatLevel.LOW,
)
__all__ = ["InjectionScanner", "InjectionScanResult"]
+98
View File
@@ -0,0 +1,98 @@
"""Rate limiter -- token bucket algorithm for per-agent/per-tool throttling."""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from typing import Dict, Optional, Tuple
__all__ = ["RateLimitConfig", "RateLimiter", "TokenBucket"]
@dataclass(slots=True)
class RateLimitConfig:
"""Configuration for rate limiting."""
requests_per_minute: int = 60
burst_size: int = 10 # max tokens in bucket
enabled: bool = True
class TokenBucket:
"""Thread-safe token bucket for rate limiting."""
def __init__(self, rate: float, capacity: int) -> None:
self._rate = rate # tokens per second
self._capacity = capacity
self._tokens = float(capacity)
self._last_refill = time.monotonic()
self._lock = threading.Lock()
def consume(self, tokens: int = 1) -> Tuple[bool, float]:
"""Try to consume tokens. Returns (allowed, wait_seconds)."""
with self._lock:
now = time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(
self._capacity,
self._tokens + elapsed * self._rate,
)
self._last_refill = now
if self._tokens >= tokens:
self._tokens -= tokens
return True, 0.0
else:
wait = (tokens - self._tokens) / self._rate
return False, wait
@property
def available(self) -> float:
"""Current available tokens (approximate)."""
with self._lock:
now = time.monotonic()
elapsed = now - self._last_refill
return min(self._capacity, self._tokens + elapsed * self._rate)
class RateLimiter:
"""Rate limiter with per-key token buckets.
Keys are typically "agent_id:tool_name" or just "agent_id".
"""
def __init__(self, config: Optional[RateLimitConfig] = None) -> None:
self._config = config or RateLimitConfig()
self._buckets: Dict[str, TokenBucket] = {}
self._lock = threading.Lock()
def check(self, key: str) -> Tuple[bool, float]:
"""Check if request is allowed for key. Returns (allowed, wait_seconds)."""
if not self._config.enabled:
return True, 0.0
bucket = self._get_bucket(key)
return bucket.consume()
def _get_bucket(self, key: str) -> TokenBucket:
"""Get or create a bucket for the given key."""
with self._lock:
if key not in self._buckets:
rate = self._config.requests_per_minute / 60.0
self._buckets[key] = TokenBucket(
rate=rate,
capacity=self._config.burst_size,
)
return self._buckets[key]
def reset(self, key: Optional[str] = None) -> None:
"""Reset rate limit state for a key or all keys."""
with self._lock:
if key:
self._buckets.pop(key, None)
else:
self._buckets.clear()
@property
def config(self) -> RateLimitConfig:
return self._config
+125
View File
@@ -0,0 +1,125 @@
"""Ed25519 signing — supply chain integrity for agent and skill manifests."""
from __future__ import annotations
import base64
from dataclasses import dataclass
@dataclass(slots=True)
class KeyPair:
"""Ed25519 key pair."""
private_key: bytes
public_key: bytes
def generate_keypair() -> KeyPair:
"""Generate a new Ed25519 key pair.
Requires the ``cryptography`` package
(``pip install openjarvis[security-signing]``).
"""
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
private_key = Ed25519PrivateKey.generate()
private_bytes = private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
public_bytes = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return KeyPair(private_key=private_bytes, public_key=public_bytes)
except ImportError as exc:
raise ImportError(
"Ed25519 signing requires the 'cryptography' package. "
"Install with: pip install openjarvis[security-signing]"
) from exc
def sign(data: bytes, private_key: bytes) -> bytes:
"""Sign *data* with an Ed25519 *private_key*.
Returns the raw 64-byte signature.
"""
try:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
key = Ed25519PrivateKey.from_private_bytes(private_key)
return key.sign(data)
except ImportError as exc:
raise ImportError(
"Ed25519 signing requires the 'cryptography' package."
) from exc
def verify(data: bytes, signature: bytes, public_key: bytes) -> bool:
"""Verify an Ed25519 *signature* on *data* with *public_key*.
Returns True if valid, False otherwise.
"""
try:
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
key = Ed25519PublicKey.from_public_bytes(public_key)
try:
key.verify(signature, data)
return True
except InvalidSignature:
return False
except ImportError as exc:
raise ImportError(
"Ed25519 signing requires the 'cryptography' package."
) from exc
def sign_b64(data: bytes, private_key: bytes) -> str:
"""Sign and return base64-encoded signature string."""
raw = sign(data, private_key)
return base64.b64encode(raw).decode("ascii")
def verify_b64(data: bytes, signature_b64: str, public_key: bytes) -> bool:
"""Verify a base64-encoded signature."""
try:
raw = base64.b64decode(signature_b64)
except Exception:
return False
return verify(data, raw, public_key)
def load_public_key(path: str) -> bytes:
"""Load a raw 32-byte Ed25519 public key from a file."""
from pathlib import Path
raw = Path(path).read_bytes()
# If base64-encoded (common), decode
if len(raw) > 32:
try:
raw = base64.b64decode(raw.strip())
except Exception:
pass
return raw
def save_keypair(keypair: KeyPair, private_path: str, public_path: str) -> None:
"""Save keypair to files (base64-encoded)."""
from pathlib import Path
Path(private_path).write_text(base64.b64encode(keypair.private_key).decode())
Path(public_path).write_text(base64.b64encode(keypair.public_key).decode())
__all__ = [
"KeyPair",
"generate_keypair",
"load_public_key",
"save_keypair",
"sign",
"sign_b64",
"verify",
"verify_b64",
]
+66
View File
@@ -0,0 +1,66 @@
"""SSRF protection — block requests to private IPs and cloud metadata endpoints."""
from __future__ import annotations
import ipaddress
import socket
from typing import Optional
# Cloud metadata endpoints to block
_BLOCKED_HOSTS = frozenset({
"169.254.169.254", # AWS/GCP/Azure metadata
"metadata.google.internal",
"metadata.google.com",
"100.100.100.200", # Alibaba Cloud metadata
})
_BLOCKED_CIDR = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), # link-local
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"), # unique local
ipaddress.ip_network("fe80::/10"), # link-local v6
]
def is_private_ip(ip_str: str) -> bool:
"""Check if an IP address is private/reserved."""
try:
addr = ipaddress.ip_address(ip_str)
return any(addr in net for net in _BLOCKED_CIDR)
except ValueError:
return False
def check_ssrf(url: str) -> Optional[str]:
"""Check a URL for SSRF vulnerabilities. Returns error message or None if safe."""
from urllib.parse import urlparse
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
return "No hostname in URL"
# Check blocked hosts
if hostname in _BLOCKED_HOSTS:
return f"Blocked host: {hostname} (cloud metadata endpoint)"
# DNS resolution check
try:
resolved = socket.getaddrinfo(
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM,
)
for family, stype, proto, canonname, sockaddr in resolved:
ip = sockaddr[0]
if is_private_ip(ip):
return f"URL resolves to private IP: {ip}"
except socket.gaierror:
pass # DNS resolution failed — allow (will fail at request time)
return None # Safe
__all__ = ["check_ssrf", "is_private_ip"]
@@ -0,0 +1,112 @@
"""Subprocess sandbox — secure process execution with environment isolation."""
from __future__ import annotations
import os
import signal
import subprocess
from dataclasses import dataclass
from typing import Dict, List, Optional
# Safe environment variables to pass through
_SAFE_ENV_VARS = frozenset({
"PATH", "HOME", "USER", "LANG", "TERM", "SHELL",
"LC_ALL", "LC_CTYPE", "TMPDIR", "TZ",
})
@dataclass(slots=True)
class SandboxResult:
"""Result of a sandboxed subprocess execution."""
stdout: str = ""
stderr: str = ""
returncode: int = -1
timed_out: bool = False
killed: bool = False
def build_safe_env(
passthrough: Optional[List[str]] = None,
extra: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
"""Build a sanitized environment dict.
Only copies safe vars from current env, plus any in passthrough list.
Extra vars are added directly.
"""
env: Dict[str, str] = {}
allowed = _SAFE_ENV_VARS | frozenset(passthrough or [])
for key in allowed:
val = os.environ.get(key)
if val is not None:
env[key] = val
if extra:
env.update(extra)
return env
def kill_process_tree(pid: int) -> None:
"""Kill a process and all its children (best effort)."""
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except (OSError, ProcessLookupError):
pass
try:
os.kill(pid, signal.SIGKILL)
except (OSError, ProcessLookupError):
pass
def run_sandboxed(
command: str,
*,
timeout: float = 30.0,
working_dir: Optional[str] = None,
env_passthrough: Optional[List[str]] = None,
env_extra: Optional[Dict[str, str]] = None,
max_output_bytes: int = 102_400,
) -> SandboxResult:
"""Execute a command in a sandboxed subprocess.
Features:
- Clean environment (only safe vars passed through)
- Timeout enforcement with process tree kill
- Output truncation
- New process group for clean cleanup
"""
env = build_safe_env(passthrough=env_passthrough, extra=env_extra)
cwd = working_dir if working_dir and os.path.isdir(working_dir) else None
result = SandboxResult()
try:
proc = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
cwd=cwd,
preexec_fn=os.setsid, # New process group
)
try:
stdout, stderr = proc.communicate(timeout=timeout)
result.stdout = stdout[:max_output_bytes] if stdout else ""
result.stderr = stderr[:max_output_bytes] if stderr else ""
result.returncode = proc.returncode
except subprocess.TimeoutExpired:
kill_process_tree(proc.pid)
proc.wait(timeout=5)
result.timed_out = True
result.killed = True
result.returncode = -1
result.stdout = "(timed out)"
result.stderr = ""
except OSError as exc:
result.stderr = f"Execution error: {exc}"
result.returncode = -1
return result
__all__ = ["SandboxResult", "build_safe_env", "kill_process_tree", "run_sandboxed"]
+139
View File
@@ -0,0 +1,139 @@
"""Taint tracking — information flow control.
Prevents data leakage through tool chains.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, FrozenSet, Optional, Set
class TaintLabel(str, Enum):
"""Labels for tainted data."""
PII = "pii"
SECRET = "secret"
USER_PRIVATE = "user_private"
EXTERNAL = "external"
@dataclass(frozen=True)
class TaintSet:
"""Immutable set of taint labels attached to data."""
labels: FrozenSet[TaintLabel] = field(default_factory=frozenset)
def union(self, other: TaintSet) -> TaintSet:
"""Merge two taint sets."""
return TaintSet(labels=self.labels | other.labels)
def has(self, label: TaintLabel) -> bool:
"""Check if a specific label is present."""
return label in self.labels
def __bool__(self) -> bool:
return bool(self.labels)
@classmethod
def from_labels(cls, *labels: TaintLabel) -> TaintSet:
"""Create from one or more labels."""
return cls(labels=frozenset(labels))
# Sink policy: which taint labels are forbidden for each tool
# If a tool appears here, data with any of the listed labels MUST NOT
# be passed to that tool.
SINK_POLICY: Dict[str, Set[TaintLabel]] = {
"web_search": {TaintLabel.PII, TaintLabel.SECRET},
"channel_send": {TaintLabel.SECRET},
"code_interpreter": {TaintLabel.SECRET},
}
# Patterns for auto-detecting taint in text
_PII_PATTERNS = [
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), # SSN
re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"), # email
re.compile(r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b"), # credit card
re.compile(r"\b\+?1?\s*\(?[2-9]\d{2}\)?\s*[-.\s]?\d{3}\s*[-.\s]?\d{4}\b"), # phone
]
_SECRET_PATTERNS = [
re.compile(r"(?:sk|pk|api)[_-][a-zA-Z0-9]{20,}"), # API keys
re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}"), # GitHub tokens
re.compile(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----"), # Private keys
re.compile(
r"(?:bearer|token|password|secret|key)\s*[=:]\s*\S{8,}",
re.IGNORECASE,
), # Generic secrets
]
def check_taint(tool_name: str, taint: TaintSet) -> Optional[str]:
"""Check if *taint* labels violate the sink policy for *tool_name*.
Returns a violation description string, or None if clean.
"""
forbidden = SINK_POLICY.get(tool_name)
if forbidden is None:
return None
violations = taint.labels & forbidden
if violations:
labels_str = ", ".join(
v.value
for v in sorted(violations, key=lambda x: x.value)
)
return (
f"Data with labels [{labels_str}] "
f"cannot be sent to '{tool_name}'."
)
return None
def declassify(taint: TaintSet, remove: TaintLabel, reason: str) -> TaintSet:
"""Remove a taint label with an explicit reason (for audit).
The *reason* is not stored on the TaintSet itself but should be
logged externally for accountability.
"""
return TaintSet(labels=taint.labels - {remove})
def auto_detect_taint(text: str) -> TaintSet:
"""Auto-detect taint labels in text content.
Uses regex patterns to detect PII and secrets in tool output.
"""
labels: set[TaintLabel] = set()
for pattern in _PII_PATTERNS:
if pattern.search(text):
labels.add(TaintLabel.PII)
break
for pattern in _SECRET_PATTERNS:
if pattern.search(text):
labels.add(TaintLabel.SECRET)
break
return TaintSet(labels=frozenset(labels))
def propagate_taint(
input_taint: TaintSet,
output_text: str,
) -> TaintSet:
"""Propagate taint: union of input taint with auto-detected output taint."""
output_taint = auto_detect_taint(output_text)
return input_taint.union(output_taint)
__all__ = [
"SINK_POLICY",
"TaintLabel",
"TaintSet",
"auto_detect_taint",
"check_taint",
"declassify",
"propagate_taint",
]
+599
View File
@@ -0,0 +1,599 @@
"""Extended API routes for agents, workflows, memory, traces, etc."""
from __future__ import annotations
import inspect
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
# ---- Request/Response models ----
class AgentCreateRequest(BaseModel):
agent_type: str
tools: Optional[List[str]] = None
agent_id: Optional[str] = None
class AgentMessageRequest(BaseModel):
message: str
class MemoryStoreRequest(BaseModel):
content: str
metadata: Optional[Dict[str, Any]] = None
class MemorySearchRequest(BaseModel):
query: str
top_k: int = 5
class BudgetLimitsRequest(BaseModel):
max_tokens_per_day: Optional[int] = None
max_requests_per_hour: Optional[int] = None
# ---- Agent routes ----
agents_router = APIRouter(prefix="/v1/agents", tags=["agents"])
@agents_router.get("")
async def list_agents(request: Request):
"""List available agent types and running agents."""
registered = []
try:
import openjarvis.agents # noqa: F401 — side-effect registration
from openjarvis.core.registry import AgentRegistry
for key in sorted(AgentRegistry.keys()):
cls = AgentRegistry.get(key)
registered.append({
"key": key,
"class": cls.__name__,
"accepts_tools": getattr(cls, "accepts_tools", False),
})
except Exception:
pass
running = []
try:
from openjarvis.tools.agent_tools import _SPAWNED_AGENTS
running = [
{"id": k, **v} for k, v in _SPAWNED_AGENTS.items()
]
except ImportError:
pass
return {"registered": registered, "running": running}
@agents_router.post("")
async def create_agent(req: AgentCreateRequest, request: Request):
"""Spawn a new agent."""
try:
from openjarvis.tools.agent_tools import AgentSpawnTool
tool = AgentSpawnTool()
params = {"agent_type": req.agent_type}
if req.tools:
params["tools"] = ",".join(req.tools)
if req.agent_id:
params["agent_id"] = req.agent_id
result = tool.execute(**params)
if not result.success:
raise HTTPException(status_code=400, detail=result.content)
return {
"status": "created",
"content": result.content,
"metadata": result.metadata,
}
except ImportError:
raise HTTPException(status_code=501, detail="Agent tools not available")
@agents_router.delete("/{agent_id}")
async def kill_agent(agent_id: str, request: Request):
"""Kill a running agent."""
try:
from openjarvis.tools.agent_tools import AgentKillTool
tool = AgentKillTool()
result = tool.execute(agent_id=agent_id)
if not result.success:
raise HTTPException(status_code=404, detail=result.content)
return {"status": "stopped", "agent_id": agent_id}
except ImportError:
raise HTTPException(status_code=501, detail="Agent tools not available")
@agents_router.post("/{agent_id}/message")
async def message_agent(agent_id: str, req: AgentMessageRequest, request: Request):
"""Send a message to a running agent."""
try:
from openjarvis.tools.agent_tools import AgentSendTool
tool = AgentSendTool()
result = tool.execute(agent_id=agent_id, message=req.message)
if not result.success:
raise HTTPException(status_code=404, detail=result.content)
return {"status": "sent", "content": result.content}
except ImportError:
raise HTTPException(status_code=501, detail="Agent tools not available")
# ---- Memory routes ----
memory_router = APIRouter(prefix="/v1/memory", tags=["memory"])
@memory_router.post("/store")
async def memory_store(req: MemoryStoreRequest, request: Request):
"""Store content in memory."""
try:
from openjarvis.tools.storage.sqlite import SQLiteMemory
backend = SQLiteMemory()
backend.store(req.content, metadata=req.metadata or {})
return {"status": "stored"}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@memory_router.post("/search")
async def memory_search(req: MemorySearchRequest, request: Request):
"""Search memory for relevant content."""
try:
from openjarvis.tools.storage.sqlite import SQLiteMemory
backend = SQLiteMemory()
results = backend.search(req.query, top_k=req.top_k)
items = [
{"content": r.content, "score": r.score, "metadata": r.metadata}
for r in results
]
return {"results": items}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@memory_router.get("/stats")
async def memory_stats(request: Request):
"""Get memory backend statistics."""
try:
from openjarvis.tools.storage.sqlite import SQLiteMemory
backend = SQLiteMemory()
stats = backend.stats()
return stats
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
# ---- Traces routes ----
traces_router = APIRouter(prefix="/v1/traces", tags=["traces"])
@traces_router.get("")
async def list_traces(request: Request, limit: int = 20):
"""List recent traces."""
try:
from openjarvis.traces.store import TraceStore
store = TraceStore()
traces = store.recent(limit=limit)
items = [
t.to_dict() if hasattr(t, "to_dict") else str(t)
for t in traces
]
return {"traces": items}
except Exception as exc:
return {"traces": [], "error": str(exc)}
@traces_router.get("/{trace_id}")
async def get_trace(trace_id: str, request: Request):
"""Get a specific trace by ID."""
try:
from openjarvis.traces.store import TraceStore
store = TraceStore()
trace = store.get(trace_id)
if trace is None:
raise HTTPException(status_code=404, detail="Trace not found")
return trace.to_dict() if hasattr(trace, 'to_dict') else {"id": trace_id}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
# ---- Telemetry routes ----
telemetry_router = APIRouter(prefix="/v1/telemetry", tags=["telemetry"])
@telemetry_router.get("/stats")
async def telemetry_stats(request: Request):
"""Get aggregated telemetry statistics."""
try:
from openjarvis.telemetry.aggregator import TelemetryAggregator
agg = TelemetryAggregator()
return agg.summary()
except Exception as exc:
return {"error": str(exc)}
@telemetry_router.get("/energy")
async def telemetry_energy(request: Request):
"""Get energy monitoring data."""
try:
from openjarvis.telemetry.aggregator import TelemetryAggregator
agg = TelemetryAggregator()
return agg.energy_summary()
except Exception as exc:
return {"error": str(exc)}
# ---- Skills routes ----
skills_router = APIRouter(prefix="/v1/skills", tags=["skills"])
@skills_router.get("")
async def list_skills(request: Request):
"""List installed skills."""
try:
from openjarvis.core.registry import SkillRegistry
skills = []
for key in sorted(SkillRegistry.keys()):
skills.append({"name": key})
return {"skills": skills}
except Exception:
return {"skills": []}
@skills_router.post("")
async def install_skill(request: Request):
"""Install a skill (placeholder)."""
return {
"status": "not_implemented",
"message": "Use TOML files in ~/.openjarvis/skills/",
}
@skills_router.delete("/{skill_name}")
async def remove_skill(skill_name: str, request: Request):
"""Remove a skill (placeholder)."""
return {
"status": "not_implemented",
"message": "Skill removal not yet supported via API",
}
# ---- Sessions routes ----
sessions_router = APIRouter(prefix="/v1/sessions", tags=["sessions"])
@sessions_router.get("")
async def list_sessions(request: Request, limit: int = 20):
"""List active sessions."""
try:
from openjarvis.sessions.store import SessionStore
store = SessionStore()
sessions = store.recent(limit=limit)
items = [
s.to_dict() if hasattr(s, "to_dict") else str(s)
for s in sessions
]
return {"sessions": items}
except Exception as exc:
return {"sessions": [], "error": str(exc)}
@sessions_router.get("/{session_id}")
async def get_session(session_id: str, request: Request):
"""Get a specific session."""
try:
from openjarvis.sessions.store import SessionStore
store = SessionStore()
session = store.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
return session.to_dict() if hasattr(session, 'to_dict') else {"id": session_id}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
# ---- Budget routes ----
budget_router = APIRouter(prefix="/v1/budget", tags=["budget"])
_budget_limits: Dict[str, Any] = {
"max_tokens_per_day": None,
"max_requests_per_hour": None,
}
_budget_usage: Dict[str, int] = {
"tokens_today": 0,
"requests_this_hour": 0,
}
@budget_router.get("")
async def get_budget(request: Request):
"""Get current budget usage and limits."""
return {"limits": _budget_limits, "usage": _budget_usage}
@budget_router.put("/limits")
async def set_budget_limits(req: BudgetLimitsRequest, request: Request):
"""Update budget limits."""
if req.max_tokens_per_day is not None:
_budget_limits["max_tokens_per_day"] = req.max_tokens_per_day
if req.max_requests_per_hour is not None:
_budget_limits["max_requests_per_hour"] = req.max_requests_per_hour
return {"status": "updated", "limits": _budget_limits}
# ---- Prometheus metrics ----
metrics_router = APIRouter(tags=["metrics"])
@metrics_router.get("/metrics")
async def prometheus_metrics(request: Request):
"""Prometheus-compatible metrics endpoint."""
try:
from openjarvis.telemetry.aggregator import TelemetryAggregator
agg = TelemetryAggregator()
stats = agg.summary()
lines = [
"# HELP openjarvis_requests_total Total requests processed",
"# TYPE openjarvis_requests_total counter",
f"openjarvis_requests_total {stats.get('total_requests', 0)}",
"# HELP openjarvis_tokens_total Total tokens generated",
"# TYPE openjarvis_tokens_total counter",
f"openjarvis_tokens_total {stats.get('total_tokens', 0)}",
"# HELP openjarvis_latency_avg_ms Average latency in milliseconds",
"# TYPE openjarvis_latency_avg_ms gauge",
f"openjarvis_latency_avg_ms {stats.get('avg_latency_ms', 0)}",
]
from starlette.responses import PlainTextResponse
return PlainTextResponse("\n".join(lines) + "\n", media_type="text/plain")
except Exception:
from starlette.responses import PlainTextResponse
return PlainTextResponse(
"# No metrics available\n", media_type="text/plain"
)
# ---- WebSocket streaming routes ----
websocket_router = APIRouter(tags=["websocket"])
@websocket_router.websocket("/v1/chat/stream")
async def websocket_chat_stream(websocket: WebSocket):
"""Stream chat responses over a WebSocket connection.
Accepts JSON messages of the form::
{"message": "...", "model": "...", "agent": "..."}
Sends back JSON chunks::
{"type": "chunk", "content": "..."} -- per-token streaming
{"type": "done", "content": "..."} -- final assembled response
{"type": "error", "detail": "..."} -- on failure
"""
await websocket.accept()
try:
while True:
raw = await websocket.receive_text()
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
await websocket.send_json(
{"type": "error", "detail": "Invalid JSON"},
)
continue
message = data.get("message")
if not message:
await websocket.send_json(
{"type": "error", "detail": "Missing 'message' field"},
)
continue
model = data.get("model") or getattr(
websocket.app.state, "model", "default",
)
engine = getattr(websocket.app.state, "engine", None)
if engine is None:
await websocket.send_json(
{"type": "error", "detail": "No engine configured"},
)
continue
messages = [{"role": "user", "content": message}]
try:
# Prefer streaming if the engine supports it
stream_fn = getattr(engine, "stream", None)
if stream_fn is not None and (
inspect.isasyncgenfunction(stream_fn)
or callable(stream_fn)
):
full_content = ""
try:
gen = stream_fn(messages, model=model)
# Handle both async and sync generators
if inspect.isasyncgen(gen):
async for token in gen:
full_content += token
await websocket.send_json(
{"type": "chunk", "content": token},
)
else:
# Sync generator — iterate in a thread to avoid
# blocking the event loop
for token in gen:
full_content += token
await websocket.send_json(
{"type": "chunk", "content": token},
)
except TypeError:
# stream() didn't return an iterable; fall back to
# generate()
result = engine.generate(messages, model=model)
content = result.get("content", "") if isinstance(
result, dict,
) else str(result)
full_content = content
await websocket.send_json(
{"type": "chunk", "content": content},
)
await websocket.send_json(
{"type": "done", "content": full_content},
)
else:
# No stream method — single-shot generate
result = engine.generate(messages, model=model)
content = result.get("content", "") if isinstance(
result, dict,
) else str(result)
await websocket.send_json(
{"type": "chunk", "content": content},
)
await websocket.send_json(
{"type": "done", "content": content},
)
except WebSocketDisconnect:
raise
except Exception as exc:
await websocket.send_json(
{"type": "error", "detail": str(exc)},
)
except WebSocketDisconnect:
pass # Client disconnected — nothing to clean up
# ---- Learning routes ----
learning_router = APIRouter(prefix="/v1/learning", tags=["learning"])
@learning_router.get("/stats")
async def learning_stats(request: Request):
"""Return learning system statistics across all sub-policies."""
result: Dict[str, Any] = {}
# GRPO policy state
try:
from openjarvis.learning.grpo_policy import GRPORouterPolicy
policy = GRPORouterPolicy()
state = policy.state
result["grpo"] = {
"available": True,
"total_updates": state.total_updates,
"sample_counts": dict(state.sample_counts),
"weight_count": sum(
len(qc_weights) for qc_weights in state.weights.values()
),
}
except Exception:
result["grpo"] = {"available": False}
# Bandit router state
try:
from openjarvis.learning.bandit_router import BanditRouterPolicy
policy = BanditRouterPolicy()
stats = policy.get_stats()
result["bandit"] = {
"available": True,
"query_classes": len(stats),
"arms": stats,
}
except Exception:
result["bandit"] = {"available": False}
# ICL updater
try:
from openjarvis.learning.icl_updater import ICLUpdaterPolicy
updater = ICLUpdaterPolicy()
result["icl"] = {
"available": True,
"example_count": len(updater.examples),
"example_db_count": len(updater.example_db),
"version": updater.version,
}
except Exception:
result["icl"] = {"available": False}
# Skill discovery
try:
from openjarvis.learning.skill_discovery import SkillDiscovery
discovery = SkillDiscovery()
result["skill_discovery"] = {
"available": True,
"discovered_count": len(discovery.discovered_skills),
}
except Exception:
result["skill_discovery"] = {"available": False}
return result
@learning_router.get("/policy")
async def learning_policy(request: Request):
"""Return current routing policy configuration."""
result: Dict[str, Any] = {}
# Load config and extract learning section
try:
from openjarvis.core.config import load_config
config = load_config()
lc = config.learning
result["enabled"] = lc.enabled
result["update_interval"] = lc.update_interval
result["auto_update"] = lc.auto_update
result["routing"] = {
"policy": lc.routing.policy,
"min_samples": lc.routing.min_samples,
}
result["intelligence"] = {
"policy": lc.intelligence.policy,
}
result["agent"] = {
"policy": lc.agent.policy,
}
result["metrics"] = {
"accuracy_weight": lc.metrics.accuracy_weight,
"latency_weight": lc.metrics.latency_weight,
"cost_weight": lc.metrics.cost_weight,
"efficiency_weight": lc.metrics.efficiency_weight,
}
except Exception:
result["enabled"] = False
result["routing"] = {"policy": "heuristic", "min_samples": 5}
result["intelligence"] = {"policy": "none"}
result["agent"] = {"policy": "none"}
result["metrics"] = {}
# Include GRPO weights if the routing policy is grpo
if result.get("routing", {}).get("policy") == "grpo":
try:
from openjarvis.learning.grpo_policy import GRPORouterPolicy
policy = GRPORouterPolicy()
state = policy.state
result["grpo_weights"] = {
model: dict(qc_weights)
for model, qc_weights in state.weights.items()
}
except Exception:
result["grpo_weights"] = {}
return result
def include_all_routes(app) -> None:
"""Include all extended API routers in a FastAPI app."""
app.include_router(agents_router)
app.include_router(memory_router)
app.include_router(traces_router)
app.include_router(telemetry_router)
app.include_router(skills_router)
app.include_router(sessions_router)
app.include_router(budget_router)
app.include_router(metrics_router)
app.include_router(websocket_router)
app.include_router(learning_router)
__all__ = [
"include_all_routes",
"agents_router",
"memory_router",
"traces_router",
"telemetry_router",
"skills_router",
"sessions_router",
"budget_router",
"metrics_router",
"websocket_router",
"learning_router",
]
+15 -1
View File
@@ -9,6 +9,7 @@ from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from openjarvis.server.api_routes import include_all_routes
from openjarvis.server.dashboard import dashboard_router
from openjarvis.server.routes import router
@@ -111,12 +112,25 @@ def create_app(
app.state.agent = agent
app.state.bus = bus
app.state.engine_name = engine_name
app.state.agent_name = agent_name or (getattr(agent, "agent_id", None) if agent else None)
app.state.agent_name = agent_name or (
getattr(agent, "agent_id", None) if agent else None
)
app.state.channel_bridge = channel_bridge
app.state.session_start = time.time()
app.include_router(router)
app.include_router(dashboard_router)
include_all_routes(app)
# Add security headers middleware
try:
from openjarvis.server.middleware import create_security_middleware
middleware_cls = create_security_middleware()
if middleware_cls is not None:
app.add_middleware(middleware_cls)
except Exception:
pass # middleware is best-effort
# Serve static frontend assets if the static/ directory exists
static_dir = pathlib.Path(__file__).parent / "static"

Some files were not shown because too many files have changed in this diff Show More