mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d32f20f9b3 | ||
|
|
0b552cbcb5 | ||
|
|
19fd3c8d2b | ||
|
|
1fa80d8ecd | ||
|
|
b3f90691bf | ||
|
|
4ebf0839e7 | ||
|
|
b1e93d4ed0 | ||
|
|
eb2b612c7c | ||
|
|
560ec860df |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "130,895",
|
||||
"message": "137,874",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 130895,
|
||||
"last_updated": "2026-06-24T07:14:43Z",
|
||||
"total_clones": 137874,
|
||||
"last_updated": "2026-06-29T07:46:59Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -90,6 +90,11 @@
|
||||
"2026-06-20": 1437,
|
||||
"2026-06-21": 1426,
|
||||
"2026-06-22": 1350,
|
||||
"2026-06-23": 1468
|
||||
"2026-06-23": 1468,
|
||||
"2026-06-24": 1635,
|
||||
"2026-06-25": 1640,
|
||||
"2026-06-26": 1338,
|
||||
"2026-06-27": 1338,
|
||||
"2026-06-28": 1028
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,26 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra docs
|
||||
|
||||
# Inject the public Supabase anon key so the savings leaderboard works on
|
||||
# the published docs site. Missing/empty (e.g. fork PRs) leaves the
|
||||
# leaderboard gracefully disabled. The key is read from env (not inlined)
|
||||
# and JSON-encoded into a JS string literal to avoid any injection.
|
||||
- name: Inject leaderboard Supabase anon key
|
||||
env:
|
||||
OPENJARVIS_LEADERBOARD_ANON: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, pathlib
|
||||
|
||||
key = os.environ.get("OPENJARVIS_LEADERBOARD_ANON", "")
|
||||
pathlib.Path("docs/javascripts/leaderboard-config.js").write_text(
|
||||
"// Generated at docs-build time from the VITE_SUPABASE_ANON_KEY secret.\n"
|
||||
"window.OPENJARVIS_SUPABASE_ANON_KEY = " + json.dumps(key) + ";\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("leaderboard anon key:", "set" if key else "empty (leaderboard disabled)")
|
||||
PY
|
||||
|
||||
- name: Build documentation
|
||||
run: uv run mkdocs build
|
||||
|
||||
|
||||
@@ -4,16 +4,30 @@
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). `uv export --frozen`
|
||||
@@ -24,11 +38,13 @@ WORKDIR /app
|
||||
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
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
# 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 rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
@@ -36,7 +52,15 @@ COPY deploy/windows deploy/windows
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps .
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
|
||||
|
||||
@@ -4,20 +4,37 @@
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
|
||||
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 && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
|
||||
@@ -25,15 +42,25 @@ WORKDIR /app
|
||||
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
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN uv pip install --system --no-deps .
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3
|
||||
|
||||
@@ -4,20 +4,37 @@
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package (AMD ROCm 7.2)
|
||||
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 && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
|
||||
@@ -25,15 +42,25 @@ WORKDIR /app
|
||||
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
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN uv pip install --system --no-deps .
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644
|
||||
|
||||
@@ -7,20 +7,18 @@
|
||||
# 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
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
|
||||
|
||||
# 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 && \
|
||||
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
|
||||
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
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -31,12 +29,41 @@ WORKDIR /app
|
||||
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
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY . .
|
||||
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps .
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust/target
|
||||
|
||||
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/*
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# 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
|
||||
|
||||
LABEL openjarvis-sandbox=true
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Public Supabase config for the savings leaderboard.
|
||||
//
|
||||
// This file is loaded *before* leaderboard.js and supplies the anon key it
|
||||
// reads from `window.OPENJARVIS_SUPABASE_ANON_KEY`. The key is injected at
|
||||
// docs-build time from the VITE_SUPABASE_ANON_KEY repo secret (see
|
||||
// .github/workflows/docs.yml). It is intentionally empty here so that local
|
||||
// `mkdocs build` and fork pull requests — which have no secret — render the
|
||||
// graceful "Leaderboard not configured yet" message instead of failing.
|
||||
//
|
||||
// The anon key is public by design: Supabase Row-Level Security protects the
|
||||
// data, so shipping it in the public docs bundle is expected.
|
||||
window.OPENJARVIS_SUPABASE_ANON_KEY = "";
|
||||
Generated
+67
-52
@@ -11,7 +11,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -42,7 +42,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -3720,9 +3720,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3730,9 +3730,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
|
||||
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
@@ -3746,23 +3746,23 @@
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.10.1",
|
||||
"@tauri-apps/cli-darwin-x64": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
|
||||
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3777,9 +3777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
|
||||
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3794,9 +3794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
|
||||
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3811,13 +3811,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3828,13 +3831,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3845,13 +3851,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3862,13 +3871,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3879,13 +3891,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3896,9 +3911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3913,9 +3928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3930,9 +3945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -49,7 +49,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
@@ -127,6 +127,7 @@ markdown_extensions:
|
||||
- pymdownx.tilde
|
||||
|
||||
extra_javascript:
|
||||
- javascripts/leaderboard-config.js
|
||||
- javascripts/leaderboard.js
|
||||
- https://cdn.jsdelivr.net/npm/@docsearch/js@3
|
||||
- javascripts/docsearch-init.js
|
||||
|
||||
@@ -11,7 +11,9 @@ from rich.markdown import Markdown
|
||||
|
||||
from openjarvis.cli._tool_names import resolve_tool_names
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.memory import publish_completed_exchange
|
||||
|
||||
|
||||
def _read_input(prompt: str = "You> ") -> Optional[str]:
|
||||
@@ -57,6 +59,7 @@ def chat(
|
||||
console = Console(stderr=True)
|
||||
|
||||
config = load_config()
|
||||
bus = EventBus(record_history=False)
|
||||
|
||||
import dataclasses as _dc
|
||||
|
||||
@@ -97,12 +100,11 @@ def chat(
|
||||
if agent_key and agent_key != "none":
|
||||
try:
|
||||
import openjarvis.agents # noqa: F401 — trigger registration
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
if AgentRegistry.contains(agent_key):
|
||||
agent_cls = AgentRegistry.get(agent_key)
|
||||
kwargs: dict = {"bus": EventBus()}
|
||||
kwargs: dict = {"bus": bus}
|
||||
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
tool_names_list = resolve_tool_names(
|
||||
@@ -184,7 +186,7 @@ def chat(
|
||||
try:
|
||||
from openjarvis.memory import build_memory_service
|
||||
|
||||
memory_service = build_memory_service(config, engine, model)
|
||||
memory_service = build_memory_service(config, engine, model, event_bus=bus)
|
||||
if memory_service is not None:
|
||||
memory_service.start()
|
||||
console.print("[dim] Memory: active[/dim]")
|
||||
@@ -280,9 +282,12 @@ def chat(
|
||||
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)
|
||||
publish_completed_exchange(
|
||||
bus,
|
||||
user_input,
|
||||
content,
|
||||
source="cli.chat",
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Generation interrupted.[/dim]")
|
||||
except Exception as exc:
|
||||
|
||||
@@ -498,7 +498,12 @@ def serve(
|
||||
try:
|
||||
from openjarvis.memory import build_memory_service
|
||||
|
||||
memory_service = build_memory_service(config, engine, model_name)
|
||||
memory_service = build_memory_service(
|
||||
config,
|
||||
engine,
|
||||
model_name,
|
||||
event_bus=bus,
|
||||
)
|
||||
if memory_service is not None:
|
||||
memory_service.start()
|
||||
console.print(" Memory svc: [cyan]active[/cyan]")
|
||||
|
||||
@@ -932,7 +932,9 @@ class StorageConfig:
|
||||
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")
|
||||
facts_path: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "memory_facts.jsonl")
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatibility alias
|
||||
|
||||
@@ -27,6 +27,7 @@ class EventType(str, Enum):
|
||||
TOOL_CALL_END = "tool_call_end"
|
||||
MEMORY_STORE = "memory_store"
|
||||
MEMORY_RETRIEVE = "memory_retrieve"
|
||||
CHAT_EXCHANGE_COMPLETED = "chat_exchange_completed"
|
||||
AGENT_TURN_START = "agent_turn_start"
|
||||
AGENT_TURN_END = "agent_turn_end"
|
||||
TELEMETRY_RECORD = "telemetry_record"
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, Tuple, Type, Typ
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.agents._stubs import BaseAgent
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
from openjarvis.memory.store import FactStore
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -109,6 +110,10 @@ class MemoryRegistry(RegistryBase[Type["MemoryBackend"]]):
|
||||
"""Registry for memory / retrieval backends."""
|
||||
|
||||
|
||||
class FactStoreRegistry(RegistryBase[Type["FactStore"]]):
|
||||
"""Registry for automatic-memory fact store backends."""
|
||||
|
||||
|
||||
class AgentRegistry(RegistryBase[Type["BaseAgent"]]):
|
||||
"""Registry for agent implementations."""
|
||||
|
||||
@@ -170,6 +175,7 @@ __all__ = [
|
||||
"CompressionRegistry",
|
||||
"ConnectorRegistry",
|
||||
"EngineRegistry",
|
||||
"FactStoreRegistry",
|
||||
"LearningRegistry",
|
||||
"MemoryRegistry",
|
||||
"MinerRegistry",
|
||||
|
||||
@@ -9,7 +9,11 @@ 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.service import (
|
||||
MemoryService,
|
||||
build_memory_service,
|
||||
publish_completed_exchange,
|
||||
)
|
||||
from openjarvis.memory.store import (
|
||||
Fact,
|
||||
FactStore,
|
||||
@@ -25,4 +29,5 @@ __all__ = [
|
||||
"MemoryService",
|
||||
"build_memory_service",
|
||||
"create_fact_store",
|
||||
"publish_completed_exchange",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ import queue
|
||||
import threading
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.core.events import Event, EventBus, EventType
|
||||
from openjarvis.memory.extractor import FactExtractor
|
||||
from openjarvis.memory.store import Fact, FactStore, create_fact_store
|
||||
|
||||
@@ -37,10 +38,13 @@ class MemoryService:
|
||||
store: FactStore,
|
||||
extractor: FactExtractor,
|
||||
*,
|
||||
event_bus: EventBus | None = None,
|
||||
max_queue: int = 256,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._extractor = extractor
|
||||
self._event_bus = event_bus
|
||||
self._subscribed = False
|
||||
self._queue: "queue.Queue[Any]" = queue.Queue(maxsize=max(1, max_queue))
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._running = threading.Event()
|
||||
@@ -52,6 +56,7 @@ class MemoryService:
|
||||
if self._running.is_set():
|
||||
return
|
||||
self._running.set()
|
||||
self._subscribe_events()
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop,
|
||||
name="memory-service",
|
||||
@@ -73,6 +78,7 @@ class MemoryService:
|
||||
if thread is not None:
|
||||
thread.join(timeout=timeout)
|
||||
self._thread = None
|
||||
self._unsubscribe_events()
|
||||
logger.debug("Memory service stopped")
|
||||
|
||||
@property
|
||||
@@ -99,6 +105,34 @@ class MemoryService:
|
||||
logger.debug("Memory service queue full; dropping exchange")
|
||||
return False
|
||||
|
||||
def _subscribe_events(self) -> None:
|
||||
"""Subscribe to lifecycle events that feed automatic memory."""
|
||||
if self._event_bus is None or self._subscribed:
|
||||
return
|
||||
self._event_bus.subscribe(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
self._on_completed_exchange,
|
||||
)
|
||||
self._subscribed = True
|
||||
|
||||
def _unsubscribe_events(self) -> None:
|
||||
"""Unsubscribe from lifecycle events (idempotent)."""
|
||||
if self._event_bus is None or not self._subscribed:
|
||||
return
|
||||
self._event_bus.unsubscribe(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
self._on_completed_exchange,
|
||||
)
|
||||
self._subscribed = False
|
||||
|
||||
def _on_completed_exchange(self, event: Event) -> None:
|
||||
"""Queue a completed chat exchange published on the event bus."""
|
||||
data = event.data or {}
|
||||
self.submit(
|
||||
str(data.get("user_text", "") or ""),
|
||||
str(data.get("assistant_text", "") or ""),
|
||||
)
|
||||
|
||||
# -- worker -------------------------------------------------------------
|
||||
|
||||
def _loop(self) -> None:
|
||||
@@ -145,6 +179,8 @@ def build_memory_service(
|
||||
config: Any,
|
||||
engine: Any,
|
||||
default_model: str = "",
|
||||
*,
|
||||
event_bus: EventBus | None = None,
|
||||
) -> Optional[MemoryService]:
|
||||
"""Build a :class:`MemoryService` from config, or ``None`` if disabled.
|
||||
|
||||
@@ -170,11 +206,32 @@ def build_memory_service(
|
||||
|
||||
store = create_fact_store(
|
||||
getattr(mem, "backend", "local"),
|
||||
path=getattr(mem, "facts_path", "~/.openjarvis/memory_facts.jsonl"),
|
||||
path=getattr(mem, "facts_path", None),
|
||||
max_facts=getattr(mem, "max_facts", 1000),
|
||||
)
|
||||
extractor = FactExtractor(engine, model)
|
||||
return MemoryService(store, extractor)
|
||||
return MemoryService(store, extractor, event_bus=event_bus)
|
||||
|
||||
|
||||
__all__ = ["MemoryService", "build_memory_service"]
|
||||
def publish_completed_exchange(
|
||||
bus: EventBus | None,
|
||||
user_text: str,
|
||||
assistant_text: str = "",
|
||||
*,
|
||||
source: str = "",
|
||||
) -> bool:
|
||||
"""Publish a completed chat exchange for lifecycle subscribers."""
|
||||
if bus is None or not user_text or not user_text.strip():
|
||||
return False
|
||||
bus.publish(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
{
|
||||
"user_text": user_text,
|
||||
"assistant_text": assistant_text or "",
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["MemoryService", "build_memory_service", "publish_completed_exchange"]
|
||||
|
||||
@@ -18,6 +18,14 @@ from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import FactStoreRegistry
|
||||
|
||||
|
||||
def _default_fact_path() -> Path:
|
||||
"""Return the env-aware default JSONL path for automatic memory facts."""
|
||||
return get_config_dir() / "memory_facts.jsonl"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Fact:
|
||||
@@ -56,6 +64,7 @@ class FactStore(ABC):
|
||||
"""Return the number of stored facts."""
|
||||
|
||||
|
||||
@FactStoreRegistry.register("local")
|
||||
class LocalFactStore(FactStore):
|
||||
"""Append-only JSONL fact store on the local filesystem.
|
||||
|
||||
@@ -67,11 +76,13 @@ class LocalFactStore(FactStore):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
|
||||
path: str | Path | None = None,
|
||||
*,
|
||||
max_facts: int = 1000,
|
||||
) -> None:
|
||||
self._path = Path(path).expanduser()
|
||||
self._path = (
|
||||
Path(path).expanduser() if path is not None else _default_fact_path()
|
||||
)
|
||||
self._max_facts = max(0, int(max_facts))
|
||||
self._lock = threading.Lock()
|
||||
self._facts: List[Fact] = self._load()
|
||||
@@ -116,6 +127,10 @@ class LocalFactStore(FactStore):
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
os.replace(tmp, self._path)
|
||||
|
||||
def _sync_from_disk_locked(self) -> None:
|
||||
"""Refresh in-memory facts from disk while holding ``self._lock``."""
|
||||
self._facts = self._load()
|
||||
|
||||
# -- FactStore API ------------------------------------------------------
|
||||
|
||||
def add(self, text: str, source: str = "") -> bool:
|
||||
@@ -123,6 +138,7 @@ class LocalFactStore(FactStore):
|
||||
if not text:
|
||||
return False
|
||||
with self._lock:
|
||||
self._sync_from_disk_locked()
|
||||
lowered = text.lower()
|
||||
if any(f.text.lower() == lowered for f in self._facts):
|
||||
return False # dedupe
|
||||
@@ -135,10 +151,12 @@ class LocalFactStore(FactStore):
|
||||
|
||||
def list(self) -> List[Fact]:
|
||||
with self._lock:
|
||||
self._sync_from_disk_locked()
|
||||
return list(self._facts)
|
||||
|
||||
def clear(self) -> int:
|
||||
with self._lock:
|
||||
self._sync_from_disk_locked()
|
||||
removed = len(self._facts)
|
||||
self._facts = []
|
||||
if self._path.exists():
|
||||
@@ -150,6 +168,7 @@ class LocalFactStore(FactStore):
|
||||
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
self._sync_from_disk_locked()
|
||||
return len(self._facts)
|
||||
|
||||
@property
|
||||
@@ -158,22 +177,32 @@ class LocalFactStore(FactStore):
|
||||
return self._path
|
||||
|
||||
|
||||
def _ensure_fact_store_backends_registered() -> None:
|
||||
"""Restore built-in fact-store registrations if a test cleared registries."""
|
||||
if not FactStoreRegistry.contains("local"):
|
||||
FactStoreRegistry.register_value("local", LocalFactStore)
|
||||
|
||||
|
||||
def create_fact_store(
|
||||
backend: str = "local",
|
||||
*,
|
||||
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
|
||||
path: str | Path | None = None,
|
||||
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.
|
||||
registry-backed constructor exists so additional backends can be added
|
||||
without changing the service or CLI wiring.
|
||||
"""
|
||||
_ensure_fact_store_backends_registered()
|
||||
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")
|
||||
if not FactStoreRegistry.contains(key):
|
||||
supported = ", ".join(FactStoreRegistry.keys())
|
||||
raise ValueError(
|
||||
f"Unknown memory backend '{backend}'. Supported backends: {supported}"
|
||||
)
|
||||
return FactStoreRegistry.create(key, path, max_facts=max_facts)
|
||||
|
||||
|
||||
__all__ = ["Fact", "FactStore", "LocalFactStore", "create_fact_store"]
|
||||
|
||||
@@ -195,7 +195,13 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
# from the engine for true real-time output.
|
||||
if request_body.tools:
|
||||
return await _handle_stream_tools(
|
||||
engine, model, request_body, complexity_info, app_config=config
|
||||
engine,
|
||||
model,
|
||||
request_body,
|
||||
complexity_info,
|
||||
app_config=config,
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
memory_service=getattr(request.app.state, "memory_service", None),
|
||||
)
|
||||
return await _handle_stream(
|
||||
engine,
|
||||
@@ -204,6 +210,8 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
complexity_info,
|
||||
trace_store=getattr(request.app.state, "trace_store", None),
|
||||
app_config=config,
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
memory_service=getattr(request.app.state, "memory_service", None),
|
||||
)
|
||||
|
||||
# Non-streaming: use agent if available, otherwise direct engine call.
|
||||
@@ -247,20 +255,44 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
getattr(request.app.state, "memory_service", None),
|
||||
query_text_for_complexity,
|
||||
response,
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
source="server.chat",
|
||||
)
|
||||
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:
|
||||
def _response_content(response) -> str:
|
||||
"""Extract assistant text from an OpenAI-compatible response object."""
|
||||
content = ""
|
||||
choices = getattr(response, "choices", None)
|
||||
if choices:
|
||||
content = getattr(choices[0].message, "content", "") or ""
|
||||
return content
|
||||
|
||||
|
||||
def _record_completed_exchange(
|
||||
memory_service,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
*,
|
||||
bus=None,
|
||||
source: str = "server.chat",
|
||||
) -> None:
|
||||
"""Publish or submit a completed exchange without blocking a reply."""
|
||||
if 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)
|
||||
if bus is not None:
|
||||
from openjarvis.memory import publish_completed_exchange
|
||||
|
||||
publish_completed_exchange(
|
||||
bus,
|
||||
user_text,
|
||||
assistant_text,
|
||||
source=source,
|
||||
)
|
||||
elif memory_service is not None:
|
||||
memory_service.submit(user_text, assistant_text)
|
||||
except Exception: # noqa: BLE001 — memory is best-effort, never fail a reply
|
||||
logging.getLogger("openjarvis.server").debug(
|
||||
"Memory submit failed",
|
||||
@@ -268,6 +300,24 @@ def _remember_exchange(memory_service, user_text: str, response) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _remember_exchange(
|
||||
memory_service,
|
||||
user_text: str,
|
||||
response,
|
||||
*,
|
||||
bus=None,
|
||||
source: str = "server.chat",
|
||||
) -> None:
|
||||
"""Record a completed non-streaming exchange."""
|
||||
_record_completed_exchange(
|
||||
memory_service,
|
||||
user_text,
|
||||
_response_content(response),
|
||||
bus=bus,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _handle_direct(
|
||||
engine,
|
||||
model: str,
|
||||
@@ -457,6 +507,8 @@ async def _handle_stream_tools(
|
||||
complexity_info=None,
|
||||
*,
|
||||
app_config=None,
|
||||
bus=None,
|
||||
memory_service=None,
|
||||
):
|
||||
"""Stream a raw OpenAI-compat function-calling response via SSE.
|
||||
|
||||
@@ -477,8 +529,14 @@ async def _handle_stream_tools(
|
||||
messages = _ensure_identity_prompt(messages, app_config)
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||
use_cloud = is_cloud_model(model)
|
||||
query_text = ""
|
||||
for _m in reversed(req.messages):
|
||||
if _m.role == "user" and _m.content:
|
||||
query_text = _m.content
|
||||
break
|
||||
|
||||
async def generate():
|
||||
full_content = ""
|
||||
# Send the role chunk first (OpenAI convention).
|
||||
first_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
@@ -497,6 +555,7 @@ async def _handle_stream_tools(
|
||||
tools=req.tools,
|
||||
):
|
||||
if sc.content:
|
||||
full_content += sc.content
|
||||
content_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
@@ -553,6 +612,14 @@ async def _handle_stream_tools(
|
||||
if complexity_info is not None:
|
||||
finish_dict["complexity"] = complexity_info.model_dump()
|
||||
yield f"data: {_json.dumps(finish_dict)}\n\n"
|
||||
if full_content:
|
||||
_record_completed_exchange(
|
||||
memory_service,
|
||||
query_text,
|
||||
full_content,
|
||||
bus=bus,
|
||||
source="server.chat.stream",
|
||||
)
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
@@ -570,6 +637,8 @@ async def _handle_stream(
|
||||
*,
|
||||
trace_store=None,
|
||||
app_config=None,
|
||||
bus=None,
|
||||
memory_service=None,
|
||||
):
|
||||
"""Stream response using SSE format.
|
||||
|
||||
@@ -710,6 +779,15 @@ async def _handle_stream(
|
||||
ended_at=time.time(),
|
||||
)
|
||||
|
||||
if full_content:
|
||||
_record_completed_exchange(
|
||||
memory_service,
|
||||
query_text,
|
||||
full_content,
|
||||
bus=bus,
|
||||
source="server.chat.stream",
|
||||
)
|
||||
|
||||
# Send finish chunk with usage data if available
|
||||
import json as _json
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from openjarvis.agents._stubs import (
|
||||
)
|
||||
from openjarvis.cli.chat_cmd import _read_input, chat
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.events import Event, EventBus, EventType
|
||||
from openjarvis.core.registry import AgentRegistry, ToolRegistry
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
@@ -121,25 +122,45 @@ class TestChatAgents:
|
||||
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."""
|
||||
"""The REPL starts memory, publishes each turn, and stops it."""
|
||||
|
||||
class _SpyMemoryService:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, bus: EventBus) -> None:
|
||||
self.bus = bus
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.submissions: list[tuple[str, str]] = []
|
||||
|
||||
def start(self) -> None:
|
||||
self.started = True
|
||||
self.bus.subscribe(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
self._on_completed_exchange,
|
||||
)
|
||||
|
||||
def submit(self, user_text: str, assistant_text: str = "") -> bool:
|
||||
self.submissions.append((user_text, assistant_text))
|
||||
return True
|
||||
def _on_completed_exchange(self, event: Event) -> None:
|
||||
self.submissions.append(
|
||||
(
|
||||
event.data["user_text"],
|
||||
event.data.get("assistant_text", ""),
|
||||
)
|
||||
)
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
self.stopped = True
|
||||
self.bus.unsubscribe(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
self._on_completed_exchange,
|
||||
)
|
||||
|
||||
spy: _SpyMemoryService | None = None
|
||||
|
||||
def _build_memory_service(*args, event_bus: EventBus | None = None, **kwargs):
|
||||
nonlocal spy
|
||||
assert event_bus is not None
|
||||
spy = _SpyMemoryService(event_bus)
|
||||
return spy
|
||||
|
||||
spy = _SpyMemoryService()
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = {"content": "engine fallback"}
|
||||
@@ -154,7 +175,7 @@ class TestChatAgents:
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
patch(
|
||||
"openjarvis.memory.build_memory_service",
|
||||
return_value=spy,
|
||||
side_effect=_build_memory_service,
|
||||
),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
@@ -164,6 +185,7 @@ class TestChatAgents:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert spy is not None
|
||||
assert spy.started is True
|
||||
assert spy.stopped is True
|
||||
assert spy.submissions == [("hello", "simple ok")]
|
||||
|
||||
@@ -18,6 +18,7 @@ from openjarvis.core.registry import (
|
||||
CompressionRegistry,
|
||||
ConnectorRegistry,
|
||||
EngineRegistry,
|
||||
FactStoreRegistry,
|
||||
MemoryRegistry,
|
||||
MinerRegistry,
|
||||
ModelRegistry,
|
||||
@@ -35,6 +36,7 @@ def _clean_registries() -> None:
|
||||
ModelRegistry.clear()
|
||||
EngineRegistry.clear()
|
||||
MemoryRegistry.clear()
|
||||
FactStoreRegistry.clear()
|
||||
MinerRegistry.clear()
|
||||
AgentRegistry.clear()
|
||||
ToolRegistry.clear()
|
||||
|
||||
@@ -48,6 +48,7 @@ class TestConfigPhase5:
|
||||
with pytest.raises(KeyError):
|
||||
ModelRegistry.get("iso-test")
|
||||
|
||||
def test_load_config_default(self):
|
||||
cfg = load_config()
|
||||
def test_load_config_default(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
cfg = load_config(tmp_path / "missing-config.toml")
|
||||
assert isinstance(cfg, JarvisConfig)
|
||||
|
||||
@@ -123,6 +123,35 @@ class TestDockerFiles:
|
||||
assert "jarvis:" in content
|
||||
assert "ollama:" in content
|
||||
|
||||
def test_dockerfiles_build_native_rust_extension(self):
|
||||
build_dockerfiles = [
|
||||
"Dockerfile",
|
||||
"Dockerfile.gpu",
|
||||
"Dockerfile.gpu.rocm",
|
||||
"Dockerfile.sandbox",
|
||||
]
|
||||
required_markers = [
|
||||
"rustup toolchain install 1.88",
|
||||
"maturin build --release",
|
||||
"rust/crates/openjarvis-python/Cargo.toml",
|
||||
"/tmp/openjarvis-rust-wheel/*.whl",
|
||||
"import openjarvis_rust",
|
||||
]
|
||||
|
||||
for name in build_dockerfiles:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
for marker in required_markers:
|
||||
assert marker in content, (
|
||||
f"{name}: missing native build marker {marker!r}"
|
||||
)
|
||||
|
||||
for name in ["Dockerfile", "Dockerfile.gpu", "Dockerfile.gpu.rocm"]:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert "COPY rust/ rust/" in content, f"{name}: rust workspace not copied"
|
||||
assert content.index("COPY rust/ rust/") < content.index(
|
||||
"maturin build --release"
|
||||
), f"{name}: rust workspace copied after native build"
|
||||
|
||||
def test_systemd_service_exists(self):
|
||||
assert (SYSTEMD_DIR / "openjarvis.service").is_file()
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Static guards for the docs-site savings-leaderboard Supabase wiring.
|
||||
|
||||
`docs/javascripts/leaderboard.js` reads the public Supabase anon key from
|
||||
`window.OPENJARVIS_SUPABASE_ANON_KEY`. That global is set by a generated
|
||||
config file (`leaderboard-config.js`) which must load *before* leaderboard.js,
|
||||
and whose value is injected at docs-build time from the VITE_SUPABASE_ANON_KEY
|
||||
secret (see `.github/workflows/docs.yml`). These are text-only checks — no
|
||||
mkdocs build required — so they run in the default CI lane.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
MKDOCS = ROOT / "mkdocs.yml"
|
||||
DOCS_WORKFLOW = ROOT / ".github" / "workflows" / "docs.yml"
|
||||
CONFIG_JS = ROOT / "docs" / "javascripts" / "leaderboard-config.js"
|
||||
LEADERBOARD_JS = ROOT / "docs" / "javascripts" / "leaderboard.js"
|
||||
|
||||
_ANON_GLOBAL = "window.OPENJARVIS_SUPABASE_ANON_KEY"
|
||||
|
||||
|
||||
def test_config_js_declares_anon_key_global():
|
||||
assert CONFIG_JS.is_file(), "leaderboard-config.js is missing"
|
||||
assert _ANON_GLOBAL in CONFIG_JS.read_text()
|
||||
|
||||
|
||||
def test_leaderboard_reads_the_anon_key_global():
|
||||
# leaderboard.js must consume the global the config file sets.
|
||||
assert _ANON_GLOBAL in LEADERBOARD_JS.read_text()
|
||||
|
||||
|
||||
def test_config_is_loaded_before_leaderboard_in_mkdocs():
|
||||
content = MKDOCS.read_text()
|
||||
cfg = content.index("javascripts/leaderboard-config.js")
|
||||
lb = content.index("javascripts/leaderboard.js")
|
||||
assert cfg < lb, "leaderboard-config.js must be listed before leaderboard.js"
|
||||
|
||||
|
||||
def test_docs_workflow_injects_the_anon_key():
|
||||
content = DOCS_WORKFLOW.read_text()
|
||||
assert "VITE_SUPABASE_ANON_KEY" in content, "workflow doesn't read the secret"
|
||||
assert "leaderboard-config.js" in content, "workflow doesn't write the config file"
|
||||
assert _ANON_GLOBAL in content, "workflow doesn't set the anon-key global"
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.registry import FactStoreRegistry
|
||||
from openjarvis.memory.store import LocalFactStore, create_fact_store
|
||||
|
||||
|
||||
@@ -74,6 +75,20 @@ def test_clear(tmp_path):
|
||||
assert LocalFactStore(path).count() == 0
|
||||
|
||||
|
||||
def test_external_clear_does_not_resurrect_stale_facts(tmp_path):
|
||||
"""A running store instance must not re-flush facts cleared elsewhere."""
|
||||
path = tmp_path / "facts.jsonl"
|
||||
running = LocalFactStore(path)
|
||||
cli = LocalFactStore(path)
|
||||
|
||||
running.add("old fact")
|
||||
assert cli.clear() == 1
|
||||
|
||||
running.add("new fact")
|
||||
|
||||
assert [f.text for f in LocalFactStore(path).list()] == ["new fact"]
|
||||
|
||||
|
||||
def test_load_skips_malformed_lines(tmp_path):
|
||||
path = tmp_path / "facts.jsonl"
|
||||
path.write_text(
|
||||
@@ -107,6 +122,26 @@ def test_create_fact_store_local(tmp_path):
|
||||
assert isinstance(store, LocalFactStore)
|
||||
|
||||
|
||||
def test_create_fact_store_uses_fact_store_registry(tmp_path):
|
||||
class CustomFactStore(LocalFactStore):
|
||||
pass
|
||||
|
||||
FactStoreRegistry.register_value("custom", CustomFactStore)
|
||||
|
||||
store = create_fact_store("custom", path=tmp_path / "f.jsonl", max_facts=5)
|
||||
|
||||
assert isinstance(store, CustomFactStore)
|
||||
|
||||
|
||||
def test_create_fact_store_default_path_uses_openjarvis_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path))
|
||||
|
||||
store = create_fact_store("local")
|
||||
|
||||
assert isinstance(store, LocalFactStore)
|
||||
assert store.path == tmp_path / "memory_facts.jsonl"
|
||||
|
||||
|
||||
def test_create_fact_store_unknown_backend(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
create_fact_store("cloud", path=tmp_path / "f.jsonl")
|
||||
|
||||
@@ -7,7 +7,12 @@ import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openjarvis.core.config import StorageConfig
|
||||
from openjarvis.memory.service import MemoryService, build_memory_service
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.memory.service import (
|
||||
MemoryService,
|
||||
build_memory_service,
|
||||
publish_completed_exchange,
|
||||
)
|
||||
from openjarvis.memory.store import LocalFactStore
|
||||
|
||||
|
||||
@@ -67,6 +72,38 @@ def test_submit_extracts_and_stores(tmp_path):
|
||||
svc.stop()
|
||||
|
||||
|
||||
def test_completed_exchange_event_extracts_and_stores(tmp_path):
|
||||
bus = EventBus(record_history=True)
|
||||
extractor = FakeExtractor(["User likes jazz"])
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
svc = MemoryService(store, extractor, event_bus=bus)
|
||||
svc.start()
|
||||
try:
|
||||
assert publish_completed_exchange(
|
||||
bus,
|
||||
"I like jazz",
|
||||
"Noted.",
|
||||
source="test",
|
||||
)
|
||||
assert _wait_until(lambda: svc.fact_count() == 1)
|
||||
assert extractor.calls == [("I like jazz", "Noted.")]
|
||||
finally:
|
||||
svc.stop()
|
||||
|
||||
|
||||
def test_completed_exchange_event_unsubscribes_on_stop(tmp_path):
|
||||
bus = EventBus(record_history=True)
|
||||
extractor = FakeExtractor(["User likes jazz"])
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
svc = MemoryService(store, extractor, event_bus=bus)
|
||||
svc.start()
|
||||
svc.stop()
|
||||
|
||||
publish_completed_exchange(bus, "I like jazz", "Noted.", source="test")
|
||||
|
||||
assert extractor.calls == []
|
||||
|
||||
|
||||
def test_submit_when_not_running_is_dropped(tmp_path):
|
||||
extractor = FakeExtractor(["x"])
|
||||
svc = _service(tmp_path, extractor)
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_path_traversal_rejected(bad):
|
||||
SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name=bad))
|
||||
|
||||
|
||||
def test_none_persona_build_does_not_raise():
|
||||
def test_none_persona_build_does_not_raise(tmp_path, monkeypatch):
|
||||
"""Regression (#497): `--persona none` resolves to empty file paths; building
|
||||
the prompt must not raise IsADirectoryError when those empty paths are read
|
||||
(Path("") is "." — reading a directory raised before the empty-path guard).
|
||||
@@ -42,7 +42,8 @@ def test_none_persona_build_does_not_raise():
|
||||
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
cfg = load_config(tmp_path / "missing-config.toml")
|
||||
mf = dataclasses.replace(cfg.memory_files, persona_name="none")
|
||||
builder = SystemPromptBuilder(
|
||||
agent_template=cfg.agent.default_system_prompt or "",
|
||||
|
||||
+130
-19
@@ -10,6 +10,7 @@ import pytest
|
||||
fastapi = pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType # noqa: E402
|
||||
from openjarvis.server.app import create_app # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -54,10 +55,19 @@ def _make_agent(content="Hello from agent"):
|
||||
return agent
|
||||
|
||||
|
||||
def _test_config():
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
cfg = JarvisConfig()
|
||||
cfg.analytics.enabled = False
|
||||
cfg.traces.enabled = False
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -65,7 +75,7 @@ def client():
|
||||
def client_with_agent():
|
||||
engine = _make_engine()
|
||||
agent = _make_agent()
|
||||
app = create_app(engine, "test-model", agent=agent)
|
||||
app = create_app(engine, "test-model", agent=agent, config=_test_config())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -92,7 +102,12 @@ 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)
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
memory_service=spy,
|
||||
config=_test_config(),
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
@@ -109,7 +124,13 @@ class TestMemoryServiceWiring:
|
||||
engine = _make_engine()
|
||||
agent = _make_agent(content="agent reply")
|
||||
spy = _SpyMemoryService()
|
||||
app = create_app(engine, "test-model", agent=agent, memory_service=spy)
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
agent=agent,
|
||||
memory_service=spy,
|
||||
config=_test_config(),
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
@@ -122,9 +143,83 @@ class TestMemoryServiceWiring:
|
||||
assert resp.status_code == 200
|
||||
assert spy.submissions == [("remember this", "agent reply")]
|
||||
|
||||
def test_non_streaming_completion_publishes_completed_exchange(self):
|
||||
bus = EventBus(record_history=True)
|
||||
engine = _make_engine(content="event reply")
|
||||
app = create_app(engine, "test-model", bus=bus, config=_test_config())
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "publish this"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = [
|
||||
e for e in bus.history if e.event_type == EventType.CHAT_EXCHANGE_COMPLETED
|
||||
]
|
||||
assert len(events) == 1
|
||||
assert events[0].data["user_text"] == "publish this"
|
||||
assert events[0].data["assistant_text"] == "event reply"
|
||||
|
||||
def test_streaming_completion_feeds_memory_without_bus(self):
|
||||
engine = _make_engine()
|
||||
spy = _SpyMemoryService()
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
memory_service=spy,
|
||||
config=_test_config(),
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "stream remember"}],
|
||||
"stream": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "data:" in resp.text
|
||||
assert spy.submissions == [("stream remember", "Hello world")]
|
||||
|
||||
def test_streaming_completion_publishes_completed_exchange(self):
|
||||
bus = EventBus(record_history=True)
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model", bus=bus, config=_test_config())
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "stream event"}],
|
||||
"stream": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "data:" in resp.text
|
||||
events = [
|
||||
e for e in bus.history if e.event_type == EventType.CHAT_EXCHANGE_COMPLETED
|
||||
]
|
||||
assert len(events) == 1
|
||||
assert events[0].data["user_text"] == "stream event"
|
||||
assert events[0].data["assistant_text"] == "Hello world"
|
||||
|
||||
def test_no_memory_service_is_noop(self):
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model") # memory_service defaults to None
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
config=_test_config(),
|
||||
) # memory_service defaults to None
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -207,7 +302,7 @@ class TestChatCompletions:
|
||||
"model": "test-model",
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -255,7 +350,7 @@ class TestChatCompletions:
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
agent = _make_agent(content="GENERIC AGENT FILLER")
|
||||
app = create_app(engine, "test-model", agent=agent)
|
||||
app = create_app(engine, "test-model", agent=agent, config=_test_config())
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
@@ -342,7 +437,7 @@ class TestChatCompletions:
|
||||
lambda data: received_records.append(data),
|
||||
)
|
||||
|
||||
app = create_app(wrapped, "test-model")
|
||||
app = create_app(wrapped, "test-model", config=_test_config())
|
||||
app.state.bus = bus
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -483,7 +578,13 @@ class TestChatCompletions:
|
||||
# bus present + agent registered == the exact live condition under
|
||||
# which the pre-fix code routed to the (broken) agent stream bridge.
|
||||
agent = _make_agent(content="GENERIC AGENT FILLER")
|
||||
app = create_app(engine, "test-model", agent=agent, bus=EventBus())
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
agent=agent,
|
||||
bus=EventBus(),
|
||||
config=_test_config(),
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
@@ -592,6 +693,15 @@ def _make_capturing_engine(captured: list):
|
||||
return engine
|
||||
|
||||
|
||||
def _identity_config():
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
cfg = JarvisConfig()
|
||||
cfg.agent.default_system_prompt = "You are OpenJarvis."
|
||||
cfg.analytics.enabled = False
|
||||
return cfg
|
||||
|
||||
|
||||
class TestIdentityPromptInjection:
|
||||
"""Regression for #540.
|
||||
|
||||
@@ -607,7 +717,7 @@ class TestIdentityPromptInjection:
|
||||
def test_stream_injects_identity_when_absent(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -628,7 +738,7 @@ class TestIdentityPromptInjection:
|
||||
def test_stream_no_double_injection_when_client_supplies_system(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -652,7 +762,7 @@ class TestIdentityPromptInjection:
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
# No agent -> non-stream request goes through _handle_direct.
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -670,7 +780,7 @@ class TestIdentityPromptInjection:
|
||||
def test_direct_no_double_injection_when_client_supplies_system(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -691,7 +801,7 @@ class TestIdentityPromptInjection:
|
||||
def test_stream_tools_injects_identity_when_absent(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -733,7 +843,7 @@ class TestModelsEndpoint:
|
||||
|
||||
def test_multiple_models(self):
|
||||
engine = _make_engine(models=["model-a", "model-b", "model-c"])
|
||||
app = create_app(engine, "model-a")
|
||||
app = create_app(engine, "model-a", config=_test_config())
|
||||
client = TestClient(app)
|
||||
resp = client.get("/v1/models")
|
||||
data = resp.json()
|
||||
@@ -754,7 +864,7 @@ class TestHealthEndpoint:
|
||||
def test_unhealthy(self):
|
||||
engine = _make_engine()
|
||||
engine.health.return_value = False
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
client = TestClient(app)
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 503
|
||||
@@ -768,19 +878,19 @@ class TestHealthEndpoint:
|
||||
class TestCreateApp:
|
||||
def test_app_state(self):
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
assert app.state.engine is engine
|
||||
assert app.state.model == "test-model"
|
||||
|
||||
def test_app_with_agent(self):
|
||||
engine = _make_engine()
|
||||
agent = _make_agent()
|
||||
app = create_app(engine, "test-model", agent=agent)
|
||||
app = create_app(engine, "test-model", agent=agent, config=_test_config())
|
||||
assert app.state.agent is agent
|
||||
|
||||
def test_app_without_agent(self):
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
assert app.state.agent is None
|
||||
|
||||
|
||||
@@ -804,6 +914,7 @@ def _traces_enabled_config(tmp_path):
|
||||
cfg = JarvisConfig()
|
||||
cfg.traces.enabled = True
|
||||
cfg.traces.db_path = str(tmp_path / "traces.db")
|
||||
cfg.analytics.enabled = False
|
||||
return cfg
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user