Compare commits

...
Author SHA1 Message Date
Elliot Slusky 5bc8d3a2f6 Harden Docker and systemd deployment configs (#581)
Pin all base images and ollama to fixed versions + @sha256 digests (no floating :latest), run Docker images as an unprivileged openjarvis user (uid 10001), replace the curl|bash NodeSource install with a digest-pinned multi-stage copy, install from the committed uv.lock via uv export --frozen --no-dev (hash-verified, --no-deps), and add systemd sandboxing (NoNewPrivileges, ProtectSystem=strict, PrivateTmp, kernel/SUID protections). Closes #228, #563, #564, #565, #566, #567.
2026-06-22 19:12:07 -07:00
Elliot Slusky 433d10db5e feat(memory): native persistent memory service integrated into core (#579)
Adds the openjarvis.memory package (LocalFactStore, FactExtractor, background MemoryService), starts/stops it in the jarvis serve and jarvis chat lifecycle, feeds completed non-streaming exchanges to it, adds [memory] config support, and adds jarvis memory list/clear CLI commands. Extraction runs on a background thread and degrades to a no-op on any failure (BrokenPipe, timeouts, unparseable output) so it can never block a reply or crash the host. Disabled by default. Closes #393, #571, #572, #573.
2026-06-22 13:59:02 -07:00
Elliot Slusky 9b7b3681f6 ci: parallelize the test suite and cut install/coverage overhead (#580)
Run pytest with -n auto (pytest-xdist) and COVERAGE_CORE=sysmon, enable the uv cache, and switch test output to -q. Cuts the test job from ~40min to ~4min without changing what's tested or the 60% coverage gate.
2026-06-22 13:58:46 -07:00
Jon Saad-FalconandClaude Opus 4.8 6dbe5461bb fix(engine): drop Qwen3 control-token tool calls from Ollama responses (#578)
Qwen3 treats /think and /no_think as soft-switch control tokens. On small
models a multi-line prompt makes the model emit one as the sole tool argument
(e.g. {"command": "/no_think"}); OpenJarvis forwards Ollama's native tool_calls
verbatim, so the operative agent executes garbage. Filter control-token-only
tool calls in both the non-streaming generate() and streaming _run_stream()
paths, keeping legitimate calls like {"command": "date"}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:40:25 -07:00
github-actions[bot] a65592fecb chore: update clone traffic data [skip ci] 2026-06-22 08:05:29 +00:00
github-actions[bot] 0513fbdb84 chore: update clone traffic data [skip ci] 2026-06-21 07:41:30 +00:00
Elliot Slusky d4eb6308b1 Fix desktop speech dependency setup (#574) 2026-06-20 17:08:24 -07:00
github-actions[bot] 2853a0001d chore: update clone traffic data [skip ci] 2026-06-20 07:23:14 +00:00
github-actions[bot] 3c99481975 chore: update clone traffic data [skip ci] 2026-06-19 07:55:20 +00:00
47 changed files with 2208 additions and 103 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "122,456",
"message": "128,077",
"color": "green",
"namedLogo": "git"
}
+7 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 122456,
"last_updated": "2026-06-18T07:44:28Z",
"total_clones": 128077,
"last_updated": "2026-06-22T08:05:29Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -84,6 +84,10 @@
"2026-06-14": 1543,
"2026-06-15": 1379,
"2026-06-16": 1317,
"2026-06-17": 1170
"2026-06-17": 1170,
"2026-06-18": 1408,
"2026-06-19": 1350,
"2026-06-20": 1437,
"2026-06-21": 1426
}
}
+12 -1
View File
@@ -23,6 +23,8 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v8.0.0
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev --extra framework-comparison --extra server
@@ -55,6 +57,8 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v8.0.0
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev --extra framework-comparison --extra server
@@ -63,8 +67,13 @@ jobs:
run: uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
- name: Run tests
# COVERAGE_CORE=sysmon uses CPython 3.12's sys.monitoring backend,
# which is dramatically cheaper than the default C trace function.
# -n auto fans the suite out across all runner cores via pytest-xdist.
env:
COVERAGE_CORE: sysmon
run: |
uv run pytest tests/ -v --tb=short -m "not live and not cloud and not hub" \
uv run pytest tests/ -n auto -q --tb=short -m "not live and not cloud and not hub" \
--cov=openjarvis \
--cov-report=term-missing \
--cov-report=xml \
@@ -107,6 +116,8 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v8.0.0
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev --extra server
+31 -6
View File
@@ -1,5 +1,9 @@
# Base images are pinned to an immutable digest (in addition to a human-readable
# tag) so every build resolves the exact same layers — reproducible builds and
# safe rollbacks (#563).
# Stage 1: Build frontend SPA
FROM node:22-slim AS frontend
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
@@ -8,10 +12,22 @@ COPY frontend/ .
RUN npm run build
# Stage 2: Build Python package
FROM python:3.12-slim-bookworm AS builder
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
WORKDIR /app
COPY pyproject.toml README.md ./
# Install dependencies from the committed lockfile (#567). `uv export --frozen`
# reads uv.lock as-is (no re-resolution) and emits a fully pinned, hash-verified
# requirements set; `--no-deps` then installs exactly that set. This is a
# separate layer from the source copy so dependency installs stay cached when
# only application code changes.
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
# Copy the source and the non-src force-include paths (see pyproject
# [tool.hatch.build.targets.wheel.force-include]) before building the project.
COPY src/ src/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
@@ -19,16 +35,25 @@ COPY deploy/windows deploy/windows
# Copy built frontend into the server static directory
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN pip install --no-cache-dir uv && \
uv pip install --system ".[server]"
# Install the project itself without re-resolving dependencies.
RUN uv pip install --system --no-deps .
# Stage 3: Runtime
FROM python:3.12-slim-bookworm
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
COPY --from=builder /usr/local /usr/local
COPY --from=builder /app /app
WORKDIR /app
# Run as an unprivileged user — the server needs no root privileges, so dropping
# them limits the blast radius of a compromise (#565). The app writes only to
# $HOME (config/cache/state), which is owned by this user.
RUN groupadd --system --gid 10001 openjarvis && \
useradd --system --uid 10001 --gid openjarvis \
--create-home --home-dir /home/openjarvis openjarvis
ENV HOME=/home/openjarvis
USER openjarvis
EXPOSE 8000
ENTRYPOINT ["jarvis"]
+24 -6
View File
@@ -1,5 +1,9 @@
# Base images are pinned to an immutable digest (in addition to a human-readable
# tag) so every build resolves the exact same layers — reproducible builds and
# safe rollbacks (#563).
# Stage 1: Build frontend SPA
FROM node:22-slim AS frontend
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
@@ -8,25 +12,31 @@ COPY frontend/ .
RUN npm run build
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 AS builder
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md ./
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
# for the rationale behind the frozen export + --no-deps install.
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
COPY src/ src/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN pip install --no-cache-dir uv && \
uv pip install --system ".[server]"
RUN uv pip install --system --no-deps .
# Stage 3: Runtime
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip && \
@@ -36,6 +46,14 @@ COPY --from=builder /usr/local /usr/local
COPY --from=builder /app /app
WORKDIR /app
# Run as an unprivileged user (#565). NVIDIA device nodes (/dev/nvidia*) are
# world-accessible, so GPU workloads do not require root.
RUN groupadd --system --gid 10001 openjarvis && \
useradd --system --uid 10001 --gid openjarvis \
--create-home --home-dir /home/openjarvis openjarvis
ENV HOME=/home/openjarvis
USER openjarvis
EXPOSE 8000
ENTRYPOINT ["jarvis"]
+28 -6
View File
@@ -1,5 +1,9 @@
# Base images are pinned to an immutable digest (in addition to a human-readable
# tag) so every build resolves the exact same layers — reproducible builds and
# safe rollbacks (#563).
# Stage 1: Build frontend SPA
FROM node:22-slim AS frontend
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
@@ -8,25 +12,31 @@ COPY frontend/ .
RUN npm run build
# Stage 2: Build Python package (AMD ROCm 7.2)
FROM rocm/dev-ubuntu-22.04:7.2 AS builder
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md ./
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
# for the rationale behind the frozen export + --no-deps install.
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
COPY src/ src/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN pip install --no-cache-dir uv && \
uv pip install --system ".[server]"
RUN uv pip install --system --no-deps .
# Stage 3: Runtime
FROM rocm/dev-ubuntu-22.04:7.2
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip && \
@@ -36,6 +46,18 @@ COPY --from=builder /usr/local /usr/local
COPY --from=builder /app /app
WORKDIR /app
# Run as an unprivileged user (#565). ROCm GPU access is gated by the `video` and
# `render` groups (see group_add in docker-compose.gpu.rocm.yml), so the user is
# added to both; root is not required.
RUN groupadd --system --gid 10001 openjarvis && \
useradd --system --uid 10001 --gid openjarvis \
--create-home --home-dir /home/openjarvis openjarvis && \
(getent group video >/dev/null || groupadd --system video) && \
(getent group render >/dev/null || groupadd --system render) && \
usermod -aG video,render openjarvis
ENV HOME=/home/openjarvis
USER openjarvis
EXPOSE 8000
ENTRYPOINT ["jarvis"]
+34 -7
View File
@@ -1,15 +1,42 @@
FROM python:3.12-slim
# Base images are pinned to an immutable digest (in addition to a human-readable
# tag) so every build resolves the exact same layers (#563).
# Install Node.js 22
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates && \
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
# Node.js is sourced from the official, digest-pinned image rather than piping a
# remote setup script into bash (`curl ... | bash -`), which performed no
# checksum or signature verification of the downloaded installer (#566). The
# image digest is the integrity check, and the copy is architecture-agnostic.
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS node
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
# libstdc++6 + ca-certificates are the only runtime requirements of the Node
# binary copied below (the python slim image already provides libc/libgcc).
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \
rm -rf /var/lib/apt/lists/*
# Transplant the Node.js runtime from the official image. Both images are Debian
# bookworm, so the glibc/libstdc++ ABI matches.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
WORKDIR /app
# Install dependencies from the committed lockfile (#567): `uv export --frozen`
# reads uv.lock as-is and emits a pinned, hash-verified set installed with
# --no-deps (no re-resolution). Copied first so this layer caches independently
# of application source.
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
COPY . .
RUN pip install --no-cache-dir ".[server]"
# Install the project itself without re-resolving dependencies.
RUN uv pip install --system --no-deps .
LABEL openjarvis-sandbox=true
+3 -1
View File
@@ -18,7 +18,9 @@ services:
capabilities: [gpu]
ollama:
image: ollama/ollama:latest
# Pinned to a fixed version + digest for reproducible deployments (#563);
# must match the tag in docker-compose.yml.
image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee
environment:
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
+3 -1
View File
@@ -18,7 +18,9 @@ services:
restart: unless-stopped
ollama:
image: ollama/ollama:latest
# Pinned to a fixed version + digest for reproducible deployments and
# predictable rollbacks (#563). Bump deliberately, not implicitly via :latest.
image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee
ports:
- "11434:11434"
volumes:
+20
View File
@@ -14,7 +14,27 @@ Environment=HOME=/opt/openjarvis
# OPENJARVIS_API_KEY=<key> (generate one: `jarvis auth generate-key`)
# It is not prefixed with "-", so the unit fails to start if the file is
# missing — preventing an accidentally unauthenticated public server.
# Keep secrets here (mode 0600, owned by root) rather than inline Environment=
# lines, which leak into `systemctl show` and the journal.
EnvironmentFile=/etc/openjarvis/env
# --- Sandboxing / hardening (#564) ---
# Conservative set: tightens the unit without blocking the server's normal I/O
# or local GPU inference. ProtectSystem=strict makes the whole filesystem
# read-only except ReadWritePaths, so $HOME (config/cache/state under
# /opt/openjarvis) stays writable.
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/openjarvis
ProtectHome=true
PrivateTmp=true
ProtectControlGroups=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
+2 -2
View File
@@ -20,7 +20,7 @@ What it does:
4. Installs `uv` (https://astral.sh/uv) if absent.
5. Clones the OpenJarvis repository to `%LOCALAPPDATA%\OpenJarvis`
(override with `$env:OPENJARVIS_HOME`).
6. Runs `uv sync --extra server` so the FastAPI server entry point is
6. Runs `uv sync --extra desktop` so the FastAPI server and speech backend are
importable.
7. Optionally prompts to register a scheduled task that auto-starts the
server at logon.
@@ -105,7 +105,7 @@ To pull the latest:
```powershell
cd "$env:LOCALAPPDATA\OpenJarvis\src"
git pull --ff-only
uv sync --extra server
uv sync --extra desktop
```
Or re-run the installer with `-Force`:
+5 -5
View File
@@ -16,8 +16,8 @@
4. Install uv (https://astral.sh/uv) if absent.
5. Clone the OpenJarvis repository to $env:LOCALAPPDATA\OpenJarvis
(override with $env:OPENJARVIS_HOME).
6. Run `uv sync --extra server` so the FastAPI server entry point
is importable.
6. Run `uv sync --extra desktop` so the FastAPI server and speech
backend are importable.
7. Optionally register the scheduled-task service (see
deploy/windows/jarvis-service.ps1).
@@ -279,13 +279,13 @@ if (Test-Path (Join-Path $srcDir '.git')) {
}
# ---------------------------------------------------------------------------
# 6. uv sync --extra server
# 6. uv sync --extra desktop
# ---------------------------------------------------------------------------
Write-Info "Running 'uv sync --extra server' in $srcDir (this can take a few minutes)..."
Write-Info "Running 'uv sync --extra desktop' in $srcDir (this can take a few minutes)..."
Push-Location $srcDir
try {
& $uvExe sync --extra server
& $uvExe sync --extra desktop
if ($LASTEXITCODE -ne 0) {
Write-Fail "uv sync failed with exit code $LASTEXITCODE. Check the output above."
}
+1 -1
View File
@@ -109,7 +109,7 @@ If you prefer to run each step yourself:
```bash
git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
uv sync --extra server
uv sync --extra desktop
cd frontend && npm install && cd ..
```
+3 -2
View File
@@ -41,7 +41,7 @@ If you prefer to run each step yourself:
```bash
git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
uv sync --extra server
uv sync --extra desktop
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
cd frontend && npm install && cd ..
```
@@ -278,6 +278,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
| Extra | Install Command | Description |
|-------|----------------|-------------|
| `desktop` | `uv sync --extra desktop` | Desktop/API server plus local speech input |
| `server` | `uv sync --extra server` | OpenAI-compatible API server (`jarvis serve`) |
| `dev` | `uv sync --extra dev` | Development and testing tools |
| `docs` | `uv sync --extra docs` | Documentation build tools |
@@ -285,7 +286,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
Combine extras:
```bash
uv sync --extra server --extra memory-faiss --extra inference-cloud
uv sync --extra desktop --extra memory-faiss --extra inference-cloud
```
## Setting Up an Inference Backend
+2 -2
View File
@@ -8,7 +8,7 @@ avoid a Linux VM; WSL2 remains the smoother experience for most users.
## What you get
- A PowerShell installer that probes prerequisites, installs `uv`,
clones the repo, and runs `uv sync --extra server`.
clones the repo, and runs `uv sync --extra desktop`.
- An optional Windows scheduled-task service equivalent to the systemd
unit and launchd plist.
- Loopback default — the service binds `127.0.0.1` so no API key is
@@ -38,7 +38,7 @@ The installer will:
4. Install `uv` if absent (via the official `astral.sh/uv` PowerShell
installer).
5. Clone the repo to `%LOCALAPPDATA%\OpenJarvis\src`.
6. Run `uv sync --extra server`.
6. Run `uv sync --extra desktop`.
7. Prompt to register the scheduled-task service (skip with
`-SkipService`).
+24 -6
View File
@@ -682,7 +682,7 @@ fn format_uv_sync_failure(
format!(
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
Try opening a terminal in that directory and running \
`uv sync --extra server` manually for the full output.",
`uv sync --extra desktop` manually for the full output.",
root.display(),
code,
uv_sync_stderr_tail(stderr, 800),
@@ -1143,7 +1143,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
sync_cmd
.args([
"sync",
"--extra", "server",
"--extra", "desktop",
"--extra", "inference-cloud",
"--extra", "inference-google",
])
@@ -1664,11 +1664,29 @@ async fn transcribe_audio(
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
if !status.is_success() {
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|value| {
value
.get("detail")
.and_then(|detail| detail.as_str())
.map(str::to_string)
})
.filter(|detail| !detail.is_empty())
.unwrap_or(body);
return Err(format!(
"Transcription failed ({}): {}",
status.as_u16(),
detail
));
}
serde_json::from_str(&body).map_err(|e| format!("Invalid response: {}", e))
}
/// Submit savings to Supabase leaderboard.
@@ -2556,7 +2574,7 @@ mod tests {
assert!(msg.contains("exit 2"));
assert!(msg.contains("/home/u/.openjarvis/src"));
assert!(msg.contains("failed to resolve numpy==2.1.3"));
assert!(msg.contains("uv sync --extra server")); // actionable next step
assert!(msg.contains("uv sync --extra desktop")); // actionable next step
}
#[test]
+13 -1
View File
@@ -97,7 +97,13 @@ export function InputArea() {
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
const corpusSync = useResearchCorpusSync(deepResearch);
const { state: speechState, available: speechAvailable, startRecording, stopRecording } = useSpeech();
const {
state: speechState,
error: speechError,
available: speechAvailable,
startRecording,
stopRecording,
} = useSpeech();
// Abort in-flight stream when the user switches models mid-generation.
// This prevents errors from trying to continue a stream with a stale model.
@@ -122,6 +128,12 @@ export function InputArea() {
: streamState.isStreaming ? 'streaming'
: undefined;
useEffect(() => {
if (speechError) {
toast.error(speechError, { duration: 8000 });
}
}, [speechError]);
const handleMicClick = useCallback(async () => {
if (speechState === 'recording') {
try {
+13 -3
View File
@@ -317,8 +317,9 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
audioData: Array.from(new Uint8Array(buffer)),
filename,
});
} catch {
// Fall through to fetch
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(msg || 'Transcription failed');
}
}
const formData = new FormData();
@@ -327,7 +328,16 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error(`Transcription failed: ${res.status}`);
if (!res.ok) {
let detail = "";
try {
const body = await res.json();
detail = typeof body.detail === 'string' ? body.detail : "";
} catch {
// Keep the status-only message below when the body is not JSON.
}
throw new Error(detail || `Transcription failed: ${res.status}`);
}
return res.json();
}
+1 -1
View File
@@ -417,7 +417,7 @@ function SelfHostedView() {
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
Launch the API server to get the full UI in your browser:
</p>
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra server\njarvis serve --port 8000"} />
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra desktop\njarvis serve --port 8000"} />
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
The chat, dashboard, energy profiling, and cost comparison all run
locally on your machine.
+8
View File
@@ -48,6 +48,7 @@ dev = [
"pytest>=8",
"pytest-asyncio>=0.24",
"pytest-cov>=5",
"pytest-xdist>=3",
"respx>=0.22",
"ruff>=0.4",
"pre-commit>=3.0",
@@ -84,6 +85,13 @@ server = [
"pydantic>=2.0",
"python-multipart>=0.0.9",
]
desktop = [
"fastapi>=0.110",
"uvicorn>=0.30",
"pydantic>=2.0",
"python-multipart>=0.0.9",
"faster-whisper>=1.0",
]
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
gpu-metrics = ["pynvml>=12.0"]
energy-amd = ["amdsmi>=6.1"]
+1 -1
View File
@@ -148,7 +148,7 @@ fi
# ── 7. Install Python dependencies ──────────────────────────────────
info "Installing Python dependencies..."
uv sync --extra server --quiet 2>/dev/null || uv sync --extra server
uv sync --extra desktop --quiet 2>/dev/null || uv sync --extra desktop
ok "Python dependencies installed"
# ── 7b. Build Rust extension ──────────────────────────────────────
+20
View File
@@ -179,6 +179,19 @@ def chat(
_notifications = NotificationDispatcher(get_status())
# Automatic long-term memory — extracts durable facts in the background.
memory_service = None
try:
from openjarvis.memory import build_memory_service
memory_service = build_memory_service(config, engine, model)
if memory_service is not None:
memory_service.start()
console.print("[dim] Memory: active[/dim]")
except Exception as exc:
console.print(f"[yellow]Memory service unavailable: {exc}[/yellow]")
memory_service = None
# Conversation state
if not system_prompt:
from openjarvis.prompt.builder import SystemPromptBuilder
@@ -266,10 +279,17 @@ def chat(
console.print()
console.print(Markdown(content))
console.print()
# Hand the exchange to the memory service (non-blocking).
if memory_service is not None:
memory_service.submit(user_input, content)
except KeyboardInterrupt:
console.print("\n[dim]Generation interrupted.[/dim]")
except Exception as exc:
console.print(f"\n[red]Error: {exc}[/red]\n")
if memory_service is not None:
memory_service.stop()
__all__ = ["chat"]
+42
View File
@@ -223,6 +223,47 @@ def _check_optional_deps() -> List[CheckResult]:
return results
def _check_speech_backend() -> CheckResult:
"""Check whether the configured speech backend can load."""
try:
from openjarvis.speech._discovery import get_speech_backend
config = _get_config()
backend = get_speech_backend(config)
if backend is None:
return CheckResult(
"Speech backend",
"warn",
"Not configured",
details="Install desktop dependencies with `uv sync --extra desktop`.",
)
if backend.health():
return CheckResult(
"Speech backend",
"ok",
f"{backend.backend_id} ready",
)
details = None
last_error = getattr(backend, "last_error", None)
if callable(last_error):
details = last_error()
return CheckResult(
"Speech backend",
"warn",
f"{backend.backend_id} unavailable",
details=details
or "Install desktop dependencies with `uv sync --extra desktop`.",
)
except Exception as exc:
return CheckResult(
"Speech backend",
"warn",
f"Could not check: {exc}",
)
def _check_security_profile() -> CheckResult:
"""Check if a security profile is configured."""
try:
@@ -306,6 +347,7 @@ def _run_all_checks() -> List[CheckResult]:
checks.extend(_check_models())
checks.append(_check_default_model())
checks.extend(_check_optional_deps())
checks.append(_check_speech_backend())
checks.append(_check_nodejs())
checks.append(_check_security_profile())
return checks
+60
View File
@@ -167,6 +167,66 @@ def search(
console.print(table)
def _get_fact_store():
"""Instantiate the automatic-memory fact store from config."""
from openjarvis.memory.store import create_fact_store
config = load_config()
mem = config.memory
return create_fact_store(
getattr(mem, "backend", "local"),
path=getattr(mem, "facts_path", "~/.openjarvis/memory_facts.jsonl"),
max_facts=getattr(mem, "max_facts", 1000),
)
@memory.command(name="list")
def list_facts() -> None:
"""List durable facts captured by the automatic memory service."""
console = Console()
store = _get_fact_store()
facts = store.list()
if not facts:
console.print("[yellow]No memory facts stored yet.[/yellow]")
return
table = Table(title=f"Memory Facts ({len(facts)})")
table.add_column("#", style="dim", width=4)
table.add_column("Fact")
table.add_column("Source", style="cyan")
for i, fact in enumerate(facts, 1):
table.add_row(str(i), fact.text, fact.source or "-")
console.print(table)
@memory.command()
@click.option(
"--yes",
"-y",
is_flag=True,
default=False,
help="Skip the confirmation prompt.",
)
def clear(yes: bool) -> None:
"""Remove all durable facts captured by the automatic memory service."""
console = Console()
store = _get_fact_store()
count = store.count()
if count == 0:
console.print("[yellow]No memory facts to clear.[/yellow]")
return
if not yes:
if not click.confirm(f"Remove all {count} stored memory fact(s)?"):
console.print("[dim]Aborted.[/dim]")
return
removed = store.clear()
console.print(f"[green]Cleared {removed} memory fact(s).[/green]")
@memory.command()
@click.option(
"--backend",
+14
View File
@@ -493,6 +493,19 @@ def serve(
except Exception as exc:
logger.debug("Memory backend init failed: %s", exc)
# Automatic long-term memory service (background fact extraction).
memory_service = None
try:
from openjarvis.memory import build_memory_service
memory_service = build_memory_service(config, engine, model_name)
if memory_service is not None:
memory_service.start()
console.print(" Memory svc: [cyan]active[/cyan]")
except Exception as exc:
logger.debug("Memory service init failed: %s", exc)
memory_service = None
# Set up agent manager
agent_manager = None
if config.agent_manager.enabled:
@@ -667,6 +680,7 @@ def serve(
channel_bridge=channel_bridge,
config=config,
memory_backend=memory_backend,
memory_service=memory_service,
speech_backend=speech_backend,
agent_manager=agent_manager,
agent_scheduler=agent_scheduler,
+24 -1
View File
@@ -910,7 +910,13 @@ class LearningConfig:
@dataclass(slots=True)
class StorageConfig:
"""Storage (memory) backend settings."""
"""Storage (memory) backend settings.
Covers both the retrieval/document store (``default_backend``, ``db_path``,
chunking, context injection) and the automatic long-term memory service
(``enabled``, ``backend``, ``extraction_model``, ``max_facts``,
``facts_path``) configured under ``[memory]`` in ``config.toml``.
"""
default_backend: str = "sqlite"
db_path: str = field(default_factory=lambda: str(get_config_dir() / "memory.db"))
@@ -920,6 +926,14 @@ class StorageConfig:
chunk_size: int = 512
chunk_overlap: int = 64
# Automatic memory service — extracts durable facts from conversations in
# the background and persists them across sessions (see openjarvis.memory).
enabled: bool = False # start the memory service with serve/chat
backend: str = "local" # fact-store backend ("local" = on-disk JSONL)
extraction_model: str = "" # model for fact extraction ("" = active model)
max_facts: int = 1000 # cap on stored facts (oldest evicted past the cap)
facts_path: str = str(DEFAULT_CONFIG_DIR / "memory_facts.jsonl")
# Backward-compatibility alias
MemoryConfig = StorageConfig
@@ -2003,6 +2017,15 @@ context_from_memory = true
[tools.storage]
default_backend = "sqlite"
# Automatic long-term memory: extracts durable facts from conversations in the
# background and persists them. Starts/stops with `jarvis serve` and
# `jarvis chat`; manage stored facts with `jarvis memory list` / `clear`.
[memory]
enabled = false # set true to enable the memory service
backend = "local" # fact-store backend (local = on-disk JSONL)
extraction_model = "" # model for fact extraction ("" = active model)
max_facts = 1000 # cap on stored facts
[tools.mcp]
enabled = true
+67 -9
View File
@@ -22,6 +22,49 @@ from openjarvis.engine._stubs import StreamChunk
logger = logging.getLogger(__name__)
# Qwen3 treats ``/think`` and ``/no_think`` as soft-switch control tokens that
# toggle reasoning mode. Small models (e.g. qwen3:14b) fed a multi-line prompt
# sometimes emit one of these as the sole tool argument, e.g.
# ``{"command": "/no_think"}`` instead of the real command. Ollama parses that
# into a fully-formed tool_call via the model's chat template, so we have to
# drop it on our side before the agent executes garbage.
_QWEN_CONTROL_TOKENS = frozenset({"/think", "/no_think"})
def _is_control_token_only_args(raw_args: Any) -> bool:
"""Return True if tool-call arguments contain nothing but a Qwen3 token.
``raw_args`` may be a dict (Ollama's native shape) or a JSON / bare string.
A call is considered degenerate only when it carries at least one control
token and no other usable content, so legitimate calls such as
``{"command": "date"}`` or ``{"command": "echo /no_think"}`` are kept.
"""
parsed: Any = raw_args
if isinstance(raw_args, str):
try:
parsed = json.loads(raw_args)
except (json.JSONDecodeError, TypeError):
parsed = raw_args
if isinstance(parsed, str):
return parsed.strip().lower() in _QWEN_CONTROL_TOKENS
if not isinstance(parsed, dict) or not parsed:
return False
saw_token = False
for value in parsed.values():
if not isinstance(value, str):
return False # a non-string value is real content
stripped = value.strip()
if not stripped:
continue
if stripped.lower() in _QWEN_CONTROL_TOKENS:
saw_token = True
else:
return False # real string content
return saw_token
def _default_num_ctx() -> int:
"""Default context window (tokens). Override with ``JARVIS_NUM_CTX``.
@@ -168,14 +211,19 @@ class OllamaEngine(InferenceEngine):
if raw_tool_calls:
tool_calls = []
for i, tc in enumerate(raw_tool_calls):
raw_args = tc.get("function", {}).get(
"arguments",
"{}",
)
fn = tc.get("function", {})
raw_args = fn.get("arguments", "{}")
if _is_control_token_only_args(raw_args):
logger.warning(
"Dropping Qwen3 control-token tool call %s(%r)",
fn.get("name", ""),
raw_args,
)
continue
tool_calls.append(
{
"id": tc.get("id", f"call_{i}"),
"name": tc.get("function", {}).get("name", ""),
"name": fn.get("name", ""),
"arguments": (
json.dumps(raw_args)
if isinstance(raw_args, dict)
@@ -183,7 +231,8 @@ class OllamaEngine(InferenceEngine):
),
}
)
result["tool_calls"] = tool_calls
if tool_calls:
result["tool_calls"] = tool_calls
return result
async def stream(
@@ -340,14 +389,22 @@ class OllamaEngine(InferenceEngine):
# OpenAI-delta fragment shape that agent_manager_routes
# expects in _merge_tool_call_fragments.
fragments: List[Dict[str, Any]] = []
for i, tc in enumerate(raw_tool_calls):
for tc in raw_tool_calls:
fn = tc.get("function", {}) or {}
raw_args = fn.get("arguments", "{}")
if _is_control_token_only_args(raw_args):
logger.warning(
"Dropping Qwen3 control-token tool call %s(%r)",
fn.get("name", ""),
raw_args,
)
continue
args_str = (
json.dumps(raw_args)
if isinstance(raw_args, dict)
else str(raw_args)
)
i = len(fragments)
fragments.append(
{
"index": i,
@@ -359,8 +416,9 @@ class OllamaEngine(InferenceEngine):
},
}
)
yield StreamChunk(tool_calls=fragments)
finish_reason = "tool_calls"
if fragments:
yield StreamChunk(tool_calls=fragments)
finish_reason = "tool_calls"
if chunk.get("done", False):
reported_prompt = chunk.get("prompt_eval_count", 0)
+28
View File
@@ -0,0 +1,28 @@
"""Native persistent long-term memory for OpenJarvis.
This package provides the automatic memory service that extracts durable facts
from conversations in the background and persists them across sessions. It is
started and stopped as part of the ``jarvis serve`` / ``jarvis chat`` lifecycle
and configured via the ``[memory]`` section of ``config.toml``.
"""
from __future__ import annotations
from openjarvis.memory.extractor import FactExtractor
from openjarvis.memory.service import MemoryService, build_memory_service
from openjarvis.memory.store import (
Fact,
FactStore,
LocalFactStore,
create_fact_store,
)
__all__ = [
"Fact",
"FactStore",
"FactExtractor",
"LocalFactStore",
"MemoryService",
"build_memory_service",
"create_fact_store",
]
+150
View File
@@ -0,0 +1,150 @@
"""LLM-backed extraction of durable facts from a conversation turn.
The extractor takes a single (user, assistant) exchange and asks a small
local model to distill any long-term, user-specific facts worth remembering.
It is deliberately defensive: extraction runs on a background thread far from
the request path, so *any* failure — a dropped Ollama connection, a timeout, a
``BrokenPipeError`` when the client went away, or simply unparseable output —
must degrade to "no facts" rather than propagate.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, List, Optional
from openjarvis.core.types import Message, Role
logger = logging.getLogger(__name__)
_DEFAULT_SYSTEM_PROMPT = (
"You extract durable, long-term facts about the user from a single "
"conversation exchange. A good fact is stable over time and useful in "
"future conversations: preferences, identity, goals, ongoing projects, "
"constraints, or relationships. Ignore one-off task details, small talk, "
"and anything the assistant said about itself.\n\n"
"Respond with ONLY a JSON array of short fact strings (each under 200 "
"characters). If there is nothing worth remembering, respond with []."
)
class FactExtractor:
"""Extract memory-worthy facts from a conversation turn via an engine."""
def __init__(
self,
engine: Any,
model: str,
*,
temperature: float = 0.0,
max_tokens: int = 512,
max_facts_per_turn: int = 10,
max_fact_chars: int = 200,
system_prompt: Optional[str] = None,
) -> None:
self._engine = engine
self._model = model
self._temperature = temperature
self._max_tokens = max_tokens
self._max_facts_per_turn = max_facts_per_turn
self._max_fact_chars = max_fact_chars
self._system_prompt = system_prompt or _DEFAULT_SYSTEM_PROMPT
def extract(self, user_text: str, assistant_text: str = "") -> List[str]:
"""Return durable facts from the exchange. Never raises."""
user_text = (user_text or "").strip()
if not user_text:
return []
exchange = f"User: {user_text}"
if assistant_text and assistant_text.strip():
exchange += f"\nAssistant: {assistant_text.strip()}"
messages = [
Message(role=Role.SYSTEM, content=self._system_prompt),
Message(role=Role.USER, content=exchange),
]
try:
result = self._engine.generate(
messages,
model=self._model,
temperature=self._temperature,
max_tokens=self._max_tokens,
)
except BrokenPipeError:
# The classic failure mode: the model call's transport died.
# Extraction is best-effort, so swallow it.
logger.debug("Memory extraction aborted: broken pipe", exc_info=True)
return []
except Exception: # noqa: BLE001 — extraction must never crash the worker
logger.debug("Memory extraction failed", exc_info=True)
return []
if isinstance(result, dict):
content = result.get("content", "") or ""
else:
content = str(result)
return self._parse(content)
# -- parsing ------------------------------------------------------------
def _parse(self, content: str) -> List[str]:
"""Parse model output into a clean, deduped, capped list of facts."""
if not content or not content.strip():
return []
raw = self._coerce_to_list(content)
facts: List[str] = []
seen: set[str] = set()
for item in raw:
fact = self._clean_fact(item)
if not fact:
continue
key = fact.lower()
if key in seen:
continue
seen.add(key)
facts.append(fact)
if len(facts) >= self._max_facts_per_turn:
break
return facts
def _coerce_to_list(self, content: str) -> List[str]:
"""Best-effort conversion of model output to a list of strings."""
# 1. Try to locate and parse a JSON array anywhere in the output
# (models often wrap it in prose or code fences).
match = re.search(r"\[.*\]", content, re.DOTALL)
if match:
try:
parsed = json.loads(match.group(0))
if isinstance(parsed, list):
return [str(x) for x in parsed]
except (json.JSONDecodeError, ValueError):
pass
# 2. Fall back to line-based parsing (markdown bullets / numbered).
items: List[str] = []
for line in content.splitlines():
line = line.strip()
if not line:
continue
line = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", line)
items.append(line)
return items
def _clean_fact(self, item: str) -> str:
fact = str(item).strip().strip("\"'").strip()
# Drop obvious non-facts the model sometimes emits.
if not fact or fact.lower() in ("[]", "none", "n/a", "null"):
return ""
if len(fact) > self._max_fact_chars:
fact = fact[: self._max_fact_chars].rstrip()
return fact
__all__ = ["FactExtractor"]
+180
View File
@@ -0,0 +1,180 @@
"""Persistent memory service: async fact extraction integrated into core.
``MemoryService`` runs fact extraction on a dedicated background thread so it
never blocks ``jarvis serve`` request handling or the ``jarvis chat`` REPL.
Callers hand off an exchange via :meth:`submit`, which enqueues the work and
returns immediately — the slow model call and disk write happen out of band.
The worker swallows every per-job error (including ``BrokenPipeError`` when a
client disconnects mid-extraction), so a flaky extraction model can never take
down the host process.
The service is started and stopped as part of the OpenJarvis lifecycle (see
``cli/serve.py`` and ``cli/chat_cmd.py``) and is configured through the
``[memory]`` section of ``config.toml``.
"""
from __future__ import annotations
import logging
import queue
import threading
from typing import Any, List, Optional
from openjarvis.memory.extractor import FactExtractor
from openjarvis.memory.store import Fact, FactStore, create_fact_store
logger = logging.getLogger(__name__)
# Sentinel pushed onto the queue to wake the worker for shutdown.
_STOP = object()
class MemoryService:
"""Background long-term-memory extraction and persistence service."""
def __init__(
self,
store: FactStore,
extractor: FactExtractor,
*,
max_queue: int = 256,
) -> None:
self._store = store
self._extractor = extractor
self._queue: "queue.Queue[Any]" = queue.Queue(maxsize=max(1, max_queue))
self._thread: Optional[threading.Thread] = None
self._running = threading.Event()
# -- lifecycle ----------------------------------------------------------
def start(self) -> None:
"""Start the background worker thread (idempotent)."""
if self._running.is_set():
return
self._running.set()
self._thread = threading.Thread(
target=self._loop,
name="memory-service",
daemon=True,
)
self._thread.start()
logger.debug("Memory service started")
def stop(self, timeout: float = 2.0) -> None:
"""Signal the worker to drain and stop, then join it (idempotent)."""
if not self._running.is_set():
return
self._running.clear()
try:
self._queue.put_nowait(_STOP)
except queue.Full:
pass # worker will notice the cleared flag on its next loop
thread = self._thread
if thread is not None:
thread.join(timeout=timeout)
self._thread = None
logger.debug("Memory service stopped")
@property
def is_running(self) -> bool:
return self._running.is_set()
# -- submission ---------------------------------------------------------
def submit(self, user_text: str, assistant_text: str = "") -> bool:
"""Queue an exchange for extraction. Non-blocking; never raises.
Returns True if the job was enqueued, False if the service is not
running or the queue is full (in which case the exchange is dropped
rather than blocking the caller — extraction is best-effort).
"""
if not self._running.is_set():
return False
if not user_text or not user_text.strip():
return False
try:
self._queue.put_nowait((user_text, assistant_text))
return True
except queue.Full:
logger.debug("Memory service queue full; dropping exchange")
return False
# -- worker -------------------------------------------------------------
def _loop(self) -> None:
while True:
try:
job = self._queue.get(timeout=0.5)
except queue.Empty:
if not self._running.is_set():
break
continue
if job is _STOP:
self._queue.task_done()
break
try:
self._process(job)
except Exception: # noqa: BLE001 — a bad job must not kill the worker
logger.debug("Memory extraction job failed", exc_info=True)
finally:
self._queue.task_done()
if not self._running.is_set() and self._queue.empty():
break
def _process(self, job: Any) -> None:
user_text, assistant_text = job
facts = self._extractor.extract(user_text, assistant_text)
if facts:
stored = self._store.add_many(facts, source="auto")
if stored:
logger.debug("Memory service stored %d new fact(s)", stored)
# -- store passthroughs -------------------------------------------------
def list_facts(self) -> List[Fact]:
return self._store.list()
def clear_facts(self) -> int:
return self._store.clear()
def fact_count(self) -> int:
return self._store.count()
def build_memory_service(
config: Any,
engine: Any,
default_model: str = "",
) -> Optional[MemoryService]:
"""Build a :class:`MemoryService` from config, or ``None`` if disabled.
Reads the ``[memory]`` section (``config.memory`` / ``config.tools.storage``)
for ``enabled``, ``backend``, ``extraction_model``, ``max_facts`` and
``facts_path``. Returns ``None`` when memory is disabled or no engine /
extraction model is available, so callers can simply do::
svc = build_memory_service(config, engine, model)
if svc is not None:
svc.start()
"""
mem = getattr(config, "memory", None)
if mem is None or not getattr(mem, "enabled", False):
return None
if engine is None:
return None
model = getattr(mem, "extraction_model", "") or default_model
if not model:
logger.debug("Memory service disabled: no extraction model available")
return None
store = create_fact_store(
getattr(mem, "backend", "local"),
path=getattr(mem, "facts_path", "~/.openjarvis/memory_facts.jsonl"),
max_facts=getattr(mem, "max_facts", 1000),
)
extractor = FactExtractor(engine, model)
return MemoryService(store, extractor)
__all__ = ["MemoryService", "build_memory_service"]
+179
View File
@@ -0,0 +1,179 @@
"""Persistent stores for automatically extracted long-term memory facts.
A *fact* is a short, durable statement worth remembering about the user
(e.g. ``"Prefers concise answers"``). Facts are produced by the memory
service's background extractor and persisted here so they survive across
sessions. The store is intentionally small and self-contained: it dedupes,
caps the total number of facts, and is safe to call from multiple threads.
"""
from __future__ import annotations
import json
import os
import threading
import time
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable, List
@dataclass(slots=True)
class Fact:
"""A single durable memory entry."""
text: str
source: str = ""
created_at: float = 0.0
class FactStore(ABC):
"""Abstract persistent store for extracted memory facts."""
@abstractmethod
def add(self, text: str, source: str = "") -> bool:
"""Store *text* as a fact. Returns True if a new fact was stored."""
def add_many(self, texts: Iterable[str], source: str = "") -> int:
"""Store several facts, returning the count of newly stored ones."""
added = 0
for text in texts:
if self.add(text, source=source):
added += 1
return added
@abstractmethod
def list(self) -> List[Fact]:
"""Return all stored facts, oldest first."""
@abstractmethod
def clear(self) -> int:
"""Remove all stored facts, returning the number removed."""
@abstractmethod
def count(self) -> int:
"""Return the number of stored facts."""
class LocalFactStore(FactStore):
"""Append-only JSONL fact store on the local filesystem.
Facts are kept human-readable (one JSON object per line) so they can be
inspected or edited by hand. Writes are atomic (temp file + rename) and
guarded by a lock, so concurrent ``add`` calls from the extraction worker
and ``list``/``clear`` from the CLI never corrupt the file.
"""
def __init__(
self,
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
*,
max_facts: int = 1000,
) -> None:
self._path = Path(path).expanduser()
self._max_facts = max(0, int(max_facts))
self._lock = threading.Lock()
self._facts: List[Fact] = self._load()
# -- persistence --------------------------------------------------------
def _load(self) -> List[Fact]:
if not self._path.exists():
return []
facts: List[Fact] = []
try:
text = self._path.read_text(encoding="utf-8")
except OSError:
return []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue # skip malformed lines rather than crashing
fact_text = str(obj.get("text", "")).strip()
if not fact_text:
continue
facts.append(
Fact(
text=fact_text,
source=str(obj.get("source", "")),
created_at=float(obj.get("created_at", 0.0) or 0.0),
)
)
return facts
def _flush(self) -> None:
"""Atomically rewrite the JSONL file from the in-memory list."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
payload = "".join(
json.dumps(asdict(f), ensure_ascii=False) + "\n" for f in self._facts
)
tmp.write_text(payload, encoding="utf-8")
os.replace(tmp, self._path)
# -- FactStore API ------------------------------------------------------
def add(self, text: str, source: str = "") -> bool:
text = (text or "").strip()
if not text:
return False
with self._lock:
lowered = text.lower()
if any(f.text.lower() == lowered for f in self._facts):
return False # dedupe
self._facts.append(Fact(text=text, source=source, created_at=time.time()))
# Enforce the cap by evicting the oldest entries.
if self._max_facts and len(self._facts) > self._max_facts:
self._facts = self._facts[-self._max_facts :]
self._flush()
return True
def list(self) -> List[Fact]:
with self._lock:
return list(self._facts)
def clear(self) -> int:
with self._lock:
removed = len(self._facts)
self._facts = []
if self._path.exists():
try:
self._path.unlink()
except OSError:
self._flush()
return removed
def count(self) -> int:
with self._lock:
return len(self._facts)
@property
def path(self) -> Path:
"""Filesystem location of the JSONL store."""
return self._path
def create_fact_store(
backend: str = "local",
*,
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
max_facts: int = 1000,
) -> FactStore:
"""Construct a fact store for the configured *backend*.
Only the ``"local"`` (on-disk JSONL) backend is supported today; the
factory exists so additional backends can be added without changing the
service or CLI wiring.
"""
key = (backend or "local").strip().lower()
if key == "local":
return LocalFactStore(path, max_facts=max_facts)
raise ValueError(f"Unknown memory backend '{backend}'. Supported backends: local")
__all__ = ["Fact", "FactStore", "LocalFactStore", "create_fact_store"]
+24 -2
View File
@@ -893,7 +893,15 @@ async def transcribe_speech(request: Request):
filename = getattr(audio_file, "filename", "audio.wav")
ext = filename.rsplit(".", 1)[-1] if "." in filename else "wav"
result = backend.transcribe(audio_bytes, format=ext, language=language or None)
try:
result = backend.transcribe(audio_bytes, format=ext, language=language or None)
except Exception as exc:
logger.exception("Speech transcription failed")
raise HTTPException(
status_code=500,
detail=f"Speech transcription failed: {exc}",
) from exc
return {
"text": result.text,
"language": result.language,
@@ -908,9 +916,23 @@ async def speech_health(request: Request):
backend = getattr(request.app.state, "speech_backend", None)
if backend is None:
return {"available": False, "reason": "No speech backend configured"}
try:
available = backend.health()
reason = None
except Exception as exc:
logger.exception("Speech health check failed")
available = False
reason = str(exc)
if not available and reason is None:
last_error = getattr(backend, "last_error", None)
if callable(last_error):
reason = last_error()
return {
"available": backend.health(),
"available": available,
"backend": backend.backend_id,
**({"reason": reason} if reason else {}),
}
+14
View File
@@ -151,6 +151,7 @@ def create_app(
channel_bridge=None,
config=None,
memory_backend=None,
memory_service=None,
speech_backend=None,
agent_manager=None,
agent_scheduler=None,
@@ -221,6 +222,7 @@ def create_app(
app.state.channel_bridge = channel_bridge
app.state.config = config
app.state.memory_backend = memory_backend
app.state.memory_service = memory_service
app.state.speech_backend = speech_backend
app.state.agent_manager = agent_manager
app.state.agent_scheduler = agent_scheduler
@@ -292,6 +294,18 @@ def create_app(
except Exception as _exc:
logger.debug("Analytics init skipped: %s", _exc)
# Stop the background memory service cleanly when the server shuts down.
if memory_service is not None:
@app.on_event("shutdown")
async def _shutdown_memory_service() -> None:
svc = getattr(app.state, "memory_service", None)
if svc is not None:
try:
svc.stop()
except Exception:
pass
app.include_router(router)
app.include_router(dashboard_router)
app.include_router(comparison_router)
+34 -9
View File
@@ -223,7 +223,7 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
# the agent to execute them), add an explicit opt-in header rather
# than removing this guard — silent re-routing is what produced #414.
if agent is not None and not request_body.tools:
return _handle_agent(
response = _handle_agent(
agent,
model,
request_body,
@@ -231,16 +231,41 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
trace_store=getattr(request.app.state, "trace_store", None),
bus=getattr(request.app.state, "bus", None),
)
else:
bus = getattr(request.app.state, "bus", None)
response = _handle_direct(
engine,
model,
request_body,
bus=bus,
complexity_info=complexity_info,
app_config=config,
)
bus = getattr(request.app.state, "bus", None)
return _handle_direct(
engine,
model,
request_body,
bus=bus,
complexity_info=complexity_info,
app_config=config,
# Hand the completed exchange to the background memory service.
_remember_exchange(
getattr(request.app.state, "memory_service", None),
query_text_for_complexity,
response,
)
return response
def _remember_exchange(memory_service, user_text: str, response) -> None:
"""Submit a completed exchange to the memory service (non-blocking)."""
if memory_service is None or not user_text:
return
try:
content = ""
choices = getattr(response, "choices", None)
if choices:
content = getattr(choices[0].message, "content", "") or ""
memory_service.submit(user_text, content)
except Exception: # noqa: BLE001 — memory is best-effort, never fail a reply
logging.getLogger("openjarvis.server").debug(
"Memory submit failed",
exc_info=True,
)
def _handle_direct(
+77 -16
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import tempfile
from typing import List, Optional
@@ -13,6 +14,13 @@ try:
except ImportError:
WhisperModel = None # type: ignore[assignment, misc]
try:
import ctranslate2
except ImportError:
ctranslate2 = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
@SpeechRegistry.register("faster-whisper")
class FasterWhisperBackend(SpeechBackend):
@@ -30,20 +38,60 @@ class FasterWhisperBackend(SpeechBackend):
self._device = device
self._compute_type = compute_type
self._model: Optional[WhisperModel] = None
self._last_error: Optional[str] = None
def _resolve_compute_type(self) -> str:
"""Pick a CTranslate2 compute type supported by the configured device."""
if ctranslate2 is None:
return self._compute_type
try:
supported = set(ctranslate2.get_supported_compute_types(self._device))
except Exception as exc:
logger.debug(
"Could not inspect CTranslate2 compute types for %s: %s",
self._device,
exc,
)
return self._compute_type
if self._compute_type in supported:
return self._compute_type
preferences = (
("int8", "float32", "int8_float32", "int16")
if self._compute_type == "float16"
else ("float32", "int8", "int8_float32", "int16")
)
fallback = next((value for value in preferences if value in supported), None)
if fallback is None:
return self._compute_type
logger.warning(
"CTranslate2 compute_type=%r is not supported on device=%r; "
"using %r instead",
self._compute_type,
self._device,
fallback,
)
return fallback
def _ensure_model(self) -> WhisperModel:
"""Lazy-load the Whisper model on first use."""
if self._model is None:
if WhisperModel is None:
raise ImportError(
self._last_error = (
"faster-whisper is not installed. "
"Install with: uv sync --extra speech"
"Install with: uv sync --extra desktop"
)
raise ImportError(self._last_error)
compute_type = self._resolve_compute_type()
self._model = WhisperModel(
self._model_size,
device=self._device,
compute_type=self._compute_type,
compute_type=compute_type,
)
self._last_error = None
return self._model
def transcribe(
@@ -54,20 +102,24 @@ class FasterWhisperBackend(SpeechBackend):
language: Optional[str] = None,
) -> TranscriptionResult:
"""Transcribe audio bytes using Faster-Whisper."""
model = self._ensure_model()
try:
model = self._ensure_model()
# Write audio to a temp file (faster-whisper needs a file path)
suffix = f".{format}" if not format.startswith(".") else format
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
tmp.write(audio)
tmp.flush()
# Write audio to a temp file (faster-whisper needs a file path)
suffix = f".{format}" if not format.startswith(".") else format
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
tmp.write(audio)
tmp.flush()
kwargs = {}
if language:
kwargs["language"] = language
kwargs = {}
if language:
kwargs["language"] = language
segments_iter, info = model.transcribe(tmp.name, **kwargs)
segments_list = list(segments_iter)
segments_iter, info = model.transcribe(tmp.name, **kwargs)
segments_list = list(segments_iter)
except Exception as exc:
self._last_error = str(exc)
raise
# Build result
text = "".join(seg.text for seg in segments_list).strip()
@@ -81,6 +133,7 @@ class FasterWhisperBackend(SpeechBackend):
for seg in segments_list
]
self._last_error = None
return TranscriptionResult(
text=text,
language=getattr(info, "language", None),
@@ -91,9 +144,17 @@ class FasterWhisperBackend(SpeechBackend):
def health(self) -> bool:
"""Check if model is loaded or loadable."""
if self._model is not None:
try:
self._ensure_model()
return True
return WhisperModel is not None
except Exception as exc:
self._last_error = str(exc)
logger.debug("Faster-Whisper health check failed: %s", exc)
return False
def last_error(self) -> Optional[str]:
"""Return the last model load or transcription error, if any."""
return self._last_error
def supported_formats(self) -> List[str]:
"""Supported audio formats (same as ffmpeg/Whisper)."""
+48
View File
@@ -120,6 +120,54 @@ class TestChatAgents:
assert "simple ok" in result.output
assert "failed" not in result.output.lower()
def test_memory_service_started_fed_and_stopped(self) -> None:
"""The REPL starts the memory service, submits each turn, and stops it."""
class _SpyMemoryService:
def __init__(self) -> None:
self.started = False
self.stopped = False
self.submissions: list[tuple[str, str]] = []
def start(self) -> None:
self.started = True
def submit(self, user_text: str, assistant_text: str = "") -> bool:
self.submissions.append((user_text, assistant_text))
return True
def stop(self, timeout: float = 2.0) -> None:
self.stopped = True
spy = _SpyMemoryService()
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = {"content": "engine fallback"}
config = JarvisConfig()
config.intelligence.default_model = "test-model"
AgentRegistry.register_value("simple_chat_agent", _SimpleChatAgent)
with (
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
patch("openjarvis.intelligence.register_builtin_models"),
patch(
"openjarvis.memory.build_memory_service",
return_value=spy,
),
):
result = CliRunner().invoke(
chat,
["--agent", "simple_chat_agent", "--model", "test-model"],
input="hello\n/quit\n",
)
assert result.exit_code == 0
assert spy.started is True
assert spy.stopped is True
assert spy.submissions == [("hello", "simple ok")]
def test_tool_agent_uses_legacy_agent_tools_and_prompts_confirmation(self) -> None:
engine = MagicMock()
engine.engine_id = "mock"
+53
View File
@@ -10,10 +10,12 @@ from click.testing import CliRunner
from openjarvis.cli import cli
from openjarvis.cli.doctor_cmd import (
CheckResult,
_check_config_exists,
_check_default_model,
_check_nodejs,
_check_python_version,
_check_speech_backend,
)
@@ -39,6 +41,10 @@ class TestDoctorRuns:
),
patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]),
patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]),
patch(
"openjarvis.cli.doctor_cmd._check_speech_backend",
return_value=CheckResult("Speech backend", "ok", "mock ready"),
),
):
result = CliRunner().invoke(cli, ["doctor"])
assert result.exit_code == 0
@@ -59,6 +65,10 @@ class TestDoctorJsonOutput:
),
patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]),
patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]),
patch(
"openjarvis.cli.doctor_cmd._check_speech_backend",
return_value=CheckResult("Speech backend", "ok", "mock ready"),
),
):
result = CliRunner().invoke(cli, ["doctor", "--json"])
assert result.exit_code == 0
@@ -142,6 +152,49 @@ class TestCheckDefaultModel:
assert "auto" in result.message.lower()
class TestCheckSpeechBackend:
def test_check_speech_backend_ready(self) -> None:
backend = MagicMock()
backend.backend_id = "faster-whisper"
backend.health.return_value = True
with patch(
"openjarvis.speech._discovery.get_speech_backend",
return_value=backend,
):
result = _check_speech_backend()
assert result.status == "ok"
assert "faster-whisper" in result.message
def test_check_speech_backend_reports_load_error(self) -> None:
backend = MagicMock()
backend.backend_id = "faster-whisper"
backend.health.return_value = False
backend.last_error.return_value = "missing cublas64_12.dll"
with patch(
"openjarvis.speech._discovery.get_speech_backend",
return_value=backend,
):
result = _check_speech_backend()
assert result.status == "warn"
assert "faster-whisper unavailable" in result.message
assert result.details == "missing cublas64_12.dll"
def test_check_speech_backend_missing_uses_desktop_hint(self) -> None:
with patch(
"openjarvis.speech._discovery.get_speech_backend",
return_value=None,
):
result = _check_speech_backend()
assert result.status == "warn"
assert result.details is not None
assert "uv sync --extra desktop" in result.details
class TestCheckNodejs:
def test_check_nodejs_found(self) -> None:
"""Node.js check reports version when node is available."""
+65
View File
@@ -9,6 +9,7 @@ from click.testing import CliRunner
from openjarvis.cli import cli
from openjarvis.core.registry import MemoryRegistry
from openjarvis.memory.store import LocalFactStore
from openjarvis.tools.storage.sqlite import SQLiteMemory
@@ -108,3 +109,67 @@ def test_memory_stats_shows_count(tmp_path: Path, monkeypatch):
assert result.exit_code == 0
assert "2" in result.output
backend.close()
def _patch_fact_store(monkeypatch, tmp_path: Path) -> LocalFactStore:
"""Point ``jarvis memory list/clear`` at a temp fact store."""
mod = importlib.import_module("openjarvis.cli.memory_cmd")
store = LocalFactStore(tmp_path / "facts.jsonl")
monkeypatch.setattr(mod, "_get_fact_store", lambda: store)
return store
def test_memory_list_empty(tmp_path: Path, monkeypatch):
_patch_fact_store(monkeypatch, tmp_path)
result = CliRunner().invoke(cli, ["memory", "list"])
assert result.exit_code == 0
assert "No memory facts" in result.output
def test_memory_list_shows_facts(tmp_path: Path, monkeypatch):
store = _patch_fact_store(monkeypatch, tmp_path)
store.add("User prefers dark mode")
store.add("User lives in Berlin")
result = CliRunner().invoke(cli, ["memory", "list"])
assert result.exit_code == 0
assert "dark mode" in result.output
assert "Berlin" in result.output
def test_memory_clear_with_confirmation(tmp_path: Path, monkeypatch):
store = _patch_fact_store(monkeypatch, tmp_path)
store.add("fact one")
store.add("fact two")
result = CliRunner().invoke(cli, ["memory", "clear"], input="y\n")
assert result.exit_code == 0
assert "Cleared 2" in result.output
assert store.count() == 0
def test_memory_clear_aborted(tmp_path: Path, monkeypatch):
store = _patch_fact_store(monkeypatch, tmp_path)
store.add("keep me")
result = CliRunner().invoke(cli, ["memory", "clear"], input="n\n")
assert result.exit_code == 0
assert "Aborted" in result.output
assert store.count() == 1
def test_memory_clear_yes_flag(tmp_path: Path, monkeypatch):
store = _patch_fact_store(monkeypatch, tmp_path)
store.add("fact")
result = CliRunner().invoke(cli, ["memory", "clear", "--yes"])
assert result.exit_code == 0
assert "Cleared 1" in result.output
assert store.count() == 0
def test_memory_clear_empty(tmp_path: Path, monkeypatch):
_patch_fact_store(monkeypatch, tmp_path)
result = CliRunner().invoke(cli, ["memory", "clear"])
assert result.exit_code == 0
assert "No memory facts to clear" in result.output
+180 -4
View File
@@ -1,7 +1,14 @@
"""Tests for Docker and deployment files."""
"""Tests for Docker and deployment files.
These are static file-content checks (no Docker daemon required) so they run in
the default CI lane. They guard the deployment hardening from #228 and its
sub-issues (#563 image pinning, #564 systemd hardening, #565 non-root, #566
secure Node install, #567 frozen lockfile installs).
"""
from __future__ import annotations
import re
from pathlib import Path
try:
@@ -11,6 +18,35 @@ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
ROOT = Path(__file__).resolve().parent.parent.parent
DOCKER_DIR = ROOT / "deploy" / "docker"
SYSTEMD_DIR = ROOT / "deploy" / "systemd"
def _dockerfiles() -> list[Path]:
return sorted(DOCKER_DIR.glob("Dockerfile*"))
def _compose_files() -> list[Path]:
return sorted(DOCKER_DIR.glob("docker-compose*.yml"))
def _from_lines(content: str) -> list[str]:
return [
ln.strip()
for ln in content.splitlines()
if ln.strip().upper().startswith("FROM ")
]
def _image_lines(content: str) -> list[str]:
"""`image: ...` lines from a compose file, ignoring comments."""
out = []
for ln in content.splitlines():
stripped = ln.strip()
if stripped.startswith("#"):
continue
if stripped.startswith("image:"):
out.append(stripped)
return out
class TestDockerFiles:
@@ -37,10 +73,12 @@ class TestDockerFiles:
]
non_src_includes = [s for s in force_include if not s.startswith("src/")]
install_marker = 'uv pip install --system ".[server]"'
# The project itself is built by the final `--no-deps .` install (#567);
# the force-includes must be present before that step.
install_marker = "uv pip install --system --no-deps ."
wheel_dockerfiles = [
p
for p in sorted(DOCKER_DIR.glob("Dockerfile*"))
for p in _dockerfiles()
if install_marker in p.read_text() and "COPY src/ src/" in p.read_text()
]
# Sanity: we actually found the wheel-building Dockerfiles to guard.
@@ -86,4 +124,142 @@ class TestDockerFiles:
assert "ollama:" in content
def test_systemd_service_exists(self):
assert (ROOT / "deploy" / "systemd" / "openjarvis.service").is_file()
assert (SYSTEMD_DIR / "openjarvis.service").is_file()
class TestImagePinning:
"""#563 — base images and ollama pinned to fixed versions + digests."""
def test_no_floating_latest_tag_in_image_directives(self):
# `:latest` only acceptable inside comments (rationale text).
for path in _dockerfiles() + _compose_files():
for ln in path.read_text().splitlines():
code = ln.split("#", 1)[0]
assert ":latest" not in code, f"{path.name}: floating :latest in {ln!r}"
def test_every_from_pins_a_digest(self):
for path in _dockerfiles():
froms = _from_lines(path.read_text())
assert froms, f"{path.name}: no FROM lines found"
for ln in froms:
assert "@sha256:" in ln, (
f"{path.name}: FROM is not digest-pinned: {ln!r}"
)
def test_ollama_image_pinned_with_version_and_digest(self):
for path in _compose_files():
for ln in _image_lines(path.read_text()):
if "ollama/ollama" in ln:
assert "@sha256:" in ln, f"{path.name}: ollama not digest-pinned"
# A concrete version tag (digits) must accompany the digest.
assert re.search(r"ollama/ollama:\d", ln), (
f"{path.name}: ollama missing a version tag"
)
class TestNonRootUser:
"""#565 — GPU images (and the base image) drop root."""
NON_ROOT = ["Dockerfile", "Dockerfile.gpu", "Dockerfile.gpu.rocm"]
def test_creates_and_switches_to_non_root_user(self):
for name in self.NON_ROOT:
content = (DOCKER_DIR / name).read_text()
assert "useradd" in content, f"{name}: no useradd"
# A USER directive switching away from root must exist and not be root.
user_lines = [
ln.strip()
for ln in content.splitlines()
if ln.strip().upper().startswith("USER ")
]
assert user_lines, f"{name}: no USER directive"
assert all("root" not in ln for ln in user_lines), (
f"{name}: USER directive still root"
)
def test_user_directive_is_last_stage(self):
# USER must appear after the final FROM so the runtime stage is non-root.
for name in self.NON_ROOT:
content = (DOCKER_DIR / name).read_text()
last_from = content.rfind("\nFROM ")
user_idx = content.rfind("\nUSER ")
assert user_idx > last_from, f"{name}: USER not in final runtime stage"
class TestSandboxNodeSecurity:
"""#566 — Node installed without an unverified curl|bash pipe."""
def test_no_curl_pipe_bash(self):
content = (DOCKER_DIR / "Dockerfile.sandbox").read_text()
for ln in content.splitlines():
code = ln.split("#", 1)[0] # ignore the explanatory comment
assert "| bash" not in code and "|bash" not in code, (
f"unverified pipe-to-bash install remains: {ln!r}"
)
assert "nodesource.com" not in code, "still using NodeSource setup script"
def test_node_sourced_from_pinned_official_image(self):
content = (DOCKER_DIR / "Dockerfile.sandbox").read_text()
assert "COPY --from=node" in content, "Node not copied from pinned image stage"
froms = _from_lines(content)
assert any("node:" in ln and "@sha256:" in ln for ln in froms), (
"no digest-pinned node base stage"
)
class TestFrozenInstall:
"""#567 — Docker builds install from the committed, frozen uv.lock."""
BUILD_DOCKERFILES = [
"Dockerfile",
"Dockerfile.gpu",
"Dockerfile.gpu.rocm",
"Dockerfile.sandbox",
]
def test_copies_lockfile(self):
for name in self.BUILD_DOCKERFILES:
content = (DOCKER_DIR / name).read_text()
assert "uv.lock" in content, f"{name}: uv.lock not referenced"
def test_uses_frozen_export(self):
for name in self.BUILD_DOCKERFILES:
content = (DOCKER_DIR / name).read_text()
assert "uv export --frozen" in content, f"{name}: no frozen export"
assert "--no-deps" in content, (
f"{name}: deps re-resolved (missing --no-deps)"
)
def test_no_unpinned_pyproject_resolution(self):
# The old `uv pip install --system ".[server]"` re-resolved deps on every
# build; it must not come back.
for name in self.BUILD_DOCKERFILES:
content = (DOCKER_DIR / name).read_text()
assert '".[server]"' not in content, (
f"{name}: still re-resolves from pyproject"
)
class TestSystemdHardening:
"""#564 — systemd unit ships secrets via EnvironmentFile and is sandboxed."""
def _service(self) -> str:
return (SYSTEMD_DIR / "openjarvis.service").read_text()
def test_environment_file_for_secrets(self):
assert "EnvironmentFile=" in self._service()
def test_core_hardening_directives_present(self):
content = self._service()
for directive in (
"NoNewPrivileges=true",
"ProtectSystem=strict",
"PrivateTmp=true",
):
assert directive in content, f"missing hardening directive: {directive}"
def test_writable_state_path_declared(self):
# ProtectSystem=strict makes the FS read-only; the working/home dir must
# be re-granted write access or the server cannot persist config/state.
content = self._service()
assert "ReadWritePaths=" in content
+176 -1
View File
@@ -11,7 +11,7 @@ import respx
from openjarvis.core.registry import EngineRegistry
from openjarvis.core.types import Message, Role
from openjarvis.engine._base import EngineConnectionError
from openjarvis.engine.ollama import OllamaEngine
from openjarvis.engine.ollama import OllamaEngine, _is_control_token_only_args
@pytest.fixture()
@@ -82,6 +82,181 @@ class TestOllamaHealth:
assert engine.health() is False
class TestControlTokenFilter:
"""Qwen3 ``/think`` / ``/no_think`` soft-switch tokens sometimes leak into
tool-call arguments on small models (e.g. ``{"command": "/no_think"}``).
Such a call is never valid and must be dropped before execution.
"""
@pytest.mark.parametrize(
"raw_args",
[
{"command": "/no_think"},
{"command": "/think"},
{"command": " /no_think "},
{"command": "/NO_THINK"},
"/no_think",
json.dumps({"command": "/no_think"}),
{"command": "/no_think", "note": ""},
],
)
def test_detects_control_token_only(self, raw_args) -> None:
assert _is_control_token_only_args(raw_args) is True
@pytest.mark.parametrize(
"raw_args",
[
{"command": "date"},
{"command": "echo /no_think"},
{"query": "what is /no_think"},
{"command": "date", "note": "/no_think"},
{"timeout": 30},
{},
"date",
"not json at all",
],
)
def test_keeps_legitimate_args(self, raw_args) -> None:
assert _is_control_token_only_args(raw_args) is False
class TestOllamaGenerateControlToken:
def test_generate_drops_control_token_tool_call(self, engine: OllamaEngine) -> None:
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(
200,
json={
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "/no_think"},
}
}
],
},
"model": "qwen3:14b",
},
)
)
result = engine.generate(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
)
assert not result.get("tool_calls")
def test_generate_keeps_valid_tool_call(self, engine: OllamaEngine) -> None:
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(
200,
json={
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "date"},
}
}
],
},
"model": "qwen3:14b",
},
)
)
result = engine.generate(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
)
assert len(result["tool_calls"]) == 1
assert json.loads(result["tool_calls"][0]["arguments"]) == {"command": "date"}
def test_generate_drops_only_control_token_among_many(
self, engine: OllamaEngine
) -> None:
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(
200,
json={
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "/no_think"},
}
},
{
"function": {
"name": "shell_exec",
"arguments": {"command": "date"},
}
},
],
},
"model": "qwen3:14b",
},
)
)
result = engine.generate(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
)
assert len(result["tool_calls"]) == 1
assert json.loads(result["tool_calls"][0]["arguments"]) == {"command": "date"}
class TestOllamaStreamFullControlToken:
@pytest.mark.asyncio
async def test_stream_full_drops_control_token_tool_call(
self, engine: OllamaEngine
) -> None:
lines = [
json.dumps(
{
"message": {
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "/no_think"},
}
}
],
},
"done": True,
}
),
]
body = "\n".join(lines)
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(200, text=body)
)
chunks = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
):
chunks.append(chunk)
assert all(not c.tool_calls for c in chunks)
class TestOllamaStream:
@pytest.mark.asyncio
async def test_stream_yields_content(self, engine: OllamaEngine) -> None:
+105
View File
@@ -0,0 +1,105 @@
"""Tests for the LLM-backed fact extractor (openjarvis.memory.extractor)."""
from __future__ import annotations
from openjarvis.memory.extractor import FactExtractor
class FakeEngine:
"""Engine stub returning a canned completion (or raising)."""
def __init__(self, content="", *, raises=None):
self._content = content
self._raises = raises
self.calls = []
def generate(self, messages, *, model, temperature=0.7, max_tokens=1024, **kwargs):
self.calls.append((messages, model, temperature, max_tokens))
if self._raises is not None:
raise self._raises
return {"content": self._content}
def test_parses_json_array():
engine = FakeEngine('["User likes coffee", "User lives in Berlin"]')
extractor = FactExtractor(engine, "qwen3:14b")
facts = extractor.extract("I like coffee and live in Berlin", "Noted.")
assert facts == ["User likes coffee", "User lives in Berlin"]
def test_parses_json_array_wrapped_in_prose():
engine = FakeEngine('Sure! Here are the facts:\n["Fact A", "Fact B"]\nDone.')
extractor = FactExtractor(engine, "m")
assert extractor.extract("hi", "hello") == ["Fact A", "Fact B"]
def test_empty_array_returns_no_facts():
engine = FakeEngine("[]")
extractor = FactExtractor(engine, "m")
assert extractor.extract("just chatting", "ok") == []
def test_line_fallback_for_bullets():
engine = FakeEngine("- User is a teacher\n- User has two kids\n")
extractor = FactExtractor(engine, "m")
assert extractor.extract("about me", "noted") == [
"User is a teacher",
"User has two kids",
]
def test_dedupe_within_turn():
engine = FakeEngine('["likes tea", "Likes Tea", "likes tea"]')
extractor = FactExtractor(engine, "m")
assert extractor.extract("x", "y") == ["likes tea"]
def test_cap_facts_per_turn():
items = [f'"fact {i}"' for i in range(20)]
engine = FakeEngine("[" + ", ".join(items) + "]")
extractor = FactExtractor(engine, "m", max_facts_per_turn=3)
assert len(extractor.extract("x", "y")) == 3
def test_truncates_long_facts():
long_fact = "z" * 500
engine = FakeEngine(f'["{long_fact}"]')
extractor = FactExtractor(engine, "m", max_fact_chars=50)
facts = extractor.extract("x", "y")
assert len(facts) == 1
assert len(facts[0]) == 50
def test_empty_user_text_skips_engine():
engine = FakeEngine('["should not be called"]')
extractor = FactExtractor(engine, "m")
assert extractor.extract(" ", "y") == []
assert engine.calls == []
def test_broken_pipe_returns_empty():
engine = FakeEngine(raises=BrokenPipeError("client gone"))
extractor = FactExtractor(engine, "m")
# Must not raise — extraction is best-effort.
assert extractor.extract("hi", "hello") == []
def test_generic_exception_returns_empty():
engine = FakeEngine(raises=RuntimeError("ollama exploded"))
extractor = FactExtractor(engine, "m")
assert extractor.extract("hi", "hello") == []
def test_handles_non_dict_result():
class StrEngine:
def generate(self, *a, **k):
return '["plain string result"]'
extractor = FactExtractor(StrEngine(), "m")
assert extractor.extract("x", "y") == ["plain string result"]
def test_filters_non_fact_tokens():
engine = FakeEngine('["none", "N/A", "Real fact"]')
extractor = FactExtractor(engine, "m")
assert extractor.extract("x", "y") == ["Real fact"]
+112
View File
@@ -0,0 +1,112 @@
"""Tests for the persistent fact store (openjarvis.memory.store)."""
from __future__ import annotations
import json
import pytest
from openjarvis.memory.store import LocalFactStore, create_fact_store
def test_add_and_list(tmp_path):
store = LocalFactStore(tmp_path / "facts.jsonl")
assert store.add("User prefers concise answers") is True
assert store.add("User lives in Berlin") is True
facts = store.list()
assert [f.text for f in facts] == [
"User prefers concise answers",
"User lives in Berlin",
]
assert store.count() == 2
def test_add_dedupes_case_insensitive(tmp_path):
store = LocalFactStore(tmp_path / "facts.jsonl")
assert store.add("Likes coffee") is True
assert store.add("likes coffee") is False # duplicate
assert store.count() == 1
def test_add_skips_empty(tmp_path):
store = LocalFactStore(tmp_path / "facts.jsonl")
assert store.add("") is False
assert store.add(" ") is False
assert store.count() == 0
def test_add_many(tmp_path):
store = LocalFactStore(tmp_path / "facts.jsonl")
added = store.add_many(["a", "b", "a", "c"]) # one dupe
assert added == 3
assert store.count() == 3
def test_max_facts_evicts_oldest(tmp_path):
store = LocalFactStore(tmp_path / "facts.jsonl", max_facts=2)
store.add("first")
store.add("second")
store.add("third")
facts = [f.text for f in store.list()]
assert facts == ["second", "third"] # oldest dropped
def test_persistence_across_instances(tmp_path):
path = tmp_path / "facts.jsonl"
store = LocalFactStore(path)
store.add("durable fact")
reloaded = LocalFactStore(path)
assert [f.text for f in reloaded.list()] == ["durable fact"]
def test_clear(tmp_path):
path = tmp_path / "facts.jsonl"
store = LocalFactStore(path)
store.add("one")
store.add("two")
removed = store.clear()
assert removed == 2
assert store.count() == 0
# A fresh instance also sees an empty store.
assert LocalFactStore(path).count() == 0
def test_load_skips_malformed_lines(tmp_path):
path = tmp_path / "facts.jsonl"
path.write_text(
'{"text": "good fact"}\n'
"this is not json\n"
'{"text": ""}\n' # empty text ignored
'{"text": "another good"}\n',
encoding="utf-8",
)
store = LocalFactStore(path)
assert [f.text for f in store.list()] == ["good fact", "another good"]
def test_jsonl_round_trip_is_valid_json(tmp_path):
path = tmp_path / "facts.jsonl"
store = LocalFactStore(path)
store.add("fact one", source="auto")
lines = [
line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()
]
assert len(lines) == 1
obj = json.loads(lines[0])
assert obj["text"] == "fact one"
assert obj["source"] == "auto"
assert "created_at" in obj
def test_create_fact_store_local(tmp_path):
store = create_fact_store("local", path=tmp_path / "f.jsonl", max_facts=5)
assert isinstance(store, LocalFactStore)
def test_create_fact_store_unknown_backend(tmp_path):
with pytest.raises(ValueError):
create_fact_store("cloud", path=tmp_path / "f.jsonl")
+171
View File
@@ -0,0 +1,171 @@
"""Tests for the background memory service (openjarvis.memory.service)."""
from __future__ import annotations
import threading
import time
from types import SimpleNamespace
from openjarvis.core.config import StorageConfig
from openjarvis.memory.service import MemoryService, build_memory_service
from openjarvis.memory.store import LocalFactStore
def _wait_until(predicate, timeout=2.0, interval=0.01):
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(interval)
return predicate()
class FakeExtractor:
"""Extractor stub with controllable output, blocking and failures."""
def __init__(self, facts=None, *, raises=None, gate=None):
self._facts = facts or []
self._raises = raises
self._gate = gate # optional threading.Event to block on
self.calls = []
def extract(self, user_text, assistant_text=""):
self.calls.append((user_text, assistant_text))
if self._gate is not None:
self._gate.wait(timeout=2.0)
if self._raises is not None:
raise self._raises
return list(self._facts)
def _service(tmp_path, extractor, **kwargs):
store = LocalFactStore(tmp_path / "facts.jsonl")
return MemoryService(store, extractor, **kwargs)
def test_start_stop_lifecycle(tmp_path):
svc = _service(tmp_path, FakeExtractor())
assert svc.is_running is False
svc.start()
assert svc.is_running is True
svc.start() # idempotent
assert svc.is_running is True
svc.stop()
assert svc.is_running is False
svc.stop() # idempotent
def test_submit_extracts_and_stores(tmp_path):
extractor = FakeExtractor(["User likes hiking"])
svc = _service(tmp_path, extractor)
svc.start()
try:
assert svc.submit("I love hiking", "Nice!") is True
assert _wait_until(lambda: svc.fact_count() == 1)
assert [f.text for f in svc.list_facts()] == ["User likes hiking"]
finally:
svc.stop()
def test_submit_when_not_running_is_dropped(tmp_path):
extractor = FakeExtractor(["x"])
svc = _service(tmp_path, extractor)
assert svc.submit("hi", "there") is False
assert extractor.calls == []
def test_submit_empty_user_text_dropped(tmp_path):
extractor = FakeExtractor(["x"])
svc = _service(tmp_path, extractor)
svc.start()
try:
assert svc.submit(" ", "y") is False
finally:
svc.stop()
def test_worker_survives_extractor_broken_pipe(tmp_path):
"""A BrokenPipeError in one job must not kill the worker."""
extractor = FakeExtractor(raises=BrokenPipeError("client gone"))
svc = _service(tmp_path, extractor)
svc.start()
try:
svc.submit("first", "a")
assert _wait_until(lambda: len(extractor.calls) == 1)
# Service is still alive and accepting work.
assert svc.is_running is True
assert svc.submit("second", "b") is True
assert _wait_until(lambda: len(extractor.calls) == 2)
finally:
svc.stop()
def test_worker_survives_generic_exception(tmp_path):
extractor = FakeExtractor(raises=RuntimeError("boom"))
svc = _service(tmp_path, extractor)
svc.start()
try:
svc.submit("x", "y")
assert _wait_until(lambda: len(extractor.calls) == 1)
assert svc.is_running is True
finally:
svc.stop()
def test_submit_returns_false_when_queue_full(tmp_path):
"""Backpressure: a full queue drops work instead of blocking the caller."""
gate = threading.Event()
extractor = FakeExtractor(["fact"], gate=gate)
svc = _service(tmp_path, extractor, max_queue=1)
svc.start()
try:
# First submit is pulled by the worker and blocks on the gate.
assert svc.submit("job1", "a") is True
assert _wait_until(lambda: len(extractor.calls) == 1)
# Fill the (size-1) queue, then the next submit must be dropped.
assert svc.submit("job2", "b") is True
dropped = svc.submit("job3", "c")
assert dropped is False
finally:
gate.set()
svc.stop()
def test_build_memory_service_disabled_returns_none(tmp_path):
cfg = SimpleNamespace(memory=StorageConfig(enabled=False))
assert build_memory_service(cfg, object(), "model") is None
def test_build_memory_service_no_engine_returns_none(tmp_path):
cfg = SimpleNamespace(memory=StorageConfig(enabled=True))
assert build_memory_service(cfg, None, "model") is None
def test_build_memory_service_no_model_returns_none(tmp_path):
cfg = SimpleNamespace(memory=StorageConfig(enabled=True, extraction_model=""))
assert build_memory_service(cfg, object(), "") is None
def test_build_memory_service_enabled(tmp_path):
cfg = SimpleNamespace(
memory=StorageConfig(
enabled=True,
extraction_model="qwen3:14b",
facts_path=str(tmp_path / "facts.jsonl"),
max_facts=10,
)
)
svc = build_memory_service(cfg, object(), "fallback-model")
assert isinstance(svc, MemoryService)
def test_build_memory_service_falls_back_to_default_model(tmp_path):
cfg = SimpleNamespace(
memory=StorageConfig(
enabled=True,
extraction_model="",
facts_path=str(tmp_path / "facts.jsonl"),
)
)
svc = build_memory_service(cfg, object(), "active-model")
assert isinstance(svc, MemoryService)
+62
View File
@@ -74,6 +74,68 @@ def client_with_agent():
# ---------------------------------------------------------------------------
class _SpyMemoryService:
"""Minimal stand-in capturing memory submissions."""
def __init__(self) -> None:
self.submissions: list[tuple[str, str]] = []
def submit(self, user_text: str, assistant_text: str = "") -> bool:
self.submissions.append((user_text, assistant_text))
return True
def stop(self, timeout: float = 2.0) -> None:
pass
class TestMemoryServiceWiring:
def test_non_streaming_completion_feeds_memory(self):
engine = _make_engine(content="remembered reply")
spy = _SpyMemoryService()
app = create_app(engine, "test-model", memory_service=spy)
client = TestClient(app)
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "I like jazz"}],
},
)
assert resp.status_code == 200
assert spy.submissions == [("I like jazz", "remembered reply")]
def test_agent_completion_feeds_memory(self):
engine = _make_engine()
agent = _make_agent(content="agent reply")
spy = _SpyMemoryService()
app = create_app(engine, "test-model", agent=agent, memory_service=spy)
client = TestClient(app)
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "remember this"}],
},
)
assert resp.status_code == 200
assert spy.submissions == [("remember this", "agent reply")]
def test_no_memory_service_is_noop(self):
engine = _make_engine()
app = create_app(engine, "test-model") # memory_service defaults to None
client = TestClient(app)
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert resp.status_code == 200
class TestChatCompletions:
def test_basic_completion(self, client):
resp = client.post(
+26
View File
@@ -56,6 +56,18 @@ def test_transcribe_endpoint(client, mock_speech_backend):
assert data["duration_seconds"] == 1.5
def test_transcribe_endpoint_surfaces_backend_error(client, mock_speech_backend):
mock_speech_backend.transcribe.side_effect = RuntimeError("missing cublas64_12.dll")
response = client.post(
"/v1/speech/transcribe",
files={"file": ("test.wav", b"fake audio data", "audio/wav")},
)
assert response.status_code == 500
assert "missing cublas64_12.dll" in response.json()["detail"]
def test_transcribe_no_file(client):
response = client.post("/v1/speech/transcribe")
assert response.status_code == 400 or response.status_code == 422
@@ -69,6 +81,20 @@ def test_health_endpoint(client):
assert data["backend"] == "mock"
def test_health_endpoint_includes_unavailable_reason(client, mock_speech_backend):
mock_speech_backend.health.return_value = False
mock_speech_backend.last_error.return_value = (
"Install with: uv sync --extra desktop"
)
response = client.get("/v1/speech/health")
assert response.status_code == 200
data = response.json()
assert data["available"] is False
assert data["reason"] == "Install with: uv sync --extra desktop"
def test_health_no_backend():
from fastapi import FastAPI
from fastapi.testclient import TestClient
+48 -4
View File
@@ -53,17 +53,61 @@ def test_faster_whisper_transcribe():
assert result.duration_seconds == 1.5
def test_faster_whisper_falls_back_from_unsupported_float16():
mock_model = MagicMock()
with (
patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
) as mock_whisper,
patch(
"openjarvis.speech.faster_whisper.ctranslate2",
MagicMock(
get_supported_compute_types=MagicMock(return_value={"float32", "int8"})
),
),
):
backend = FasterWhisperBackend(
model_size="base",
device="cpu",
compute_type="float16",
)
assert backend._ensure_model() is mock_model
mock_whisper.assert_called_once_with("base", device="cpu", compute_type="int8")
def test_faster_whisper_missing_dependency_hint_uses_desktop_extra():
with patch("openjarvis.speech.faster_whisper.WhisperModel", new=None):
backend = FasterWhisperBackend()
with pytest.raises(ImportError) as excinfo:
backend._ensure_model()
assert "uv sync --extra desktop" in str(excinfo.value)
assert "uv sync --extra speech" not in str(excinfo.value)
def test_faster_whisper_health_no_model():
"""Health returns False before model is loaded."""
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
new=None,
):
from openjarvis.speech.faster_whisper import FasterWhisperBackend
backend = FasterWhisperBackend.__new__(FasterWhisperBackend)
backend._model = None
backend = FasterWhisperBackend()
assert backend.health() is False
assert "uv sync --extra desktop" in (backend.last_error() or "")
def test_faster_whisper_health_captures_load_error():
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
side_effect=RuntimeError("missing cublas64_12.dll"),
):
backend = FasterWhisperBackend()
assert backend.health() is False
assert "missing cublas64_12.dll" in (backend.last_error() or "")
def test_faster_whisper_supported_formats():
Generated
+13 -1
View File
@@ -5065,6 +5065,13 @@ docs = [
{ name = "mkdocs-material" },
{ name = "mkdocstrings", extra = ["python"] },
]
desktop = [
{ name = "fastapi" },
{ name = "faster-whisper" },
{ name = "pydantic" },
{ name = "python-multipart" },
{ name = "uvicorn" },
]
energy-all = [
{ name = "amdsmi" },
{ name = "nvidia-ml-py" },
@@ -5200,7 +5207,9 @@ requires-dist = [
{ name = "docker", marker = "extra == 'sandbox-docker'", specifier = ">=7.0" },
{ name = "dspy", marker = "extra == 'learning-dspy'", specifier = ">=2.6" },
{ name = "faiss-cpu", marker = "extra == 'memory-faiss'", specifier = ">=1.7" },
{ name = "fastapi", marker = "extra == 'desktop'", specifier = ">=0.110" },
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.110" },
{ name = "faster-whisper", marker = "extra == 'desktop'", specifier = ">=1.0" },
{ name = "faster-whisper", marker = "extra == 'speech'", specifier = ">=1.0" },
{ name = "gepa", marker = "extra == 'learning-gepa'", specifier = ">=0.1" },
{ name = "google-api-python-client", marker = "extra == 'channel-gmail'", specifier = ">=2.0" },
@@ -5237,6 +5246,7 @@ requires-dist = [
{ name = "posthog", specifier = ">=3.0" },
{ name = "praw", marker = "extra == 'channel-reddit'", specifier = ">=7.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" },
{ name = "pydantic", marker = "extra == 'desktop'", specifier = ">=2.0" },
{ name = "pydantic", marker = "extra == 'server'", specifier = ">=2.0" },
{ name = "pygemma", marker = "extra == 'inference-gemma'", specifier = ">=0.1.3" },
{ name = "pymessenger", marker = "extra == 'channel-messenger'", specifier = ">=0.0.7" },
@@ -5244,6 +5254,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5" },
{ name = "python-multipart", marker = "extra == 'desktop'", specifier = ">=0.0.9" },
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.9" },
{ name = "python-telegram-bot", specifier = ">=22.6" },
{ name = "python-telegram-bot", marker = "extra == 'channel-telegram'", specifier = ">=21.0" },
@@ -5264,6 +5275,7 @@ requires-dist = [
{ name = "transformers", marker = "extra == 'orchestrator-training'", specifier = ">=4.40" },
{ name = "twilio", marker = "extra == 'channel-twilio'", specifier = ">=9.0" },
{ name = "twitchio", marker = "extra == 'channel-twitch'", specifier = ">=2.6" },
{ name = "uvicorn", marker = "extra == 'desktop'", specifier = ">=0.30" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.30" },
{ name = "viberbot", marker = "extra == 'channel-viber'", specifier = ">=1.0" },
{ name = "vllm", marker = "extra == 'inference-vllm'", specifier = ">=0.16.0" },
@@ -5274,7 +5286,7 @@ requires-dist = [
{ name = "zeus-ml", extras = ["apple"], marker = "extra == 'energy-apple'" },
{ name = "zulip", marker = "extra == 'channel-zulip'", specifier = ">=0.9" },
]
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "mining-pearl-vllm", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "mining-pearl-cpu", "framework-comparison", "docs"]
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "desktop", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "mining-pearl-vllm", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "mining-pearl-cpu", "framework-comparison", "docs"]
[package.metadata.requires-dev]
dev = [{ name = "maturin", specifier = ">=1.12.6" }]