mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26bc7efb09 | ||
|
|
f3954e087a | ||
|
|
d865b4bed4 | ||
|
|
c686517cc7 | ||
|
|
904133cb25 | ||
|
|
299dee1f40 | ||
|
|
44ff286005 | ||
|
|
be51eb8684 | ||
|
|
420908401c | ||
|
|
b70be55681 | ||
|
|
a0187e40e6 | ||
|
|
d32f20f9b3 | ||
|
|
0b552cbcb5 | ||
|
|
19fd3c8d2b | ||
|
|
1fa80d8ecd | ||
|
|
b3f90691bf | ||
|
|
4ebf0839e7 | ||
|
|
b1e93d4ed0 | ||
|
|
eb2b612c7c | ||
|
|
560ec860df | ||
|
|
e7c46c1985 | ||
|
|
00d1e39b6d | ||
|
|
843375d6ef | ||
|
|
8d33cb58fa | ||
|
|
e4c4bcbae3 | ||
|
|
993c24c8b9 | ||
|
|
5bc8d3a2f6 | ||
|
|
433d10db5e | ||
|
|
9b7b3681f6 | ||
|
|
6dbe5461bb | ||
|
|
a65592fecb | ||
|
|
0513fbdb84 | ||
|
|
d4eb6308b1 | ||
|
|
2853a0001d | ||
|
|
3c99481975 | ||
|
|
4bf39af9bd | ||
|
|
0a3e812751 | ||
|
|
eb46febad5 | ||
|
|
81482b45d4 | ||
|
|
3e2f4bcdb4 | ||
|
|
a35b21195f | ||
|
|
f9d1bc8c27 | ||
|
|
dfa908c358 | ||
|
|
8ef1ab1928 | ||
|
|
28e75cb513 | ||
|
|
4b9948250b | ||
|
|
79e23719d4 | ||
|
|
7ba334b5f0 | ||
|
|
48a2627c9a | ||
|
|
8625f4f95f | ||
|
|
cf08f164c0 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "110,366",
|
||||
"message": "139,590",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 110366,
|
||||
"last_updated": "2026-06-11T07:44:18Z",
|
||||
"total_clones": 139590,
|
||||
"last_updated": "2026-07-01T07:33:10Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -77,6 +77,26 @@
|
||||
"2026-06-07": 1174,
|
||||
"2026-06-08": 2369,
|
||||
"2026-06-09": 1361,
|
||||
"2026-06-10": 1310
|
||||
"2026-06-10": 1310,
|
||||
"2026-06-11": 2564,
|
||||
"2026-06-12": 1313,
|
||||
"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,
|
||||
"2026-06-23": 1468,
|
||||
"2026-06-24": 1635,
|
||||
"2026-06-25": 1640,
|
||||
"2026-06-26": 1338,
|
||||
"2026-06-27": 1338,
|
||||
"2026-06-28": 1028,
|
||||
"2026-06-29": 765,
|
||||
"2026-06-30": 951
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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].*$//')
|
||||
@@ -238,6 +254,10 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_CONFIG: '{"version":"${{ steps.release-info.outputs.tauri_version }}","bundle":{"externalBin":["binaries/ollama"]}}'
|
||||
# tauri-action runs beforeBuildCommand (npm run build:tauri -> vite
|
||||
# build), which requires this at build time (#587). Strict for
|
||||
# releases: a missing/empty secret fails the build by design.
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
with:
|
||||
projectPath: frontend
|
||||
tauriScript: npx tauri
|
||||
|
||||
@@ -41,6 +41,26 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra docs
|
||||
|
||||
# Inject the public Supabase anon key so the savings leaderboard works on
|
||||
# the published docs site. Missing/empty (e.g. fork PRs) leaves the
|
||||
# leaderboard gracefully disabled. The key is read from env (not inlined)
|
||||
# and JSON-encoded into a JS string literal to avoid any injection.
|
||||
- name: Inject leaderboard Supabase anon key
|
||||
env:
|
||||
OPENJARVIS_LEADERBOARD_ANON: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, pathlib
|
||||
|
||||
key = os.environ.get("OPENJARVIS_LEADERBOARD_ANON", "")
|
||||
pathlib.Path("docs/javascripts/leaderboard-config.js").write_text(
|
||||
"// Generated at docs-build time from the VITE_SUPABASE_ANON_KEY secret.\n"
|
||||
"window.OPENJARVIS_SUPABASE_ANON_KEY = " + json.dumps(key) + ";\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("leaderboard anon key:", "set" if key else "empty (leaderboard disabled)")
|
||||
PY
|
||||
|
||||
- name: Build documentation
|
||||
run: uv run mkdocs build
|
||||
|
||||
|
||||
@@ -35,3 +35,8 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
- run: npm run build
|
||||
env:
|
||||
# Optional: when the secret is unset the build still succeeds and the
|
||||
# leaderboard is disabled (see src/lib/supabase.ts). No placeholder,
|
||||
# so a keyless CI build doesn't bake in a bogus anon key.
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_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
|
||||
@@ -50,6 +55,8 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend and bundle into package
|
||||
env:
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd frontend
|
||||
@@ -67,27 +74,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
|
||||
|
||||
@@ -8,6 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
**Vision input for `jarvis ask`** — attach images to a query with
|
||||
`-i`/`--image` (repeatable) or capture the current screen with
|
||||
`-S`/`--screen`, for vision-capable models such as `gemma3:4b`. Images flow
|
||||
through `Message.images` into Ollama's `/api/chat` `images` field; text-only
|
||||
requests are unaffected. A privacy guard warns before any image is sent to a
|
||||
non-local engine, and the security guardrail now preserves images when it
|
||||
sanitizes a flagged prompt. Screen capture uses the built-in Windows .NET
|
||||
stack with `mss`/`Pillow` fallbacks on other platforms. Adds the
|
||||
`JARVIS_NUM_CTX` environment variable to tune the Ollama context window
|
||||
(default `16384`).
|
||||
|
||||
## [1.0.2] - 2026-05-24
|
||||
|
||||
A patch release that fixes a packaging bug which broke the v1.0.1
|
||||
|
||||
@@ -1,34 +1,83 @@
|
||||
# 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
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
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 && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
# Copy the source and the non-src force-include paths (see pyproject
|
||||
# [tool.hatch.build.targets.wheel.force-include]) before building the project.
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
# 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 . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM python:3.12-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,32 +1,69 @@
|
||||
# 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
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 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 && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
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 && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04
|
||||
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 +73,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,32 +1,69 @@
|
||||
# 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
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package (AMD ROCm 7.2)
|
||||
FROM rocm/dev-ubuntu-22.04:7.2 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 && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
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 && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM rocm/dev-ubuntu-22.04:7.2
|
||||
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 +73,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,69 @@
|
||||
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 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies from the committed lockfile (#567): `uv export --frozen`
|
||||
# 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 && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY . .
|
||||
RUN pip install --no-cache-dir ".[server]"
|
||||
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust/target
|
||||
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
|
||||
|
||||
# libstdc++6 + ca-certificates are the only runtime requirements of the Node
|
||||
# binary copied below (the python slim image already provides libc/libgcc).
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# Transplant the Node.js runtime from the official image. Both images are Debian
|
||||
# bookworm, so the glibc/libstdc++ ABI matches.
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
LABEL openjarvis-sandbox=true
|
||||
|
||||
|
||||
@@ -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,8 +20,8 @@ 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
|
||||
importable.
|
||||
6. Runs `uv sync --extra desktop --group desktop-native` so the FastAPI server,
|
||||
speech backend, and native extension 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 --group desktop-native
|
||||
```
|
||||
|
||||
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 --group desktop-native` so the FastAPI
|
||||
server, speech backend, and native extension 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 --group desktop-native
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Running 'uv sync --extra server' in $srcDir (this can take a few minutes)..."
|
||||
Write-Info "Running 'uv sync --extra desktop --group desktop-native' in $srcDir (this can take a few minutes)..."
|
||||
Push-Location $srcDir
|
||||
try {
|
||||
& $uvExe sync --extra server
|
||||
& $uvExe sync --extra desktop --group desktop-native
|
||||
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 --group desktop-native`.
|
||||
- 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 --group desktop-native`.
|
||||
7. Prompt to register the scheduled-task service (skip with
|
||||
`-SkipService`).
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Public Supabase config for the savings leaderboard.
|
||||
//
|
||||
// This file is loaded *before* leaderboard.js and supplies the anon key it
|
||||
// reads from `window.OPENJARVIS_SUPABASE_ANON_KEY`. The key is injected at
|
||||
// docs-build time from the VITE_SUPABASE_ANON_KEY repo secret (see
|
||||
// .github/workflows/docs.yml). It is intentionally empty here so that local
|
||||
// `mkdocs build` and fork pull requests — which have no secret — render the
|
||||
// graceful "Leaderboard not configured yet" message instead of failing.
|
||||
//
|
||||
// The anon key is public by design: Supabase Row-Level Security protects the
|
||||
// data, so shipping it in the public docs bundle is expected.
|
||||
window.OPENJARVIS_SUPABASE_ANON_KEY = "";
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -19,6 +19,71 @@ Agents are the agentic logic layer of OpenJarvis. They determine how a query is
|
||||
|
||||
---
|
||||
|
||||
## Persistent Persona: SOUL.md, MEMORY.md, USER.md
|
||||
|
||||
Every agent's system prompt is assembled at conversation start by the `SystemPromptBuilder`, which injects up to three optional Markdown files -- the **persistent persona**. They are plain text you own and edit, loaded at the start of each conversation. There is no vector database or embedding cache behind them.
|
||||
|
||||
| File | What it holds | Example line |
|
||||
|------|---------------|--------------|
|
||||
| `SOUL.md` | How the agent should behave -- tone, length, what to push back on | `Be concise. Challenge weak assumptions.` |
|
||||
| `MEMORY.md` | Facts about you, your projects, your preferences | `I deploy to Postgres, never MySQL.` |
|
||||
| `USER.md` | Who you are -- role, team, context | `Backend engineer at Acme, on the payments team.` |
|
||||
|
||||
This persona is distinct from the retrieval [memory backend](memory.md): the persona is always-on Markdown context loaded into the prompt, while the memory backend is searchable long-term storage the agent queries on demand.
|
||||
|
||||
### Where they live
|
||||
|
||||
By default the files are read from the config directory:
|
||||
|
||||
```
|
||||
~/.openjarvis/SOUL.md
|
||||
~/.openjarvis/MEMORY.md
|
||||
~/.openjarvis/USER.md
|
||||
```
|
||||
|
||||
(The config directory honors `$OPENJARVIS_HOME` / `$XDG_DATA_HOME` when set.) The paths are configurable under `[memory_files]`:
|
||||
|
||||
```toml
|
||||
[memory_files]
|
||||
soul_path = "~/.openjarvis/SOUL.md"
|
||||
memory_path = "~/.openjarvis/MEMORY.md"
|
||||
user_path = "~/.openjarvis/USER.md"
|
||||
persona_name = "" # optional named persona -- see below
|
||||
```
|
||||
|
||||
### How they're loaded
|
||||
|
||||
At the start of each conversation, `SystemPromptBuilder` reads each file as UTF-8 and adds its contents as a section of the system prompt, after the agent template and before the skill catalog:
|
||||
|
||||
- **All three are optional.** A missing or empty file is skipped, so any subset works and an install with no persona files behaves exactly as before.
|
||||
- **Edits apply to the next conversation.** The files are read once when a conversation's prompt is built, so there is no restart or re-indexing -- edit or delete a line and it takes effect the next time you start a conversation.
|
||||
- **Each section is length-capped.** Files are truncated to a per-section character budget so a large `MEMORY.md` cannot crowd out the rest of the prompt.
|
||||
|
||||
### Named personas
|
||||
|
||||
A single install can answer as different personas without changing global config. A named persona lives in its own directory:
|
||||
|
||||
```
|
||||
~/.openjarvis/personas/<name>/SOUL.md
|
||||
~/.openjarvis/personas/<name>/MEMORY.md
|
||||
~/.openjarvis/personas/<name>/USER.md
|
||||
```
|
||||
|
||||
Select one per invocation, or opt out entirely:
|
||||
|
||||
```bash
|
||||
jarvis ask --persona work "summarize my open PRs"
|
||||
jarvis ask --persona none "what is 2 + 2?" # inject no persona
|
||||
```
|
||||
|
||||
Set `persona_name` under `[memory_files]` to make a named persona the default. `persona_name = "none"` (equivalently `--persona none`) disables persona injection for that run.
|
||||
|
||||
### Editing them
|
||||
|
||||
`SOUL.md`, `MEMORY.md`, and `USER.md` are plain Markdown -- open them in any editor. `MEMORY.md` and `USER.md` can also be updated by the agent itself through the `memory_manage` and `user_profile_manage` tools when those are enabled, so the agent can record a new fact mid-conversation. These tools always target the default `MEMORY.md` and `USER.md` (under `~/.openjarvis/`), never a named persona's copies -- edit those by hand.
|
||||
|
||||
---
|
||||
|
||||
## BaseAgent ABC
|
||||
|
||||
All agents extend the abstract `BaseAgent` class.
|
||||
|
||||
@@ -66,6 +66,8 @@ jarvis ask "What is the capital of France?"
|
||||
| `--no-context` | flag | off | Disable memory context injection |
|
||||
| `-a`, `--agent AGENT` | string | none | Agent to use (`simple`, `orchestrator`) |
|
||||
| `--tools TOOLS` | string | none | Comma-separated tool names to enable |
|
||||
| `-i`, `--image PATH` | path | none | Image file for a vision model (e.g. `gemma3:4b`); repeatable |
|
||||
| `-S`, `--screen` | flag | off | Capture the current screen and send it to the vision model |
|
||||
|
||||
### Direct Mode vs Agent Mode
|
||||
|
||||
@@ -105,6 +107,39 @@ jarvis ask --no-context "Tell me about Python"
|
||||
jarvis ask --max-tokens 2048 "Write a detailed essay about AI"
|
||||
```
|
||||
|
||||
### Vision Input
|
||||
|
||||
Vision-capable models (such as `gemma3:4b`) can read images alongside your
|
||||
text prompt. Attach one or more image files with `-i`/`--image`, or capture
|
||||
the current screen with `-S`/`--screen`:
|
||||
|
||||
```bash
|
||||
# Ask about a local image
|
||||
jarvis ask -i screenshot.png "What is shown in this image?"
|
||||
|
||||
# Send multiple images (the flag is repeatable)
|
||||
jarvis ask -i chart-a.png -i chart-b.png "Compare these two charts"
|
||||
|
||||
# Capture the current screen and ask about it
|
||||
jarvis ask --screen "Summarize what's on my screen"
|
||||
```
|
||||
|
||||
Vision runs in **direct mode** only. If you also pass `--agent`, the image is
|
||||
ignored and a note is printed — re-run with `--agent ""` to force direct mode.
|
||||
|
||||
The Ollama context window can be tuned for large images or long prompts with
|
||||
the `JARVIS_NUM_CTX` environment variable (default `16384`):
|
||||
|
||||
```bash
|
||||
JARVIS_NUM_CTX=8192 jarvis ask --screen "What's on my screen?"
|
||||
```
|
||||
|
||||
!!! note "Keep vision on-device"
|
||||
Images are sensitive. OpenJarvis prints a privacy warning before sending
|
||||
an image to a non-local engine, so a screenshot never leaves your machine
|
||||
unnoticed. Use a local engine (e.g. `ollama` with `gemma3:4b`) to keep
|
||||
vision fully local.
|
||||
|
||||
### JSON Output Format
|
||||
|
||||
When using `--json` in **direct mode**, the output includes:
|
||||
|
||||
Generated
+67
-52
@@ -11,7 +11,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -42,7 +42,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -3720,9 +3720,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3730,9 +3730,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
|
||||
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
@@ -3746,23 +3746,23 @@
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.10.1",
|
||||
"@tauri-apps/cli-darwin-x64": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
|
||||
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3777,9 +3777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
|
||||
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3794,9 +3794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
|
||||
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3811,13 +3811,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3828,13 +3831,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3845,13 +3851,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3862,13 +3871,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3879,13 +3891,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3896,9 +3911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3913,9 +3928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3930,9 +3945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -49,7 +49,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
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"]
|
||||
|
||||
+597
-102
@@ -8,8 +8,10 @@ use tokio::sync::Mutex;
|
||||
|
||||
const OLLAMA_PORT: u16 = 11434;
|
||||
const JARVIS_PORT: u16 = 8000;
|
||||
const DESKTOP_UV_SYNC_COMMAND: &str =
|
||||
"uv sync --extra desktop --extra inference-cloud --extra inference-google --group desktop-native";
|
||||
|
||||
/// Small, fast model pulled at startup so the app opens quickly.
|
||||
/// Small, fast model used when startup needs a default Ollama tag.
|
||||
const STARTUP_MODEL: &str = "qwen3.5:4b";
|
||||
|
||||
/// Tiny fallback model if even the startup model can't be pulled.
|
||||
@@ -104,7 +106,7 @@ fn default_local_model(ram_gb: f64) -> &'static str {
|
||||
struct BootPlan {
|
||||
/// Whether to start and wait for the bundled Ollama.
|
||||
launch_ollama: bool,
|
||||
/// The single Ollama model to pull (None for custom endpoints).
|
||||
/// The preferred Ollama model (None for custom endpoints).
|
||||
model_to_pull: Option<String>,
|
||||
/// Optional `(engine_key, bare_host)` override for a custom endpoint,
|
||||
/// e.g. `("lmstudio", "http://localhost:1234")`. Written into
|
||||
@@ -608,6 +610,69 @@ async fn wait_for_jarvis_health(
|
||||
}
|
||||
|
||||
async fn ollama_has_model(model: &str) -> bool {
|
||||
let models = ollama_model_names().await;
|
||||
matching_installed_model(&models, model).is_some()
|
||||
}
|
||||
|
||||
fn parse_ollama_model_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body.get("models")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|models| {
|
||||
models
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
m.get("name")
|
||||
.or_else(|| m.get("model"))
|
||||
.and_then(|n| n.as_str())
|
||||
})
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.map(|name| name.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn model_names_match(installed: &str, requested: &str) -> bool {
|
||||
installed == requested
|
||||
|| installed.strip_suffix(":latest") == Some(requested)
|
||||
|| requested.strip_suffix(":latest") == Some(installed)
|
||||
}
|
||||
|
||||
fn matching_installed_model(models: &[String], requested: &str) -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| model_names_match(model, requested))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn model_name_looks_embedding_only(model: &str) -> bool {
|
||||
let name = model.to_ascii_lowercase();
|
||||
["embed", "embedding", "rerank", "minilm", "bge-", "bge_", "e5-", "e5_"]
|
||||
.iter()
|
||||
.any(|marker| name.contains(marker))
|
||||
}
|
||||
|
||||
fn preferred_installed_model(models: &[String]) -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| !model.trim().is_empty() && !model_name_looks_embedding_only(model))
|
||||
.or_else(|| models.iter().find(|model| !model.trim().is_empty()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn startup_installed_model(requested_model: &str, installed_models: &[String]) -> Option<String> {
|
||||
matching_installed_model(installed_models, requested_model)
|
||||
.or_else(|| preferred_installed_model(installed_models))
|
||||
}
|
||||
|
||||
fn should_persist_resolved_model(cfg: &InferenceConfig) -> bool {
|
||||
cfg.model
|
||||
.as_deref()
|
||||
.map(|model| model.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn ollama_model_names() -> Vec<String> {
|
||||
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
@@ -615,21 +680,10 @@ async fn ollama_has_model(model: &str) -> bool {
|
||||
.unwrap();
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(models) = body.get("models").and_then(|m| m.as_array()) {
|
||||
return models.iter().any(|m| {
|
||||
m.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|n| {
|
||||
n == model
|
||||
|| n.strip_suffix(":latest") == Some(model)
|
||||
|| model.strip_suffix(":latest") == Some(n)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
return parse_ollama_model_names(&body);
|
||||
}
|
||||
}
|
||||
false
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn pull_model(model: &str) -> Result<(), String> {
|
||||
@@ -679,13 +733,21 @@ fn format_uv_sync_failure(
|
||||
let code = exit_code
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let tail = uv_sync_stderr_tail(stderr, 800);
|
||||
let rust_hint = if looks_like_rust_extension_build_error(stderr) {
|
||||
format!("\n\n{}", rust_toolchain_install_hint())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
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.",
|
||||
`{}` manually for the full output.{}",
|
||||
root.display(),
|
||||
code,
|
||||
uv_sync_stderr_tail(stderr, 800),
|
||||
tail,
|
||||
DESKTOP_UV_SYNC_COMMAND,
|
||||
rust_hint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -734,6 +796,122 @@ fn format_uv_sync_spawn_error(root: &std::path::Path, uv_bin: &str, err: &str) -
|
||||
)
|
||||
}
|
||||
|
||||
fn rust_toolchain_install_hint() -> &'static str {
|
||||
"The desktop app needs the Rust toolchain to build `openjarvis_rust`. \
|
||||
Install Rust from https://rustup.rs. On Windows, also install Visual Studio \
|
||||
Build Tools with the C++ workload, then relaunch."
|
||||
}
|
||||
|
||||
fn looks_like_rust_extension_build_error(stderr: &str) -> bool {
|
||||
let lower = stderr.to_ascii_lowercase();
|
||||
[
|
||||
"openjarvis-rust",
|
||||
"openjarvis_rust",
|
||||
"maturin",
|
||||
"cargo",
|
||||
"rustc",
|
||||
"link.exe",
|
||||
"visual studio",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
}
|
||||
|
||||
fn format_missing_rust_toolchain() -> String {
|
||||
format!(
|
||||
"Could not find Rust's `cargo` command. {}\n\n\
|
||||
If Rust is already installed, close and relaunch the desktop app so \
|
||||
PATH includes `~/.cargo/bin`.",
|
||||
rust_toolchain_install_hint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_extension_import_failure(root: &std::path::Path, stderr: &str) -> String {
|
||||
let tail = uv_sync_stderr_tail(stderr, 4000);
|
||||
format!(
|
||||
"`openjarvis_rust` is still not importable after building. Last output:\n\n{}\n\n\
|
||||
Run these manually for the full build log:\n\n\
|
||||
cd {}\n\
|
||||
{}\n\
|
||||
uv run python -c \"import openjarvis_rust\"",
|
||||
if tail.is_empty() {
|
||||
"(no stderr output)"
|
||||
} else {
|
||||
&tail
|
||||
},
|
||||
root.display(),
|
||||
DESKTOP_UV_SYNC_COMMAND,
|
||||
)
|
||||
}
|
||||
|
||||
fn add_cargo_bin_to_path(cmd: &mut tokio::process::Command) {
|
||||
let mut paths: Vec<std::path::PathBuf> = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect())
|
||||
.unwrap_or_default();
|
||||
paths.insert(
|
||||
0,
|
||||
std::path::PathBuf::from(home_dir())
|
||||
.join(".cargo")
|
||||
.join("bin"),
|
||||
);
|
||||
if let Ok(joined) = std::env::join_paths(paths) {
|
||||
cmd.env("PATH", joined);
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_openjarvis_rust_extension(
|
||||
root: &std::path::Path,
|
||||
uv_bin: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut cmd = tokio::process::Command::new(uv_bin);
|
||||
cmd.args(["run", "python", "-c", "import openjarvis_rust"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(root);
|
||||
prepare_subprocess_for_appimage(&mut cmd);
|
||||
add_cargo_bin_to_path(&mut cmd);
|
||||
|
||||
match cmd.output().await {
|
||||
Ok(out) if out.status.success() => Ok(()),
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
Err(format_extension_import_failure(root, &stderr))
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"Could not verify `openjarvis_rust`: {}. Verify uv is installed at `{}`.",
|
||||
e, uv_bin
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn port_owner_hint() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
format!("netstat -ano | findstr :{}", JARVIS_PORT)
|
||||
} else {
|
||||
format!("lsof -i :{}", JARVIS_PORT)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_port_unavailable(port: u16, reason: &str) -> String {
|
||||
format!(
|
||||
"Port {} is not available: {}. Stop the process using that port or \
|
||||
change the OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
|
||||
port,
|
||||
reason,
|
||||
port_owner_hint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn check_jarvis_port_available() -> Result<(), String> {
|
||||
match std::net::TcpListener::bind(("127.0.0.1", JARVIS_PORT)) {
|
||||
Ok(listener) => {
|
||||
drop(listener);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(format_port_unavailable(JARVIS_PORT, &err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend boot sequence (runs in background after app launch)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -751,7 +929,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
.into();
|
||||
}
|
||||
|
||||
// For the Ollama path, the model pull may fall back to FALLBACK_MODEL; we
|
||||
// For the Ollama path, model resolution may fall back to FALLBACK_MODEL; we
|
||||
// record what is actually available here so the serve command below uses
|
||||
// it instead of the originally-planned tag. None on the custom path.
|
||||
let mut serve_model_override: Option<String> = None;
|
||||
@@ -798,8 +976,8 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
s.detail = "Inference engine ready.".into();
|
||||
}
|
||||
|
||||
// Phase 2: Pull the single default model (see default_local_model /
|
||||
// boot_plan). We deliberately do NOT pull any others.
|
||||
// Phase 2: Resolve one model to serve. Prefer an installed model on
|
||||
// first run so startup does not depend on a download succeeding.
|
||||
let model = plan
|
||||
.model_to_pull
|
||||
.clone()
|
||||
@@ -810,41 +988,63 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
s.detail = format!("Checking for {}...", model);
|
||||
}
|
||||
|
||||
if !ollama_has_model(&model).await {
|
||||
let installed_models = ollama_model_names().await;
|
||||
let resolved_model = if let Some(installed) = startup_installed_model(&model, &installed_models) {
|
||||
installed
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}... (this may take a minute)", model);
|
||||
}
|
||||
if let Err(e) = pull_model(&model).await {
|
||||
// If the chosen model fails, try the tiny fallback
|
||||
eprintln!("Warning: failed to pull {}: {}", model, e);
|
||||
if !ollama_has_model(FALLBACK_MODEL).await {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
||||
}
|
||||
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!("Failed to download model: {}", e2));
|
||||
return;
|
||||
match pull_model(&model).await {
|
||||
Ok(()) => model.clone(),
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to pull {}: {}", model, e);
|
||||
|
||||
// If a local model appeared while pulling, use it instead of
|
||||
// making startup depend on another network pull.
|
||||
if let Some(installed) = preferred_installed_model(&ollama_model_names().await) {
|
||||
installed
|
||||
} else if ollama_has_model(FALLBACK_MODEL).await {
|
||||
FALLBACK_MODEL.to_string()
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
||||
}
|
||||
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
|
||||
if let Some(installed) =
|
||||
preferred_installed_model(&ollama_model_names().await)
|
||||
{
|
||||
installed
|
||||
} else {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!("Failed to download model: {}", e2));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
FALLBACK_MODEL.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if resolved_model != model {
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Using installed model {}.", resolved_model);
|
||||
}
|
||||
|
||||
// The pull may have fallen back to FALLBACK_MODEL; serve and persist
|
||||
// whatever is actually available now, not the originally-planned tag.
|
||||
let resolved_model = if ollama_has_model(&model).await {
|
||||
model
|
||||
} else {
|
||||
FALLBACK_MODEL.to_string()
|
||||
};
|
||||
serve_model_override = Some(resolved_model.clone());
|
||||
|
||||
// Persist the resolved model so Settings shows it and future boots reuse it.
|
||||
let mut persisted = cfg.clone();
|
||||
persisted.model = Some(resolved_model);
|
||||
let _ = write_inference_config(&persisted);
|
||||
// Persist only first-run/default resolution. If the user explicitly
|
||||
// configured a model, do not overwrite that choice with a temporary
|
||||
// fallback selected just to keep startup nonfatal.
|
||||
if should_persist_resolved_model(&cfg) {
|
||||
let mut persisted = cfg.clone();
|
||||
persisted.model = Some(resolved_model);
|
||||
let _ = write_inference_config(&persisted);
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
@@ -1097,11 +1297,6 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
// Something else (a different web server, a stale process,
|
||||
// a 4xx-returning instance) is on our port. Don't kill it —
|
||||
// give the user actionable info instead.
|
||||
let lsof_hint = if cfg!(target_os = "windows") {
|
||||
format!("netstat -ano | findstr :{}", JARVIS_PORT)
|
||||
} else {
|
||||
format!("lsof -i :{}", JARVIS_PORT)
|
||||
};
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!(
|
||||
"Port {} is already in use by another service (it answered \
|
||||
@@ -1109,7 +1304,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
|
||||
JARVIS_PORT,
|
||||
resp.status(),
|
||||
lsof_hint,
|
||||
port_owner_hint(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
@@ -1119,8 +1314,21 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = check_jarvis_port_available() {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let root = project_root.as_ref().unwrap();
|
||||
|
||||
let cargo_bin = resolve_bin("cargo");
|
||||
if !std::path::Path::new(&cargo_bin).exists() && cargo_bin == "cargo" {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format_missing_rust_toolchain());
|
||||
return;
|
||||
}
|
||||
|
||||
// Install dependencies automatically (handles fresh clones).
|
||||
//
|
||||
// Previously we ran `uv sync` with both stdout AND stderr piped to
|
||||
@@ -1143,15 +1351,19 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
sync_cmd
|
||||
.args([
|
||||
"sync",
|
||||
"--extra", "server",
|
||||
"--extra", "desktop",
|
||||
"--extra", "inference-cloud",
|
||||
"--extra", "inference-google",
|
||||
// openjarvis_rust lives in a uv dependency group (not the published
|
||||
// `desktop` extra) so pip installs from PyPI don't require it (#584).
|
||||
"--group", "desktop-native",
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(root);
|
||||
// Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455).
|
||||
prepare_subprocess_for_appimage(&mut sync_cmd);
|
||||
add_cargo_bin_to_path(&mut sync_cmd);
|
||||
let sync_output = sync_cmd.output().await;
|
||||
match sync_output {
|
||||
Ok(out) if !out.status.success() => {
|
||||
@@ -1168,6 +1380,16 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
Ok(_) => {} // success — fall through
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = "Verifying Rust extension (openjarvis_rust)...".into();
|
||||
}
|
||||
if let Err(err) = verify_openjarvis_rust_extension(root, &uv_bin).await {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(err);
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Starting API server from {}...", root.display());
|
||||
@@ -1204,7 +1426,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 +1886,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 +1942,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 +2062,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 +2131,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 +2149,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 +2182,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).
|
||||
@@ -2510,9 +2868,12 @@ pub fn run() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error,
|
||||
normalize_host, parse_inference_config, upsert_engine_host, uv_sync_stderr_tail,
|
||||
InferenceConfig, SourceKind,
|
||||
boot_plan, default_local_model, format_extension_import_failure,
|
||||
format_missing_rust_toolchain, format_port_unavailable, format_uv_sync_failure,
|
||||
format_uv_sync_spawn_error, matching_installed_model, model_names_match, normalize_host,
|
||||
parse_inference_config, parse_ollama_model_names, preferred_installed_model,
|
||||
should_persist_resolved_model, startup_installed_model, upsert_engine_host,
|
||||
uv_sync_stderr_tail, InferenceConfig, SourceKind, DESKTOP_UV_SYNC_COMMAND,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -2556,7 +2917,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(DESKTOP_UV_SYNC_COMMAND)); // actionable next step
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2579,6 +2940,49 @@ mod tests {
|
||||
assert!(msg.contains("No such file or directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rust_toolchain_message_names_cargo_and_installer() {
|
||||
let msg = format_missing_rust_toolchain();
|
||||
assert!(msg.contains("cargo"));
|
||||
assert!(msg.contains("https://rustup.rs"));
|
||||
assert!(msg.contains("openjarvis_rust"));
|
||||
assert!(msg.contains("Visual Studio Build Tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_sync_rust_failure_mentions_toolchain() {
|
||||
let msg = format_uv_sync_failure(
|
||||
Path::new("C:\\Users\\me\\OpenJarvis"),
|
||||
Some(1),
|
||||
"maturin failed: linker `link.exe` not found while building openjarvis-rust",
|
||||
);
|
||||
assert!(msg.contains("exit 1"));
|
||||
assert!(msg.contains("link.exe"));
|
||||
assert!(msg.contains("https://rustup.rs"));
|
||||
assert!(msg.contains("Visual Studio Build Tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_import_failure_names_verification_command() {
|
||||
let msg = format_extension_import_failure(
|
||||
Path::new("C:\\Users\\me\\OpenJarvis"),
|
||||
"ModuleNotFoundError: No module named 'openjarvis_rust'",
|
||||
);
|
||||
assert!(msg.contains("openjarvis_rust"));
|
||||
assert!(msg.contains(DESKTOP_UV_SYNC_COMMAND));
|
||||
assert!(msg.contains("uv run python -c \"import openjarvis_rust\""));
|
||||
assert!(msg.contains("ModuleNotFoundError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_unavailable_message_names_port_and_owner_hint() {
|
||||
let msg = format_port_unavailable(8000, "address already in use");
|
||||
assert!(msg.contains("Port 8000 is not available"));
|
||||
assert!(msg.contains("address already in use"));
|
||||
assert!(msg.contains("To identify it"));
|
||||
assert!(msg.contains("8000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_local_model_picks_second_largest_that_fits() {
|
||||
// QWEN35_MODELS min_ram ladder: 4,6,8,12,24,32,96 GB
|
||||
@@ -2594,6 +2998,97 @@ mod tests {
|
||||
assert_eq!(default_local_model(1.0), super::FALLBACK_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_model_names_reads_nonempty_names() {
|
||||
let body = serde_json::json!({
|
||||
"models": [
|
||||
{"name": "llama3.2:latest"},
|
||||
{"name": ""},
|
||||
{"name": "qwen3.5:4b"},
|
||||
{"model": "mistral:latest"}
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
parse_ollama_model_names(&body),
|
||||
vec![
|
||||
"llama3.2:latest".to_string(),
|
||||
"qwen3.5:4b".to_string(),
|
||||
"mistral:latest".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_names_match_treats_latest_as_optional() {
|
||||
assert!(model_names_match("llama3.2:latest", "llama3.2"));
|
||||
assert!(model_names_match("llama3.2", "llama3.2:latest"));
|
||||
assert!(model_names_match("qwen3.5:4b", "qwen3.5:4b"));
|
||||
assert!(!model_names_match("llama3.2:latest", "qwen3.5:4b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_model_helpers_pick_matching_or_first_model() {
|
||||
let models = vec!["llama3.2:latest".to_string(), "qwen3.5:4b".to_string()];
|
||||
assert_eq!(
|
||||
matching_installed_model(&models, "llama3.2"),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_installed_model(&models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_installed_model_skips_embedding_names_when_chat_model_exists() {
|
||||
let models = vec![
|
||||
"nomic-embed-text:latest".to_string(),
|
||||
"llama3.2:latest".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
preferred_installed_model(&models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_installed_model_uses_existing_model_for_defaults() {
|
||||
let models = vec!["llama3.2:latest".to_string()];
|
||||
assert_eq!(
|
||||
startup_installed_model("qwen3.5:4b", &models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_installed_model_uses_existing_model_when_configured_model_missing() {
|
||||
let models = vec!["llama3.2:latest".to_string()];
|
||||
assert_eq!(
|
||||
startup_installed_model("qwen3.5:4b", &models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_model_is_only_persisted_when_no_model_was_configured() {
|
||||
let default_cfg = InferenceConfig { kind: SourceKind::Ollama, ..Default::default() };
|
||||
assert!(should_persist_resolved_model(&default_cfg));
|
||||
|
||||
let empty_cfg = InferenceConfig {
|
||||
kind: SourceKind::Ollama,
|
||||
model: Some(" ".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(should_persist_resolved_model(&empty_cfg));
|
||||
|
||||
let user_cfg = InferenceConfig {
|
||||
kind: SourceKind::Ollama,
|
||||
model: Some("qwen3.5:9b".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!should_persist_resolved_model(&user_cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_defaults_to_ollama_when_file_missing_or_garbage() {
|
||||
assert!(matches!(parse_inference_config("").kind, SourceKind::Ollama));
|
||||
|
||||
@@ -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 {
|
||||
@@ -231,7 +243,11 @@ export function InputArea() {
|
||||
|
||||
try {
|
||||
if (deepResearch) {
|
||||
for await (const ev of streamResearch(content, controller.signal)) {
|
||||
for await (const ev of streamResearch(
|
||||
content,
|
||||
selectedModel,
|
||||
controller.signal,
|
||||
)) {
|
||||
if (ev.type === 'search_call') {
|
||||
const trace: ResearchSearchTrace = {
|
||||
id: generateId(),
|
||||
|
||||
@@ -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 { LEADERBOARD_ENABLED, 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 }) {
|
||||
@@ -318,15 +316,16 @@ export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchData]);
|
||||
|
||||
// Share savings to Supabase when opted in and data changes
|
||||
// Share savings to Supabase when opted in and data changes. Skipped entirely
|
||||
// when no anon key was built in (leaderboard disabled).
|
||||
useEffect(() => {
|
||||
if (!optInEnabled || !displayName || !data) return;
|
||||
if (!LEADERBOARD_ENABLED || !optInEnabled || !displayName || !data) return;
|
||||
const dollarSavings = data.per_provider.reduce((s, p) => s + p.total_cost, 0);
|
||||
const energySaved = data.per_provider.reduce((s, p) => s + (p.energy_wh || 0), 0);
|
||||
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',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ResearchEvent, SSEEvent } from '../types';
|
||||
import { getBase } from './api';
|
||||
import { getBase, authHeaders } from './api';
|
||||
|
||||
export interface ChatRequest {
|
||||
model: string;
|
||||
@@ -16,7 +16,7 @@ export async function* streamChat(
|
||||
const base = getBase();
|
||||
const response = await fetch(`${base}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
@@ -60,6 +60,7 @@ export async function* streamChat(
|
||||
|
||||
export async function* streamResearch(
|
||||
query: string,
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<ResearchEvent> {
|
||||
// /api/research is mounted at the server root — strip any trailing /v1
|
||||
@@ -67,8 +68,8 @@ export async function* streamResearch(
|
||||
const base = getBase().replace(/\/v1\/?$/, '');
|
||||
const response = await fetch(`${base}/api/research`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ query, ...(model ? { model } : {}) }),
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const SUPABASE_URL =
|
||||
import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
|
||||
// The Supabase anon key is optional at build time. When it is unset the public
|
||||
// savings leaderboard is disabled rather than failing the build — this keeps
|
||||
// the `openjarvis` package and desktop app buildable without coupling
|
||||
// publishability to a leaderboard credential. Set VITE_SUPABASE_ANON_KEY at
|
||||
// build time (from a repo secret) to enable the leaderboard.
|
||||
export const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY ?? '';
|
||||
|
||||
export const LEADERBOARD_ENABLED = SUPABASE_ANON_KEY.length > 0;
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
// VITE_SUPABASE_ANON_KEY is intentionally NOT required here: a missing key
|
||||
// disables the savings leaderboard at runtime (see src/lib/supabase.ts) rather
|
||||
// than failing the build, so the package/app stays publishable without it.
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -127,6 +127,7 @@ markdown_extensions:
|
||||
- pymdownx.tilde
|
||||
|
||||
extra_javascript:
|
||||
- javascripts/leaderboard-config.js
|
||||
- javascripts/leaderboard.js
|
||||
- https://cdn.jsdelivr.net/npm/@docsearch/js@3
|
||||
- javascripts/docsearch-init.js
|
||||
|
||||
+46
-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,33 @@ 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.uv.sources]
|
||||
openjarvis-rust = { path = "rust/crates/openjarvis-python" }
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openjarvis"]
|
||||
|
||||
@@ -203,3 +238,12 @@ select = ["E", "F", "I", "W"]
|
||||
dev = [
|
||||
"maturin>=1.12.6",
|
||||
]
|
||||
# openjarvis_rust is the native PyO3 extension, built from the local Rust
|
||||
# workspace. It lives in a uv dependency group (PEP 735) — not the published
|
||||
# `desktop` extra — so `uv sync --group desktop-native` builds it from source
|
||||
# for the desktop app, while `pip install openjarvis[desktop]` from PyPI does
|
||||
# NOT try to resolve openjarvis-rust from PyPI, where it isn't published
|
||||
# (dependency groups are excluded from wheel metadata). See #584 / #615.
|
||||
desktop-native = [
|
||||
"openjarvis-rust",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,7 @@ from openjarvis.agents.prompt_loader import (
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
|
||||
from openjarvis.engine._base import estimate_prompt_tokens
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
from openjarvis.tools._stubs import BaseTool, build_tool_descriptions
|
||||
|
||||
@@ -116,8 +117,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
max_prompt_tokens: int = 3000,
|
||||
) -> list[Message]:
|
||||
"""Truncate messages if estimated token count exceeds limit."""
|
||||
total_chars = sum(len(m.content) for m in messages)
|
||||
estimated_tokens = total_chars // 4
|
||||
estimated_tokens = estimate_prompt_tokens(messages)
|
||||
if estimated_tokens <= max_prompt_tokens:
|
||||
return messages
|
||||
# Find the last user message and truncate its content
|
||||
@@ -125,7 +125,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
if messages[i].role == Role.USER:
|
||||
excess_tokens = estimated_tokens - max_prompt_tokens
|
||||
excess_chars = excess_tokens * 4
|
||||
original = messages[i].content
|
||||
original = messages[i].content or ""
|
||||
if len(original) > excess_chars + 200:
|
||||
truncated = original[: len(original) - excess_chars]
|
||||
messages[i] = Message(
|
||||
@@ -258,7 +258,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
# still emitted before re-raising.
|
||||
self._emit_turn_end(turns=1, error=True)
|
||||
raise
|
||||
content = self._strip_think_tags(result.get("content", ""))
|
||||
content = self._strip_think_tags(result.get("content") or "")
|
||||
usage = result.get("usage", {})
|
||||
self._emit_turn_end(turns=1)
|
||||
return AgentResult(
|
||||
@@ -315,7 +315,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
for k in total_usage:
|
||||
total_usage[k] += usage.get(k, 0)
|
||||
|
||||
content = result.get("content", "")
|
||||
content = result.get("content") or ""
|
||||
# Strip think tags so they don't interfere with parsing
|
||||
content = self._strip_think_tags(content)
|
||||
last_content = content
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
A small, self-contained planner-executor loop:
|
||||
|
||||
* the planner is a local Ollama chat model (default ``gemma4:31b``),
|
||||
* the planner is supplied by the caller (the web endpoint resolves it from
|
||||
config, falling back to ``gemma4:31b`` on Ollama for legacy installs),
|
||||
* the only tool it can call is :meth:`HybridSearch.search`,
|
||||
* it gets up to ``max_iterations`` tool calls,
|
||||
* tool results are trimmed before re-entering the context window, and
|
||||
@@ -142,10 +143,11 @@ Strategy:
|
||||
3. The `time_range` argument is a JSON object: `{{"start": "<ISO 8601>", "end": "<ISO 8601>"}}`. Either bound may be omitted, but pass at least one whenever the user gave you a temporal cue.
|
||||
4. When the user names a specific data source — "my Granola notes", "in Slack", "from my email" — you MUST pass `sources=[...]` with the matching connector ID. Only use IDs that appear in the connected-sources list above; do NOT invent or assume sources that are not connected. Common synonyms: "meeting notes"/"meetings"/"transcripts" → granola; "email"/"inbox" → gmail; "DMs"/"channels" → slack. Without this filter the search returns mail/messages ABOUT a tool instead of records FROM that tool.
|
||||
4a. Never apologize about sources that aren't in the connected-sources list — if the user asks about "Notion" but Notion isn't connected, just say "Notion isn't connected, but here's what I found in {available_sources}" and answer from what is available.
|
||||
5. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
6. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
7. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Never send an empty query or a query with no parameters — extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
8. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
5. When the user asks for "next", "upcoming", "future", or "soon" calendar events/meetings/appointments, use `sources=["gcalendar"]` if gcalendar is connected, set `time_range={{"start": "{today}"}}`, and use `query=""` unless the user gave a specific topic such as "dentist" or "music lesson". This returns the nearest upcoming calendar items across calendars instead of keyword-matching only birthdays or event titles.
|
||||
6. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
7. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
8. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Only use an empty query when structured filters carry the request; never send a search with no concrete parameters. Extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
9. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
|
||||
Synthesis rules:
|
||||
- Cite sources as individual numbers in square brackets. Always separate — write [4] [7] [20], never [4, 7, 20]. Never format citations as markdown links. Just the number in brackets: [1]. The `ref` field on each hit is the citation number.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Screen capture for vision input (``jarvis ask --screen``).
|
||||
|
||||
Captures the primary monitor to a temporary PNG so it can be handed to a
|
||||
vision-capable model. On Windows this uses the built-in .NET
|
||||
``System.Drawing`` stack (no third-party dependency). Other platforms fall
|
||||
back to ``mss`` or ``Pillow`` if installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# PowerShell: capture the PRIMARY monitor (more legible for a vision model
|
||||
# than a downscaled multi-monitor grab). {path} is filled in with forward
|
||||
# slashes, which .NET accepts on Windows and which avoids backslash escaping.
|
||||
_PS_CAPTURE = """
|
||||
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
||||
$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.CopyFromScreen($b.X, $b.Y, 0, 0, $bmp.Size)
|
||||
$bmp.Save("{path}", [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$g.Dispose(); $bmp.Dispose()
|
||||
"""
|
||||
|
||||
|
||||
def capture_screen_to_temp() -> str:
|
||||
"""Capture the screen to a temp PNG and return its absolute path.
|
||||
|
||||
Raises ``RuntimeError`` with actionable guidance if capture fails or the
|
||||
platform has no available backend.
|
||||
"""
|
||||
fd, path = tempfile.mkstemp(prefix="jarvis_screen_", suffix=".png")
|
||||
os.close(fd)
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
script = _PS_CAPTURE.replace("{path}", path.replace("\\", "/"))
|
||||
proc = subprocess.run(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if (
|
||||
proc.returncode != 0
|
||||
or not os.path.exists(path)
|
||||
or not os.path.getsize(path)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"screen capture failed: "
|
||||
+ (proc.stderr.strip() or "empty image written")
|
||||
)
|
||||
return path
|
||||
|
||||
# Non-Windows: optional backends.
|
||||
try:
|
||||
import mss # type: ignore
|
||||
|
||||
with mss.mss() as sct:
|
||||
sct.shot(mon=-1, output=path)
|
||||
return path
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from PIL import ImageGrab # type: ignore
|
||||
|
||||
ImageGrab.grab().save(path)
|
||||
return path
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
"screen capture on this platform needs 'mss' or 'Pillow' "
|
||||
"(try: pip install mss)"
|
||||
) from exc
|
||||
|
||||
|
||||
__all__ = ["capture_screen_to_temp"]
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json as json_mod
|
||||
import logging
|
||||
import sys
|
||||
@@ -619,6 +620,21 @@ def _print_profile(
|
||||
"(default: ~/.openjarvis/knowledge.db)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"-i",
|
||||
"--image",
|
||||
"image_paths",
|
||||
multiple=True,
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
help="Image file for a vision model (e.g. gemma3). Repeatable.",
|
||||
)
|
||||
@click.option(
|
||||
"-S",
|
||||
"--screen",
|
||||
"capture_screen",
|
||||
is_flag=True,
|
||||
help="Capture the current screen and send it to the vision model.",
|
||||
)
|
||||
@click.option(
|
||||
"--persona",
|
||||
"persona_name",
|
||||
@@ -645,6 +661,8 @@ def ask(
|
||||
research_mode: bool,
|
||||
knowledge_db: str | None,
|
||||
persona_name: str | None,
|
||||
image_paths: tuple[str, ...] = (),
|
||||
capture_screen: bool = False,
|
||||
) -> None:
|
||||
"""Ask Jarvis a question."""
|
||||
quiet = (ctx.obj or {}).get("quiet", False) or output_json
|
||||
@@ -652,6 +670,27 @@ def ask(
|
||||
console = Console(stderr=True)
|
||||
query_text = " ".join(query)
|
||||
|
||||
# Vision: collect base64 images from --image files and/or --screen.
|
||||
image_b64: list[str] = []
|
||||
for _img_path in image_paths:
|
||||
try:
|
||||
with open(_img_path, "rb") as _fh:
|
||||
image_b64.append(base64.b64encode(_fh.read()).decode("ascii"))
|
||||
except OSError as exc:
|
||||
console.print(f"[red]Could not read image {_img_path}: {exc}[/red]")
|
||||
sys.exit(1)
|
||||
if capture_screen:
|
||||
try:
|
||||
from openjarvis.cli._screen import capture_screen_to_temp
|
||||
|
||||
_shot = capture_screen_to_temp()
|
||||
with open(_shot, "rb") as _fh:
|
||||
image_b64.append(base64.b64encode(_fh.read()).decode("ascii"))
|
||||
logger.debug("Captured screen to %s", _shot)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f"[red]Screen capture failed:[/red] {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
wall_start = time.monotonic() if enable_profile else None
|
||||
|
||||
# Load config
|
||||
@@ -671,11 +710,26 @@ def ask(
|
||||
# Without this fallback, `[agent].default_system_prompt` and the
|
||||
# SOUL.md / MEMORY.md / USER.md persona system are silently bypassed for
|
||||
# the most common command (`jarvis ask "..."`).
|
||||
agent_explicitly_set = agent_name is not None
|
||||
if agent_name is None:
|
||||
configured_default = (config.agent.default_agent or "").strip()
|
||||
if configured_default:
|
||||
agent_name = configured_default
|
||||
|
||||
# Vision flows only through direct-to-engine mode. If an image/screenshot
|
||||
# was supplied without an explicit --agent, route to direct mode so the
|
||||
# picture reaches the model; if an agent was explicitly requested, say
|
||||
# plainly that the image is being skipped rather than dropping it silently.
|
||||
if image_b64:
|
||||
if not agent_explicitly_set:
|
||||
agent_name = ""
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]Note:[/yellow] --image/--screen only works in direct "
|
||||
"mode; the image is ignored with --agent set. Re-run with "
|
||||
'`--agent ""` to use vision.'
|
||||
)
|
||||
|
||||
# Track whether the user explicitly set --max-tokens
|
||||
user_set_max_tokens = max_tokens is not None
|
||||
|
||||
@@ -871,6 +925,27 @@ def ask(
|
||||
return
|
||||
|
||||
# Direct-to-engine mode (no agent)
|
||||
# Privacy guard: a screenshot/image is sensitive, and OpenJarvis is
|
||||
# local-first. If the active engine isn't local, warn before the image
|
||||
# leaves the machine rather than silently uploading it to a third party.
|
||||
_LOCAL_ENGINES = {
|
||||
"ollama",
|
||||
"llamacpp",
|
||||
"vllm",
|
||||
"sglang",
|
||||
"exo",
|
||||
"nexa",
|
||||
"uzu",
|
||||
"apple_fm",
|
||||
"gemma_cpp",
|
||||
}
|
||||
if image_b64 and engine_name not in _LOCAL_ENGINES:
|
||||
console.print(
|
||||
f"[yellow]Privacy warning:[/yellow] sending {len(image_b64)} "
|
||||
f"image(s) to a non-local engine ('{engine_name}'). The image will "
|
||||
"leave this machine. Use a local engine (e.g. ollama) to keep "
|
||||
"vision on-device."
|
||||
)
|
||||
messages = [Message(role=Role.USER, content=query_text)]
|
||||
|
||||
# Memory-augmented context injection
|
||||
@@ -897,6 +972,15 @@ def ask(
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to inject memory context: %s", exc)
|
||||
|
||||
# Vision: attach images to the final user message *after* any context
|
||||
# injection (which may rebuild the list). messages_to_dicts() forwards
|
||||
# the "images" field to Ollama's /api/chat.
|
||||
if image_b64:
|
||||
for _m in reversed(messages):
|
||||
if _m.role == Role.USER:
|
||||
_m.images = image_b64
|
||||
break
|
||||
|
||||
# Generate (InstrumentedEngine handles telemetry + energy recording)
|
||||
try:
|
||||
with console.status("[bold green]Generating...[/bold green]"):
|
||||
|
||||
@@ -11,7 +11,9 @@ from rich.markdown import Markdown
|
||||
|
||||
from openjarvis.cli._tool_names import resolve_tool_names
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.memory import publish_completed_exchange
|
||||
|
||||
|
||||
def _read_input(prompt: str = "You> ") -> Optional[str]:
|
||||
@@ -57,6 +59,7 @@ def chat(
|
||||
console = Console(stderr=True)
|
||||
|
||||
config = load_config()
|
||||
bus = EventBus(record_history=False)
|
||||
|
||||
import dataclasses as _dc
|
||||
|
||||
@@ -97,12 +100,11 @@ def chat(
|
||||
if agent_key and agent_key != "none":
|
||||
try:
|
||||
import openjarvis.agents # noqa: F401 — trigger registration
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
if AgentRegistry.contains(agent_key):
|
||||
agent_cls = AgentRegistry.get(agent_key)
|
||||
kwargs: dict = {"bus": EventBus()}
|
||||
kwargs: dict = {"bus": bus}
|
||||
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
tool_names_list = resolve_tool_names(
|
||||
@@ -179,6 +181,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, event_bus=bus)
|
||||
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 +281,20 @@ def chat(
|
||||
console.print()
|
||||
console.print(Markdown(content))
|
||||
console.print()
|
||||
|
||||
publish_completed_exchange(
|
||||
bus,
|
||||
user_input,
|
||||
content,
|
||||
source="cli.chat",
|
||||
)
|
||||
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,31 @@ 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,
|
||||
event_bus=bus,
|
||||
)
|
||||
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 +622,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 +685,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
|
||||
@@ -214,12 +213,13 @@ def _parse_event_timestamp(event: Dict[str, Any]) -> datetime:
|
||||
"""
|
||||
start = event.get("start", {})
|
||||
date_time_str: str = start.get("dateTime", "")
|
||||
if not date_time_str:
|
||||
date_str: str = start.get("date", "")
|
||||
if not date_time_str and not date_str:
|
||||
return datetime.now()
|
||||
try:
|
||||
# RFC3339 — Python 3.11+ fromisoformat handles the trailing 'Z'.
|
||||
# For older versions we replace 'Z' with '+00:00'.
|
||||
normalized = date_time_str.replace("Z", "+00:00")
|
||||
normalized = (date_time_str or date_str).replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized)
|
||||
except (ValueError, TypeError):
|
||||
return datetime.now()
|
||||
@@ -290,12 +290,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 +311,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})
|
||||
|
||||
@@ -20,8 +20,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
# numpy imported lazily inside _vector_recall (see embeddings.py) so importing
|
||||
@@ -32,6 +33,61 @@ from openjarvis.connectors.store import KnowledgeStore
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_UPCOMING_TERMS = {
|
||||
"next",
|
||||
"upcoming",
|
||||
"future",
|
||||
"forthcoming",
|
||||
"coming",
|
||||
"soon",
|
||||
}
|
||||
_CALENDAR_TERMS = {
|
||||
"calendar",
|
||||
"calendars",
|
||||
"event",
|
||||
"events",
|
||||
}
|
||||
_CALENDAR_REQUEST_TERMS = _CALENDAR_TERMS | {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_GCALENDAR_GENERIC_TERMS = _UPCOMING_TERMS | _CALENDAR_TERMS | {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_QUERY_STOPWORDS = {
|
||||
"a",
|
||||
"all",
|
||||
"am",
|
||||
"are",
|
||||
"do",
|
||||
"for",
|
||||
"have",
|
||||
"i",
|
||||
"in",
|
||||
"is",
|
||||
"list",
|
||||
"me",
|
||||
"my",
|
||||
"on",
|
||||
"s",
|
||||
"show",
|
||||
"tell",
|
||||
"the",
|
||||
"there",
|
||||
"to",
|
||||
"what",
|
||||
"whats",
|
||||
"when",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result types
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -120,6 +176,101 @@ def _snippet(content: str, max_chars: int = 500) -> str:
|
||||
return flat[:max_chars].rstrip() + "…"
|
||||
|
||||
|
||||
def _query_tokens(query: str) -> set[str]:
|
||||
return set(re.findall(r"[a-z0-9_]+", query.lower()))
|
||||
|
||||
|
||||
def _sources_include_gcalendar(sources: Optional[Sequence[str]]) -> bool:
|
||||
return any(str(source).lower() == "gcalendar" for source in sources or [])
|
||||
|
||||
|
||||
def _has_upcoming_calendar_intent(
|
||||
query: str,
|
||||
sources: Optional[Sequence[str]],
|
||||
) -> bool:
|
||||
tokens = _query_tokens(query)
|
||||
if not tokens or not (tokens & _UPCOMING_TERMS):
|
||||
return False
|
||||
if _sources_include_gcalendar(sources):
|
||||
return True
|
||||
if sources:
|
||||
return False
|
||||
return bool(tokens & _CALENDAR_REQUEST_TERMS)
|
||||
|
||||
|
||||
def _is_generic_calendar_timeline_query(query: str) -> bool:
|
||||
tokens = _query_tokens(query)
|
||||
if not tokens:
|
||||
return True
|
||||
topic_tokens = tokens - _GCALENDAR_GENERIC_TERMS - _QUERY_STOPWORDS
|
||||
return not topic_tokens
|
||||
|
||||
|
||||
def _start_is_nowish_or_future(start: Optional[datetime]) -> bool:
|
||||
if start is None:
|
||||
return False
|
||||
now = datetime.now(tz=start.tzinfo) if start.tzinfo else datetime.now()
|
||||
return start >= now - timedelta(days=1)
|
||||
|
||||
|
||||
def _start_of_day(ts: datetime) -> datetime:
|
||||
return ts.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _as_utc(ts: Optional[datetime]) -> Optional[datetime]:
|
||||
if ts is None:
|
||||
return None
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=timezone.utc)
|
||||
return ts.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _parse_timestamp_for_timeline(
|
||||
raw: Any,
|
||||
) -> Tuple[Optional[datetime], Optional[date]]:
|
||||
if raw is None:
|
||||
return None, None
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None, None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None, None
|
||||
is_naive_midnight = (
|
||||
parsed.tzinfo is None
|
||||
and parsed.hour == 0
|
||||
and parsed.minute == 0
|
||||
and parsed.second == 0
|
||||
and parsed.microsecond == 0
|
||||
)
|
||||
return _as_utc(parsed), parsed.date() if is_naive_midnight else None
|
||||
|
||||
|
||||
def _timestamp_in_range(
|
||||
timestamp: Optional[datetime],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
*,
|
||||
all_day_date: Optional[date] = None,
|
||||
) -> bool:
|
||||
if timestamp is None or time_range is None:
|
||||
return timestamp is not None
|
||||
start, end = time_range
|
||||
if all_day_date is not None:
|
||||
if start is not None and all_day_date < start.date():
|
||||
return False
|
||||
if end is not None and all_day_date > end.date():
|
||||
return False
|
||||
return True
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
if start_utc is not None and timestamp < start_utc:
|
||||
return False
|
||||
if end_utc is not None and timestamp > end_utc:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HybridSearch
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -377,6 +528,128 @@ class HybridSearch:
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def _normalise_calendar_timeline_scope(
|
||||
self,
|
||||
query: str,
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
sources: Optional[Sequence[str]],
|
||||
) -> Tuple[
|
||||
Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
Optional[Sequence[str]],
|
||||
bool,
|
||||
bool,
|
||||
]:
|
||||
"""Fill in structured filters for generic upcoming-calendar requests.
|
||||
|
||||
Queries like "what are my next calendar events?" often have no useful
|
||||
lexical terms in the stored event text, so BM25/vector ranking can miss
|
||||
nearby events. Treat that shape as a source-filtered timeline request.
|
||||
"""
|
||||
scoped_sources = list(sources) if sources else None
|
||||
has_upcoming_intent = _has_upcoming_calendar_intent(query, scoped_sources)
|
||||
|
||||
if has_upcoming_intent and (
|
||||
scoped_sources is None or _sources_include_gcalendar(scoped_sources)
|
||||
):
|
||||
scoped_sources = ["gcalendar"]
|
||||
|
||||
if not _sources_include_gcalendar(scoped_sources):
|
||||
return time_range, scoped_sources, False, False
|
||||
|
||||
if has_upcoming_intent:
|
||||
if time_range is None:
|
||||
time_range = (_start_of_day(datetime.now(timezone.utc)), None)
|
||||
else:
|
||||
start, end = time_range
|
||||
if start is None:
|
||||
time_range = (_start_of_day(datetime.now(timezone.utc)), end)
|
||||
else:
|
||||
time_range = (_start_of_day(start), end)
|
||||
|
||||
chronological = has_upcoming_intent or (
|
||||
time_range is not None
|
||||
and time_range[1] is None
|
||||
and _start_is_nowish_or_future(time_range[0])
|
||||
)
|
||||
metadata_only = chronological and _is_generic_calendar_timeline_query(query)
|
||||
return time_range, scoped_sources, chronological, metadata_only
|
||||
|
||||
def _calendar_timeline_ids(
|
||||
self,
|
||||
*,
|
||||
person: Optional[str],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
sources: Optional[Sequence[str]],
|
||||
limit: int,
|
||||
) -> List[str]:
|
||||
"""Return gcalendar rows sorted by normalized event start time."""
|
||||
filter_sql, filter_params = self._build_filters(
|
||||
person=person,
|
||||
time_range=None,
|
||||
sources=sources,
|
||||
)
|
||||
rows = self._store._conn.execute(
|
||||
f"""
|
||||
SELECT id, timestamp, created_at
|
||||
FROM knowledge_chunks
|
||||
WHERE {filter_sql}
|
||||
""",
|
||||
filter_params,
|
||||
).fetchall()
|
||||
|
||||
candidates: List[Tuple[str, datetime, float]] = []
|
||||
for row in rows:
|
||||
timestamp, all_day_date = _parse_timestamp_for_timeline(row["timestamp"])
|
||||
if not _timestamp_in_range(
|
||||
timestamp,
|
||||
time_range,
|
||||
all_day_date=all_day_date,
|
||||
):
|
||||
continue
|
||||
candidates.append(
|
||||
(
|
||||
row["id"],
|
||||
timestamp or datetime.max.replace(tzinfo=timezone.utc),
|
||||
float(row["created_at"] or 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda item: (item[1], item[2]))
|
||||
return [chunk_id for chunk_id, *_ in candidates[:limit]]
|
||||
|
||||
def _filter_calendar_timeline_fused(
|
||||
self,
|
||||
fused: List[Tuple[str, float, float, float]],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
) -> List[Tuple[str, float, float, float]]:
|
||||
"""Apply normalized timestamp filtering to ranked calendar candidates."""
|
||||
if not fused:
|
||||
return fused
|
||||
ids = [chunk_id for chunk_id, *_ in fused]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
rows = self._store._conn.execute(
|
||||
f"""
|
||||
SELECT id, timestamp
|
||||
FROM knowledge_chunks
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
ids,
|
||||
).fetchall()
|
||||
timestamps = {
|
||||
row["id"]: _parse_timestamp_for_timeline(row["timestamp"])
|
||||
for row in rows
|
||||
}
|
||||
|
||||
def _keeps_item(item: Tuple[str, float, float, float]) -> bool:
|
||||
timestamp, all_day_date = timestamps.get(item[0], (None, None))
|
||||
return _timestamp_in_range(
|
||||
timestamp,
|
||||
time_range,
|
||||
all_day_date=all_day_date,
|
||||
)
|
||||
|
||||
return [item for item in fused if _keeps_item(item)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
@@ -396,42 +669,65 @@ class HybridSearch:
|
||||
when callers want a pure metadata filter (e.g. "all mail from X in
|
||||
May") — in that case only the vector leg runs (and only if an
|
||||
embedder is configured); if neither leg yields anything the
|
||||
structured filter is applied directly and the most recent rows are
|
||||
returned.
|
||||
structured filter is applied directly. Upcoming calendar timelines are
|
||||
returned nearest-first; other fallbacks return the most recent rows.
|
||||
"""
|
||||
time_range, sources, chronological_order, metadata_only = (
|
||||
self._normalise_calendar_timeline_scope(query, time_range, sources)
|
||||
)
|
||||
rank_query = "" if metadata_only else query
|
||||
calendar_timeline = chronological_order and _sources_include_gcalendar(sources)
|
||||
recall_time_range = None if calendar_timeline else time_range
|
||||
|
||||
bm25_filter_sql, bm25_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources, alias="kc"
|
||||
person=person, time_range=recall_time_range, sources=sources, alias="kc"
|
||||
)
|
||||
unaliased_filter_sql, unaliased_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources
|
||||
person=person, time_range=recall_time_range, sources=sources
|
||||
)
|
||||
|
||||
bm25 = (
|
||||
self._bm25_recall(query, bm25_filter_sql, bm25_filter_params)
|
||||
if query.strip()
|
||||
self._bm25_recall(rank_query, bm25_filter_sql, bm25_filter_params)
|
||||
if rank_query.strip()
|
||||
else []
|
||||
)
|
||||
vector = (
|
||||
self._vector_recall(query, unaliased_filter_sql, unaliased_filter_params)
|
||||
if query.strip()
|
||||
self._vector_recall(
|
||||
rank_query,
|
||||
unaliased_filter_sql,
|
||||
unaliased_filter_params,
|
||||
)
|
||||
if rank_query.strip()
|
||||
else []
|
||||
)
|
||||
fused = self._fuse(bm25, vector)
|
||||
if calendar_timeline:
|
||||
fused = self._filter_calendar_timeline_fused(fused, time_range)
|
||||
|
||||
# Metadata-only fallback: empty query, or both legs produced nothing
|
||||
# despite a non-empty query. Return the most recent rows matching the
|
||||
# filter so the agent still gets a useful corpus snapshot.
|
||||
# despite a non-empty query. Calendar timeline requests use start-time
|
||||
# ascending; other searches use recency so the agent still gets a
|
||||
# useful corpus snapshot.
|
||||
if not fused:
|
||||
sql = f"""
|
||||
SELECT id FROM knowledge_chunks
|
||||
WHERE {unaliased_filter_sql}
|
||||
ORDER BY timestamp DESC, created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = self._store._conn.execute(
|
||||
sql, [*unaliased_filter_params, limit]
|
||||
).fetchall()
|
||||
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
|
||||
if calendar_timeline:
|
||||
chunk_ids = self._calendar_timeline_ids(
|
||||
person=person,
|
||||
time_range=time_range,
|
||||
sources=sources,
|
||||
limit=limit,
|
||||
)
|
||||
fused = [(chunk_id, 0.0, 0.0, 0.0) for chunk_id in chunk_ids]
|
||||
else:
|
||||
sql = f"""
|
||||
SELECT id FROM knowledge_chunks
|
||||
WHERE {unaliased_filter_sql}
|
||||
ORDER BY timestamp DESC, created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = self._store._conn.execute(
|
||||
sql, [*unaliased_filter_params, limit]
|
||||
).fetchall()
|
||||
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
|
||||
|
||||
# Materialise the top-N rows in one IN-clause round trip.
|
||||
top = fused[:limit]
|
||||
|
||||
+100
-24
@@ -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)
|
||||
@@ -576,6 +593,14 @@ class IntelligenceConfig:
|
||||
stop_sequences: str = "" # Comma-separated stop strings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeepResearchConfig:
|
||||
"""Planner settings for the web Deep Research endpoint."""
|
||||
|
||||
engine: str = "" # Empty means use the active chat engine.
|
||||
model: str = "" # Empty means use the active chat model.
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RoutingLearningConfig:
|
||||
"""Routing sub-policy config within Learning."""
|
||||
@@ -742,7 +767,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 +918,32 @@ 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 = field(
|
||||
default_factory=lambda: str(get_config_dir() / "memory_facts.jsonl")
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatibility alias
|
||||
MemoryConfig = StorageConfig
|
||||
@@ -946,8 +989,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 +1041,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 +1066,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 +1076,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 +1278,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 +1291,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 +1414,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 +1432,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 +1460,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 +1468,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 +1520,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)
|
||||
@@ -1531,6 +1586,7 @@ class JarvisConfig:
|
||||
hardware: HardwareInfo = field(default_factory=HardwareInfo)
|
||||
engine: EngineConfig = field(default_factory=EngineConfig)
|
||||
intelligence: IntelligenceConfig = field(default_factory=IntelligenceConfig)
|
||||
deep_research: DeepResearchConfig = field(default_factory=DeepResearchConfig)
|
||||
learning: LearningConfig = field(default_factory=LearningConfig)
|
||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||
agent: AgentConfig = field(default_factory=AgentConfig)
|
||||
@@ -1779,7 +1835,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)
|
||||
@@ -1792,6 +1848,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
|
||||
top_sections = (
|
||||
"engine",
|
||||
"intelligence",
|
||||
"deep_research",
|
||||
"learning",
|
||||
"agent",
|
||||
"server",
|
||||
@@ -1960,6 +2017,10 @@ max_tokens = 1024
|
||||
# repetition_penalty = 1.0
|
||||
# stop_sequences = ""
|
||||
|
||||
# [deep_research]
|
||||
# engine = "" # empty = use [engine].default
|
||||
# model = "" # empty = use [intelligence].default_model
|
||||
|
||||
[agent]
|
||||
default_agent = "simple"
|
||||
max_turns = 10
|
||||
@@ -1972,6 +2033,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 +2187,15 @@ __all__ = [
|
||||
"BrowserConfig",
|
||||
"CapabilitiesConfig",
|
||||
"ChannelConfig",
|
||||
"ConfigurationError",
|
||||
"DEFAULT_CONFIG_DIR",
|
||||
"DEFAULT_CONFIG_PATH",
|
||||
"DiscordChannelConfig",
|
||||
"DeepResearchConfig",
|
||||
"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:
|
||||
|
||||
@@ -27,6 +27,7 @@ class EventType(str, Enum):
|
||||
TOOL_CALL_END = "tool_call_end"
|
||||
MEMORY_STORE = "memory_store"
|
||||
MEMORY_RETRIEVE = "memory_retrieve"
|
||||
CHAT_EXCHANGE_COMPLETED = "chat_exchange_completed"
|
||||
AGENT_TURN_START = "agent_turn_start"
|
||||
AGENT_TURN_END = "agent_turn_end"
|
||||
TELEMETRY_RECORD = "telemetry_record"
|
||||
|
||||
@@ -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"
|
||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, Tuple, Type, Typ
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.agents._stubs import BaseAgent
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
from openjarvis.memory.store import FactStore
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -109,6 +110,10 @@ class MemoryRegistry(RegistryBase[Type["MemoryBackend"]]):
|
||||
"""Registry for memory / retrieval backends."""
|
||||
|
||||
|
||||
class FactStoreRegistry(RegistryBase[Type["FactStore"]]):
|
||||
"""Registry for automatic-memory fact store backends."""
|
||||
|
||||
|
||||
class AgentRegistry(RegistryBase[Type["BaseAgent"]]):
|
||||
"""Registry for agent implementations."""
|
||||
|
||||
@@ -170,6 +175,7 @@ __all__ = [
|
||||
"CompressionRegistry",
|
||||
"ConnectorRegistry",
|
||||
"EngineRegistry",
|
||||
"FactStoreRegistry",
|
||||
"LearningRegistry",
|
||||
"MemoryRegistry",
|
||||
"MinerRegistry",
|
||||
|
||||
@@ -63,11 +63,20 @@ class Message:
|
||||
"""A single chat message (OpenAI-compatible structure)."""
|
||||
|
||||
role: Role
|
||||
content: str = ""
|
||||
content: str | None = ""
|
||||
name: Optional[str] = None
|
||||
tool_calls: Optional[List[ToolCall]] = None
|
||||
tool_call_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
# Base64-encoded image data for vision-capable models (e.g. gemma3,
|
||||
# qwen2.5-vl). Forwarded to Ollama's /api/chat "images" field; None or
|
||||
# empty for text-only messages (the common case).
|
||||
images: Optional[List[str]] = None
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Return message content as text, treating ``None`` as empty."""
|
||||
return self.content or ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -13,6 +13,22 @@ class EngineConnectionError(Exception):
|
||||
"""Raised when an engine is unreachable."""
|
||||
|
||||
|
||||
_REASONING_METADATA_KEYS = ("reasoning_content", "thinking")
|
||||
|
||||
|
||||
def _message_estimated_chars(message: Message) -> int:
|
||||
parts = [message.text]
|
||||
for key in _REASONING_METADATA_KEYS:
|
||||
value = message.metadata.get(key)
|
||||
if isinstance(value, str):
|
||||
parts.append(value)
|
||||
for tc in message.tool_calls or []:
|
||||
parts.extend((tc.id, tc.name, tc.arguments))
|
||||
if message.tool_call_id:
|
||||
parts.append(message.tool_call_id)
|
||||
return sum(len(part) for part in parts)
|
||||
|
||||
|
||||
def messages_to_dicts(messages: Sequence[Message]) -> List[Dict[str, Any]]:
|
||||
"""Convert ``Message`` objects to OpenAI-format dicts."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
@@ -34,6 +50,10 @@ def messages_to_dicts(messages: Sequence[Message]) -> List[Dict[str, Any]]:
|
||||
]
|
||||
if m.tool_call_id:
|
||||
d["tool_call_id"] = m.tool_call_id
|
||||
# Vision: forward base64 images to the engine. Ollama's /api/chat
|
||||
# accepts an "images" array on a message; text messages skip this.
|
||||
if getattr(m, "images", None):
|
||||
d["images"] = list(m.images)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
@@ -49,9 +69,11 @@ def estimate_prompt_tokens(messages: Sequence[Message]) -> int:
|
||||
provider would charge.
|
||||
|
||||
Uses ~4 characters per token (standard BPE average for English) plus
|
||||
a small per-message overhead for role markers and separators.
|
||||
a small per-message overhead for role markers and separators. Counts
|
||||
content, reasoning metadata, tool-call payloads, and tool result IDs
|
||||
because all are replayed into later prompt turns when present.
|
||||
"""
|
||||
total_chars = sum(len(m.content) for m in messages)
|
||||
total_chars = sum(_message_estimated_chars(m) for m in messages)
|
||||
# ~4 tokens overhead per message for role markers / separators
|
||||
overhead = len(messages) * 4
|
||||
return max(1, total_chars // 4 + overhead)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Cloud inference engine — OpenAI, Anthropic, Google, and MiniMax API backends."""
|
||||
"""Cloud inference engine.
|
||||
|
||||
OpenAI, Anthropic, Google, MiniMax, and DeepSeek API backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -48,6 +51,8 @@ PRICING: Dict[str, tuple[float, float]] = {
|
||||
"MiniMax-M2.7-highspeed": (0.60, 2.40),
|
||||
"MiniMax-M2.5": (0.30, 1.20),
|
||||
"MiniMax-M2.5-highspeed": (0.60, 2.40),
|
||||
"deepseek-v4-flash": (0.27, 1.10),
|
||||
"deepseek-v4-pro": (0.55, 2.19),
|
||||
}
|
||||
|
||||
# Well-known model IDs per provider
|
||||
@@ -83,6 +88,10 @@ _MINIMAX_MODELS = [
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
]
|
||||
_DEEPSEEK_MODELS = [
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
]
|
||||
|
||||
# OpenRouter models — prefixed with "openrouter/" so they can be identified
|
||||
_OPENROUTER_POPULAR = [
|
||||
@@ -111,6 +120,10 @@ def _is_minimax_model(model: str) -> bool:
|
||||
return model.lower().startswith("minimax")
|
||||
|
||||
|
||||
def _is_deepseek_model(model: str) -> bool:
|
||||
return model.lower().startswith("deepseek")
|
||||
|
||||
|
||||
def _is_openrouter_model(model: str) -> bool:
|
||||
return model.startswith("openrouter/")
|
||||
|
||||
@@ -127,6 +140,35 @@ def _is_google_model(model: str) -> bool:
|
||||
return "gemini" in model.lower() and not _is_openrouter_model(model)
|
||||
|
||||
|
||||
# Positive prefix predicate for genuine OpenAI models. Kept in sync with
|
||||
# ``server/cloud_router.py:_OPENAI_PREFIXES`` so local-vs-cloud classification
|
||||
# agrees across the codebase. Used by ``_client_for_model``/``can_serve`` so the
|
||||
# cloud engine never claims it can serve an unrecognized (e.g. local Ollama)
|
||||
# model name just because an OpenAI key happens to be present (see #335).
|
||||
_OPENAI_PREFIXES = ("gpt-", "chatgpt-", "o1", "o3", "o4")
|
||||
|
||||
|
||||
def _is_openai_model(model: str) -> bool:
|
||||
"""True only for genuine OpenAI models (gpt-*, chatgpt-*, o1/o3/o4 series).
|
||||
|
||||
Defined positively so that an unrecognized model name (a local Ollama model
|
||||
like ``qwen3.5:0.8b``, or a typo) is NOT treated as an OpenAI model. This is
|
||||
the routing surface ``can_serve`` relies on; ``generate``/``stream`` keep
|
||||
their OpenAI fall-through so an explicitly-requested unknown cloud model
|
||||
still errors loudly at call time.
|
||||
|
||||
Caveat: a user may repoint the OpenAI client at an OpenAI-compatible server
|
||||
(vLLM/LM Studio) via ``OPENAI_BASE_URL`` and legitimately serve non-gpt
|
||||
names. That path is undocumented/untested in this engine; if it is added,
|
||||
this predicate (or ``_client_for_model``) should treat a configured custom
|
||||
base_url as "serves anything".
|
||||
"""
|
||||
m = model.lower()
|
||||
if m in (name.lower() for name in _OPENAI_MODELS):
|
||||
return True
|
||||
return m.startswith(_OPENAI_PREFIXES)
|
||||
|
||||
|
||||
def _is_openai_reasoning_model(model: str) -> bool:
|
||||
"""Check if model is an OpenAI reasoning model that restricts temperature."""
|
||||
m = model.lower()
|
||||
@@ -269,7 +311,7 @@ def _convert_tools_to_google(
|
||||
|
||||
@EngineRegistry.register("cloud")
|
||||
class CloudEngine(InferenceEngine):
|
||||
"""Cloud inference via OpenAI, Anthropic, Google, and MiniMax SDKs."""
|
||||
"""Cloud inference via OpenAI, Anthropic, Google, MiniMax, and DeepSeek SDKs."""
|
||||
|
||||
engine_id = "cloud"
|
||||
is_cloud = True
|
||||
@@ -280,6 +322,7 @@ class CloudEngine(InferenceEngine):
|
||||
self._google_client: Any = None
|
||||
self._openrouter_client: Any = None
|
||||
self._minimax_client: Any = None
|
||||
self._deepseek_client: Any = None
|
||||
self._codex_client: Any = None
|
||||
# Gemini thought_signatures: tool_call_id -> signature bytes
|
||||
self._thought_sigs: Dict[str, bytes] = {}
|
||||
@@ -332,6 +375,17 @@ class CloudEngine(InferenceEngine):
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
deepseek_key = os.environ.get("DEEPSEEK_API_KEY")
|
||||
if deepseek_key:
|
||||
try:
|
||||
import openai
|
||||
|
||||
self._deepseek_client = openai.OpenAI(
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
api_key=deepseek_key,
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
# Codex — uses the OpenAI Responses API.
|
||||
# Supports both standard API keys (api.openai.com) and ChatGPT
|
||||
# OAuth tokens (chatgpt.com) via OPENAI_CODEX_BASE_URL override.
|
||||
@@ -985,6 +1039,56 @@ class CloudEngine(InferenceEngine):
|
||||
]
|
||||
return result
|
||||
|
||||
def _generate_deepseek(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
if self._deepseek_client is None:
|
||||
raise EngineConnectionError(
|
||||
"DeepSeek client not available — set DEEPSEEK_API_KEY"
|
||||
)
|
||||
kwargs.pop("response_format", None)
|
||||
create_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages_to_dicts(messages),
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
t0 = time.monotonic()
|
||||
resp = self._deepseek_client.chat.completions.create(**create_kwargs)
|
||||
elapsed = time.monotonic() - t0
|
||||
choice = resp.choices[0]
|
||||
usage = resp.usage
|
||||
prompt_tokens = usage.prompt_tokens if usage else 0
|
||||
completion_tokens = usage.completion_tokens if usage else 0
|
||||
result: Dict[str, Any] = {
|
||||
"content": choice.message.content or "",
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": (usage.total_tokens if usage else 0),
|
||||
},
|
||||
"model": resp.model,
|
||||
"finish_reason": choice.finish_reason or "stop",
|
||||
"cost_usd": estimate_cost(model, prompt_tokens, completion_tokens),
|
||||
"ttft": elapsed,
|
||||
}
|
||||
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
|
||||
result["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
}
|
||||
for tc in choice.message.tool_calls
|
||||
]
|
||||
return result
|
||||
|
||||
def generate(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
@@ -1006,6 +1110,8 @@ class CloudEngine(InferenceEngine):
|
||||
return self._generate_openrouter(messages, **kw)
|
||||
if _is_minimax_model(model):
|
||||
return self._generate_minimax(messages, **kw)
|
||||
if _is_deepseek_model(model):
|
||||
return self._generate_deepseek(messages, **kw)
|
||||
if _is_anthropic_model(model):
|
||||
return self._generate_anthropic(messages, **kw)
|
||||
if _is_google_model(model):
|
||||
@@ -1036,6 +1142,9 @@ class CloudEngine(InferenceEngine):
|
||||
elif _is_minimax_model(model):
|
||||
async for token in self._stream_minimax(messages, **kw):
|
||||
yield token
|
||||
elif _is_deepseek_model(model):
|
||||
async for token in self._stream_deepseek(messages, **kw):
|
||||
yield token
|
||||
elif _is_anthropic_model(model):
|
||||
async for token in self._stream_anthropic(messages, **kw):
|
||||
yield token
|
||||
@@ -1254,6 +1363,30 @@ class CloudEngine(InferenceEngine):
|
||||
if delta and delta.content:
|
||||
yield delta.content
|
||||
|
||||
async def _stream_deepseek(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[str]:
|
||||
if self._deepseek_client is None:
|
||||
raise EngineConnectionError("DeepSeek client not available")
|
||||
create_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages_to_dicts(messages),
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
}
|
||||
resp = self._deepseek_client.chat.completions.create(**create_kwargs)
|
||||
for chunk in resp:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
if delta and delta.content:
|
||||
yield delta.content
|
||||
|
||||
# -- stream_full: rich streaming with tool_calls support ----------------
|
||||
|
||||
async def _stream_full_openai(
|
||||
@@ -1307,6 +1440,18 @@ class CloudEngine(InferenceEngine):
|
||||
"stream": True,
|
||||
**kwargs,
|
||||
}
|
||||
elif _is_deepseek_model(model):
|
||||
client = self._deepseek_client
|
||||
if client is None:
|
||||
raise EngineConnectionError("DeepSeek client not available")
|
||||
create_kwargs = {
|
||||
"model": model,
|
||||
"messages": messages_to_dicts(messages),
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
**kwargs,
|
||||
}
|
||||
else:
|
||||
client = self._openai_client
|
||||
if client is None:
|
||||
@@ -1473,24 +1618,40 @@ class CloudEngine(InferenceEngine):
|
||||
models.extend(_OPENROUTER_POPULAR)
|
||||
if self._minimax_client is not None:
|
||||
models.extend(_MINIMAX_MODELS)
|
||||
if self._deepseek_client is not None:
|
||||
models.extend(_DEEPSEEK_MODELS)
|
||||
if self._codex_client is not None:
|
||||
models.extend(_CODEX_MODELS)
|
||||
return models
|
||||
|
||||
def _client_for_model(self, model: str) -> Any:
|
||||
"""Return the provider client ``generate``/``stream`` will dispatch to
|
||||
for *model* (mirrors the routing in those methods)."""
|
||||
for *model*, or ``None`` for a model this engine cannot route.
|
||||
|
||||
Mirrors the routing in ``generate``/``stream``, but is intentionally
|
||||
*stricter* on the OpenAI fall-through: only genuine OpenAI models map to
|
||||
the OpenAI client. Unrecognized names (e.g. a local Ollama model like
|
||||
``qwen3.5:0.8b``) return ``None`` so ``can_serve`` declines them and the
|
||||
cloud engine is not mis-selected as a fallback when the local engine is
|
||||
transiently down and any (even dummy) ``OPENAI_API_KEY`` is set (#335).
|
||||
``generate``/``stream`` keep their OpenAI fall-through, so an
|
||||
explicitly-requested unknown cloud model still fails loudly at call time.
|
||||
"""
|
||||
if _is_codex_model(model):
|
||||
return self._codex_client
|
||||
if _is_openrouter_model(model):
|
||||
return self._openrouter_client
|
||||
if _is_minimax_model(model):
|
||||
return self._minimax_client
|
||||
if _is_deepseek_model(model):
|
||||
return self._deepseek_client
|
||||
if _is_anthropic_model(model):
|
||||
return self._anthropic_client
|
||||
if _is_google_model(model):
|
||||
return self._google_client
|
||||
return self._openai_client
|
||||
if _is_openai_model(model):
|
||||
return self._openai_client
|
||||
return None
|
||||
|
||||
def can_serve(self, model: str) -> bool:
|
||||
"""Return ``True`` only if the provider client for *model* exists.
|
||||
@@ -1512,6 +1673,7 @@ class CloudEngine(InferenceEngine):
|
||||
or self._google_client is not None
|
||||
or self._openrouter_client is not None
|
||||
or self._minimax_client is not None
|
||||
or self._deepseek_client is not None
|
||||
or self._codex_client is not None
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,62 @@ 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``.
|
||||
|
||||
Raised above Ollama's 4k default so an image (which costs many tokens)
|
||||
plus a real conversation fit. 16k is comfortable for small models on a
|
||||
typical consumer GPU.
|
||||
"""
|
||||
try:
|
||||
return int(os.environ.get("JARVIS_NUM_CTX", "16384"))
|
||||
except ValueError:
|
||||
return 16384
|
||||
|
||||
|
||||
@EngineRegistry.register("ollama")
|
||||
class OllamaEngine(InferenceEngine):
|
||||
@@ -73,7 +129,7 @@ class OllamaEngine(InferenceEngine):
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
"num_ctx": kwargs.get("num_ctx", 8192),
|
||||
"num_ctx": kwargs.get("num_ctx", _default_num_ctx()),
|
||||
},
|
||||
}
|
||||
# Disable extended thinking by default (Qwen3.5 etc.).
|
||||
@@ -155,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)
|
||||
@@ -170,7 +231,8 @@ class OllamaEngine(InferenceEngine):
|
||||
),
|
||||
}
|
||||
)
|
||||
result["tool_calls"] = tool_calls
|
||||
if tool_calls:
|
||||
result["tool_calls"] = tool_calls
|
||||
return result
|
||||
|
||||
async def stream(
|
||||
@@ -189,7 +251,7 @@ class OllamaEngine(InferenceEngine):
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
"num_ctx": kwargs.get("num_ctx", 8192),
|
||||
"num_ctx": kwargs.get("num_ctx", _default_num_ctx()),
|
||||
},
|
||||
}
|
||||
# Mirror generate()'s default: disable extended thinking unless the
|
||||
@@ -268,7 +330,7 @@ class OllamaEngine(InferenceEngine):
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
"num_ctx": kwargs.get("num_ctx", 8192),
|
||||
"num_ctx": kwargs.get("num_ctx", _default_num_ctx()),
|
||||
},
|
||||
}
|
||||
if "think" not in kwargs:
|
||||
@@ -327,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,
|
||||
@@ -346,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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user