mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
22
Commits
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "117,047",
|
||||
"message": "129,427",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 117047,
|
||||
"last_updated": "2026-06-14T07:38:47Z",
|
||||
"total_clones": 129427,
|
||||
"last_updated": "2026-06-23T07:18:12Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -80,6 +80,15 @@
|
||||
"2026-06-10": 1310,
|
||||
"2026-06-11": 2564,
|
||||
"2026-06-12": 1313,
|
||||
"2026-06-13": 2804
|
||||
"2026-06-13": 2804,
|
||||
"2026-06-14": 1543,
|
||||
"2026-06-15": 1379,
|
||||
"2026-06-16": 1317,
|
||||
"2026-06-17": 1170,
|
||||
"2026-06-18": 1408,
|
||||
"2026-06-19": 1350,
|
||||
"2026-06-20": 1437,
|
||||
"2026-06-21": 1426,
|
||||
"2026-06-22": 1350
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,20 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Base version is the next patch above whatever is in pyproject.toml.
|
||||
# Base version is the next patch above the latest plain release tag
|
||||
# (vX.Y.Z) reachable from HEAD. pyproject.toml no longer carries a
|
||||
# static version (#526 switched it to hatch-vcs), so the release tag
|
||||
# is the source of truth. `.devN`/`.rcN`/`desktop-*` tags are excluded
|
||||
# so they can't be mistaken for the release base.
|
||||
# Any future manual `X.Y.Z` release will outrank every `X.Y.Z.devN`
|
||||
# autotag — PEP 440 sorts dev releases strictly below the final.
|
||||
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
|
||||
if [[ -z "$BASE" ]]; then
|
||||
echo "::error::Could not parse version from pyproject.toml"
|
||||
LATEST_RELEASE=$(git tag --list 'v[0-9]*' --merged HEAD \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
if [[ -z "$LATEST_RELEASE" ]]; then
|
||||
echo "::error::No release tag (vX.Y.Z) reachable from HEAD"
|
||||
exit 1
|
||||
fi
|
||||
BASE="${LATEST_RELEASE#v}"
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,22 +11,33 @@ concurrency:
|
||||
group: claude-issues-${{ github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least-privilege: only what the issue-fixer job actually needs.
|
||||
# id-token (OIDC) is intentionally omitted — claude-code-action@v1 is passed
|
||||
# github_token directly, so OIDC is unused here.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
fix:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 15
|
||||
# Security gate: this job reaches secrets.ANTHROPIC_API_KEY and holds a
|
||||
# write-scoped GITHUB_TOKEN. `issues` / `issue_comment` are public,
|
||||
# attacker-controllable events that run in the base-repo context with full
|
||||
# secret access, so the human-triggered paths are restricted to actors with
|
||||
# write-level association (OWNER / MEMBER / COLLABORATOR). This blocks
|
||||
# external / first-time contributors from draining the API budget or
|
||||
# creating branches/PRs, while leaving maintainer use unaffected.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) &&
|
||||
(contains(github.event.issue.labels.*.name, 'bug') ||
|
||||
contains(github.event.issue.labels.*.name, 'autofix'))) ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
!github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'claude[bot]')
|
||||
|
||||
@@ -11,23 +11,33 @@ concurrency:
|
||||
group: claude-review-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least-privilege: PR review only needs to post comments on the PR.
|
||||
# id-token (OIDC) is omitted — claude-code-action@v1 is passed github_token
|
||||
# directly, so OIDC is unused here.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Security gate: this job reaches secrets.ANTHROPIC_API_KEY. Both
|
||||
# issue_comment and pull_request_review_comment are public,
|
||||
# attacker-controllable events that run in the base-repo context with full
|
||||
# secret access, so the @claude paths are restricted to actors with
|
||||
# write-level association (OWNER / MEMBER / COLLABORATOR). External /
|
||||
# first-time contributors cannot trigger the key; maintainers are unaffected.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'claude[bot]') ||
|
||||
(github.event_name == 'pull_request_review_comment' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'claude[bot]')
|
||||
steps:
|
||||
|
||||
@@ -40,7 +40,8 @@ jobs:
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
libxdo-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -114,6 +115,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Full history + tags so the workflow_dispatch fallback in
|
||||
# "Determine release info" can derive the dev version from the
|
||||
# latest release tag (#526).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -125,7 +131,8 @@ jobs:
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
libxdo-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -183,7 +190,16 @@ jobs:
|
||||
# workflow_dispatch fallback (manual UI dispatch without --ref).
|
||||
# Derive a PEP 440 dev version aligned with autotag.yml so we
|
||||
# don't burn the X.Y.Z release-version namespace.
|
||||
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
|
||||
# pyproject.toml no longer carries a static version (#526), so the
|
||||
# base comes from the latest plain release tag (vX.Y.Z), matching
|
||||
# autotag.yml. .dev/.rc/desktop-* tags are excluded.
|
||||
LATEST_RELEASE=$(git tag --list 'v[0-9]*' --merged HEAD \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
if [[ -z "$LATEST_RELEASE" ]]; then
|
||||
echo "::error::No release tag (vX.Y.Z) reachable from HEAD"
|
||||
exit 1
|
||||
fi
|
||||
BASE="${LATEST_RELEASE#v}"
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
|
||||
|
||||
@@ -35,3 +35,9 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
- run: npm run build
|
||||
env:
|
||||
# vite.config/supabase.ts require this at build time. The real value
|
||||
# comes from the repo secret; CI falls back to a placeholder so the
|
||||
# build check passes (the leaderboard is not exercised in CI builds).
|
||||
# Release/docs builds MUST set the secret to the real anon key.
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY || 'ci-placeholder-anon-key' }}
|
||||
|
||||
@@ -12,6 +12,11 @@ on:
|
||||
description: 'Tag to publish (e.g. v1.0.2.dev500). Overrides github.ref.'
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run: build + validate, then publish to TestPyPI instead of PyPI (no production upload).'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -67,27 +72,41 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Set version from tag
|
||||
- name: Resolve build version from tag
|
||||
env:
|
||||
REF: ${{ steps.ref.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Strip leading "v" if present (e.g. v1.0.2.dev500 -> 1.0.2.dev500)
|
||||
# Strip leading "v" (e.g. v1.0.3.dev825 -> 1.0.3.dev825).
|
||||
VERSION="${REF#v}"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "::error::Could not resolve version from ref '$REF'"
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "::error::ref '$REF' is not a version tag (expected vX.Y.Z[.devN]); pass -f tag=vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
|
||||
# Sanity check the substitution actually took
|
||||
grep -q "^version = \"${VERSION}\"" pyproject.toml || {
|
||||
echo "::error::sed failed to update pyproject.toml version"
|
||||
exit 1
|
||||
}
|
||||
echo "Building version $VERSION"
|
||||
# pyproject.toml is now dynamic = ["version"] via hatch-vcs (#526), so
|
||||
# there is no static line to sed. setuptools_scm cannot bump custom
|
||||
# `.devN` tags, so we pin the exact build version explicitly — the
|
||||
# published version always equals the pushed tag.
|
||||
echo "SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "Building version ${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
- name: Publish to TestPyPI (dry run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${UV_PUBLISH_TOKEN:-}" ]]; then
|
||||
echo "::warning::TEST_PYPI_API_TOKEN is not set — skipping the TestPyPI upload."
|
||||
echo "Build + twine check passed, which validated version derivation and packaging end to end."
|
||||
echo "To exercise a real upload, add a TEST_PYPI_API_TOKEN secret (or a TestPyPI trusted publisher)."
|
||||
exit 0
|
||||
fi
|
||||
uv publish --publish-url https://test.pypi.org/legacy/
|
||||
|
||||
- name: Publish to PyPI
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: uv publish
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
@@ -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 ..
|
||||
```
|
||||
|
||||
|
||||
@@ -17,6 +17,46 @@ The configuration file lives at:
|
||||
|
||||
OpenJarvis creates the `~/.openjarvis/` directory and populates it with a default config when you run `jarvis init`.
|
||||
|
||||
## Relocating the OpenJarvis directory
|
||||
|
||||
OpenJarvis keeps **all** of its state — config, databases, caches, logs,
|
||||
credentials, skills, recipes, connectors — under a **single root** so it never
|
||||
clutters your home directory beyond one folder. By default that root is
|
||||
`~/.openjarvis`, but you can move it.
|
||||
|
||||
The root is resolved in priority order:
|
||||
|
||||
1. **`$OPENJARVIS_HOME`** — explicit override. Honored by both the installer
|
||||
and the Python runtime.
|
||||
2. **`$XDG_DATA_HOME/openjarvis`** — used when `$XDG_DATA_HOME` is set (a single
|
||||
`openjarvis` directory nested under it, per the XDG Base Directory spec).
|
||||
3. **`~/.openjarvis`** — the default. With no environment variables set, the
|
||||
resolved path is exactly this, so existing installs are untouched.
|
||||
|
||||
```bash
|
||||
# Relocate the whole install + runtime tree at install time:
|
||||
OPENJARVIS_HOME=~/apps/openjarvis curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
|
||||
# Or for a single run / your shell profile:
|
||||
export OPENJARVIS_HOME=~/apps/openjarvis
|
||||
```
|
||||
|
||||
Confirm where your data lives with:
|
||||
|
||||
```bash
|
||||
jarvis config path
|
||||
```
|
||||
|
||||
!!! note "Migration"
|
||||
Because the default is unchanged, **no data migration is required** for
|
||||
existing installs. If you set `OPENJARVIS_HOME` (or `XDG_DATA_HOME`) on a
|
||||
machine that already has data in `~/.openjarvis`, OpenJarvis will look in
|
||||
the new location and not see your old data — move it yourself if you want
|
||||
to keep it: `mv ~/.openjarvis "$OPENJARVIS_HOME"`.
|
||||
|
||||
`$OPENJARVIS_CONFIG` still points at an explicit `config.toml` file
|
||||
independently of the root, if you need to override just the config file path.
|
||||
|
||||
## Generating Configuration
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var SUPABASE_URL = "https://mtbtgpwzrbostweaanpr.supabase.co";
|
||||
var SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c";
|
||||
var SUPABASE_URL =
|
||||
window.OPENJARVIS_SUPABASE_URL || "https://mtbtgpwzrbostweaanpr.supabase.co";
|
||||
var SUPABASE_ANON_KEY = window.OPENJARVIS_SUPABASE_ANON_KEY || "";
|
||||
|
||||
var PAGE_SIZE = 50;
|
||||
var allRows = [];
|
||||
|
||||
Generated
+1108
-1007
File diff suppressed because it is too large
Load Diff
@@ -24,9 +24,22 @@ serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Cloud API keys are stored in the OS credential store via `keyring`. keyring v3
|
||||
# enables NO backend by default — without an explicit per-platform feature it
|
||||
# silently falls back to a non-persistent in-memory mock, so keys would not
|
||||
# survive an app restart. Each desktop target opts into its native store.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2"
|
||||
dispatch = "0.2"
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Blocking Secret Service backend (no internal async runtime, so it is safe to
|
||||
# call from the tokio-driven Tauri commands). Needs libdbus-1-dev at build time.
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
|
||||
+186
-50
@@ -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",
|
||||
])
|
||||
@@ -1204,7 +1204,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
// additions aren't accidentally stripped.
|
||||
prepare_subprocess_for_appimage(&mut cmd);
|
||||
|
||||
// Inject cloud API keys from ~/.openjarvis/cloud-keys.env
|
||||
// Inject cloud API keys from secure desktop storage.
|
||||
for (key, value) in read_cloud_keys() {
|
||||
cmd.env(&key, &value);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1702,17 +1720,111 @@ async fn submit_savings(
|
||||
// Cloud API key management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Path to the cloud keys file (~/.openjarvis/cloud-keys.env).
|
||||
fn cloud_keys_path() -> std::path::PathBuf {
|
||||
const SECURE_KEY_SERVICE: &str = "OpenJarvis Cloud Keys";
|
||||
const MANAGED_CLOUD_KEY_NAMES: &[&str] = &[
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
];
|
||||
|
||||
/// Legacy path used by older desktop builds. New saves never write here.
|
||||
fn legacy_cloud_keys_path() -> std::path::PathBuf {
|
||||
let home = home_dir();
|
||||
std::path::PathBuf::from(home)
|
||||
.join(".openjarvis")
|
||||
.join("cloud-keys.env")
|
||||
}
|
||||
|
||||
/// Read cloud keys from disk and return as key=value pairs.
|
||||
fn read_cloud_keys() -> Vec<(String, String)> {
|
||||
let path = cloud_keys_path();
|
||||
fn validate_cloud_key_name(key_name: &str) -> Result<(), String> {
|
||||
let valid = !key_name.is_empty()
|
||||
&& key_name.len() <= 128
|
||||
&& key_name.ends_with("_API_KEY")
|
||||
&& key_name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_');
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Invalid API key name: {}", key_name))
|
||||
}
|
||||
}
|
||||
|
||||
fn engine_api_key_name(engine: &str) -> String {
|
||||
let normalized: String = engine
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_uppercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = normalized.trim_matches('_');
|
||||
let engine_name = if trimmed.is_empty() {
|
||||
CUSTOM_FALLBACK_ENGINE.to_ascii_uppercase()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
format!("{}_API_KEY", engine_name)
|
||||
}
|
||||
|
||||
fn managed_cloud_key_names() -> Vec<String> {
|
||||
let mut names: Vec<String> = MANAGED_CLOUD_KEY_NAMES
|
||||
.iter()
|
||||
.map(|name| (*name).to_string())
|
||||
.collect();
|
||||
|
||||
let cfg = read_inference_config();
|
||||
if matches!(&cfg.kind, SourceKind::Custom) {
|
||||
let engine = cfg.engine.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
|
||||
let key_name = engine_api_key_name(&engine);
|
||||
if validate_cloud_key_name(&key_name).is_ok() {
|
||||
names.push(key_name);
|
||||
}
|
||||
}
|
||||
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
fn secure_store_get(key_name: &str) -> Result<Option<String>, String> {
|
||||
validate_cloud_key_name(key_name)?;
|
||||
let entry = keyring::Entry::new(SECURE_KEY_SERVICE, key_name)
|
||||
.map_err(|err| format!("Failed to open secure key storage for {}: {}", key_name, err))?;
|
||||
match entry.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(format!("Failed to read {} from secure key storage: {}", key_name, err)),
|
||||
}
|
||||
}
|
||||
|
||||
fn secure_store_set(key_name: &str, key_value: &str) -> Result<(), String> {
|
||||
validate_cloud_key_name(key_name)?;
|
||||
let entry = keyring::Entry::new(SECURE_KEY_SERVICE, key_name)
|
||||
.map_err(|err| format!("Failed to open secure key storage for {}: {}", key_name, err))?;
|
||||
if key_value.is_empty() {
|
||||
return match entry.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!(
|
||||
"Failed to remove {} from secure key storage: {}",
|
||||
key_name, err
|
||||
)),
|
||||
};
|
||||
}
|
||||
entry
|
||||
.set_password(key_value)
|
||||
.map_err(|err| format!("Failed to save {} in secure key storage: {}", key_name, err))
|
||||
}
|
||||
|
||||
fn read_legacy_cloud_keys() -> Vec<(String, String)> {
|
||||
let path = legacy_cloud_keys_path();
|
||||
let mut keys = Vec::new();
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
for line in contents.lines() {
|
||||
@@ -1728,47 +1840,68 @@ fn read_cloud_keys() -> Vec<(String, String)> {
|
||||
keys
|
||||
}
|
||||
|
||||
/// Save a single cloud API key to the keys file.
|
||||
#[tauri::command]
|
||||
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
|
||||
let path = cloud_keys_path();
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
fn migrate_legacy_cloud_keys() {
|
||||
let path = legacy_cloud_keys_path();
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read existing keys, update/add the one being saved
|
||||
let mut keys: Vec<(String, String)> = read_cloud_keys()
|
||||
let legacy_keys = read_legacy_cloud_keys();
|
||||
if legacy_keys.is_empty() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut migrated_all = true;
|
||||
for (key, value) in legacy_keys {
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if secure_store_set(&key, &value).is_err() {
|
||||
migrated_all = false;
|
||||
}
|
||||
}
|
||||
|
||||
if migrated_all {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read cloud keys from secure desktop storage and return key=value pairs.
|
||||
fn read_cloud_keys() -> Vec<(String, String)> {
|
||||
migrate_legacy_cloud_keys();
|
||||
managed_cloud_key_names()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| k != &key_name)
|
||||
.collect();
|
||||
if !key_value.is_empty() {
|
||||
keys.push((key_name, key_value));
|
||||
}
|
||||
.filter_map(|key| match secure_store_get(&key) {
|
||||
Ok(Some(value)) if !value.is_empty() => Some((key, value)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Write back
|
||||
let content: String = keys
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
std::fs::write(&path, content + "\n").map_err(|e| format!("Failed to save key: {}", e))?;
|
||||
|
||||
// Set permissions to owner-only (chmod 600)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
// Tell the running server to hot-reload its cloud engine so the user
|
||||
// doesn't need to restart the app after entering an API key.
|
||||
async fn reload_cloud_keys(keys: Vec<(String, String)>) {
|
||||
let reload_url = format!("http://127.0.0.1:{}/v1/cloud/reload", JARVIS_PORT);
|
||||
let key_map: serde_json::Map<String, serde_json::Value> = keys
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, serde_json::Value::String(value)))
|
||||
.collect();
|
||||
let _ = reqwest::Client::new()
|
||||
.post(&reload_url)
|
||||
.json(&serde_json::json!({ "keys": key_map }))
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Save a single cloud API key to secure desktop storage.
|
||||
#[tauri::command]
|
||||
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
|
||||
let key_value = key_value.trim().to_string();
|
||||
secure_store_set(&key_name, &key_value)?;
|
||||
|
||||
// Tell the running server to hot-reload its cloud engine so the user
|
||||
// doesn't need to restart the app after entering an API key.
|
||||
reload_cloud_keys(vec![(key_name, key_value)]).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1776,10 +1909,13 @@ async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), Strin
|
||||
/// Get which cloud providers have keys configured (without exposing values).
|
||||
#[tauri::command]
|
||||
async fn get_cloud_key_status() -> Result<serde_json::Value, String> {
|
||||
let keys = read_cloud_keys();
|
||||
let status: Vec<serde_json::Value> = keys
|
||||
.iter()
|
||||
.map(|(k, v)| serde_json::json!({ "key": k, "set": !v.is_empty() }))
|
||||
migrate_legacy_cloud_keys();
|
||||
let status: Vec<serde_json::Value> = managed_cloud_key_names()
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let set = matches!(secure_store_get(&key), Ok(Some(value)) if !value.is_empty());
|
||||
serde_json::json!({ "key": key, "set": set })
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!(status))
|
||||
}
|
||||
@@ -1791,8 +1927,8 @@ async fn get_inference_source() -> Result<InferenceConfig, String> {
|
||||
}
|
||||
|
||||
/// Persist the chosen inference source. `host` is normalized to a bare base
|
||||
/// URL. For custom endpoints, an optional API key is stored in cloud-keys.env
|
||||
/// under `<ENGINE>_API_KEY`. Applies on next app launch.
|
||||
/// URL. For custom endpoints, an optional API key is stored in secure desktop
|
||||
/// storage under `<ENGINE>_API_KEY`. Applies on next app launch.
|
||||
#[tauri::command]
|
||||
async fn set_inference_source(
|
||||
kind: String,
|
||||
@@ -1824,7 +1960,7 @@ async fn set_inference_source(
|
||||
.engine
|
||||
.clone()
|
||||
.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
|
||||
let key_name = format!("{}_API_KEY", engine.to_ascii_uppercase());
|
||||
let key_name = engine_api_key_name(&engine);
|
||||
// Save the key before persisting the config: if the key can't be
|
||||
// written, surface it and DON'T record a custom source whose
|
||||
// credential is missing (which would fail confusingly at runtime).
|
||||
@@ -2556,7 +2692,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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Search, Cpu, X, Download, Loader2, Trash2, Check, Cloud, Key, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAppStore } from '../lib/store';
|
||||
import { pullModel, deleteModel, fetchModels, preloadModel, isTauri } from '../lib/api';
|
||||
import {
|
||||
pullModel,
|
||||
deleteModel,
|
||||
fetchModels,
|
||||
preloadModel,
|
||||
isTauri,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
} from '../lib/api';
|
||||
|
||||
/** Popular models that users can download from the catalogue. */
|
||||
const CATALOGUE_MODELS = [
|
||||
@@ -23,7 +31,6 @@ const CATALOGUE_MODELS = [
|
||||
interface CloudProvider {
|
||||
name: string;
|
||||
envKey: string;
|
||||
storageKey: string;
|
||||
models: Array<{ id: string; desc: string }>;
|
||||
}
|
||||
|
||||
@@ -31,7 +38,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
storageKey: 'openjarvis-openai-key',
|
||||
models: [
|
||||
{ id: 'gpt-4o', desc: 'GPT-4o — fast, multimodal' },
|
||||
{ id: 'gpt-4o-mini', desc: 'GPT-4o Mini — cheap, fast' },
|
||||
@@ -41,7 +47,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'Anthropic',
|
||||
envKey: 'ANTHROPIC_API_KEY',
|
||||
storageKey: 'openjarvis-anthropic-key',
|
||||
models: [
|
||||
{ id: 'claude-sonnet-4-6', desc: 'Claude Sonnet 4.6 — balanced' },
|
||||
{ id: 'claude-opus-4-6', desc: 'Claude Opus 4.6 — most capable' },
|
||||
@@ -51,7 +56,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'Google',
|
||||
envKey: 'GEMINI_API_KEY',
|
||||
storageKey: 'openjarvis-gemini-key',
|
||||
models: [
|
||||
{ id: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro — flagship' },
|
||||
{ id: 'gemini-2.5-flash', desc: 'Gemini 2.5 Flash — fast' },
|
||||
@@ -61,7 +65,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
envKey: 'OPENROUTER_API_KEY',
|
||||
storageKey: 'openjarvis-openrouter-key',
|
||||
models: [
|
||||
{ id: 'openrouter/auto', desc: 'Auto — best model for the task' },
|
||||
{ id: 'openrouter/anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 via OpenRouter' },
|
||||
@@ -70,16 +73,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function getStoredKey(storageKey: string): string {
|
||||
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
|
||||
}
|
||||
function setStoredKey(storageKey: string, value: string): void {
|
||||
try {
|
||||
if (value) localStorage.setItem(storageKey, value);
|
||||
else localStorage.removeItem(storageKey);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
type Tab = 'installed' | 'catalogue' | 'cloud';
|
||||
|
||||
export function CommandPalette() {
|
||||
@@ -92,11 +85,10 @@ export function CommandPalette() {
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
|
||||
const keys: Record<string, string> = {};
|
||||
for (const p of CLOUD_PROVIDERS) keys[p.storageKey] = getStoredKey(p.storageKey);
|
||||
return keys;
|
||||
});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
const [cloudKeyStatus, setCloudKeyStatus] = useState<Record<string, boolean>>({});
|
||||
const [cloudKeyError, setCloudKeyError] = useState<string | null>(null);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const models = useAppStore((s) => s.models);
|
||||
@@ -106,6 +98,20 @@ export function CommandPalette() {
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
|
||||
const installedIds = new Set(models.map((m) => m.id));
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refreshCloudKeyStatus = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setCloudKeyStatus({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCloudKeyStatus(await getCloudKeyStatus());
|
||||
setCloudKeyError(null);
|
||||
} catch (e: any) {
|
||||
setCloudKeyError(e?.message || 'Failed to read cloud key status');
|
||||
}
|
||||
}, [desktopKeyStorage]);
|
||||
|
||||
const filtered = tab === 'installed'
|
||||
? (query
|
||||
@@ -122,6 +128,10 @@ export function CommandPalette() {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCloudKeyStatus();
|
||||
}, [refreshCloudKeyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIdx(0);
|
||||
}, [query, tab]);
|
||||
@@ -210,24 +220,29 @@ export function CommandPalette() {
|
||||
};
|
||||
|
||||
const handleSaveKey = async (provider: CloudProvider, value: string) => {
|
||||
setStoredKey(provider.storageKey, value);
|
||||
setApiKeys((prev) => ({ ...prev, [provider.storageKey]: value }));
|
||||
const keyValue = value.trim();
|
||||
setSavingKey(provider.envKey);
|
||||
setCloudKeyError(null);
|
||||
|
||||
// Also save to Tauri backend so the server process picks up the key
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('save_cloud_key', { keyName: provider.envKey, keyValue: value });
|
||||
} catch {}
|
||||
try {
|
||||
await saveCloudKey(provider.envKey, keyValue);
|
||||
setApiKeys((prev) => ({ ...prev, [provider.envKey]: '' }));
|
||||
await refreshCloudKeyStatus();
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'model',
|
||||
message: `${provider.name} API key ${keyValue ? 'saved' : 'removed'}. Refreshing model list...`,
|
||||
});
|
||||
await refreshModels();
|
||||
} catch (e: any) {
|
||||
setCloudKeyError(e?.message || `Failed to save ${provider.name} API key`);
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'model',
|
||||
message: `${provider.name} API key ${value ? 'saved' : 'removed'}. Refreshing model list…`,
|
||||
});
|
||||
|
||||
// Refresh the model list so cloud models appear immediately.
|
||||
await refreshModels();
|
||||
const handleKeyBlur = (provider: CloudProvider) => {
|
||||
const draft = apiKeys[provider.envKey] || '';
|
||||
if (draft.trim()) void handleSaveKey(provider, draft);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -323,6 +338,11 @@ export function CommandPalette() {
|
||||
<Check size={12} /> Downloaded {pullSuccess} successfully
|
||||
</div>
|
||||
)}
|
||||
{tab === 'cloud' && cloudKeyError && (
|
||||
<div className="px-4 py-2 text-xs" style={{ color: 'var(--color-error)', background: 'rgba(220,38,38,0.05)' }}>
|
||||
{cloudKeyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[400px] overflow-y-auto py-2">
|
||||
@@ -431,13 +451,17 @@ export function CommandPalette() {
|
||||
/* ── Cloud Models tab ── */
|
||||
<div className="px-4 py-2">
|
||||
<div className="text-[11px] mb-3" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Add your API keys to use cloud models. Keys are stored locally on your device only.
|
||||
{desktopKeyStorage
|
||||
? 'Add your API keys to use cloud models. Keys are stored in secure desktop storage.'
|
||||
: 'Configure cloud provider keys in the server environment to use cloud models.'}
|
||||
</div>
|
||||
|
||||
{CLOUD_PROVIDERS.map((provider) => {
|
||||
const key = apiKeys[provider.storageKey] || '';
|
||||
const hasKey = !!key;
|
||||
const isVisible = showKeys[provider.storageKey];
|
||||
const key = apiKeys[provider.envKey] || '';
|
||||
const hasSavedKey = !!cloudKeyStatus[provider.envKey];
|
||||
const hasKey = hasSavedKey || !!key.trim();
|
||||
const isVisible = showKeys[provider.envKey];
|
||||
const isSaving = savingKey === provider.envKey;
|
||||
|
||||
return (
|
||||
<div key={provider.name} className="mb-4">
|
||||
@@ -458,26 +482,28 @@ export function CommandPalette() {
|
||||
<input
|
||||
type={isVisible ? 'text' : 'password'}
|
||||
value={key}
|
||||
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.storageKey]: e.target.value }))}
|
||||
onBlur={() => handleSaveKey(provider, apiKeys[provider.storageKey] || '')}
|
||||
placeholder={`${provider.envKey}`}
|
||||
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.envKey]: e.target.value }))}
|
||||
onBlur={() => handleKeyBlur(provider)}
|
||||
placeholder={hasSavedKey ? 'Saved in secure storage' : provider.envKey}
|
||||
disabled={!desktopKeyStorage || isSaving}
|
||||
className="flex-1 text-xs px-2 py-1.5 bg-transparent outline-none font-mono"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.storageKey]: !prev[provider.storageKey] }))}
|
||||
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.envKey]: !prev[provider.envKey] }))}
|
||||
className="px-2 cursor-pointer" style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{isVisible ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
{hasKey && (
|
||||
{hasSavedKey && (
|
||||
<button
|
||||
onClick={() => handleSaveKey(provider, '')}
|
||||
disabled={isSaving}
|
||||
className="px-2 py-1 rounded-lg text-[10px] cursor-pointer"
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)', opacity: isSaving ? 0.5 : 1 }}
|
||||
>
|
||||
Remove
|
||||
{isSaving ? 'Saving' : 'Remove'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { SUPABASE_ANON_KEY, SUPABASE_URL } from '../../lib/supabase';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -279,9 +280,6 @@ function getOrCreateAnonId(): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
const SUPABASE_URL = 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
@@ -326,7 +324,7 @@ export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
const flopsSaved = data.per_provider.reduce((s, p) => s + (p.flops || 0), 0);
|
||||
invoke('submit_savings', {
|
||||
supabaseUrl: SUPABASE_URL,
|
||||
supabaseKey: SUPABASE_KEY,
|
||||
supabaseKey: SUPABASE_ANON_KEY,
|
||||
payload: {
|
||||
anon_id: anonId,
|
||||
display_name: displayName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Regression for #266: the frontend must send the local API key as a Bearer
|
||||
// token on /v1 + /api requests, or `jarvis serve` with a key configured 401s
|
||||
@@ -26,11 +26,14 @@ class MemoryStorage {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
+40
-7
@@ -1,12 +1,10 @@
|
||||
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
|
||||
import { SUPABASE_ANON_KEY, SUPABASE_URL } from './supabase';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supabase config — safe to embed (RLS protects writes)
|
||||
// Supabase config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
@@ -15,6 +13,31 @@ declare global {
|
||||
|
||||
export const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI_INTERNALS__;
|
||||
|
||||
export type CloudKeyStatus = Record<string, boolean>;
|
||||
|
||||
export async function getCloudKeyStatus(): Promise<CloudKeyStatus> {
|
||||
if (!isTauri()) return {};
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const rows = await invoke<Array<{ key: string; set: boolean }>>('get_cloud_key_status');
|
||||
return Object.fromEntries(rows.map((row) => [row.key, row.set]));
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message ?? e ?? 'Failed to read cloud key status');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCloudKey(keyName: string, keyValue: string): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Cloud API keys can be saved in the desktop app only.');
|
||||
}
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('save_cloud_key', { keyName, keyValue });
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message ?? e ?? 'Failed to save cloud key');
|
||||
}
|
||||
}
|
||||
|
||||
// Cached API base URL fetched from the Tauri backend at startup.
|
||||
// This avoids hardcoding the port — the Rust backend is the single
|
||||
// source of truth for JARVIS_PORT.
|
||||
@@ -317,8 +340,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 +351,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,5 +1,5 @@
|
||||
import { getBase } from './api';
|
||||
import type { ConnectorInfo, SyncStatus, ConnectRequest } from '../types/connectors';
|
||||
import type { ConnectorInfo, SyncStatus, ConnectRequest, ConnectResponse } from '../types/connectors';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connectors API
|
||||
@@ -18,16 +18,47 @@ export async function getConnector(id: string): Promise<ConnectorInfo> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function connectSource(id: string, req: ConnectRequest): Promise<ConnectorInfo> {
|
||||
export async function connectSource(id: string, req: ConnectRequest): Promise<ConnectResponse> {
|
||||
const res = await fetch(`${getBase()}/v1/connectors/${encodeURIComponent(id)}/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to connect ${id}: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// Surface the backend's actionable detail (e.g. malformed Client ID /
|
||||
// Secret) instead of a bare status code so the UI can render it.
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `Failed to connect ${id}: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Open the server-side OAuth consent flow in a popup and resolve once the
|
||||
* connector reports connected (or reject on timeout). Reused for any OAuth
|
||||
* connector whose /connect returned `oauth_required` (issue #512). */
|
||||
export function startServerOAuth(id: string, oauthStartPath?: string): Promise<void> {
|
||||
const path = oauthStartPath || `/v1/connectors/${encodeURIComponent(id)}/oauth/start`;
|
||||
window.open(`${getBase()}${path}`, '_blank', 'width=600,height=700');
|
||||
return new Promise((resolve, reject) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const info = await getConnector(id);
|
||||
if (info.connected) {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
} catch {
|
||||
// ignore transient polling errors
|
||||
}
|
||||
}, 2000);
|
||||
const timer = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
reject(new Error('Authorization timed out — please try again.'));
|
||||
}, 180000);
|
||||
});
|
||||
}
|
||||
|
||||
export async function disconnectSource(id: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/connectors/${encodeURIComponent(id)}/disconnect`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const SUPABASE_URL =
|
||||
import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
|
||||
export const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!SUPABASE_ANON_KEY) {
|
||||
throw new Error('VITE_SUPABASE_ANON_KEY is required');
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { SOURCE_CATALOG } from '../types/connectors';
|
||||
import type { ConnectRequest } from '../types/connectors';
|
||||
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync } from '../lib/connectors-api';
|
||||
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync, startServerOAuth } from '../lib/connectors-api';
|
||||
import type { SyncStatus } from '../types/connectors';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -673,7 +673,19 @@ function DataSourcesSection() {
|
||||
setConnectStage('Connecting...');
|
||||
setConnectError('');
|
||||
try {
|
||||
await connectSource(id, req);
|
||||
const resp = await connectSource(id, req);
|
||||
|
||||
// OAuth connectors (Google Drive/Calendar/Contacts/Gmail/Tasks): pasting
|
||||
// a Client ID / Secret only registers the app credentials. The backend
|
||||
// returns `oauth_required` with the path to the in-process consent flow,
|
||||
// which is the only path that actually mints an access token. Open it now
|
||||
// and wait for the callback to flip the connector to connected. Without
|
||||
// this the connector would stay "pending" forever — the exact #512 bug.
|
||||
if (resp.status === 'oauth_required') {
|
||||
setConnectStage('Opening Google sign-in...');
|
||||
await startServerOAuth(id, resp.oauth_start);
|
||||
}
|
||||
|
||||
setConnectStage('Connected! Starting sync...');
|
||||
|
||||
// Wait for connector to show as connected
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,9 +19,21 @@ import {
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore, type ThemeMode } from '../lib/store';
|
||||
import { checkHealth, fetchSpeechHealth, getMemoryStats, getInferenceSource, setInferenceSource, type InferenceSource } from '../lib/api';
|
||||
import {
|
||||
checkHealth,
|
||||
fetchSpeechHealth,
|
||||
getMemoryStats,
|
||||
getInferenceSource,
|
||||
setInferenceSource,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
isTauri,
|
||||
type InferenceSource,
|
||||
} from '../lib/api';
|
||||
import { isAutoUpdateDisabled, setAutoUpdateDisabled } from '../components/Desktop/UpdateChecker';
|
||||
|
||||
const CLOUD_KEY_STATUS_CHANGED = 'openjarvis-cloud-key-status-changed';
|
||||
|
||||
function OllamaModelList() {
|
||||
const [models, setModels] = useState<Array<{ name: string; size: number }>>([]);
|
||||
useEffect(() => {
|
||||
@@ -44,32 +56,111 @@ function OllamaModelList() {
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyInput({ storageKey, placeholder }: { storageKey: string; placeholder: string }) {
|
||||
const [value, setValue] = useState(() => {
|
||||
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
|
||||
});
|
||||
function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: string }) {
|
||||
const [value, setValue] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const save = (v: string) => {
|
||||
setValue(v);
|
||||
try { if (v) localStorage.setItem(storageKey, v); else localStorage.removeItem(storageKey); } catch {}
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
const save = async (v: string) => {
|
||||
const next = v.trim();
|
||||
if (!next) return;
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, next);
|
||||
setValue('');
|
||||
setHasKey(true);
|
||||
setSaved(true);
|
||||
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save API key');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, '');
|
||||
setValue('');
|
||||
setHasKey(false);
|
||||
setSaved(true);
|
||||
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to remove API key');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="password" value={value} onChange={e => save(e.target.value)} placeholder={placeholder}
|
||||
<input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => { if (value.trim()) void save(value); }}
|
||||
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
|
||||
disabled={!desktopKeyStorage}
|
||||
className="w-48 px-2 py-1 rounded text-xs"
|
||||
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }} />
|
||||
{hasKey && (
|
||||
<button
|
||||
onClick={() => void remove()}
|
||||
className="px-2 py-1 rounded text-[10px] cursor-pointer"
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
{saved && <span className="text-[10px]" style={{ color: 'var(--color-success)' }}>Saved</span>}
|
||||
{error && <span className="text-[10px]" style={{ color: 'var(--color-error)' }}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudProviderStatus({ label, storageKey }: { label: string; storageKey: string }) {
|
||||
function CloudProviderStatus({ label, keyName }: { label: string; keyName: string }) {
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
|
||||
useEffect(() => {
|
||||
try { setHasKey(!!localStorage.getItem(storageKey)); } catch { setHasKey(false); }
|
||||
}, [storageKey]);
|
||||
void refresh();
|
||||
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
<span style={{
|
||||
@@ -424,10 +515,10 @@ export function SettingsPage() {
|
||||
</div>
|
||||
<SettingRow label="Cloud providers" description="Green dot means API key is configured">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<CloudProviderStatus label="OpenAI" storageKey="openjarvis-openai-key" />
|
||||
<CloudProviderStatus label="Anthropic" storageKey="openjarvis-anthropic-key" />
|
||||
<CloudProviderStatus label="Google" storageKey="openjarvis-gemini-key" />
|
||||
<CloudProviderStatus label="OpenRouter" storageKey="openjarvis-openrouter-key" />
|
||||
<CloudProviderStatus label="OpenAI" keyName="OPENAI_API_KEY" />
|
||||
<CloudProviderStatus label="Anthropic" keyName="ANTHROPIC_API_KEY" />
|
||||
<CloudProviderStatus label="Google" keyName="GEMINI_API_KEY" />
|
||||
<CloudProviderStatus label="OpenRouter" keyName="OPENROUTER_API_KEY" />
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
@@ -435,23 +526,23 @@ export function SettingsPage() {
|
||||
{/* API Keys */}
|
||||
<Section title="API Keys">
|
||||
<SettingRow label="OpenAI" description="GPT-4, GPT-3.5, etc.">
|
||||
<ApiKeyInput storageKey="openjarvis-openai-key" placeholder="sk-..." />
|
||||
<ApiKeyInput keyName="OPENAI_API_KEY" placeholder="sk-..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="Anthropic" description="Claude models">
|
||||
<ApiKeyInput storageKey="openjarvis-anthropic-key" placeholder="sk-ant-..." />
|
||||
<ApiKeyInput keyName="ANTHROPIC_API_KEY" placeholder="sk-ant-..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="Google" description="Gemini models">
|
||||
<ApiKeyInput storageKey="openjarvis-gemini-key" placeholder="AI..." />
|
||||
<ApiKeyInput keyName="GEMINI_API_KEY" placeholder="AI..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="OpenRouter" description="Multi-provider routing">
|
||||
<ApiKeyInput storageKey="openjarvis-openrouter-key" placeholder="sk-or-..." />
|
||||
<ApiKeyInput keyName="OPENROUTER_API_KEY" placeholder="sk-or-..." />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Tools */}
|
||||
<Section title="Tools">
|
||||
<SettingRow label="Web Search" description="SerpAPI or Tavily key for web search tool">
|
||||
<ApiKeyInput storageKey="openjarvis-search-key" placeholder="API key..." />
|
||||
<SettingRow label="Web Search" description="Tavily key for web search tool">
|
||||
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -55,6 +55,19 @@ export interface ConnectRequest {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/** Response from POST /v1/connectors/{id}/connect.
|
||||
* For OAuth connectors, pasting a Client ID / Secret pair only registers the
|
||||
* app credentials; the backend returns `status: "oauth_required"` plus an
|
||||
* `oauth_start` path the UI must open to run the browser consent flow that
|
||||
* actually mints an access token (see issue #512). */
|
||||
export interface ConnectResponse {
|
||||
connector_id: string;
|
||||
connected: boolean;
|
||||
status: "connected" | "pending" | "oauth_required" | "disconnected";
|
||||
oauth_start?: string;
|
||||
sync_status?: string | null;
|
||||
}
|
||||
|
||||
export type WizardStep = "pick" | "connect" | "ingest" | "ready";
|
||||
|
||||
// Backward-compatible alias
|
||||
@@ -257,12 +270,12 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
urlLabel: 'Enable Drive API',
|
||||
},
|
||||
{
|
||||
label: 'Create OAuth credentials: go to Credentials (link below) → click "+ Create Credentials" → choose "OAuth client ID" → Application type: "Desktop app" → click "Create"',
|
||||
label: 'Create OAuth credentials: go to Credentials (link below) → click "+ Create Credentials" → choose "OAuth client ID" → Application type: "Web application". Under "Authorized redirect URIs" add this server\'s callback (e.g. http://localhost:1313/v1/connectors/gdrive/oauth/callback — match the host/port your OpenJarvis server is bound to) → click "Create".',
|
||||
url: 'https://console.cloud.google.com/apis/credentials',
|
||||
urlLabel: 'Open Credentials',
|
||||
},
|
||||
{
|
||||
label: 'A dialog will show your Client ID and Client Secret. Copy both and paste them below. (If you miss it, click the download icon next to your OAuth client to see them again)',
|
||||
label: 'A dialog will show your Client ID and Client Secret. Copy both and paste them below, then click Connect — a Google sign-in window opens to finish authorization. (If you miss the dialog, click the download icon next to your OAuth client to see them again.)',
|
||||
},
|
||||
],
|
||||
inputFields: [
|
||||
|
||||
Vendored
+3
-1
@@ -1,7 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_SUPABASE_URL?: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
const env = loadEnv(mode, __dirname, '');
|
||||
if (command === 'build' && !env.VITE_SUPABASE_ANON_KEY) {
|
||||
throw new Error('VITE_SUPABASE_ANON_KEY is required');
|
||||
}
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
@@ -56,4 +62,5 @@ export default defineConfig({
|
||||
'/api': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+34
-2
@@ -1,10 +1,10 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "OpenJarvis"
|
||||
version = "1.0.2"
|
||||
dynamic = ["version"]
|
||||
description = "OpenJarvis — modular AI assistant backend with composable intelligence primitives"
|
||||
readme = "README.md"
|
||||
# Upper bound: numpy 2.2.x (pinned transitively via datasets/pandas) ships no
|
||||
@@ -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"]
|
||||
@@ -154,6 +162,30 @@ Issues = "https://github.com/open-jarvis/OpenJarvis/issues"
|
||||
jarvis = "openjarvis.cli:main"
|
||||
openjarvis-eval = "openjarvis.evals.cli:main"
|
||||
|
||||
# Version is derived from git tags by hatch-vcs (see #526). For source/editable
|
||||
# checkouts this yields the true `git describe` version (e.g. 1.0.3.dev109+g<sha>)
|
||||
# rather than a stale static string. CI release builds override this with
|
||||
# SETUPTOOLS_SCM_PRETEND_VERSION so the published version equals the pushed tag.
|
||||
#
|
||||
# setuptools_scm cannot bump custom `.devN` tags (only `.dev0`), so the autotag
|
||||
# `vX.Y.Z.devN` tags are deliberately EXCLUDED from version derivation here; the
|
||||
# base is taken from the latest plain release tag (vX.Y.Z) and the dev distance
|
||||
# is computed from commit count since that release.
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.version.raw-options]
|
||||
tag_regex = '^v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$'
|
||||
git_describe_command = [
|
||||
"git", "describe", "--dirty", "--tags", "--long",
|
||||
"--match", "v[0-9]*", "--exclude", "*dev*", "--exclude", "*rc*", "--exclude", "desktop-*",
|
||||
]
|
||||
# Builds without a git checkout (e.g. the `COPY src/ src/` Docker stages, which
|
||||
# never include .git) can't run `git describe`. Without a fallback that would
|
||||
# hard-fail the build. Mirror the runtime sentinel in src/openjarvis/__init__.py.
|
||||
# Such builds can inject the real version via SETUPTOOLS_SCM_PRETEND_VERSION.
|
||||
fallback_version = "0.0.0+unknown"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openjarvis"]
|
||||
|
||||
|
||||
@@ -210,6 +210,14 @@ if ! command -v python3 >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
# ---- env ----
|
||||
# OpenJarvis keeps ALL of its state (install tree + runtime data, configs,
|
||||
# databases, caches, logs) under a single root so it never clutters $HOME
|
||||
# beyond one directory. Relocate it by exporting OPENJARVIS_HOME before
|
||||
# running the installer, e.g.:
|
||||
# OPENJARVIS_HOME=~/apps/openjarvis curl ... | bash
|
||||
# The Python runtime honors the same override (and, when OPENJARVIS_HOME is
|
||||
# unset, $XDG_DATA_HOME/openjarvis if XDG_DATA_HOME is set). With nothing set
|
||||
# the root is ~/.openjarvis, so existing installs are untouched.
|
||||
OPENJARVIS_HOME="${OPENJARVIS_HOME:-$HOME/.openjarvis}"
|
||||
OPENJARVIS_REPO_URL="${OPENJARVIS_REPO_URL:-https://github.com/open-jarvis/OpenJarvis.git}"
|
||||
SRC_DIR="$OPENJARVIS_HOME/src"
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
@@ -103,7 +104,7 @@ class ClaudeCodeAgent(BaseAgent):
|
||||
"Install it from https://nodejs.org/ or via your package manager."
|
||||
)
|
||||
|
||||
dest = Path.home() / ".openjarvis" / "claude_code_runner"
|
||||
dest = get_config_dir() / "claude_code_runner"
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy runner files if missing or outdated
|
||||
|
||||
@@ -9,6 +9,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
|
||||
@dataclass
|
||||
class DigestArtifact:
|
||||
@@ -30,7 +32,7 @@ class DigestStore:
|
||||
|
||||
def __init__(self, db_path: str = "") -> None:
|
||||
if not db_path:
|
||||
db_path = str(Path.home() / ".openjarvis" / "digest.db")
|
||||
db_path = str(get_config_dir() / "digest.db")
|
||||
self._db_path = db_path
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
|
||||
@@ -73,12 +73,19 @@ WEB_SEARCH_COST_PER_CALL = 0.01
|
||||
# $0.01/call number — kept as a separate constant so it can drift.
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL = 0.01
|
||||
|
||||
# Gemini Google-Search grounding: billed at $35 per 1000 grounded
|
||||
# *requests* (2025-12 public list price for the Grounding-with-Google-Search
|
||||
# tool, charged once per request that uses the tool regardless of how many
|
||||
# internal queries it issues). We charge per grounded request, not per
|
||||
# `web_search_queries` entry.
|
||||
GEMINI_SEARCH_COST_PER_CALL = 0.035
|
||||
# Gemini 3 Google-Search grounding: billed at $14 per 1000 search queries.
|
||||
# `_call_gemini_agent` reports the model's `web_search_queries`, so this is
|
||||
# charged per query, not per outer generate_content request.
|
||||
GEMINI_SEARCH_COST_PER_CALL = 0.014
|
||||
|
||||
# Tavily Search, advanced depth: 2 API credits per search request at $0.008
|
||||
# per credit on the public pay-as-you-go plan. WebSearchTool captures actual
|
||||
# credits when Tavily returns usage metadata; this is the fallback estimate.
|
||||
TAVILY_SEARCH_COST_PER_CREDIT = 0.008
|
||||
TAVILY_ADVANCED_SEARCH_CREDITS = 2
|
||||
TAVILY_SEARCH_COST_PER_CALL = (
|
||||
TAVILY_SEARCH_COST_PER_CREDIT * TAVILY_ADVANCED_SEARCH_CREDITS
|
||||
)
|
||||
|
||||
ANTHROPIC_WEB_SEARCH_TOOL = {
|
||||
"type": "web_search_20250305",
|
||||
@@ -101,6 +108,40 @@ def build_web_search_tool(max_uses: int = 8) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def tavily_search_context(
|
||||
query: str,
|
||||
*,
|
||||
max_results: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run OpenJarvis WebSearchTool and return accounting-friendly metadata."""
|
||||
from openjarvis.tools.web_search import WebSearchTool
|
||||
|
||||
tool = WebSearchTool(max_results=max_results)
|
||||
res = tool.execute(query=query, max_results=max_results)
|
||||
meta = dict(res.metadata or {})
|
||||
engine = str(meta.get("engine") or "unknown")
|
||||
credits = 0
|
||||
cost_usd = 0.0
|
||||
if engine == "tavily":
|
||||
try:
|
||||
credits = int(meta.get("credits") or TAVILY_ADVANCED_SEARCH_CREDITS)
|
||||
except (TypeError, ValueError):
|
||||
credits = TAVILY_ADVANCED_SEARCH_CREDITS
|
||||
cost_usd = credits * TAVILY_SEARCH_COST_PER_CREDIT
|
||||
text = res.content or ""
|
||||
if not res.success and not text:
|
||||
text = "(no search results)"
|
||||
return {
|
||||
"text": text,
|
||||
"success": bool(res.success),
|
||||
"engine": engine,
|
||||
"credits": credits,
|
||||
"cost_usd": cost_usd,
|
||||
"n_searches": 1 if (query or "").strip() else 0,
|
||||
"error": None if res.success else text,
|
||||
}
|
||||
|
||||
|
||||
def web_search_cfg(method_cfg: Optional[Dict[str, Any]]) -> Tuple[bool, int]:
|
||||
"""Parse ``method_cfg.web_search = { enabled, max_uses }``.
|
||||
|
||||
@@ -1322,6 +1363,9 @@ __all__ = [
|
||||
"LocalCloudAgent",
|
||||
"NO_TEMP_PREFIXES",
|
||||
"OPENAI_WEB_SEARCH_COST_PER_CALL",
|
||||
"TAVILY_ADVANCED_SEARCH_CREDITS",
|
||||
"TAVILY_SEARCH_COST_PER_CALL",
|
||||
"TAVILY_SEARCH_COST_PER_CREDIT",
|
||||
"WEB_SEARCH_COST_PER_CALL",
|
||||
"_bump_cloud_calls",
|
||||
"_bump_local_calls",
|
||||
@@ -1329,5 +1373,6 @@ __all__ = [
|
||||
"estimate_cost",
|
||||
"is_gpt5_family",
|
||||
"supports_temperature",
|
||||
"tavily_search_context",
|
||||
"web_search_cfg",
|
||||
]
|
||||
|
||||
@@ -15,13 +15,16 @@ PRICES: dict[str, tuple[float, float]] = {
|
||||
"claude-sonnet-4-6": (3.00, 15.0),
|
||||
"claude-haiku-4-5": (1.00, 5.00),
|
||||
"claude-haiku-4-5-20251001": (1.00, 5.00),
|
||||
"gpt-5.5": (5.00, 30.0),
|
||||
"gpt-5": (1.25, 10.0),
|
||||
"gpt-5-mini": (0.25, 2.00),
|
||||
"gpt-5-mini-2025-08-07": (0.25, 2.00),
|
||||
"gpt-4o": (0.15, 0.60),
|
||||
# Gemini Developer API prices (USD per 1M tokens), 2025-12 list price.
|
||||
# 2.5 Pro uses tiered pricing (>200K context = $2.50/$15); we charge the
|
||||
# low-context tier since GAIA / SWE-bench prompts stay well under 200K.
|
||||
# Gemini Developer API prices (USD per 1M tokens). Pro models use tiered
|
||||
# pricing above 200K prompt tokens; GAIA prompts stay under that tier, so
|
||||
# charge the low-context standard rate.
|
||||
"gemini-3.1-pro-preview": (2.00, 12.0),
|
||||
"gemini-3.1-pro-preview-customtools": (2.00, 12.0),
|
||||
"gemini-2.5-pro": (1.25, 10.0),
|
||||
"gemini-2.5-flash": (0.30, 2.50),
|
||||
"gemini-2.5-flash-lite": (0.10, 0.40),
|
||||
@@ -61,7 +64,11 @@ def is_reasoning_model(model: str) -> bool:
|
||||
before emitting visible answer text. At max_tokens=4096 these silently
|
||||
truncate with empty answers on GAIA (26/100 GPT-5, 18/100 Gemini Pro)."""
|
||||
m = (model or "").lower()
|
||||
return is_gpt5_family(model) or "gemini-2.5-pro" in m
|
||||
return (
|
||||
is_gpt5_family(model)
|
||||
or "gemini-2.5-pro" in m
|
||||
or "gemini-3.1-pro" in m
|
||||
)
|
||||
|
||||
|
||||
def default_max_output_tokens(model: str) -> int:
|
||||
|
||||
@@ -34,6 +34,7 @@ from openjarvis.agents.hybrid._base import (
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
web_search_cfg,
|
||||
)
|
||||
from openjarvis.agents.hybrid.mini_swe_agent import (
|
||||
@@ -135,7 +136,12 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
advisor_temperature = float(cfg.get("advisor_temperature", 0.2))
|
||||
|
||||
ws_enabled, ws_max_uses = web_search_cfg(cfg)
|
||||
if ws_enabled and self._cloud_endpoint not in _SEARCH_CAPABLE_ENDPOINTS:
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
if (
|
||||
ws_enabled
|
||||
and search_backend != "tavily"
|
||||
and self._cloud_endpoint not in _SEARCH_CAPABLE_ENDPOINTS
|
||||
):
|
||||
raise ValueError(
|
||||
f"web_search.enabled=true but cloud_endpoint={self._cloud_endpoint!r}; "
|
||||
"server-side web_search is wired for anthropic / openai / gemini "
|
||||
@@ -146,19 +152,23 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
use_ws = ws_enabled
|
||||
gaia_max_turns = int(cfg.get("gaia_max_turns", 8))
|
||||
n_searches_total = 0
|
||||
search_cost_total = 0.0
|
||||
|
||||
# 1. Initial executor pass — advisor (Qwen) doesn't get tools;
|
||||
# only the cloud executor passes do. With web_search on, dispatch
|
||||
# to the search-capable agent loop for the configured provider.
|
||||
if use_ws:
|
||||
initial_resp, e1_in, e1_out, n_s1, e1_turns = self._executor_search(
|
||||
(initial_resp, e1_in, e1_out, n_s1, e1_turns,
|
||||
e1_search_cost) = self._executor_search(
|
||||
user=f"Question:\n{question}",
|
||||
system=EXECUTOR_INITIAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
)
|
||||
n_searches_total += n_s1
|
||||
search_cost_total += e1_search_cost
|
||||
else:
|
||||
initial_resp, e1_in, e1_out = self._call_cloud(
|
||||
user=f"Question:\n{question}",
|
||||
@@ -196,14 +206,17 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
f"answer-format rules."
|
||||
)
|
||||
if use_ws:
|
||||
final_answer, e2_in, e2_out, n_s2, e2_turns = self._executor_search(
|
||||
(final_answer, e2_in, e2_out, n_s2, e2_turns,
|
||||
e2_search_cost) = self._executor_search(
|
||||
user=final_user,
|
||||
system=EXECUTOR_FINAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
)
|
||||
n_searches_total += n_s2
|
||||
search_cost_total += e2_search_cost
|
||||
else:
|
||||
final_answer, e2_in, e2_out = self._call_cloud(
|
||||
user=final_user,
|
||||
@@ -216,7 +229,10 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
tokens_local = adv_in + adv_out
|
||||
tokens_cloud = e1_in + e1_out + e2_in + e2_out
|
||||
cost = self.cost_usd(self._cloud_model, e1_in + e2_in, e1_out + e2_out)
|
||||
cost += n_searches_total * _search_cost_per_call(self._cloud_endpoint)
|
||||
if search_backend == "tavily":
|
||||
cost += search_cost_total
|
||||
else:
|
||||
cost += n_searches_total * _search_cost_per_call(self._cloud_endpoint)
|
||||
|
||||
meta: Dict[str, Any] = {
|
||||
"tokens_local": tokens_local,
|
||||
@@ -233,7 +249,9 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
"initial_response": initial_resp,
|
||||
"advisor_feedback": advisor_text,
|
||||
"web_search_enabled": use_ws,
|
||||
"search_backend": search_backend,
|
||||
"n_web_searches": n_searches_total,
|
||||
"search_cost_usd": search_cost_total,
|
||||
"note": "inference-only advisor (untrained); lower bound on the technique.",
|
||||
},
|
||||
}
|
||||
@@ -251,16 +269,40 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
max_tokens: int,
|
||||
ws_max_uses: int,
|
||||
max_turns: int,
|
||||
) -> Tuple[str, int, int, int, int]:
|
||||
query: Optional[str] = None,
|
||||
) -> Tuple[str, int, int, int, int, float]:
|
||||
"""Run a search-capable executor pass for the configured cloud.
|
||||
|
||||
Dispatches by ``self._cloud_endpoint`` to the matching ``_base``
|
||||
agent loop. Returns the shared 5-tuple ``(text, p_tok, c_tok,
|
||||
n_searches, turns)``. The endpoint is assumed already validated
|
||||
against ``_SEARCH_CAPABLE_ENDPOINTS`` by the caller.
|
||||
agent loop, or through Tavily when ``method_cfg.search_backend`` is
|
||||
``"tavily"``. Returns ``(text, p_tok, c_tok, n_searches, turns,
|
||||
search_cost_usd)``.
|
||||
"""
|
||||
if str(self._cfg.get("search_backend", "provider")).lower() == "tavily":
|
||||
res = tavily_search_context(
|
||||
query or user,
|
||||
max_results=int(self._cfg.get("tavily_max_results", 5)),
|
||||
)
|
||||
grounded_user = (
|
||||
f"Web search results:\n{res['text']}\n\n"
|
||||
f"Using the search results above, answer this request:\n{user}"
|
||||
)
|
||||
text, p, c = self._call_cloud(
|
||||
user=grounded_user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
)
|
||||
return (
|
||||
text,
|
||||
p,
|
||||
c,
|
||||
int(res["n_searches"]),
|
||||
1,
|
||||
float(res["cost_usd"]),
|
||||
)
|
||||
if self._cloud_endpoint == "anthropic":
|
||||
return self._call_anthropic_agent(
|
||||
text, p, c, n_searches, turns = self._call_anthropic_agent(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=system,
|
||||
@@ -269,8 +311,9 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
tools=[build_web_search_tool(ws_max_uses)],
|
||||
max_turns=max_turns,
|
||||
)
|
||||
return text, p, c, n_searches, turns, 0.0
|
||||
if self._cloud_endpoint == "openai":
|
||||
return self._call_openai_agent(
|
||||
text, p, c, n_searches, turns = self._call_openai_agent(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=system,
|
||||
@@ -278,8 +321,9 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
temperature=0.0,
|
||||
max_turns=max_turns,
|
||||
)
|
||||
return text, p, c, n_searches, turns, 0.0
|
||||
if self._cloud_endpoint == "gemini":
|
||||
return self._call_gemini_agent(
|
||||
text, p, c, n_searches, turns = self._call_gemini_agent(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=system,
|
||||
@@ -287,6 +331,7 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
temperature=0.0,
|
||||
max_turns=max_turns,
|
||||
)
|
||||
return text, p, c, n_searches, turns, 0.0
|
||||
# Genuinely unsupported (openrouter / vllm / unknown). The caller
|
||||
# guard should have caught this; raise defensively.
|
||||
raise ValueError(
|
||||
|
||||
@@ -46,6 +46,7 @@ from openjarvis.agents.hybrid._base import (
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
web_search_cfg,
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import (
|
||||
@@ -456,8 +457,14 @@ def _format_worker_pool(workers: List[Dict[str, Any]]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _search_capable_indices(workers: List[Dict[str, Any]]) -> List[int]:
|
||||
def _search_capable_indices(
|
||||
workers: List[Dict[str, Any]],
|
||||
*,
|
||||
search_backend: str = "provider",
|
||||
) -> List[int]:
|
||||
"""Indices of workers whose endpoint can run server-side web search."""
|
||||
if search_backend == "tavily":
|
||||
return [w["id"] for w in workers]
|
||||
return [
|
||||
w["id"] for w in workers
|
||||
if (w.get("endpoint") or "openai").lower()
|
||||
@@ -470,6 +477,7 @@ def _build_conductor_prompt(
|
||||
workers: List[Dict[str, Any]],
|
||||
*,
|
||||
web_search_enabled: bool = False,
|
||||
search_backend: str = "provider",
|
||||
) -> str:
|
||||
"""Build the planner prompt.
|
||||
|
||||
@@ -485,12 +493,16 @@ def _build_conductor_prompt(
|
||||
)
|
||||
if not web_search_enabled:
|
||||
return base
|
||||
capable = _search_capable_indices(workers)
|
||||
capable = _search_capable_indices(workers, search_backend=search_backend)
|
||||
if capable:
|
||||
cap_str = ", ".join(str(i) for i in capable)
|
||||
if search_backend == "tavily":
|
||||
capability = "External Tavily search results will be prepended to worker prompts"
|
||||
else:
|
||||
capability = "Only these model indices can perform live web search"
|
||||
constraint = (
|
||||
"\n\nWEB SEARCH CONSTRAINT:\n"
|
||||
f"Only these model indices can perform live web search: [{cap_str}]. "
|
||||
f"{capability}: [{cap_str}]. "
|
||||
"Any step that needs to look up facts, current events, or other "
|
||||
"information not reliably known from memory MUST be routed to one "
|
||||
"of those indices. Steps routed to any other model can only use "
|
||||
@@ -550,8 +562,8 @@ def _call_worker(
|
||||
*,
|
||||
web_search_tool: Optional[Dict[str, Any]] = None,
|
||||
web_search_max_uses: int = 8,
|
||||
) -> Tuple[str, int, int, bool, int]:
|
||||
"""Returns (text, p_tok, c_tok, is_local, n_web_searches).
|
||||
) -> Tuple[str, int, int, bool, int, float]:
|
||||
"""Returns (text, p_tok, c_tok, is_local, n_web_searches, extra_cost).
|
||||
|
||||
``web_search_tool``: a truthy marker that web_search is enabled for
|
||||
this run. When set AND the worker endpoint is search-capable
|
||||
@@ -565,6 +577,22 @@ def _call_worker(
|
||||
max_tok = int(cfg.get("worker_max_tokens", 4096))
|
||||
temp = float(cfg.get("worker_temperature", 0.2))
|
||||
use_ws = web_search_tool is not None
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
extra_cost = 0.0
|
||||
if use_ws and search_backend == "tavily":
|
||||
res = tavily_search_context(
|
||||
prompt,
|
||||
max_results=int(cfg.get("tavily_max_results", 5)),
|
||||
)
|
||||
prompt = (
|
||||
f"Web search results:\n{res['text']}\n\n"
|
||||
f"Using the search results above, answer this request:\n{prompt}"
|
||||
)
|
||||
extra_cost = float(res["cost_usd"])
|
||||
use_ws = False
|
||||
tavily_searches = int(res["n_searches"])
|
||||
else:
|
||||
tavily_searches = 0
|
||||
|
||||
if ep == "vllm":
|
||||
text, p, c = LocalCloudAgent._call_vllm(
|
||||
@@ -575,7 +603,7 @@ def _call_worker(
|
||||
temperature=temp,
|
||||
enable_thinking=False,
|
||||
)
|
||||
return text, p, c, True, 0
|
||||
return text, p, c, True, tavily_searches, extra_cost
|
||||
if ep == "openai":
|
||||
if use_ws:
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
|
||||
@@ -584,14 +612,14 @@ def _call_worker(
|
||||
max_tokens=max_tok,
|
||||
temperature=(1.0 if is_gpt5_family(worker["model"]) else temp),
|
||||
)
|
||||
return text, p, c, False, n_searches
|
||||
return text, p, c, False, n_searches, 0.0
|
||||
text, p, c = LocalCloudAgent._call_openai(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=(1.0 if is_gpt5_family(worker["model"]) else temp),
|
||||
)
|
||||
return text, p, c, False, 0
|
||||
return text, p, c, False, tavily_searches, extra_cost
|
||||
if ep == "openrouter":
|
||||
# OpenRouter is OpenAI-compatible; the helper handles the
|
||||
# base_url + OPENROUTER_API_KEY plumbing. No server-side web
|
||||
@@ -607,7 +635,7 @@ def _call_worker(
|
||||
temperature=temp,
|
||||
extra_body=extra_body if isinstance(extra_body, dict) else None,
|
||||
)
|
||||
return text, p, c, False, 0
|
||||
return text, p, c, False, tavily_searches, extra_cost
|
||||
if ep == "anthropic":
|
||||
eff_temp = temp if supports_temperature(worker["model"]) else 0.0
|
||||
anthropic_kwargs: Dict[str, Any] = dict(
|
||||
@@ -620,7 +648,7 @@ def _call_worker(
|
||||
text, p, c, n_searches = LocalCloudAgent._call_anthropic(
|
||||
worker["model"], **anthropic_kwargs
|
||||
)
|
||||
return text, p, c, False, n_searches
|
||||
return text, p, c, False, n_searches or tavily_searches, extra_cost
|
||||
if ep == "gemini":
|
||||
# Gemini Developer API via google-genai. With web_search on, route
|
||||
# through the Google-Search-grounded agent loop; otherwise plain
|
||||
@@ -632,14 +660,14 @@ def _call_worker(
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
return text, p, c, False, n_searches
|
||||
return text, p, c, False, n_searches, 0.0
|
||||
text, p, c = LocalCloudAgent._call_gemini(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
return text, p, c, False, 0
|
||||
return text, p, c, False, tavily_searches, extra_cost
|
||||
raise ValueError(f"unsupported worker endpoint: {ep!r}")
|
||||
|
||||
|
||||
@@ -672,7 +700,7 @@ def _swe_worker_step(
|
||||
# backbones today (the loop's tool-call format is Anthropic- or
|
||||
# OpenAI-via-vllm-shaped only). Fall back to one-shot for those —
|
||||
# SWE-bench-wise they were already weak; this preserves behavior.
|
||||
text, p, c, is_local, n_searches = _call_worker(worker, prompt, cfg)
|
||||
text, p, c, is_local, n_searches, _extra = _call_worker(worker, prompt, cfg)
|
||||
return text, p, c, is_local, n_searches, 0
|
||||
out = run_swe_agent_loop(
|
||||
task,
|
||||
@@ -753,13 +781,17 @@ class ConductorAgent(LocalCloudAgent):
|
||||
and bool(task_meta_early.get("base_commit"))
|
||||
)
|
||||
ws_enabled, ws_max_uses = web_search_cfg(cfg)
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
planner_ws = ws_enabled and not swe_mode_early
|
||||
|
||||
# 1. Plan — when web_search is on (GAIA), the prompt names which
|
||||
# worker indices can actually search, so the planner routes
|
||||
# research steps to a search-capable worker.
|
||||
user = _build_conductor_prompt(
|
||||
question, workers, web_search_enabled=planner_ws,
|
||||
question,
|
||||
workers,
|
||||
web_search_enabled=planner_ws,
|
||||
search_backend=search_backend,
|
||||
)
|
||||
plan_text, p_in, p_out = self._call_cloud(
|
||||
user=user,
|
||||
@@ -833,7 +865,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
# memory. Fail loud instead of degrading silently.
|
||||
# ``ws_enabled`` / ``ws_max_uses`` computed up front for the planner
|
||||
# constraint — reuse them here.
|
||||
if ws_enabled and not swe_mode:
|
||||
if ws_enabled and search_backend != "tavily" and not swe_mode:
|
||||
search_workers = [
|
||||
w for w in workers
|
||||
if (w.get("endpoint") or "openai").lower()
|
||||
@@ -894,7 +926,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
# may legitimately not need search; see Task-3 planner
|
||||
# constraint that tries to prevent this upfront).
|
||||
if (
|
||||
ws_enabled and not swe_mode
|
||||
ws_enabled and search_backend != "tavily" and not swe_mode
|
||||
and worker_ep not in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
):
|
||||
self.record_trace_event({
|
||||
@@ -911,6 +943,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
),
|
||||
})
|
||||
|
||||
extra_cost = 0.0
|
||||
if swe_mode:
|
||||
text, w_in, w_out, is_local, n_searches, bash_turns = (
|
||||
_swe_worker_step(
|
||||
@@ -919,7 +952,9 @@ class ConductorAgent(LocalCloudAgent):
|
||||
)
|
||||
tool_calls += bash_turns
|
||||
else:
|
||||
text, w_in, w_out, is_local, n_searches = _call_worker(
|
||||
(
|
||||
text, w_in, w_out, is_local, n_searches, extra_cost
|
||||
) = _call_worker(
|
||||
worker, prompt, cfg,
|
||||
web_search_tool=ws_tool,
|
||||
web_search_max_uses=ws_max_uses,
|
||||
@@ -930,7 +965,10 @@ class ConductorAgent(LocalCloudAgent):
|
||||
else:
|
||||
tokens_cloud += w_in + w_out
|
||||
cost += self.cost_usd(worker["model"], w_in, w_out)
|
||||
cost += n_searches * _worker_search_cost_per_call(worker_ep)
|
||||
if search_backend != "tavily":
|
||||
cost += n_searches * _worker_search_cost_per_call(worker_ep)
|
||||
if search_backend == "tavily":
|
||||
cost += extra_cost
|
||||
n_web_searches_total += n_searches
|
||||
tool_calls += n_searches
|
||||
steps.append({
|
||||
@@ -981,6 +1019,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
"plan": plan,
|
||||
"fallback_used": fallback_used,
|
||||
"web_search_enabled": ws_enabled,
|
||||
"search_backend": search_backend,
|
||||
"n_web_searches": n_web_searches_total,
|
||||
"parse_attempts": parse_attempts,
|
||||
"workers": [
|
||||
|
||||
@@ -48,12 +48,13 @@ from openjarvis.agents.hybrid._base import (
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
web_search_cfg,
|
||||
)
|
||||
from openjarvis.agents.hybrid._openai_retry import (
|
||||
patch_openai_globally as _patch_openai_globally,
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import NO_TEMP_PREFIXES
|
||||
from openjarvis.agents.hybrid._prices import NO_TEMP_PREFIXES, default_max_output_tokens
|
||||
from openjarvis.agents.hybrid.mini_swe_agent import run_swe_agent_loop
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
@@ -362,6 +363,8 @@ def _prefetch_context(
|
||||
cloud_endpoint: str,
|
||||
cloud_model: str,
|
||||
max_uses: int = 8,
|
||||
search_backend: str = "provider",
|
||||
tavily_max_results: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Use Anthropic web_search to fetch real source material the worker can read.
|
||||
|
||||
@@ -375,6 +378,22 @@ def _prefetch_context(
|
||||
out: Dict[str, Any] = {
|
||||
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
|
||||
}
|
||||
if search_backend == "tavily":
|
||||
try:
|
||||
res = tavily_search_context(question, max_results=tavily_max_results)
|
||||
out.update(
|
||||
text=res["text"],
|
||||
cost_usd=float(res["cost_usd"]),
|
||||
n_searches=int(res["n_searches"]),
|
||||
tokens=0,
|
||||
engine=res.get("engine"),
|
||||
credits=res.get("credits"),
|
||||
)
|
||||
if res.get("error"):
|
||||
out["error"] = res["error"]
|
||||
except Exception as e:
|
||||
out["error"] = f"{type(e).__name__}: {e}"
|
||||
return out
|
||||
if cloud_endpoint != "anthropic" or not (question or "").strip():
|
||||
return out
|
||||
try:
|
||||
@@ -498,18 +517,22 @@ class MinionsAgent(LocalCloudAgent):
|
||||
max_tokens=cfg.get("worker_max_tokens", 4096),
|
||||
local=True,
|
||||
)
|
||||
cloud_max_tokens = int(
|
||||
cfg.get("cloud_max_tokens")
|
||||
or default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
if self._cloud_endpoint == "openai":
|
||||
cloud_client = OpenAIClient(
|
||||
model_name=self._cloud_model,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
max_tokens=cloud_max_tokens,
|
||||
)
|
||||
elif self._cloud_endpoint == "anthropic":
|
||||
# Temperature stripping is handled by the global patch above for Opus 4.7+.
|
||||
cloud_client = AnthropicClient(
|
||||
model_name=self._cloud_model,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
max_tokens=cloud_max_tokens,
|
||||
)
|
||||
elif self._cloud_endpoint == "gemini":
|
||||
# The vendored Minion library already special-cases GeminiClient
|
||||
@@ -520,7 +543,7 @@ class MinionsAgent(LocalCloudAgent):
|
||||
cloud_client = GeminiClient(
|
||||
model_name=self._cloud_model,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
max_tokens=cloud_max_tokens,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported cloud endpoint: {self._cloud_endpoint!r}")
|
||||
@@ -560,6 +583,8 @@ class MinionsAgent(LocalCloudAgent):
|
||||
self._cloud_endpoint,
|
||||
self._cloud_model,
|
||||
max_uses=ws_max_uses,
|
||||
search_backend=str(cfg.get("search_backend", "provider")).lower(),
|
||||
tavily_max_results=int(cfg.get("tavily_max_results", 5)),
|
||||
)
|
||||
|
||||
if prefetch.get("text"):
|
||||
|
||||
@@ -38,13 +38,14 @@ except ModuleNotFoundError:
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult
|
||||
from openjarvis.agents.hybrid._energy import EnergyCollector
|
||||
from openjarvis.agents.hybrid._prompts import format_prompt as _format_prompt
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
PACKAGE_DIR = Path(__file__).parent
|
||||
DEFAULT_REGISTRY_DIR = PACKAGE_DIR / "registry"
|
||||
DEFAULT_EXPERIMENTS_DIR = Path(
|
||||
os.environ.get(
|
||||
"OPENJARVIS_HYBRID_EXPERIMENTS_DIR",
|
||||
Path.home() / ".openjarvis" / "experiments" / "hybrid",
|
||||
get_config_dir() / "experiments" / "hybrid",
|
||||
)
|
||||
)
|
||||
DEFAULT_SUBSETS_DIR = DEFAULT_EXPERIMENTS_DIR / "subsets"
|
||||
@@ -122,6 +123,7 @@ def load_registry(registry_dir: Optional[Path] = None) -> Dict[str, Dict[str, An
|
||||
|
||||
# ---------- Bench dispatch ----------
|
||||
|
||||
|
||||
def _load_gaia_tasks(n: Optional[int]) -> List[Dict[str, Any]]:
|
||||
"""GAIA validation. Each task is a dict with `task_id` + `question`."""
|
||||
from openjarvis.evals.datasets.gaia import GAIADataset
|
||||
@@ -137,12 +139,14 @@ def _load_gaia_tasks(n: Optional[int]) -> List[Dict[str, Any]]:
|
||||
# id round-trip.
|
||||
md = rec.metadata or {}
|
||||
task_id = md.get("task_id") or rec.record_id
|
||||
out.append({
|
||||
"task_id": task_id,
|
||||
"question": md.get("question", rec.problem),
|
||||
"reference": rec.reference,
|
||||
"metadata": dict(md),
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"question": md.get("question", rec.problem),
|
||||
"reference": rec.reference,
|
||||
"metadata": dict(md),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -155,19 +159,21 @@ def _load_swebench_tasks(n: Optional[int]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for rec in ds.iter_records():
|
||||
md = rec.metadata or {}
|
||||
out.append({
|
||||
"task_id": md.get("instance_id", rec.record_id),
|
||||
"repo": md.get("repo", ""),
|
||||
"base_commit": md.get("base_commit", ""),
|
||||
"problem_statement": md.get("problem_statement", rec.problem),
|
||||
"hints_text": md.get("hints_text", ""),
|
||||
"test_patch": md.get("test_patch", ""),
|
||||
"FAIL_TO_PASS": md.get("FAIL_TO_PASS", []),
|
||||
"PASS_TO_PASS": md.get("PASS_TO_PASS", []),
|
||||
"version": md.get("version"),
|
||||
"reference": rec.reference,
|
||||
"metadata": dict(md),
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"task_id": md.get("instance_id", rec.record_id),
|
||||
"repo": md.get("repo", ""),
|
||||
"base_commit": md.get("base_commit", ""),
|
||||
"problem_statement": md.get("problem_statement", rec.problem),
|
||||
"hints_text": md.get("hints_text", ""),
|
||||
"test_patch": md.get("test_patch", ""),
|
||||
"FAIL_TO_PASS": md.get("FAIL_TO_PASS", []),
|
||||
"PASS_TO_PASS": md.get("PASS_TO_PASS", []),
|
||||
"version": md.get("version"),
|
||||
"reference": rec.reference,
|
||||
"metadata": dict(md),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -206,7 +212,9 @@ def _load_subset_file(subset_path: str) -> Dict[str, Any]:
|
||||
f"subset {p.name} has no 'task_ids' field; got keys {list(data.keys())}"
|
||||
)
|
||||
return data
|
||||
raise ValueError(f"subset {p.name} must be a list or dict; got {type(data).__name__}")
|
||||
raise ValueError(
|
||||
f"subset {p.name} must be a list or dict; got {type(data).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def _apply_subset(
|
||||
@@ -332,7 +340,11 @@ def _score_swebench(
|
||||
|
||||
patch = extract_patch(answer)
|
||||
if patch is None:
|
||||
return {"success": False, "score": 0.0, "details": {"reason": "no_patch_extracted"}}
|
||||
return {
|
||||
"success": False,
|
||||
"score": 0.0,
|
||||
"details": {"reason": "no_patch_extracted"},
|
||||
}
|
||||
|
||||
record = EvalRecord(
|
||||
record_id=task["task_id"],
|
||||
@@ -368,6 +380,7 @@ def score(
|
||||
|
||||
# ---------- Cell run ----------
|
||||
|
||||
|
||||
def _cell_dir(cell_name: str, root: Path) -> Path:
|
||||
d = root / cell_name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
@@ -440,8 +453,10 @@ def _error_row(task: Dict[str, Any], t0: float, error: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"task_id": task["task_id"],
|
||||
"answer": "",
|
||||
"tokens_local": 0, "tokens_cloud": 0,
|
||||
"cost_usd": 0.0, "latency_s": time.time() - t0,
|
||||
"tokens_local": 0,
|
||||
"tokens_cloud": 0,
|
||||
"cost_usd": 0.0,
|
||||
"latency_s": time.time() - t0,
|
||||
"web_search_uses": 0,
|
||||
"tool_calls": 0,
|
||||
"n_cloud_calls": 0,
|
||||
@@ -456,11 +471,13 @@ def _run_one_inner(
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the agent on one task. Returns a hybrid-shape row."""
|
||||
prompt = _format_prompt(task)
|
||||
ctx = AgentContext(metadata={
|
||||
"task": task,
|
||||
"task_id": task["task_id"],
|
||||
"log_dir": log_dir,
|
||||
})
|
||||
ctx = AgentContext(
|
||||
metadata={
|
||||
"task": task,
|
||||
"task_id": task["task_id"],
|
||||
"log_dir": log_dir,
|
||||
}
|
||||
)
|
||||
t0 = time.time()
|
||||
try:
|
||||
result: AgentResult = agent.run(prompt, ctx)
|
||||
@@ -534,11 +551,12 @@ def _run_one(
|
||||
if worker.is_alive():
|
||||
print(
|
||||
f"[timeout] task={task['task_id']} exceeded "
|
||||
f"{task_timeout_s/60:.1f}m — abandoning worker, recording error row",
|
||||
f"{task_timeout_s / 60:.1f}m — abandoning worker, recording error row",
|
||||
flush=True,
|
||||
)
|
||||
return _error_row(
|
||||
task, t0,
|
||||
task,
|
||||
t0,
|
||||
f"TaskTimeout: task exceeded the {task_timeout_s:.0f}s hybrid "
|
||||
"per-task wall-clock cap (likely a hung network or Modal-harness "
|
||||
"call); worker thread abandoned, task left for resume.",
|
||||
@@ -558,7 +576,7 @@ def _heartbeat(done: int, total: int, row: Dict[str, Any], t_start: float) -> No
|
||||
print(
|
||||
f"[{done}/{total}] {ok} task={row['task_id']} score={sc_str} "
|
||||
f"local={row['tokens_local']} cloud={row['tokens_cloud']} "
|
||||
f"${row['cost_usd']:.3f} {row['latency_s']:.1f}s eta={eta/60:.1f}m",
|
||||
f"${row['cost_usd']:.3f} {row['latency_s']:.1f}s eta={eta / 60:.1f}m",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
@@ -644,9 +662,9 @@ def _write_summary(
|
||||
summary_path.write_text(json.dumps(summary, indent=2))
|
||||
print(
|
||||
f"[summary] {cell_name}: n={n_done}/{cell['n']} err={n_err} "
|
||||
f"acc={acc:.3f} cost=${total_cost:.2f} time={wall/60:.1f}m "
|
||||
f"energy={energy_j/1000:.1f}kJ "
|
||||
f"(session +{elapsed/60:.1f}m +{energy_j_session/1000:.1f}kJ, "
|
||||
f"acc={acc:.3f} cost=${total_cost:.2f} time={wall / 60:.1f}m "
|
||||
f"energy={energy_j / 1000:.1f}kJ "
|
||||
f"(session +{elapsed / 60:.1f}m +{energy_j_session / 1000:.1f}kJ, "
|
||||
f"processed={n_processed})",
|
||||
flush=True,
|
||||
)
|
||||
@@ -664,8 +682,11 @@ def run_cell(
|
||||
out_dir = _cell_dir(cell_name, out_root)
|
||||
with _cell_lock(out_dir, cell_name):
|
||||
_run_cell_locked(
|
||||
cell_name, cell, out_dir,
|
||||
do_score=do_score, resume=resume,
|
||||
cell_name,
|
||||
cell,
|
||||
out_dir,
|
||||
do_score=do_score,
|
||||
resume=resume,
|
||||
)
|
||||
|
||||
|
||||
@@ -727,7 +748,7 @@ def _run_cell_locked(
|
||||
mcfg = cell.get("method_cfg") or {}
|
||||
task_timeout_s = float(mcfg.get("task_timeout_s", DEFAULT_TASK_TIMEOUT_S))
|
||||
if task_timeout_s > 0:
|
||||
print(f"[task-timeout] {task_timeout_s/60:.1f}m per task", flush=True)
|
||||
print(f"[task-timeout] {task_timeout_s / 60:.1f}m per task", flush=True)
|
||||
|
||||
agent = _build_agent(cell)
|
||||
|
||||
@@ -739,18 +760,25 @@ def _run_cell_locked(
|
||||
|
||||
def _process(task: Dict[str, Any]) -> None:
|
||||
row = _run_one(
|
||||
agent, cell["bench"], task, log_dir,
|
||||
agent,
|
||||
cell["bench"],
|
||||
task,
|
||||
log_dir,
|
||||
task_timeout_s=task_timeout_s,
|
||||
)
|
||||
scored: Optional[Dict[str, Any]] = None
|
||||
if do_score and row.get("error") is None:
|
||||
try:
|
||||
scored = score(
|
||||
cell["bench"], task, row["answer"], cell_name=cell_name,
|
||||
cell["bench"],
|
||||
task,
|
||||
row["answer"],
|
||||
cell_name=cell_name,
|
||||
)
|
||||
except Exception as e:
|
||||
scored = {
|
||||
"success": False, "score": 0.0,
|
||||
"success": False,
|
||||
"score": 0.0,
|
||||
"details": {"score_error": str(e)},
|
||||
}
|
||||
full_row = {**row, "score": scored}
|
||||
@@ -789,9 +817,7 @@ def _run_cell_locked(
|
||||
while not watchdog_stop.wait(60.0):
|
||||
try:
|
||||
cur = (
|
||||
results_path.stat().st_mtime
|
||||
if results_path.exists()
|
||||
else last_seen
|
||||
results_path.stat().st_mtime if results_path.exists() else last_seen
|
||||
)
|
||||
except Exception:
|
||||
cur = last_seen
|
||||
@@ -830,7 +856,11 @@ def _run_cell_locked(
|
||||
watchdog_stop.set()
|
||||
|
||||
_write_summary(
|
||||
out_dir, cell_name, cell, tasks, t_start,
|
||||
out_dir,
|
||||
cell_name,
|
||||
cell,
|
||||
tasks,
|
||||
t_start,
|
||||
n_processed=len(pending),
|
||||
energy_j_session=energy.energy_j_total,
|
||||
)
|
||||
@@ -838,6 +868,7 @@ def _run_cell_locked(
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="python -m openjarvis.agents.hybrid.runner",
|
||||
@@ -855,13 +886,18 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
help="Override experiments output root.",
|
||||
)
|
||||
p.add_argument("--no-score", action="store_true", help="Skip scoring.")
|
||||
p.add_argument("--no-resume", action="store_true", help="Don't resume from results.jsonl.")
|
||||
p.add_argument(
|
||||
"--no-resume", action="store_true", help="Don't resume from results.jsonl."
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
reg_dir = Path(args.registry_dir) if args.registry_dir else None
|
||||
cells = load_registry(reg_dir)
|
||||
if not cells:
|
||||
print(f"[error] no cells found in {reg_dir or DEFAULT_REGISTRY_DIR}", file=sys.stderr)
|
||||
print(
|
||||
f"[error] no cells found in {reg_dir or DEFAULT_REGISTRY_DIR}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
if args.cell not in cells:
|
||||
print(
|
||||
@@ -871,7 +907,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
return 2
|
||||
root = Path(args.root) if args.root else None
|
||||
run_cell(
|
||||
args.cell, cells[args.cell],
|
||||
args.cell,
|
||||
cells[args.cell],
|
||||
do_score=not args.no_score,
|
||||
resume=not args.no_resume,
|
||||
root=root,
|
||||
|
||||
@@ -149,6 +149,17 @@ def _build_router_schema(agent_ids: List[str]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _openai_response_format(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "skillorchestra_route",
|
||||
"schema": schema["format"]["schema"],
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parse_router_json(text: str) -> Dict[str, Any]:
|
||||
s = (text or "").strip()
|
||||
try:
|
||||
@@ -196,6 +207,70 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
|
||||
agent_id = "skillorchestra"
|
||||
|
||||
def _route_call(
|
||||
self,
|
||||
*,
|
||||
question: str,
|
||||
router_sys: str,
|
||||
router_schema: Dict[str, Any],
|
||||
router_max: int,
|
||||
) -> Tuple[str, int, int]:
|
||||
user = f"Question:\n{question}"
|
||||
if self._cloud_endpoint == "anthropic":
|
||||
kwargs: Dict[str, Any] = {
|
||||
"user": user,
|
||||
"system": router_sys,
|
||||
"max_tokens": router_max,
|
||||
"output_config": router_schema,
|
||||
}
|
||||
if supports_temperature(self._cloud_model):
|
||||
kwargs["temperature"] = 0.0
|
||||
text, r_in, r_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
**kwargs,
|
||||
)
|
||||
return text, r_in, r_out
|
||||
if self._cloud_endpoint == "openai":
|
||||
return self._call_openai(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
temperature=0.0,
|
||||
response_format=_openai_response_format(router_schema),
|
||||
)
|
||||
if self._cloud_endpoint == "gemini":
|
||||
return self._call_gemini(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
temperature=0.0,
|
||||
)
|
||||
raise ValueError(
|
||||
f"SkillOrchestra router unsupported cloud_endpoint={self._cloud_endpoint!r}"
|
||||
)
|
||||
|
||||
def _executor_call(
|
||||
self,
|
||||
*,
|
||||
question: str,
|
||||
max_tokens: int,
|
||||
) -> Tuple[str, int, int]:
|
||||
if self._cloud_endpoint == "anthropic":
|
||||
text, w_in, w_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=question,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
)
|
||||
return text, w_in, w_out
|
||||
return self._call_cloud(
|
||||
user=question,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
def _is_soft_failure(self, exc: BaseException) -> Optional[str]:
|
||||
# Empty/unbalanced router JSON — treat as soft failure to match the
|
||||
# hybrid adapter's behavior (matches `err=1` rows in the n=30 cell).
|
||||
@@ -220,33 +295,13 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
router_sys = _build_router_sys(competence, cost)
|
||||
router_schema = _build_router_schema(agent_ids)
|
||||
|
||||
# 1. Route — Anthropic only (output_config schema is Anthropic-specific
|
||||
# in the hybrid adapter). If you need OpenAI routing, swap the prompt
|
||||
# to JSON-mode and bypass output_config.
|
||||
if self._cloud_endpoint != "anthropic":
|
||||
raise ValueError(
|
||||
"SkillOrchestra router requires cloud_endpoint='anthropic'; "
|
||||
f"got {self._cloud_endpoint!r}"
|
||||
)
|
||||
router_max = int(cfg.get("router_max_tokens", 1024))
|
||||
# Strip temperature for Opus 4.7+; Anthropic's output_config does the schema.
|
||||
if supports_temperature(self._cloud_model):
|
||||
router_text, r_in, r_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=f"Question:\n{question}",
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
temperature=0.0,
|
||||
output_config=router_schema,
|
||||
)
|
||||
else:
|
||||
router_text, r_in, r_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=f"Question:\n{question}",
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
output_config=router_schema,
|
||||
)
|
||||
router_text, r_in, r_out = self._route_call(
|
||||
question=question,
|
||||
router_sys=router_sys,
|
||||
router_schema=router_schema,
|
||||
router_max=router_max,
|
||||
)
|
||||
|
||||
decision = _parse_router_json(router_text)
|
||||
skill_weights: Dict[str, float] = decision.get("skill_weights") or {}
|
||||
@@ -329,11 +384,9 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
tokens_cloud += out["tokens_in"] + out["tokens_out"]
|
||||
run_cost += out["cost_usd"]
|
||||
else:
|
||||
ans, w_in, w_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=question,
|
||||
ans, w_in, w_out = self._executor_call(
|
||||
question=question,
|
||||
max_tokens=int(cfg.get("cloud_max_tokens", 4096)),
|
||||
temperature=0.0,
|
||||
)
|
||||
tokens_cloud += w_in + w_out
|
||||
run_cost += self.cost_usd(self._cloud_model, w_in, w_out)
|
||||
|
||||
@@ -31,7 +31,14 @@ from .stage_router import (
|
||||
get_routing_strategy,
|
||||
parse_skill_analysis,
|
||||
)
|
||||
from .tools import anthropic_tools, openai_tools, run_answer, run_code, run_search
|
||||
from .tools import (
|
||||
anthropic_tools,
|
||||
gemini_tools,
|
||||
openai_tools,
|
||||
run_answer,
|
||||
run_code,
|
||||
run_search,
|
||||
)
|
||||
|
||||
# tool name -> routing stage (stage_router uses "reasoning" for code).
|
||||
_TOOL_STAGE = {
|
||||
@@ -115,10 +122,49 @@ def _orchestrate_step(
|
||||
u = resp.usage
|
||||
p = getattr(u, "prompt_tokens", 0) if u else 0
|
||||
c = getattr(u, "completion_tokens", 0) if u else 0
|
||||
elif endpoint == "gemini":
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(
|
||||
http_options=types.HttpOptions(timeout=600_000)
|
||||
)
|
||||
cfg = types.GenerateContentConfig(
|
||||
temperature=1.0,
|
||||
max_output_tokens=max_tokens,
|
||||
tools=[types.Tool(function_declarations=gemini_tools())],
|
||||
)
|
||||
resp = client.models.generate_content(
|
||||
model=model,
|
||||
contents=user,
|
||||
config=cfg,
|
||||
)
|
||||
text = (resp.text or "") if hasattr(resp, "text") else ""
|
||||
tool_calls = []
|
||||
try:
|
||||
parts = resp.candidates[0].content.parts or []
|
||||
except Exception: # noqa: BLE001
|
||||
parts = []
|
||||
for part in parts:
|
||||
fc = getattr(part, "function_call", None)
|
||||
if fc is None:
|
||||
continue
|
||||
name = getattr(fc, "name", None)
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
args = getattr(fc, "args", None) or {}
|
||||
try:
|
||||
args = dict(args)
|
||||
except Exception: # noqa: BLE001
|
||||
args = {}
|
||||
tool_calls.append({"name": name, "input": args})
|
||||
um = getattr(resp, "usage_metadata", None)
|
||||
p = int(getattr(um, "prompt_token_count", 0) or 0) if um else 0
|
||||
c = int(getattr(um, "candidates_token_count", 0) or 0) if um else 0
|
||||
else:
|
||||
raise ValueError(
|
||||
f"orchestrator endpoint {endpoint!r} unsupported — route the "
|
||||
"orchestrator through anthropic/openai (set method_cfg."
|
||||
"orchestrator through anthropic/openai/gemini (set method_cfg."
|
||||
"orchestrator_endpoint)."
|
||||
)
|
||||
|
||||
@@ -192,6 +238,8 @@ def run_orchestrator(
|
||||
code_timeout = int(cfg.get("code_timeout_s", 60))
|
||||
answer_max_tokens = int(cfg.get("answer_max_tokens", 40000))
|
||||
ws_max_uses = int(cfg.get("web_search_max_uses", 5))
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
tavily_max_results = int(cfg.get("tavily_max_results", 5))
|
||||
|
||||
# The orchestrator model: a fixed model per run (the original's
|
||||
# MODEL_NAME). Defaults to the cell's cloud model when that endpoint
|
||||
@@ -203,7 +251,7 @@ def run_orchestrator(
|
||||
orch_model = (cfg.get("orchestrator_model")
|
||||
or cfg.get("router_model")
|
||||
or agent._cloud_model)
|
||||
if orch_endpoint not in ("anthropic", "openai"):
|
||||
if orch_endpoint not in ("anthropic", "openai", "gemini"):
|
||||
orch_endpoint, orch_model = "anthropic", "claude-opus-4-7"
|
||||
orch_max_tokens = int(cfg.get("orchestrator_max_tokens", 4096))
|
||||
|
||||
@@ -306,6 +354,8 @@ def run_orchestrator(
|
||||
res = run_search(
|
||||
agent, spec, context_str=context_str, problem=problem,
|
||||
retriever_url=retriever_url, web_search_max_uses=ws_max_uses,
|
||||
search_backend=search_backend,
|
||||
tavily_max_results=tavily_max_results,
|
||||
)
|
||||
docs = res["search_results_data"]
|
||||
joined = "\n---\n".join(d for d in docs if d)[:char_cap]
|
||||
|
||||
@@ -27,6 +27,7 @@ from .._base import (
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL,
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
)
|
||||
from .pool import ModelSpec, call_alias
|
||||
|
||||
@@ -110,6 +111,26 @@ def openai_tools() -> List[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def gemini_tools() -> List[Dict[str, Any]]:
|
||||
"""The 3 orchestrator tools in Gemini function-declaration shape."""
|
||||
out = []
|
||||
for name, desc in (
|
||||
("search", _SEARCH_DESC),
|
||||
("enhance_reasoning", _CODE_DESC),
|
||||
("answer", _ANSWER_DESC),
|
||||
):
|
||||
out.append({
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enhance_reasoning / code — eval_frames.py:659-812
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -256,6 +277,8 @@ def run_search(
|
||||
retriever_url: Optional[str] = None,
|
||||
topk: int = 150,
|
||||
web_search_max_uses: int = 5,
|
||||
search_backend: str = "provider",
|
||||
tavily_max_results: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Write a search query with ``spec``, then retrieve documents.
|
||||
|
||||
@@ -283,7 +306,12 @@ def run_search(
|
||||
contents: List[str] = []
|
||||
search_uses = 0
|
||||
|
||||
if retriever_url:
|
||||
if search_backend == "tavily":
|
||||
res = tavily_search_context(query, max_results=tavily_max_results)
|
||||
contents.append(res["text"])
|
||||
search_uses = int(res["n_searches"])
|
||||
cost += float(res["cost_usd"])
|
||||
elif retriever_url:
|
||||
# Faithful path — the original FAISS retriever service.
|
||||
import requests
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ Two modes, gated by ``method_cfg.orchestrator_mode``:
|
||||
(``answer-1``, ``reasoner-2``, ``search-3``, …) is mapped to a real
|
||||
backend through ``EXPERT_MODEL_MAPPING`` — by default the frontier
|
||||
Anthropic worker for `*-1` slots, gpt-5-mini for `*-2`, local Qwen
|
||||
for `*-3`. Search routes to the Anthropic server-side web_search.
|
||||
for `*-3`. Search routes to the configured provider's server-side
|
||||
web-search helper when available.
|
||||
|
||||
We do NOT reproduce the upstream Tavily / FAISS-wiki retriever, the
|
||||
code-interpreter sandbox, or the multi-vLLM mix (Llama-3.3-70B,
|
||||
@@ -43,8 +44,8 @@ Prompted-mode pipeline:
|
||||
prompt; fallback to strongest worker on parse failure.
|
||||
|
||||
Workers come from ``cfg["workers"]`` or a sensible default pool (local
|
||||
Qwen if vLLM up, plus a web-search tool via Anthropic, Opus 4.7,
|
||||
gpt-5-mini).
|
||||
Qwen if vLLM up, plus provider-native web search, the configured frontier
|
||||
cloud model, and gpt-5-mini).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -59,8 +60,11 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from openjarvis.agents._stubs import AgentContext
|
||||
from openjarvis.agents.hybrid._base import (
|
||||
ANTHROPIC_WEB_SEARCH_TOOL,
|
||||
GEMINI_SEARCH_COST_PER_CALL,
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL,
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
tavily_search_context,
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import (
|
||||
PRICES,
|
||||
@@ -197,10 +201,23 @@ def _expert_for(slot: str, local_model: Optional[str],
|
||||
cost tier for mid OpenAI calls)
|
||||
- `*-3` (local tier) -> local vLLM (`local_model`)
|
||||
- `answer-math-*` -> same tiers as the numeric suffix
|
||||
- `search-*` -> always the Anthropic web_search tool (the
|
||||
upstream uses Tavily; we have web_search)
|
||||
- `search-*` -> provider-native web search when the cloud
|
||||
endpoint supports it; otherwise Anthropic
|
||||
"""
|
||||
if slot.startswith("search"):
|
||||
ep = (cloud_endpoint or "anthropic").lower()
|
||||
if ep == "openai":
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "openai-web-search",
|
||||
"model": cloud_model,
|
||||
}
|
||||
if ep == "gemini":
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "gemini-web-search",
|
||||
"model": cloud_model,
|
||||
}
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "anthropic-web-search",
|
||||
@@ -340,21 +357,18 @@ def _paper_expert_for(
|
||||
|
||||
# ---- Tavily + Modal helpers -------------------------------------------------
|
||||
|
||||
def _call_tavily_search(query: str, max_results: int = 5) -> Tuple[str, int, int]:
|
||||
"""One-shot Tavily search. Returns (text, p_tok=0, c_tok=0).
|
||||
def _call_tavily_search(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
) -> Tuple[str, int, int, float, int]:
|
||||
"""One-shot Tavily search. Returns (text, p_tok=0, c_tok=0, cost, uses).
|
||||
|
||||
Token counts are reported as zero (no LLM was billed); the OpenJarvis
|
||||
accounting layer separately tallies tool-call counts. Falls back to
|
||||
DuckDuckGo if Tavily is unreachable (see ``WebSearchTool``).
|
||||
"""
|
||||
from openjarvis.tools.web_search import WebSearchTool
|
||||
|
||||
tool = WebSearchTool(max_results=max_results)
|
||||
res = tool.execute(query=query, max_results=max_results)
|
||||
text = res.content or ""
|
||||
if not res.success and not text:
|
||||
text = "(no results)"
|
||||
return text, 0, 0
|
||||
res = tavily_search_context(query, max_results=max_results)
|
||||
return res["text"], 0, 0, float(res["cost_usd"]), int(res["n_searches"])
|
||||
|
||||
|
||||
_MODAL_APP_NAME = "openjarvis-toolorchestra-sandbox"
|
||||
@@ -674,13 +688,25 @@ def _default_pool(
|
||||
"concise extraction, formatting, arithmetic on given data."
|
||||
),
|
||||
})
|
||||
if ep == "openai":
|
||||
search_type = "openai-web-search"
|
||||
search_model = cloud_model
|
||||
search_desc = "OpenAI hosted web search on the configured frontier model."
|
||||
elif ep == "gemini":
|
||||
search_type = "gemini-web-search"
|
||||
search_model = cloud_model
|
||||
search_desc = "Gemini Google Search grounding on the configured frontier model."
|
||||
else:
|
||||
search_type = "anthropic-web-search"
|
||||
search_model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
search_desc = "Anthropic server-side web_search."
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "web-search",
|
||||
"type": "anthropic-web-search",
|
||||
"model": "claude-haiku-4-5",
|
||||
"type": search_type,
|
||||
"model": search_model,
|
||||
"description": (
|
||||
"Anthropic server-side web_search. Use for facts that need a lookup "
|
||||
f"{search_desc} Use for facts that need a lookup "
|
||||
"(recent events, rare names/dates, niche sources). Returns a digest."
|
||||
),
|
||||
})
|
||||
@@ -717,8 +743,13 @@ def _default_pool(
|
||||
# `modal-python` — One-shot Python exec in a fresh Modal Sandbox (the
|
||||
# paper's "Python sandbox" inside `enhance_reasoning`).
|
||||
_TOOLORCH_VALID_TYPES = (
|
||||
"vllm", "openai", "anthropic", "anthropic-web-search", "gemini",
|
||||
"tavily-search", "openrouter", "modal-python",
|
||||
"vllm", "openai", "anthropic", "anthropic-web-search",
|
||||
"openai-web-search", "gemini", "gemini-web-search", "tavily-search",
|
||||
"openrouter", "modal-python",
|
||||
)
|
||||
_TOOLORCH_SEARCH_TYPES = (
|
||||
"anthropic-web-search", "openai-web-search", "gemini-web-search",
|
||||
"tavily-search",
|
||||
)
|
||||
|
||||
# Default model used when an `anthropic-web-search` entry omits `model`.
|
||||
@@ -739,10 +770,12 @@ def _resolve_worker_pool(
|
||||
the override is absent.
|
||||
|
||||
Each user-supplied entry must be a dict with keys ``id``, ``name``,
|
||||
``type``, and (for non-search types) ``model``. ``type`` must be one
|
||||
of ``vllm`` / ``openai`` / ``anthropic`` / ``anthropic-web-search``.
|
||||
``anthropic-web-search`` entries may omit ``model`` — it defaults to
|
||||
``claude-haiku-4-5``.
|
||||
``type``, and (for non-search types) ``model``. Search worker types are
|
||||
``anthropic-web-search``, ``openai-web-search``, ``gemini-web-search``,
|
||||
and ``tavily-search``. ``anthropic-web-search`` entries may omit
|
||||
``model`` — it defaults to ``claude-haiku-4-5``. OpenAI and Gemini
|
||||
search workers default to the configured cloud model. Tavily does not
|
||||
require a model.
|
||||
|
||||
Substitution: ``model = "$local"`` (or ``"<local>"``) resolves to
|
||||
``local_model``; ``model = "$cloud"`` / ``"<cloud>"`` to ``cloud_model``.
|
||||
@@ -804,14 +837,24 @@ def _resolve_worker_pool(
|
||||
elif isinstance(model, str) and model in ("$cloud", "<cloud>"):
|
||||
model = cloud_model
|
||||
entry["model"] = model
|
||||
if wtype == "anthropic-web-search":
|
||||
if wtype in _TOOLORCH_SEARCH_TYPES:
|
||||
if model in (None, ""):
|
||||
model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
if wtype == "anthropic-web-search":
|
||||
model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
elif wtype in ("openai-web-search", "gemini-web-search"):
|
||||
model = cloud_model
|
||||
else:
|
||||
model = wtype
|
||||
entry["model"] = model
|
||||
elif not isinstance(model, str):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: 'model' must be a string when set"
|
||||
)
|
||||
if wtype in ("openai-web-search", "gemini-web-search") and model not in PRICES:
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: model {model!r} "
|
||||
f"is not in PRICES (known: {sorted(PRICES)})"
|
||||
)
|
||||
# Search workers don't satisfy the "needs a solver" requirement.
|
||||
else:
|
||||
if not isinstance(model, str) or not model:
|
||||
@@ -843,7 +886,7 @@ def _resolve_worker_pool(
|
||||
if not has_non_search:
|
||||
raise ValueError(
|
||||
"Invalid worker_pool entry [-]: worker_pool must contain at least "
|
||||
"one non-search worker (vllm / openai / anthropic)"
|
||||
"one non-search worker (vllm / openai / anthropic / gemini)"
|
||||
)
|
||||
return resolved
|
||||
|
||||
@@ -910,13 +953,31 @@ def _call_worker(
|
||||
)
|
||||
extra = n_searches * WEB_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "openai-web-search":
|
||||
eff_temp = 1.0 if is_gpt5_family(worker["model"]) else temp
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max(max_tok, 16384) if is_gpt5_family(worker["model"]) else max_tok,
|
||||
temperature=eff_temp,
|
||||
)
|
||||
extra = n_searches * OPENAI_WEB_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "gemini-web-search":
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_gemini_agent(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
extra = n_searches * GEMINI_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "tavily-search":
|
||||
# Tavily costs are flat per call; charge `WEB_SEARCH_COST_PER_CALL`
|
||||
# for parity with the Anthropic web-search worker. One call = one
|
||||
# "n_search" for accounting.
|
||||
max_results = int(cfg.get("tavily_max_results", 5))
|
||||
text, p, c = _call_tavily_search(str(prompt), max_results=max_results)
|
||||
return text, p, c, False, WEB_SEARCH_COST_PER_CALL, 1
|
||||
text, p, c, extra, n_searches = _call_tavily_search(
|
||||
str(prompt), max_results=max_results,
|
||||
)
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "openrouter":
|
||||
text, p, c = LocalCloudAgent._call_openrouter(
|
||||
worker["model"],
|
||||
@@ -951,7 +1012,7 @@ def _swe_call_worker(
|
||||
caller can surface ``tool_calls`` per row. Fallbacks to one-shot
|
||||
workers return 0 bash turns (no agent loop ran)."""
|
||||
wtype = worker.get("type", "openai")
|
||||
if wtype == "anthropic-web-search":
|
||||
if wtype in _TOOLORCH_SEARCH_TYPES:
|
||||
# Search workers stay one-shot.
|
||||
text, p, c, is_local, extra, n_searches = _call_worker(worker, prompt, cfg)
|
||||
return text, p, c, is_local, extra, n_searches, 0
|
||||
@@ -1197,7 +1258,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# Search workers are excluded — they answer fact-lookup
|
||||
# questions, not synthesis.
|
||||
non_search = [
|
||||
w for w in workers if w.get("type") != "anthropic-web-search"
|
||||
w for w in workers
|
||||
if w.get("type") not in _TOOLORCH_SEARCH_TYPES
|
||||
] or workers
|
||||
worker = max(
|
||||
non_search,
|
||||
|
||||
@@ -11,10 +11,11 @@ import logging
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CREATE_AGENTS = """\
|
||||
@@ -537,7 +538,7 @@ class AgentManager:
|
||||
pass
|
||||
|
||||
# User templates
|
||||
user_dir = Path("~/.openjarvis/templates").expanduser()
|
||||
user_dir = get_config_dir() / "templates"
|
||||
if user_dir.is_dir():
|
||||
for f in user_dir.glob("*.toml"):
|
||||
try:
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
|
||||
from openjarvis.agents.digest_store import DigestArtifact, DigestStore
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
|
||||
@@ -21,7 +22,7 @@ def _load_persona(persona_name: str) -> str:
|
||||
"""Load a persona prompt file by name."""
|
||||
search_paths = [
|
||||
Path("configs/openjarvis/prompts/personas") / f"{persona_name}.md",
|
||||
Path.home() / ".openjarvis" / "prompts" / "personas" / f"{persona_name}.md",
|
||||
get_config_dir() / "prompts" / "personas" / f"{persona_name}.md",
|
||||
]
|
||||
for p in search_paths:
|
||||
if p.exists():
|
||||
@@ -202,7 +203,7 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
tts_text = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", tts_text)
|
||||
tts_text = tts_text.strip()
|
||||
|
||||
output_dir = str(Path.home() / ".openjarvis" / "digests")
|
||||
output_dir = str(get_config_dir() / "digests")
|
||||
tts_call = ToolCall(
|
||||
id="digest-tts-1",
|
||||
name="text_to_speech",
|
||||
|
||||
@@ -43,6 +43,7 @@ from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
from openjarvis.tools.approval_store import (
|
||||
@@ -342,8 +343,8 @@ class ProactiveAgent(ToolUsingAgent):
|
||||
return self._approval_store
|
||||
|
||||
def _build_system_prompt(self) -> str:
|
||||
user_md = _load_md_file(Path.home() / ".openjarvis" / "USER.md")
|
||||
memory_md = _load_md_file(Path.home() / ".openjarvis" / "MEMORY.md")
|
||||
user_md = _load_md_file(get_config_dir() / "USER.md")
|
||||
memory_md = _load_md_file(get_config_dir() / "MEMORY.md")
|
||||
now = datetime.now()
|
||||
context_block = ""
|
||||
if user_md or memory_md:
|
||||
|
||||
@@ -15,16 +15,17 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _openjarvis_home() -> Path:
|
||||
"""Resolve $OPENJARVIS_HOME, defaulting to ~/.openjarvis."""
|
||||
return Path(os.environ.get("OPENJARVIS_HOME", "~/.openjarvis")).expanduser()
|
||||
"""Resolve the OpenJarvis root, honoring OPENJARVIS_HOME / XDG_DATA_HOME."""
|
||||
return get_config_dir()
|
||||
|
||||
|
||||
def load_system_prompt_override(agent_name: str) -> str | None:
|
||||
|
||||
@@ -18,11 +18,13 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_DB_PATH = str(Path.home() / "Library" / "Messages" / "chat.db")
|
||||
_POLL_INTERVAL = 5
|
||||
_PID_FILE = str(Path.home() / ".openjarvis" / "imessage-agent.pid")
|
||||
_PID_FILE = str(get_config_dir() / "imessage-agent.pid")
|
||||
|
||||
|
||||
def poll_new_messages(
|
||||
|
||||
@@ -14,9 +14,11 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PID_FILE = str(Path.home() / ".openjarvis" / "slack-daemon.pid")
|
||||
_PID_FILE = str(get_config_dir() / "slack-daemon.pid")
|
||||
|
||||
|
||||
def _to_slack_fmt(text: str) -> str:
|
||||
|
||||
@@ -22,6 +22,7 @@ from openjarvis.channels._stubs import (
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,7 +39,7 @@ if not _BRIDGE_SRC.exists():
|
||||
)
|
||||
|
||||
# Default runtime directory (npm install + auth state).
|
||||
_DEFAULT_RUNTIME_DIR = Path.home() / ".openjarvis" / "whatsapp_baileys_bridge"
|
||||
_DEFAULT_RUNTIME_DIR = get_config_dir() / "whatsapp_baileys_bridge"
|
||||
|
||||
|
||||
@ChannelRegistry.register("whatsapp_baileys")
|
||||
|
||||
@@ -9,9 +9,11 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_PATH = Path("~/.openjarvis/version-check.json").expanduser()
|
||||
_CACHE_PATH = get_config_dir() / "version-check.json"
|
||||
_CACHE_TTL = 86400 # 24 hours
|
||||
_PYPI_API = "https://pypi.org/pypi/openjarvis/json"
|
||||
|
||||
@@ -21,7 +23,7 @@ def _config_path() -> Path:
|
||||
override = os.environ.get("OPENJARVIS_CONFIG")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return Path("~/.openjarvis/config.toml").expanduser()
|
||||
return get_config_dir() / "config.toml"
|
||||
|
||||
|
||||
# Commands that surface the "new version available" nudge. We deliberately
|
||||
|
||||
@@ -12,15 +12,12 @@ from rich.table import Table
|
||||
|
||||
def _get_manager():
|
||||
"""Get or create the AgentManager singleton."""
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.agents.manager import AgentManager
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
config = load_config()
|
||||
db_path = config.agent_manager.db_path or str(
|
||||
Path("~/.openjarvis/agents.db").expanduser()
|
||||
)
|
||||
db_path = config.agent_manager.db_path or str(get_config_dir() / "agents.db")
|
||||
return AgentManager(db_path=db_path)
|
||||
|
||||
|
||||
@@ -273,6 +270,7 @@ def search(agent_id: str, query: str, limit: int) -> None:
|
||||
console = Console(stderr=True)
|
||||
try:
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
config = load_config()
|
||||
@@ -281,7 +279,7 @@ def search(agent_id: str, query: str, limit: int) -> None:
|
||||
if not agent:
|
||||
console.print(f"[red]Agent not found: {agent_id}[/red]")
|
||||
return
|
||||
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
|
||||
store = TraceStore(config.traces.db_path or str(get_config_dir() / "traces.db"))
|
||||
results = store.search(query, agent=agent["name"], limit=limit)
|
||||
if not results:
|
||||
console.print("[dim]No results.[/dim]")
|
||||
@@ -545,8 +543,7 @@ def run_agent(agent_id):
|
||||
updated = manager.get_agent(agent_id)
|
||||
runs = updated.get("total_runs", 0)
|
||||
console.print(
|
||||
f"[green]✓[/green] Tick complete. "
|
||||
f"Status: {updated['status']}, runs: {runs}"
|
||||
f"[green]✓[/green] Tick complete. Status: {updated['status']}, runs: {runs}"
|
||||
)
|
||||
|
||||
# Print the agent's actual output. summary_memory holds the latest tick's
|
||||
@@ -662,6 +659,7 @@ def trace(agent_id, run_number, limit):
|
||||
import datetime
|
||||
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
manager = _get_manager()
|
||||
@@ -671,7 +669,7 @@ def trace(agent_id, run_number, limit):
|
||||
raise SystemExit(1)
|
||||
|
||||
config = load_config()
|
||||
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
|
||||
store = TraceStore(config.traces.db_path or str(get_config_dir() / "traces.db"))
|
||||
traces = store.list_traces(agent=agent_id, limit=limit)
|
||||
|
||||
if not traces:
|
||||
@@ -841,8 +839,8 @@ def ask(agent_id, message, auto_approve):
|
||||
if auto_approve:
|
||||
executor._confirm_callback = lambda _prompt: True
|
||||
else:
|
||||
executor._confirm_callback = (
|
||||
lambda prompt: click.confirm(f"\n{prompt}", default=False)
|
||||
executor._confirm_callback = lambda prompt: click.confirm(
|
||||
f"\n{prompt}", default=False
|
||||
)
|
||||
# Run the tick with a live trace rather than blocking in silence — the
|
||||
# message we just queued is consumed as this tick's input, so the user
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -276,6 +276,36 @@ def hardware() -> None:
|
||||
config.add_command(show_group, "show")
|
||||
|
||||
|
||||
@config.command("path")
|
||||
def show_path() -> None:
|
||||
"""Print the resolved OpenJarvis directories (home, config, cache).
|
||||
|
||||
All OpenJarvis state lives under a single root, resolved in priority
|
||||
order: ``$OPENJARVIS_HOME`` > ``$XDG_DATA_HOME/openjarvis`` >
|
||||
``~/.openjarvis``. Use this to confirm where your data is stored after
|
||||
setting an override.
|
||||
"""
|
||||
from openjarvis.core.paths import get_cache_dir, get_config_dir, get_config_path
|
||||
|
||||
console = Console(stderr=True)
|
||||
home = get_config_dir()
|
||||
override = (
|
||||
"OPENJARVIS_HOME"
|
||||
if os.environ.get("OPENJARVIS_HOME")
|
||||
else "XDG_DATA_HOME"
|
||||
if os.environ.get("XDG_DATA_HOME")
|
||||
else "default (~/.openjarvis)"
|
||||
)
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Directory")
|
||||
table.add_column("Path", style="cyan")
|
||||
table.add_row("Home (root)", str(home))
|
||||
table.add_row("Config file", str(get_config_path()))
|
||||
table.add_row("Cache", str(get_cache_dir()))
|
||||
console.print(table)
|
||||
console.print(f"[dim]Resolved via: {override}[/dim]")
|
||||
|
||||
|
||||
def _probe_engine_host(url: str, console: Console) -> None:
|
||||
"""Probe an engine host URL and print reachability status."""
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
@@ -354,7 +396,9 @@ def doctor(as_json: bool) -> None:
|
||||
|
||||
# Background tasks section
|
||||
from openjarvis.cli._bg_state import get_status
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
scripts_dir = get_config_dir() / ".scripts"
|
||||
console.print("[bold]Background tasks[/bold]")
|
||||
bg = get_status()
|
||||
bg_failed = False
|
||||
@@ -364,8 +408,8 @@ def doctor(as_json: bool) -> None:
|
||||
elif bg.rust_extension == "failed":
|
||||
console.print(f" [red]✗[/red] Rust extension: failed — {bg.rust_error[:80]}")
|
||||
console.print(
|
||||
" retry: ~/.openjarvis/.scripts/install-rust.sh && "
|
||||
"~/.openjarvis/.scripts/build-extension.sh"
|
||||
f" retry: {scripts_dir}/install-rust.sh && "
|
||||
f"{scripts_dir}/build-extension.sh"
|
||||
)
|
||||
bg_failed = True
|
||||
else:
|
||||
@@ -380,7 +424,7 @@ def doctor(as_json: bool) -> None:
|
||||
console.print(f" [green]✓[/green] {model_id}: ready")
|
||||
elif state == "failed":
|
||||
console.print(f" [red]✗[/red] {model_id}: failed")
|
||||
console.print(f" retry: ~/.openjarvis/.scripts/pull-model.sh {model_id}")
|
||||
console.print(f" retry: {scripts_dir}/pull-model.sh {model_id}")
|
||||
bg_failed = True
|
||||
else:
|
||||
console.print(f" [yellow]…[/yellow] {model_id}: downloading")
|
||||
|
||||
@@ -7,6 +7,7 @@ from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.security.credential_stripper import CredentialStripper
|
||||
|
||||
_stripper = CredentialStripper()
|
||||
@@ -68,7 +69,7 @@ def setup_logging(
|
||||
if log_file is None:
|
||||
from openjarvis.security.file_utils import secure_mkdir
|
||||
|
||||
log_dir = Path.home() / ".openjarvis"
|
||||
log_dir = get_config_dir()
|
||||
secure_mkdir(log_dir)
|
||||
log_file = log_dir / "cli.log"
|
||||
file_handler = RotatingFileHandler(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,8 @@ from typing import Callable, List
|
||||
|
||||
import click
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
# Engine ports that should only be listening on localhost.
|
||||
_ENGINE_PORTS = {11434, 8080, 8000, 30000, 1234, 52415, 18181}
|
||||
|
||||
@@ -132,7 +134,7 @@ class PrivacyScanner:
|
||||
def check_icloud_sync(self) -> ScanResult:
|
||||
"""Check whether ~/.openjarvis is inside iCloud Drive sync scope."""
|
||||
try:
|
||||
config_path = Path("~/.openjarvis").expanduser().resolve()
|
||||
config_path = get_config_dir().resolve()
|
||||
icloud_path = Path("~/Library/Mobile Documents/").expanduser().resolve()
|
||||
if str(config_path).startswith(str(icloud_path)):
|
||||
return ScanResult(
|
||||
|
||||
@@ -11,6 +11,7 @@ from rich.console import Console
|
||||
from openjarvis.cli._banner import print_banner
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.engine import (
|
||||
discover_engines,
|
||||
discover_models,
|
||||
@@ -492,17 +493,26 @@ 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:
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.agents.manager import AgentManager
|
||||
|
||||
am_db = config.agent_manager.db_path or str(
|
||||
Path("~/.openjarvis/agents.db").expanduser()
|
||||
)
|
||||
am_db = config.agent_manager.db_path or str(get_config_dir() / "agents.db")
|
||||
# The server owns the scheduler and is the authoritative tick
|
||||
# runner — on boot it holds no locks, so it (and only it) sweeps
|
||||
# any zombie running→idle left by a previous crash.
|
||||
@@ -607,9 +617,7 @@ def serve(
|
||||
try:
|
||||
import tomllib
|
||||
|
||||
_cfg_path = str(
|
||||
__import__("pathlib").Path.home() / ".openjarvis" / "config.toml"
|
||||
)
|
||||
_cfg_path = str(get_config_dir() / "config.toml")
|
||||
with open(_cfg_path, "rb") as _f:
|
||||
_raw = tomllib.load(_f)
|
||||
api_key = _raw.get("server", {}).get("auth", {}).get("api_key", "")
|
||||
@@ -672,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,
|
||||
|
||||
@@ -11,6 +11,7 @@ from rich.table import Table
|
||||
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.skills.manager import SkillManager
|
||||
|
||||
|
||||
@@ -28,12 +29,12 @@ def _get_trace_store():
|
||||
|
||||
def _get_discovered_dir() -> Path:
|
||||
"""Return the directory where discovered skill manifests are written."""
|
||||
return Path("~/.openjarvis/skills/discovered/").expanduser()
|
||||
return get_config_dir() / "skills" / "discovered"
|
||||
|
||||
|
||||
def _get_overlay_dir() -> Path:
|
||||
"""Return the directory where optimization overlays are stored."""
|
||||
return Path("~/.openjarvis/learning/skills/").expanduser()
|
||||
return get_config_dir() / "learning" / "skills"
|
||||
|
||||
|
||||
def _get_skill_paths() -> List[Path]:
|
||||
@@ -41,7 +42,7 @@ def _get_skill_paths() -> List[Path]:
|
||||
workspace = Path("./skills")
|
||||
if workspace.exists():
|
||||
paths.append(workspace)
|
||||
user_dir = Path("~/.openjarvis/skills/").expanduser()
|
||||
user_dir = get_config_dir() / "skills"
|
||||
paths.append(user_dir)
|
||||
return paths
|
||||
|
||||
@@ -174,8 +175,10 @@ def _get_resolver(source: str, url: str = ""):
|
||||
from openjarvis.skills.sources.github import GitHubResolver
|
||||
|
||||
cache = _Path(
|
||||
"~/.openjarvis/skill-cache/github/" + url.rstrip("/").rsplit("/", 1)[-1]
|
||||
).expanduser()
|
||||
str(get_config_dir() / "skill-cache" / "github")
|
||||
+ "/"
|
||||
+ url.rstrip("/").rsplit("/", 1)[-1]
|
||||
)
|
||||
return GitHubResolver(cache_root=cache, repo_url=url)
|
||||
raise click.BadParameter(f"Unknown source: {source!r}")
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
|
||||
delete_tokens,
|
||||
load_tokens,
|
||||
resolve_google_credentials,
|
||||
run_oauth_flow,
|
||||
save_tokens,
|
||||
)
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
@@ -290,12 +289,18 @@ class GCalendarConnector(BaseConnector):
|
||||
"""Handle the OAuth callback.
|
||||
|
||||
If *code* looks like a ``client_id:client_secret`` pair (containing
|
||||
``.apps.googleusercontent.com``), store the credentials and trigger
|
||||
the full browser-based OAuth flow. Otherwise treat it as a raw
|
||||
token / auth code.
|
||||
``.apps.googleusercontent.com``), persist the client credentials only.
|
||||
The browser consent + code→token exchange is owned by the in-process
|
||||
server flow (``/v1/connectors/{id}/oauth/start`` → ``/oauth/callback``),
|
||||
which writes the real ``access_token`` to every Google credential file.
|
||||
|
||||
The previous daemon-thread browser flow (its own ``localhost:8789``
|
||||
callback server) failed silently in the bundled desktop context and is
|
||||
intentionally removed here (issue #512).
|
||||
|
||||
Any other *code* is treated as a raw token / auth code.
|
||||
"""
|
||||
code = code.strip()
|
||||
# If user pastes client_id:client_secret, store and run OAuth flow
|
||||
if ":" in code and ".apps.googleusercontent.com" in code:
|
||||
client_id, client_secret = code.split(":", 1)
|
||||
save_tokens(
|
||||
@@ -305,20 +310,6 @@ class GCalendarConnector(BaseConnector):
|
||||
"client_secret": client_secret.strip(),
|
||||
},
|
||||
)
|
||||
import threading
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
run_oauth_flow(
|
||||
client_id=client_id.strip(),
|
||||
client_secret=client_secret.strip(),
|
||||
scopes=GOOGLE_ALL_SCOPES,
|
||||
credentials_path=self._credentials_path,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
else:
|
||||
# Raw token or auth code
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
|
||||
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
|
||||
delete_tokens,
|
||||
load_tokens,
|
||||
resolve_google_credentials,
|
||||
run_oauth_flow,
|
||||
save_tokens,
|
||||
)
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
@@ -195,12 +194,18 @@ class GContactsConnector(BaseConnector):
|
||||
"""Handle the OAuth callback.
|
||||
|
||||
If *code* looks like a ``client_id:client_secret`` pair (containing
|
||||
``.apps.googleusercontent.com``), store the credentials and trigger
|
||||
the full browser-based OAuth flow. Otherwise treat it as a raw
|
||||
token / auth code.
|
||||
``.apps.googleusercontent.com``), persist the client credentials only.
|
||||
The browser consent + code→token exchange is owned by the in-process
|
||||
server flow (``/v1/connectors/{id}/oauth/start`` → ``/oauth/callback``),
|
||||
which writes the real ``access_token`` to every Google credential file.
|
||||
|
||||
The previous daemon-thread browser flow (its own ``localhost:8789``
|
||||
callback server) failed silently in the bundled desktop context and is
|
||||
intentionally removed here (issue #512).
|
||||
|
||||
Any other *code* is treated as a raw token / auth code.
|
||||
"""
|
||||
code = code.strip()
|
||||
# If user pastes client_id:client_secret, store and run OAuth flow
|
||||
if ":" in code and ".apps.googleusercontent.com" in code:
|
||||
client_id, client_secret = code.split(":", 1)
|
||||
save_tokens(
|
||||
@@ -210,20 +215,6 @@ class GContactsConnector(BaseConnector):
|
||||
"client_secret": client_secret.strip(),
|
||||
},
|
||||
)
|
||||
import threading
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
run_oauth_flow(
|
||||
client_id=client_id.strip(),
|
||||
client_secret=client_secret.strip(),
|
||||
scopes=GOOGLE_ALL_SCOPES,
|
||||
credentials_path=self._credentials_path,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
else:
|
||||
# Raw token or auth code
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
|
||||
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
|
||||
delete_tokens,
|
||||
load_tokens,
|
||||
resolve_google_credentials,
|
||||
run_oauth_flow,
|
||||
save_tokens,
|
||||
)
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
@@ -178,15 +177,25 @@ class GDriveConnector(BaseConnector):
|
||||
"""Handle the OAuth callback.
|
||||
|
||||
If *code* looks like a ``client_id:client_secret`` pair (containing
|
||||
``.apps.googleusercontent.com``), store the credentials and trigger
|
||||
the full browser-based OAuth flow. Otherwise treat it as a raw
|
||||
token / auth code.
|
||||
``.apps.googleusercontent.com``), persist the client credentials only.
|
||||
The actual browser consent + code→token exchange is owned by the
|
||||
in-process server flow (``/v1/connectors/{id}/oauth/start`` →
|
||||
``/oauth/callback``), which writes the real ``access_token`` to every
|
||||
Google credential file.
|
||||
|
||||
Previously this spawned a daemon thread that popped a browser and ran
|
||||
its own ``localhost:8789`` callback server; that thread failed silently
|
||||
in the bundled desktop context, so the connector never gained an access
|
||||
token and never appeared in Data Sources (issue #512). The background
|
||||
flow is intentionally removed here.
|
||||
|
||||
Any other *code* is treated as a raw token / auth code.
|
||||
"""
|
||||
code = code.strip()
|
||||
# If user pastes client_id:client_secret, store and run OAuth flow
|
||||
# A pasted client_id:client_secret pair is the app registration, not a
|
||||
# completed credential — persist it and let the server flow finish auth.
|
||||
if ":" in code and ".apps.googleusercontent.com" in code:
|
||||
client_id, client_secret = code.split(":", 1)
|
||||
# Save credentials immediately
|
||||
save_tokens(
|
||||
self._credentials_path,
|
||||
{
|
||||
@@ -194,21 +203,6 @@ class GDriveConnector(BaseConnector):
|
||||
"client_secret": client_secret.strip(),
|
||||
},
|
||||
)
|
||||
# Run OAuth flow in background thread to avoid blocking
|
||||
import threading
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
run_oauth_flow(
|
||||
client_id=client_id.strip(),
|
||||
client_secret=client_secret.strip(),
|
||||
scopes=GOOGLE_ALL_SCOPES,
|
||||
credentials_path=self._credentials_path,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
else:
|
||||
# Raw token or auth code
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
|
||||
@@ -16,6 +16,14 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.core.paths import (
|
||||
ConfigurationError,
|
||||
get_cache_dir,
|
||||
get_config_dir,
|
||||
get_config_path,
|
||||
get_data_dir,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Only used by type-checkers (mypy/pyright) for the ``JarvisConfig.mining``
|
||||
# field annotation. The runtime import is deferred inside
|
||||
@@ -33,15 +41,24 @@ except ModuleNotFoundError:
|
||||
# Hardware dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_CONFIG_DIR = Path.home() / ".openjarvis"
|
||||
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
|
||||
# Legacy names, kept for the ~45 modules that import them. They are resolved
|
||||
# once at import via the env-aware resolver in ``openjarvis.core.paths`` (the
|
||||
# install-script model: ``OPENJARVIS_HOME`` / ``XDG_DATA_HOME`` are set before
|
||||
# the process starts). They are real module attributes — not computed lazily —
|
||||
# so existing tests can ``monkeypatch.setattr`` them and so dataclass-instance
|
||||
# defaults stay consistent. Code that must react to a mid-process env change
|
||||
# (or wants the override regardless of import order) should call
|
||||
# ``get_config_dir()`` / ``get_config_path()`` directly; the dataclass field
|
||||
# defaults below already do this via ``default_factory``.
|
||||
DEFAULT_CONFIG_DIR = get_config_dir()
|
||||
DEFAULT_CONFIG_PATH = get_config_path()
|
||||
|
||||
|
||||
def _ensure_config_dir() -> Path:
|
||||
"""Ensure the config directory exists with restrictive permissions."""
|
||||
from openjarvis.security.file_utils import secure_mkdir
|
||||
|
||||
return secure_mkdir(DEFAULT_CONFIG_DIR)
|
||||
return secure_mkdir(get_config_dir())
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -742,7 +759,9 @@ class SkillsLearningConfig:
|
||||
optimizer: str = "dspy" # "dspy" or "gepa"
|
||||
min_traces_per_skill: int = 20
|
||||
optimization_interval_seconds: int = 86400
|
||||
overlay_dir: str = "~/.openjarvis/learning/skills/"
|
||||
overlay_dir: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "learning" / "skills")
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -891,16 +910,30 @@ 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 = str(DEFAULT_CONFIG_DIR / "memory.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "memory.db"))
|
||||
context_top_k: int = 5
|
||||
context_min_score: float = 0.0
|
||||
context_max_tokens: int = 2048
|
||||
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
|
||||
@@ -946,8 +979,10 @@ class AgentConfig:
|
||||
system_prompt_path: str = "" # path to system prompt file (.txt, .md)
|
||||
context_from_memory: bool = True # inject relevant memory context into prompts
|
||||
default_system_prompt: str = (
|
||||
"You are a helpful AI assistant running locally on the user's own "
|
||||
"hardware through OpenJarvis. You are not a cloud service. Respond "
|
||||
"You are OpenJarvis, a helpful AI assistant running locally on the "
|
||||
"user's own hardware. You are not a cloud service, and you are not "
|
||||
"Claude, ChatGPT, Gemini, or any other branded assistant. If asked "
|
||||
"who or what you are, identify yourself as OpenJarvis. Respond "
|
||||
"helpfully, concisely, and accurately."
|
||||
)
|
||||
|
||||
@@ -996,7 +1031,7 @@ class TelemetryConfig:
|
||||
"""Telemetry persistence settings."""
|
||||
|
||||
enabled: bool = True
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "telemetry.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "telemetry.db"))
|
||||
gpu_metrics: bool = False
|
||||
gpu_poll_interval_ms: int = 50
|
||||
energy_vendor: str = "" # auto-detect or force "nvidia"/"amd"/"apple"/"cpu_rapl"
|
||||
@@ -1021,7 +1056,7 @@ class AnalyticsConfig:
|
||||
enabled: bool = True
|
||||
host: str = "https://34.231.106.201.sslip.io"
|
||||
key: str = "phc_ysKu72QaxzYNmDpHFcesD2ZZAe68zkdWJEKoYYkc5e3n"
|
||||
anon_id_path: str = str(DEFAULT_CONFIG_DIR / "anon_id")
|
||||
anon_id_path: str = field(default_factory=lambda: str(get_config_dir() / "anon_id"))
|
||||
flush_interval_seconds: int = 30
|
||||
flush_at_size: int = 100
|
||||
|
||||
@@ -1031,7 +1066,7 @@ class TracesConfig:
|
||||
"""Trace system settings."""
|
||||
|
||||
enabled: bool = True
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "traces.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "traces.db"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -1233,7 +1268,9 @@ class SecurityConfig:
|
||||
mode: str = "redact" # "redact" | "warn" | "block"
|
||||
secret_scanner: bool = True
|
||||
pii_scanner: bool = True
|
||||
audit_log_path: str = str(DEFAULT_CONFIG_DIR / "audit.db")
|
||||
audit_log_path: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "audit.db")
|
||||
)
|
||||
enforce_tool_confirmation: bool = True
|
||||
merkle_audit: bool = True
|
||||
signing_key_path: str = ""
|
||||
@@ -1244,7 +1281,9 @@ class SecurityConfig:
|
||||
local_engine_bypass: bool = False
|
||||
local_tool_bypass: bool = False
|
||||
profile: str = ""
|
||||
vault_key_path: str = str(DEFAULT_CONFIG_DIR / ".vault_key")
|
||||
vault_key_path: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / ".vault_key")
|
||||
)
|
||||
capabilities: CapabilitiesConfig = field(default_factory=CapabilitiesConfig)
|
||||
|
||||
|
||||
@@ -1365,7 +1404,7 @@ class SessionConfig:
|
||||
enabled: bool = False
|
||||
max_age_hours: float = 24.0
|
||||
consolidation_threshold: int = 100
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "sessions.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "sessions.db"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -1383,7 +1422,9 @@ class OperatorsConfig:
|
||||
"""Operator lifecycle settings."""
|
||||
|
||||
enabled: bool = False
|
||||
manifests_dir: str = "~/.openjarvis/operators"
|
||||
manifests_dir: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "operators")
|
||||
)
|
||||
auto_activate: str = "" # Comma-separated operator IDs
|
||||
|
||||
|
||||
@@ -1409,7 +1450,7 @@ class OptimizeConfig:
|
||||
benchmark: str = ""
|
||||
max_samples: int = 50
|
||||
judge_model: str = "gpt-5-mini-2025-08-07"
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "optimize.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "optimize.db"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -1417,18 +1458,20 @@ class AgentManagerConfig:
|
||||
"""Persistent agent manager settings."""
|
||||
|
||||
enabled: bool = True
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "agents.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "agents.db"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MemoryFilesConfig:
|
||||
"""Persistent memory-file paths and nudge settings."""
|
||||
|
||||
soul_path: str = "~/.openjarvis/SOUL.md"
|
||||
memory_path: str = "~/.openjarvis/MEMORY.md"
|
||||
user_path: str = "~/.openjarvis/USER.md"
|
||||
soul_path: str = field(default_factory=lambda: str(get_config_dir() / "SOUL.md"))
|
||||
memory_path: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "MEMORY.md")
|
||||
)
|
||||
user_path: str = field(default_factory=lambda: str(get_config_dir() / "USER.md"))
|
||||
nudge_interval: int = 10
|
||||
persona_name: str = "" # named persona dir under ~/.openjarvis/personas/<name>/
|
||||
persona_name: str = "" # named persona dir under <config-dir>/personas/<name>/
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -1467,13 +1510,15 @@ class SkillsConfig:
|
||||
"""Configuration for agent-authored procedural skills."""
|
||||
|
||||
enabled: bool = True
|
||||
skills_dir: str = "~/.openjarvis/skills/"
|
||||
skills_dir: str = field(default_factory=lambda: str(get_config_dir() / "skills"))
|
||||
active: str = "*"
|
||||
auto_discover: bool = True
|
||||
auto_sync: bool = False
|
||||
nudge_interval: int = 15
|
||||
index_repo: str = "https://github.com/openjarvis/skill-index.git"
|
||||
index_dir: str = "~/.openjarvis/skill-index/"
|
||||
index_dir: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "skill-index")
|
||||
)
|
||||
max_depth: int = 5
|
||||
sandbox_dangerous: bool = True
|
||||
sources: List[SkillSourceConfig] = field(default_factory=list)
|
||||
@@ -1779,7 +1824,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
|
||||
elif os.environ.get("OPENJARVIS_CONFIG"):
|
||||
config_path = Path(os.environ["OPENJARVIS_CONFIG"]).expanduser().resolve()
|
||||
else:
|
||||
config_path = DEFAULT_CONFIG_PATH
|
||||
config_path = get_config_path()
|
||||
if config_path.exists():
|
||||
with open(config_path, "rb") as fh:
|
||||
data = tomllib.load(fh)
|
||||
@@ -1972,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
|
||||
|
||||
@@ -2117,9 +2171,14 @@ __all__ = [
|
||||
"BrowserConfig",
|
||||
"CapabilitiesConfig",
|
||||
"ChannelConfig",
|
||||
"ConfigurationError",
|
||||
"DEFAULT_CONFIG_DIR",
|
||||
"DEFAULT_CONFIG_PATH",
|
||||
"DiscordChannelConfig",
|
||||
"get_cache_dir",
|
||||
"get_config_dir",
|
||||
"get_config_path",
|
||||
"get_data_dir",
|
||||
"EmailChannelConfig",
|
||||
"EngineConfig",
|
||||
"FeishuChannelConfig",
|
||||
|
||||
@@ -10,13 +10,20 @@ import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_DEFAULT_PATH = Path.home() / ".openjarvis" / "credentials.toml"
|
||||
|
||||
|
||||
def _default_path() -> Path:
|
||||
"""Resolve the credentials file under the OpenJarvis root (env-aware)."""
|
||||
return get_config_dir() / "credentials.toml"
|
||||
|
||||
|
||||
TOOL_CREDENTIALS: dict[str, list[str]] = {
|
||||
"web_search": ["TAVILY_API_KEY"],
|
||||
@@ -53,7 +60,7 @@ TOOL_CREDENTIALS: dict[str, list[str]] = {
|
||||
|
||||
def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
|
||||
"""Load credentials from TOML file."""
|
||||
p = Path(path) if path else _DEFAULT_PATH
|
||||
p = Path(path) if path else _default_path()
|
||||
if not p.exists():
|
||||
return {}
|
||||
with open(p, "rb") as f:
|
||||
@@ -75,7 +82,7 @@ def save_credential(
|
||||
if not stripped:
|
||||
raise ValueError("Credential value must not be empty")
|
||||
|
||||
p = Path(path) if path else _DEFAULT_PATH
|
||||
p = Path(path) if path else _default_path()
|
||||
with _LOCK:
|
||||
creds = load_credentials(path=p)
|
||||
if tool_name not in creds:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Central, env-aware resolution of OpenJarvis' home directory.
|
||||
|
||||
OpenJarvis keeps all of its runtime state (config, databases, caches, logs,
|
||||
credentials, skills, recipes, …) under a single root so it never clutters the
|
||||
user's home directory beyond one directory. That root is resolved here, with
|
||||
the following precedence (highest first):
|
||||
|
||||
1. ``$OPENJARVIS_HOME`` — explicit override (also honored by the shell
|
||||
installer, see ``scripts/install/install.sh``).
|
||||
2. ``$XDG_DATA_HOME/openjarvis`` — when ``$XDG_DATA_HOME`` is set, follow the
|
||||
XDG Base Directory spec by nesting a single ``openjarvis`` directory under
|
||||
it. We deliberately use ONE directory rather than splitting across XDG
|
||||
config/data/cache so the install tree stays self-contained and relocatable.
|
||||
3. ``~/.openjarvis`` — the historical default. With no env vars set, the
|
||||
resolved path is exactly this, so existing installs are untouched.
|
||||
|
||||
``config.py`` re-exports :func:`get_config_dir` results through the legacy
|
||||
``DEFAULT_CONFIG_DIR``/``DEFAULT_CONFIG_PATH`` names (computed dynamically) so
|
||||
the ~45 modules that import those names keep working while honoring the
|
||||
override. Modules that previously hardcoded ``Path.home() / ".openjarvis"``
|
||||
should call :func:`get_config_dir` (or :func:`get_data_dir` /
|
||||
:func:`get_cache_dir`) instead.
|
||||
|
||||
Defense in depth: the resolved root must never live inside the OpenJarvis
|
||||
source tree (a misconfigured ``$OPENJARVIS_HOME`` pointing at the repo would
|
||||
otherwise scatter runtime artifacts into the working tree). This mirrors the
|
||||
guard in ``learning/spec_search/storage/paths.py`` and fails loudly per
|
||||
REVIEW.md's no-silent-failure discipline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_DIR_NAME = ".openjarvis"
|
||||
_XDG_SUBDIR_NAME = "openjarvis"
|
||||
|
||||
|
||||
class ConfigurationError(RuntimeError):
|
||||
"""Raised when the resolved home directory would violate isolation guarantees."""
|
||||
|
||||
|
||||
def _find_source_root() -> Path | None:
|
||||
"""Walk upward from this module to find the OpenJarvis source root.
|
||||
|
||||
Returns the directory containing the OpenJarvis ``pyproject.toml`` (the one
|
||||
whose ``name = "openjarvis"``), or ``None`` when running from an installed
|
||||
wheel rather than a source checkout.
|
||||
"""
|
||||
here = Path(__file__).resolve()
|
||||
for candidate in (here, *here.parents):
|
||||
py = candidate / "pyproject.toml"
|
||||
if py.exists():
|
||||
try:
|
||||
content = py.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
if 'name = "openjarvis"' in content.lower():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _reject_source_tree(path: Path) -> Path:
|
||||
"""Raise if ``path`` resolves inside the OpenJarvis source tree."""
|
||||
source_root = _find_source_root()
|
||||
if source_root is not None:
|
||||
try:
|
||||
path.relative_to(source_root)
|
||||
except ValueError:
|
||||
pass # Good — not inside the source tree.
|
||||
else:
|
||||
raise ConfigurationError(
|
||||
f"OpenJarvis home ({path}) is inside the source tree "
|
||||
f"({source_root}). OpenJarvis refuses to write runtime state "
|
||||
"inside its own repo. Set OPENJARVIS_HOME (or XDG_DATA_HOME) "
|
||||
"to a directory outside the repo (default: ~/.openjarvis)."
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def get_config_dir() -> Path:
|
||||
"""Resolve OpenJarvis' single root directory, honoring env overrides.
|
||||
|
||||
Precedence: ``$OPENJARVIS_HOME`` > ``$XDG_DATA_HOME/openjarvis`` >
|
||||
``~/.openjarvis``. The result is always absolute and is rejected if it
|
||||
falls inside the OpenJarvis source tree.
|
||||
"""
|
||||
env_home = os.environ.get("OPENJARVIS_HOME")
|
||||
if env_home:
|
||||
resolved = Path(env_home).expanduser().resolve()
|
||||
return _reject_source_tree(resolved)
|
||||
|
||||
xdg_data = os.environ.get("XDG_DATA_HOME")
|
||||
if xdg_data:
|
||||
resolved = (Path(xdg_data).expanduser() / _XDG_SUBDIR_NAME).resolve()
|
||||
return _reject_source_tree(resolved)
|
||||
|
||||
return (Path.home() / _DEFAULT_DIR_NAME).resolve()
|
||||
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Resolve the path to ``config.toml`` under the OpenJarvis root."""
|
||||
return get_config_dir() / "config.toml"
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Resolve the directory for persistent data (databases, blobs, …).
|
||||
|
||||
Consolidated under the single root; identical to :func:`get_config_dir`.
|
||||
Provided as a distinct name so call sites read intentionally.
|
||||
"""
|
||||
return get_config_dir()
|
||||
|
||||
|
||||
def get_cache_dir() -> Path:
|
||||
"""Resolve the directory for regenerable caches (eval datasets, etc.).
|
||||
|
||||
Lives at ``<root>/cache`` so caches stay inside the single OpenJarvis
|
||||
directory instead of scattering across ``~/.cache``.
|
||||
"""
|
||||
return get_config_dir() / "cache"
|
||||
@@ -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)
|
||||
|
||||
@@ -11,11 +11,12 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, MutableMapping, Optional, Sequence
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
_DEFAULT_CACHE_DIR = Path.home() / ".cache" / "gaia_benchmark"
|
||||
_DEFAULT_CACHE_DIR = get_cache_dir() / "gaia_benchmark"
|
||||
|
||||
_DEFAULT_INPUT_PROMPT = """Please answer the question below. You should:
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
@@ -24,7 +25,7 @@ from openjarvis.evals.core.types import EvalRecord
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LIVERESEARCH_REPO = "https://github.com/Ayanami0730/deep_research_bench.git"
|
||||
CACHE_DIR = Path.home() / ".cache" / "liveresearch_bench"
|
||||
CACHE_DIR = get_cache_dir() / "liveresearch_bench"
|
||||
|
||||
|
||||
def _load_jsonl(path: Path) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -13,6 +13,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
@@ -64,9 +65,7 @@ class LogHubDataset(DatasetProvider):
|
||||
f"Choose from: {list(_DATASETS.keys())}"
|
||||
)
|
||||
self._subset = subset
|
||||
self._cache_dir = (
|
||||
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "loghub"
|
||||
)
|
||||
self._cache_dir = Path(cache_dir) if cache_dir else get_cache_dir() / "loghub"
|
||||
self._records: List[EvalRecord] = []
|
||||
|
||||
def load(
|
||||
|
||||
@@ -15,6 +15,7 @@ import random
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
@@ -41,7 +42,7 @@ class PaperArenaDataset(DatasetProvider):
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
self._cache_dir = (
|
||||
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "paperarena"
|
||||
Path(cache_dir) if cache_dir else get_cache_dir() / "paperarena"
|
||||
)
|
||||
self._records: List[EvalRecord] = []
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
@@ -25,7 +26,7 @@ from openjarvis.evals.core.types import EvalRecord
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PINCHBENCH_REPO = "https://github.com/pinchbench/skill.git"
|
||||
CACHE_DIR = Path.home() / ".cache" / "pinchbench"
|
||||
CACHE_DIR = get_cache_dir() / "pinchbench"
|
||||
|
||||
|
||||
def _parse_task_markdown(content: str, filename: str = "") -> Dict[str, Any]:
|
||||
|
||||
@@ -12,9 +12,9 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
@@ -22,7 +22,7 @@ from openjarvis.evals.core.types import EvalRecord
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TAU2_REPO = "https://github.com/sierra-research/tau2-bench.git"
|
||||
CACHE_DIR = Path.home() / ".cache" / "tau2-bench"
|
||||
CACHE_DIR = get_cache_dir() / "tau2-bench"
|
||||
|
||||
DOMAINS = ("airline", "retail", "telecom")
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
@@ -56,7 +57,7 @@ class WebChoreArenaDataset(DatasetProvider):
|
||||
) -> None:
|
||||
self._subset = subset # "all", "small", or a site name
|
||||
self._cache_dir = (
|
||||
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "webchorearena"
|
||||
Path(cache_dir) if cache_dir else get_cache_dir() / "webchorearena"
|
||||
)
|
||||
self._headless = headless
|
||||
self._records: List[EvalRecord] = []
|
||||
|
||||
@@ -49,6 +49,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.scorer import Scorer
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
@@ -93,9 +94,7 @@ def _run_subprocess_hard_timeout(
|
||||
)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout_s)
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, proc.returncode, stdout, stderr
|
||||
)
|
||||
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill the whole group, not just the direct child — Modal harness
|
||||
# subprocesses fork workers that would otherwise keep pipes open.
|
||||
@@ -115,9 +114,7 @@ def _run_subprocess_hard_timeout(
|
||||
stdout, stderr = proc.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
stdout, stderr = "", ""
|
||||
raise subprocess.TimeoutExpired(
|
||||
cmd, timeout_s, output=stdout, stderr=stderr
|
||||
)
|
||||
raise subprocess.TimeoutExpired(cmd, timeout_s, output=stdout, stderr=stderr)
|
||||
|
||||
|
||||
# ---------- Patch tracking ----------
|
||||
@@ -208,11 +205,11 @@ def _patch_modal_sandbox_source() -> None:
|
||||
# Upstream changed the line — bail rather than apply blindly.
|
||||
return
|
||||
replacement = (
|
||||
' # ' + _CGROUP_SOURCE_SENTINEL + '\n'
|
||||
' try:\n'
|
||||
" # " + _CGROUP_SOURCE_SENTINEL + "\n"
|
||||
" try:\n"
|
||||
' self.write_file("/sys/fs/cgroup/cpu/cpu.shares", "2048")\n'
|
||||
' except FileNotFoundError:\n'
|
||||
' pass # cgroup v2 Modal sandbox — path missing is fine\n'
|
||||
" except FileNotFoundError:\n"
|
||||
" pass # cgroup v2 Modal sandbox — path missing is fine\n"
|
||||
)
|
||||
new_src = src.replace(needle + "\n", replacement, 1)
|
||||
try:
|
||||
@@ -324,22 +321,23 @@ def extract_patch(text: str) -> Optional[str]:
|
||||
|
||||
# ---------- Harness invocation ----------
|
||||
|
||||
|
||||
def _harness_cache_dir() -> Path:
|
||||
"""Where the swebench subprocess writes its report JSON + logs/ tree.
|
||||
|
||||
Defaults to ``$OPENJARVIS_HOME/.swebench-cache`` if set, otherwise to a
|
||||
process-shared tempdir. Pin both so we don't pollute the project root.
|
||||
Consolidated under the env-aware OpenJarvis cache root
|
||||
(``<openjarvis-home>/cache/swebench``) so it never pollutes the project
|
||||
root or scatters across ``$HOME``. Honors ``OPENJARVIS_HOME`` /
|
||||
``XDG_DATA_HOME`` via :func:`openjarvis.core.paths.get_cache_dir`.
|
||||
"""
|
||||
home = os.environ.get("OPENJARVIS_HOME")
|
||||
if home:
|
||||
cache = Path(home) / ".swebench-cache"
|
||||
else:
|
||||
cache = Path(tempfile.gettempdir()) / "openjarvis-swebench-cache"
|
||||
cache = get_cache_dir() / "swebench"
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
return cache
|
||||
|
||||
|
||||
def _find_report(cache: Path, instance_id: str, run_id: str) -> Optional[Dict[str, Any]]:
|
||||
def _find_report(
|
||||
cache: Path, instance_id: str, run_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Find the harness's report JSON for one instance.
|
||||
|
||||
swebench writes ``<model_name_or_path>.<run_id>.json`` inside the
|
||||
@@ -427,26 +425,40 @@ def _run_harness(
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
preds_path = tmp_path / "predictions.jsonl"
|
||||
preds_path.write_text(json.dumps({
|
||||
"instance_id": instance_id,
|
||||
"model_name_or_path": "openjarvis-harness",
|
||||
"model_patch": patch,
|
||||
}) + "\n")
|
||||
preds_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"instance_id": instance_id,
|
||||
"model_name_or_path": "openjarvis-harness",
|
||||
"model_patch": patch,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "swebench.harness.run_evaluation",
|
||||
"--predictions_path", str(preds_path),
|
||||
"--max_workers", "1",
|
||||
"--run_id", run_id,
|
||||
"--dataset_name", "SWE-bench/SWE-bench_Verified",
|
||||
"--instance_ids", instance_id,
|
||||
sys.executable,
|
||||
"-m",
|
||||
"swebench.harness.run_evaluation",
|
||||
"--predictions_path",
|
||||
str(preds_path),
|
||||
"--max_workers",
|
||||
"1",
|
||||
"--run_id",
|
||||
run_id,
|
||||
"--dataset_name",
|
||||
"SWE-bench/SWE-bench_Verified",
|
||||
"--instance_ids",
|
||||
instance_id,
|
||||
]
|
||||
if backend == "modal":
|
||||
cmd += ["--modal", "true"]
|
||||
|
||||
try:
|
||||
proc = _run_subprocess_hard_timeout(
|
||||
cmd, timeout_s=timeout_s, cwd=str(cache),
|
||||
cmd,
|
||||
timeout_s=timeout_s,
|
||||
cwd=str(cache),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# The harness subprocess (and its Modal grandchildren) exceeded
|
||||
@@ -497,6 +509,7 @@ def _run_harness(
|
||||
|
||||
# ---------- Scorer ----------
|
||||
|
||||
|
||||
class SWEBenchHarnessScorer(Scorer):
|
||||
"""SWE-bench Verified scorer that runs the official harness.
|
||||
|
||||
@@ -516,7 +529,7 @@ class SWEBenchHarnessScorer(Scorer):
|
||||
timeout_s: int = 1800,
|
||||
cell_name: Optional[str] = None,
|
||||
judge_backend: object = None, # noqa: ARG002 — CLI factory compat
|
||||
judge_model: str = "", # noqa: ARG002 — CLI factory compat
|
||||
judge_model: str = "", # noqa: ARG002 — CLI factory compat
|
||||
) -> None:
|
||||
self._timeout_s = int(timeout_s)
|
||||
# ``cell_name`` namespaces the ``run_id`` so concurrent cells scoring
|
||||
@@ -538,16 +551,15 @@ class SWEBenchHarnessScorer(Scorer):
|
||||
if patch is None:
|
||||
return False, {"reason": "no_patch_extracted"}
|
||||
|
||||
instance_id = (
|
||||
record.metadata.get("instance_id")
|
||||
or record.record_id
|
||||
or ""
|
||||
)
|
||||
instance_id = record.metadata.get("instance_id") or record.record_id or ""
|
||||
if not instance_id:
|
||||
return False, {"reason": "missing_instance_id"}
|
||||
|
||||
result = _run_harness(
|
||||
instance_id, patch, self._timeout_s, cell_name=self._cell_name,
|
||||
instance_id,
|
||||
patch,
|
||||
self._timeout_s,
|
||||
cell_name=self._cell_name,
|
||||
)
|
||||
details = dict(result.get("details", {}))
|
||||
details["patch"] = patch
|
||||
|
||||
@@ -16,6 +16,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONDITIONS = (
|
||||
@@ -52,14 +54,12 @@ class SkillBenchmarkConfig:
|
||||
seeds: List[int] = field(default_factory=lambda: [42, 43, 44])
|
||||
max_samples: Optional[int] = None
|
||||
output_dir: Path = field(default_factory=lambda: Path("docs/superpowers/results/"))
|
||||
skills_dir: Path = field(
|
||||
default_factory=lambda: Path("~/.openjarvis/skills/").expanduser()
|
||||
)
|
||||
skills_dir: Path = field(default_factory=lambda: get_config_dir() / "skills")
|
||||
overlay_dir_dspy: Path = field(
|
||||
default_factory=lambda: Path("~/.openjarvis/learning/skills-dspy/").expanduser()
|
||||
default_factory=lambda: get_config_dir() / "learning" / "skills-dspy"
|
||||
)
|
||||
overlay_dir_gepa: Path = field(
|
||||
default_factory=lambda: Path("~/.openjarvis/learning/skills-gepa/").expanduser()
|
||||
default_factory=lambda: get_config_dir() / "learning" / "skills-gepa"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from openjarvis.core.config import ACEOptimizerConfig
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import LearningRegistry
|
||||
from openjarvis.learning._stubs import AgentLearningPolicy
|
||||
|
||||
@@ -48,7 +49,7 @@ except ImportError:
|
||||
|
||||
|
||||
def _default_save_dir(task_name: str) -> Path:
|
||||
return Path.home() / ".openjarvis" / "learning" / "ace" / task_name
|
||||
return get_config_dir() / "learning" / "ace" / task_name
|
||||
|
||||
|
||||
class _TraceDataProcessor:
|
||||
@@ -87,9 +88,7 @@ class _TraceDataProcessor:
|
||||
if not predictions:
|
||||
return 0.0
|
||||
n_correct = sum(
|
||||
1
|
||||
for p, g in zip(predictions, ground_truths)
|
||||
if cls.answer_is_correct(p, g)
|
||||
1 for p, g in zip(predictions, ground_truths) if cls.answer_is_correct(p, g)
|
||||
)
|
||||
return n_correct / len(predictions)
|
||||
|
||||
@@ -149,8 +148,7 @@ class ACEAgentOptimizer:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": (
|
||||
f"only {len(traces)} traces, "
|
||||
f"min_traces={self.config.min_traces}"
|
||||
f"only {len(traces)} traces, min_traces={self.config.min_traces}"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -158,8 +156,7 @@ class ACEAgentOptimizer:
|
||||
return {
|
||||
"status": "error",
|
||||
"reason": (
|
||||
"ace not installed (pip install "
|
||||
"'openjarvis[learning-ace]')"
|
||||
"ace not installed (pip install 'openjarvis[learning-ace]')"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.types import Trace, TraceStep
|
||||
from openjarvis.skills.manager import SkillManager
|
||||
from openjarvis.skills.overlay import SkillOverlay, write_overlay
|
||||
@@ -99,7 +100,7 @@ class SkillOptimizer:
|
||||
pass
|
||||
if overlay_dir is None:
|
||||
overlay_dir = Path(
|
||||
"~/.openjarvis/learning/skills/"
|
||||
str(get_config_dir() / "learning" / "skills")
|
||||
).expanduser()
|
||||
overlay_dir = Path(overlay_dir).expanduser()
|
||||
|
||||
|
||||
@@ -11,14 +11,21 @@ writing artifacts into the working tree.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.paths import ConfigurationError, get_config_dir
|
||||
from openjarvis.security.file_utils import secure_mkdir
|
||||
|
||||
|
||||
class ConfigurationError(RuntimeError):
|
||||
"""Raised when path configuration would violate isolation guarantees."""
|
||||
# ``ConfigurationError`` is re-exported from ``openjarvis.core.paths`` (it used
|
||||
# to be defined here). Spec search now resolves the home dir through the unified
|
||||
# core resolver, which raises the same exception type on a source-tree path, so
|
||||
# we alias rather than redefine to keep ``except ConfigurationError`` callers and
|
||||
# existing tests working.
|
||||
__all__ = [
|
||||
"ConfigurationError",
|
||||
"ensure_spec_search_dirs",
|
||||
"resolve_spec_search_root",
|
||||
]
|
||||
|
||||
|
||||
def _find_source_root() -> Path | None:
|
||||
@@ -42,11 +49,12 @@ def _find_source_root() -> Path | None:
|
||||
|
||||
|
||||
def _resolve_openjarvis_home() -> Path:
|
||||
"""Resolve the OPENJARVIS_HOME directory (env var or default)."""
|
||||
env = os.environ.get("OPENJARVIS_HOME")
|
||||
if env:
|
||||
return Path(env).expanduser().resolve()
|
||||
return (Path.home() / ".openjarvis").resolve()
|
||||
"""Resolve the OpenJarvis home directory via the unified core resolver.
|
||||
|
||||
Delegates to ``get_config_dir`` so spec-search honors the same env-aware
|
||||
resolution (OPENJARVIS_HOME and XDG) as the rest of the framework.
|
||||
"""
|
||||
return get_config_dir()
|
||||
|
||||
|
||||
def resolve_spec_search_root() -> Path:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -9,7 +9,7 @@ section 7.3 for the rev-bump workflow.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
PEARL_REPO = "https://github.com/pearl-research-labs/pearl.git"
|
||||
# TODO at implementation time: replace with the specific commit/tag verified
|
||||
@@ -47,9 +47,9 @@ DEFAULT_PEARLD_RPC_URL = "http://localhost:44107"
|
||||
MIN_FREE_DISK_GB = 200
|
||||
|
||||
# Runtime sidecar location (single-session assumption — see spec §8.8).
|
||||
RUNTIME_DIR = Path.home() / ".openjarvis" / "runtime"
|
||||
RUNTIME_DIR = get_config_dir() / "runtime"
|
||||
SIDECAR_PATH = RUNTIME_DIR / "mining.json"
|
||||
SIDECAR_LOCK_PATH = RUNTIME_DIR / "mining.lock"
|
||||
|
||||
# Pearl source cache for build-from-pin path (see spec §7.2).
|
||||
PEARL_CACHE_DIR = Path.home() / ".openjarvis" / "cache" / "pearl"
|
||||
PEARL_CACHE_DIR = get_config_dir() / "cache" / "pearl"
|
||||
|
||||
@@ -17,6 +17,7 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.config import HardwareInfo
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import MinerRegistry
|
||||
|
||||
from . import _install
|
||||
@@ -47,7 +48,7 @@ def _sidecar_path() -> Path:
|
||||
|
||||
def _log_dir() -> Path:
|
||||
"""Return the logs directory. Override in tests."""
|
||||
return Path.home() / ".openjarvis" / "logs" / "mining"
|
||||
return get_config_dir() / "logs" / "mining"
|
||||
|
||||
|
||||
def _parse_gateway_metrics(text: str, *, provider_id: str) -> MiningStats:
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from typing import List, Literal, Optional, Tuple
|
||||
|
||||
from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
PromptCacheSegment = Literal["frozen_prefix", "dynamic_suffix"]
|
||||
|
||||
@@ -279,7 +280,7 @@ class SystemPromptBuilder:
|
||||
f"Invalid persona name {name!r}: must be a simple "
|
||||
"identifier (no path separators or '..')."
|
||||
)
|
||||
base = Path.home() / ".openjarvis" / "personas" / name
|
||||
base = get_config_dir() / "personas" / name
|
||||
return MemoryFilesConfig(
|
||||
soul_path=str(base / "SOUL.md"),
|
||||
memory_path=str(base / "MEMORY.md"),
|
||||
|
||||
@@ -19,13 +19,14 @@ try:
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
# Built-in recipes directory (package data)
|
||||
_PROJECT_RECIPES_DIR = Path(__file__).resolve().parent / "data"
|
||||
_PROJECT_OPERATORS_DIR = _PROJECT_RECIPES_DIR / "operators"
|
||||
# User-level directories
|
||||
_USER_RECIPES_DIR = Path.home() / ".openjarvis" / "recipes"
|
||||
_USER_OPERATORS_DIR = Path.home() / ".openjarvis" / "operators"
|
||||
_USER_RECIPES_DIR = get_config_dir() / "recipes"
|
||||
_USER_OPERATORS_DIR = get_config_dir() / "operators"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -2095,10 +2095,13 @@ def create_agent_manager_router(
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
try:
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
config = load_config()
|
||||
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
|
||||
store = TraceStore(
|
||||
config.traces.db_path or str(get_config_dir() / "traces.db")
|
||||
)
|
||||
traces = store.list_traces(agent=agent_id, limit=limit)
|
||||
return {
|
||||
"traces": [
|
||||
@@ -2120,10 +2123,13 @@ def create_agent_manager_router(
|
||||
def get_trace(agent_id: str, trace_id: str):
|
||||
try:
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
config = load_config()
|
||||
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
|
||||
store = TraceStore(
|
||||
config.traces.db_path or str(get_config_dir() / "traces.db")
|
||||
)
|
||||
trace = store.get(trace_id)
|
||||
if trace is None:
|
||||
raise HTTPException(status_code=404, detail="Trace not found")
|
||||
|
||||
@@ -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 {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user