mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-16 18:02:02 +00:00
Compare commits
72
Commits
v1.0.0
...
desktop-edge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e8db280e4 | ||
|
|
e3f2b008d2 | ||
|
|
cdae426d48 | ||
|
|
ae9727599b | ||
|
|
a86d022947 | ||
|
|
8e6bc343d8 | ||
|
|
56c9a59f8d | ||
|
|
bed3524b62 | ||
|
|
eb5d1e467f | ||
|
|
b43f4d2291 | ||
|
|
658effb964 | ||
|
|
0c34817ecd | ||
|
|
c06633e26f | ||
|
|
deb0dd509c | ||
|
|
a76319841a | ||
|
|
cf8d3e2cbe | ||
|
|
bced6cc274 | ||
|
|
612d3e1f88 | ||
|
|
58f7b753bf | ||
|
|
158fc83feb | ||
|
|
52e830e1a4 | ||
|
|
0483f5377d | ||
|
|
2d6d6ed7bd | ||
|
|
71c5b2bce1 | ||
|
|
440915c822 | ||
|
|
c84f1fddee | ||
|
|
fecbc51767 | ||
|
|
07b6cba6df | ||
|
|
7b6fe86c91 | ||
|
|
a6f18dff43 | ||
|
|
5a74fd82b8 | ||
|
|
9d0bb25516 | ||
|
|
e963321114 | ||
|
|
363bdbfa41 | ||
|
|
75cb70526e | ||
|
|
e087773b36 | ||
|
|
9c9c883f24 | ||
|
|
ef267abcc1 | ||
|
|
5f685a2e3b | ||
|
|
1d37528637 | ||
|
|
86c42e4e4a | ||
|
|
a65cd12ea2 | ||
|
|
36482b1a27 | ||
|
|
333cb9a370 | ||
|
|
4c37aa83df | ||
|
|
c8f1b3c54a | ||
|
|
855ed51780 | ||
|
|
4652b6e8a8 | ||
|
|
30f8d57398 | ||
|
|
9a42e62172 | ||
|
|
90f009e906 | ||
|
|
0217a901dd | ||
|
|
c7f451fbc8 | ||
|
|
30ac635e83 | ||
|
|
31efd9e672 | ||
|
|
8b5a304222 | ||
|
|
df566d4186 | ||
|
|
4177b5c50e | ||
|
|
f21eec6c86 | ||
|
|
4a7817509b | ||
|
|
2b0e6e7130 | ||
|
|
289fcb00e2 | ||
|
|
4668ea0b7f | ||
|
|
a58d6b6c48 | ||
|
|
af21bc18ea | ||
|
|
fea8d3e872 | ||
|
|
5b847cd081 | ||
|
|
a5029e297f | ||
|
|
9af0dfe336 | ||
|
|
b863cbb07b | ||
|
|
7081be7bd3 | ||
|
|
df4332b0e8 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "59,867",
|
||||
"message": "77,239",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 59867,
|
||||
"last_updated": "2026-05-16T06:49:33Z",
|
||||
"total_clones": 77239,
|
||||
"last_updated": "2026-05-25T07:37:53Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -50,6 +50,16 @@
|
||||
"2026-05-11": 1008,
|
||||
"2026-05-12": 1390,
|
||||
"2026-05-13": 1397,
|
||||
"2026-05-14": 846
|
||||
"2026-05-14": 846,
|
||||
"2026-05-15": 1671,
|
||||
"2026-05-16": 2264,
|
||||
"2026-05-17": 654,
|
||||
"2026-05-18": 1425,
|
||||
"2026-05-19": 850,
|
||||
"2026-05-20": 954,
|
||||
"2026-05-21": 1605,
|
||||
"2026-05-22": 612,
|
||||
"2026-05-23": 2437,
|
||||
"2026-05-24": 4900
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Auto-tag on main push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute dev version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Base version is the next patch above whatever is in pyproject.toml.
|
||||
# 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"
|
||||
exit 1
|
||||
fi
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
BUILD=$(git rev-list --count HEAD)
|
||||
VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}.dev${BUILD}"
|
||||
TAG="v${VERSION}"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed ${TAG} (base=${BASE})"
|
||||
|
||||
- name: Create and push tag
|
||||
id: tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
EXISTING_SHA=$(git rev-parse "$TAG")
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
if [[ "$EXISTING_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "::error::Tag $TAG already exists at $EXISTING_SHA but HEAD is $HEAD_SHA"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag $TAG already exists at HEAD, skipping creation"
|
||||
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
echo "Created and pushed $TAG"
|
||||
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Tag pushes made with the default GITHUB_TOKEN do NOT trigger other
|
||||
# workflows (recursion prevention). workflow_dispatch is the documented
|
||||
# exception, so we explicitly dispatch the downstream CD workflows here.
|
||||
# See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication
|
||||
- name: Dispatch downstream workflows
|
||||
if: steps.tag.outputs.created == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Dispatching pypi-publish.yml @ ${TAG}"
|
||||
gh workflow run pypi-publish.yml \
|
||||
--ref "${TAG}" \
|
||||
-f tag="${TAG}"
|
||||
echo "Dispatching desktop.yml @ ${TAG}"
|
||||
gh workflow run desktop.yml \
|
||||
--ref "${TAG}" \
|
||||
-f tag="${TAG}"
|
||||
@@ -72,12 +72,64 @@ jobs:
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
timeout-minutes: 5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-xml
|
||||
path: coverage.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
# Windows job — empirically exercises the platform-specific code paths that
|
||||
# the Ubuntu `test` job can never reach: GlobalMemoryStatusEx RAM detection
|
||||
# (#373) and the cp9xx -> UTF-8 stdout reconfigure (#293). Also the only CI
|
||||
# job that builds + imports the mandatory `openjarvis_rust` PyO3 extension
|
||||
# on Windows. Public repo -> Windows runner minutes are free.
|
||||
#
|
||||
# All `run:` steps use static commands only (no `github.event.*`
|
||||
# interpolation), so there is no workflow-injection surface here.
|
||||
test-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra server
|
||||
|
||||
# Pure-Python check — runs before the Rust build so a flaky toolchain
|
||||
# install can never mask the actual RAM-detection verification.
|
||||
- name: Verify Windows RAM detection (#373)
|
||||
shell: bash
|
||||
run: |
|
||||
uv run python -c "from openjarvis.core.config import _total_ram_gb; ram = _total_ram_gb(); print(f'GlobalMemoryStatusEx RAM = {ram} GB'); assert ram > 0, f'Windows RAM detection returned {ram}, expected > 0'"
|
||||
|
||||
- name: Run Windows-specific tests (hardware + CLI)
|
||||
shell: bash
|
||||
run: |
|
||||
uv run pytest tests/hardware/test_hardware_profiles.py tests/cli/test_cli.py -v -m "not live and not cloud"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Build + import the PyO3 extension on Windows
|
||||
shell: bash
|
||||
run: |
|
||||
uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
|
||||
uv run python -c "import openjarvis_rust; print('openjarvis_rust imports on Windows OK')"
|
||||
|
||||
- name: Smoke-test CLI
|
||||
shell: bash
|
||||
run: |
|
||||
uv run jarvis --version
|
||||
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
||||
@@ -2,11 +2,8 @@ name: Desktop Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/desktop.yml'
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'desktop-v*'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -14,9 +11,14 @@ on:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/desktop.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to build (e.g. v1.0.2.dev500). If set, autotag dispatches use this. github.ref still controls the checkout.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: desktop-${{ github.ref }}
|
||||
group: desktop-${{ inputs.tag || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -64,23 +66,27 @@ jobs:
|
||||
- name: Create frontend dist stub
|
||||
run: mkdir -p frontend/dist && echo '<html><body></body></html>' > frontend/dist/index.html
|
||||
|
||||
- name: Cargo check
|
||||
# `cargo test` builds the crate (same coverage as the old `cargo check`)
|
||||
# and runs the unit tests, including the #331 uv-sync error-formatting
|
||||
# helpers. Static command, no untrusted input — no injection surface.
|
||||
- name: Cargo test
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo check
|
||||
run: cargo test
|
||||
|
||||
# Remove stale artifacts from the desktop-latest pre-release so that
|
||||
# only the current build's files are available for download.
|
||||
# Remove stale artifacts from the desktop-edge rolling pre-release so that
|
||||
# only the current build's files are available for download. (The stable
|
||||
# `desktop-latest` channel the installed app polls is never cleaned here.)
|
||||
clean-release:
|
||||
needs: [validate]
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete old assets from desktop-latest
|
||||
- name: Delete old assets from desktop-edge
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="desktop-latest"
|
||||
TAG="desktop-edge"
|
||||
# List all asset IDs on the release and delete them
|
||||
ASSET_IDS=$(gh api "repos/${{ github.repository }}/releases/tags/${TAG}" \
|
||||
--jq '.assets[].id' 2>/dev/null || true)
|
||||
@@ -156,14 +162,46 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/desktop-v* ]]; then
|
||||
# Explicit stable desktop release tag
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#desktop-v}"
|
||||
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop ${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
# Auto-tagged rolling build from autotag.yml — use the same
|
||||
# version as the CLI/PyPI release so all surfaces stay in sync.
|
||||
# Rolling/dev builds go to the `desktop-edge` channel, NOT the
|
||||
# `desktop-latest` channel the installed app polls — so users on
|
||||
# stable are never auto-updated onto an unvetted dev build.
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
echo "tag=desktop-edge" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Edge Build)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=desktop-latest" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Latest Build)" >> "$GITHUB_OUTPUT"
|
||||
# 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/')
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
BUILD=$(git rev-list --count HEAD)
|
||||
VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}.dev${BUILD}"
|
||||
# Manual dispatches are also dev builds -> the edge channel.
|
||||
echo "tag=desktop-edge" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Edge Build)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
# Tauri's build script requires strict SemVer (MAJOR.MINOR.PATCH[-pre][+build]).
|
||||
# PEP 440 dev releases (`1.0.2.dev661`) are NOT valid SemVer, so we
|
||||
# translate `.devN` to the SemVer-equivalent `-dev.N` prerelease form.
|
||||
# PyPI keeps the PEP 440 form; only the Tauri bundle uses SemVer.
|
||||
TAURI_VERSION="${VERSION/.dev/-dev.}"
|
||||
echo "tauri_version=${TAURI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure Apple signing
|
||||
if: runner.os == 'macOS'
|
||||
@@ -199,7 +237,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_CONFIG: '{"bundle":{"externalBin":["binaries/ollama"]}}'
|
||||
TAURI_CONFIG: '{"version":"${{ steps.release-info.outputs.tauri_version }}","bundle":{"externalBin":["binaries/ollama"]}}'
|
||||
with:
|
||||
projectPath: frontend
|
||||
tauriScript: npx tauri
|
||||
@@ -210,3 +248,43 @@ jobs:
|
||||
prerelease: ${{ steps.release-info.outputs.prerelease }}
|
||||
includeUpdaterJson: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# When a stable `desktop-v*` release is published, repoint the
|
||||
# `desktop-latest` auto-update channel (the endpoint the installed app
|
||||
# polls) at it. The stable release's own `latest.json` already references
|
||||
# this release's signed assets, so we copy it verbatim — installed apps are
|
||||
# only ever offered vetted stable builds, never `desktop-edge` dev builds.
|
||||
refresh-stable-channel:
|
||||
needs: [build-and-release]
|
||||
if: startsWith(github.ref, 'refs/tags/desktop-v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror stable latest.json into desktop-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
STABLE_TAG: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The stable release's updater manifest may take a moment to become
|
||||
# downloadable after tauri-action publishes it; retry briefly.
|
||||
URL="https://github.com/${REPO}/releases/download/${STABLE_TAG}/latest.json"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if curl -fsSL -o latest.json "$URL"; then
|
||||
echo "Fetched ${STABLE_TAG}/latest.json on attempt ${attempt}"
|
||||
break
|
||||
fi
|
||||
echo "latest.json not ready yet (attempt ${attempt}); sleeping 15s"
|
||||
sleep 15
|
||||
done
|
||||
test -s latest.json || { echo "::error::Could not fetch ${URL}"; exit 1; }
|
||||
# Ensure the channel release exists (prerelease so it never usurps
|
||||
# the stable "Latest" badge), then replace its manifest in place.
|
||||
if ! gh release view desktop-latest --repo "$REPO" >/dev/null 2>&1; then
|
||||
gh release create desktop-latest --repo "$REPO" \
|
||||
--prerelease \
|
||||
--title "Desktop Auto-Update Channel" \
|
||||
--notes "Auto-update channel pointer for the desktop app. Mirrors the latest stable \`desktop-v*\` release; the in-app updater polls this \`latest.json\`. Download the app from the latest stable release, not here."
|
||||
fi
|
||||
gh release upload desktop-latest latest.json --repo "$REPO" --clobber
|
||||
echo "desktop-latest now mirrors ${STABLE_TAG}"
|
||||
|
||||
@@ -7,6 +7,11 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to publish (e.g. v1.0.2.dev500). Overrides github.ref.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,11 +22,70 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- name: Resolve target ref
|
||||
id: ref
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
DEFAULT_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$INPUT_TAG" ]]; then
|
||||
echo "ref=${INPUT_TAG}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=${DEFAULT_REF}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend and bundle into package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd frontend
|
||||
npm ci
|
||||
# Vite is configured (frontend/vite.config.ts) with
|
||||
# `outDir: '../src/openjarvis/server/static'` and
|
||||
# `emptyOutDir: true`, so the build writes directly into the
|
||||
# Python package's static dir and clears stale assets itself.
|
||||
# No rm/cp is needed — and the previous `dist/`-assuming logic
|
||||
# was broken because `frontend/dist/` is never produced.
|
||||
npm run build
|
||||
STATIC=../src/openjarvis/server/static
|
||||
test -s "$STATIC/index.html" || {
|
||||
echo "::error::${STATIC}/index.html missing or empty after build"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Set 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)
|
||||
VERSION="${REF#v}"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "::error::Could not resolve version from ref '$REF'"
|
||||
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"
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
|
||||
+8
-1
@@ -48,7 +48,11 @@ Thumbs.db
|
||||
*.log
|
||||
results/
|
||||
logs/
|
||||
traces/
|
||||
# Anchored to repo root — DO NOT use the unanchored form `traces/`.
|
||||
# hatchling honors .gitignore when building the wheel; an unanchored
|
||||
# `traces/` pattern matches src/openjarvis/traces/ and silently drops
|
||||
# the runtime module from the wheel (issue #372).
|
||||
/traces/
|
||||
coding_task_*
|
||||
get-pip.py
|
||||
# Junk from mocked-path tests that write to their mock's __repr__ as a path
|
||||
@@ -99,6 +103,9 @@ src/openjarvis/channels/whatsapp_baileys_bridge/node_modules/
|
||||
# SQLite in-memory artifacts
|
||||
:memory:
|
||||
|
||||
# Dogfood reports (regenerated locally; not for VCS)
|
||||
dogfood_report*.md
|
||||
|
||||
# Second Repos
|
||||
Inline/
|
||||
scratch/
|
||||
|
||||
+132
@@ -8,6 +8,138 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.2] - 2026-05-24
|
||||
|
||||
A patch release that fixes a packaging bug which broke the v1.0.1
|
||||
wheel on PyPI, silences a noisy startup warning, restores a working
|
||||
install path while `openjarvis.ai` is down, improves desktop
|
||||
first-boot diagnostics on Windows, and ships the RAM-detection fix
|
||||
for Windows that missed the v1.0.1 cutoff.
|
||||
|
||||
### Fixed
|
||||
|
||||
**`openjarvis/traces/` missing from the v1.0.1 PyPI wheel** (#372).
|
||||
The `.gitignore` carried an unanchored `traces/` pattern, which
|
||||
hatchling honored at wheel-build time and matched the runtime module
|
||||
`src/openjarvis/traces/` — silently dropping the whole package. Every
|
||||
fresh `pip install openjarvis==1.0.1` then failed at import with
|
||||
`ModuleNotFoundError: No module named 'openjarvis.traces'` on the
|
||||
first `jarvis ask`, learning, or server call. Anchored the pattern to
|
||||
`/traces/`. Verified: a clean `uv build` now produces a wheel
|
||||
containing all four `traces/` files.
|
||||
|
||||
**`pynvml` deprecation `FutureWarning` on every command** (#389).
|
||||
Switched the dependency from the legacy `pynvml` package to NVIDIA's
|
||||
official `nvidia-ml-py` (same `pynvml` module name, no warning shim),
|
||||
and added defensive `warnings.filterwarnings` at every `import pynvml`
|
||||
site to suppress the warning even when `pynvml` is pulled in
|
||||
transitively.
|
||||
|
||||
**Windows RAM detection returning `0.0 GB`** (#373). The Windows
|
||||
branch of `_total_ram_gb()` (via `GlobalMemoryStatusEx`) landed after
|
||||
the v1.0.1 cutoff, so v1.0.1 users still saw `0.0 GB` from `jarvis
|
||||
init`. Now shipping in the wheel. A new `windows-latest` CI job runs
|
||||
the real `GlobalMemoryStatusEx` path on every PR as a regression
|
||||
guard.
|
||||
|
||||
**Desktop first-boot hung on "did not become healthy in time"**
|
||||
(#331). The Tauri boot path ran `uv sync` with stderr discarded and
|
||||
the exit code ignored, so a failed dependency install surfaced only
|
||||
as a generic 600-second health-check timeout. Now captures stderr,
|
||||
checks the exit status, and surfaces the actual `uv sync` error
|
||||
(with the diagnostic tail) before the long wait. The error-formatting
|
||||
logic is covered by unit tests.
|
||||
|
||||
### Changed
|
||||
|
||||
**Install URL moved to GitHub Pages** (#337, #352). The documented
|
||||
`openjarvis.ai/install.sh` URL was failing with `sslv3 alert
|
||||
handshake failure` (the domain is community-operated and had a broken
|
||||
TLS config). The canonical installer is now served from the
|
||||
project-controlled GitHub Pages site at
|
||||
`https://open-jarvis.github.io/OpenJarvis/install.sh`, generated from
|
||||
the same `scripts/install/install.sh` at docs-build time. The README
|
||||
also documents the WSL2 path for Windows and the `uv` prerequisite
|
||||
for the desktop binary, and the installer bails early with a clear
|
||||
message when run under Git Bash / MSYS2 / Cygwin.
|
||||
|
||||
## [1.0.1] - 2026-05-17
|
||||
|
||||
A patch release that closes the auto-update gap so the analytics
|
||||
module added in #351 actually reaches users on the desktop, adds
|
||||
runtime opt-out for that analytics, fixes the misleading upgrade
|
||||
hint the CLI was printing, and lands the ACE optimizer alongside
|
||||
DSPy and GEPA.
|
||||
|
||||
### Added
|
||||
|
||||
**ACE agent optimizer** (`learning/agents/ace_optimizer.py`). Adds
|
||||
[ACE](https://github.com/ace-agent/ace) as a third agent-learning
|
||||
policy alongside DSPy and GEPA. Where DSPy bootstraps few-shot
|
||||
examples and GEPA evolves prompt populations, ACE evolves a textual
|
||||
*playbook* of strategies the agent reads at inference time, updated
|
||||
by a Generator / Reflector / Curator triad. Pick via
|
||||
`[learning.agent] policy = "ace"`. Setup is manual (ACE isn't on
|
||||
PyPI and isn't a properly-packaged Python project as of v1.0.1) —
|
||||
see `docs/learning/ace.md` for the install path and trace-adapter
|
||||
behavior.
|
||||
|
||||
**`jarvis self-update`** subcommand. Detects how OpenJarvis was
|
||||
installed (pip, uv tool, editable git checkout) by inspecting
|
||||
`openjarvis.__file__`, then runs the right upgrade command. Supports
|
||||
`--check` (print the command without running) and `-y` (skip the
|
||||
confirmation prompt). The post-command "new version available" hint
|
||||
now points users at this command instead of guessing at the right
|
||||
flow.
|
||||
|
||||
**Desktop auto-update endpoint wired to the rolling
|
||||
`desktop-latest` GitHub release.** The Tauri updater plugin was
|
||||
configured on the build side (`createUpdaterArtifacts: true`,
|
||||
`includeUpdaterJson: true`, signing key in `TAURI_SIGNING_PRIVATE_KEY`)
|
||||
but inert on the runtime side (`active: false`, `endpoints: []`). The
|
||||
installed desktop app would never check. Both are now fixed; the app
|
||||
polls `releases/download/desktop-latest/latest.json` every 30 minutes
|
||||
and signature-verifies downloads against the minisign pubkey baked
|
||||
into the app. Full flow, key-rotation runbook, and dev escape hatch
|
||||
(`OPENJARVIS_NO_UPDATER=1`) documented in `docs/desktop-auto-update.md`.
|
||||
|
||||
**Analytics env-var opt-out** (`DO_NOT_TRACK`, `OPENJARVIS_NO_ANALYTICS`).
|
||||
Tanvir's analytics module (#351) only respected the
|
||||
`[analytics] enabled` config-file setting. Both env vars are now
|
||||
honored in `is_analytics_enabled()` and in the install.sh beacon
|
||||
script. Any truthy value (`1`, `true`, `yes`, `on`) disables for
|
||||
that process; env opt-out takes precedence over the config file.
|
||||
Documented under a new "Opting out" section in `docs/telemetry.md`.
|
||||
|
||||
### Changed
|
||||
|
||||
**Version-check trigger widened.** The "new version available" hint
|
||||
in `_version_check.py` used to fire only on `{ask, chat, serve}` and
|
||||
hardcoded the wrong upgrade command (`git pull && uv sync` — only
|
||||
correct for editable installs). Now fires on every interactive
|
||||
command (`doctor`, `init`, `quickstart`, `model`, `agents`, `skill`,
|
||||
`memory`, `bench`, `telemetry`, `config`, `eval`, `optimize`, plus
|
||||
the original three) and uses install-detection to print the right
|
||||
upgrade command. Honors `JARVIS_NO_UPDATE_CHECK=1` and `CI=true` to
|
||||
stay silent in automation.
|
||||
|
||||
**Desktop app version bumped 0.1.0 → 1.0.1** across
|
||||
`tauri.conf.json`, `frontend/package.json`, and
|
||||
`frontend/src-tauri/Cargo.toml` so the Python and desktop release
|
||||
streams are aligned and the auto-updater has a real version to
|
||||
compare against.
|
||||
|
||||
### Migration from 1.0.0
|
||||
|
||||
- **Importing `is_analytics_enabled`?** Same signature; behavior now
|
||||
short-circuits on env opt-out before checking the config. Callers
|
||||
that want the raw "is the config flag set" semantic should read
|
||||
`cfg.enabled` directly.
|
||||
- **Editable-git users running `jarvis self-update`** get the
|
||||
detected `git pull && uv sync` command pointed at their actual
|
||||
checkout, not `~/OpenJarvis`. If you'd come to rely on the
|
||||
hardcoded path, update your muscle memory.
|
||||
|
||||
## [1.0.0] - 2026-05-15
|
||||
|
||||
The five-primitive architecture (Intelligence, Engine, Agents,
|
||||
|
||||
-1
Submodule Inline deleted from 03673aaa42
@@ -9,6 +9,7 @@
|
||||
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
|
||||
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
|
||||
<a href="https://discord.gg/YZZRxCAhmm"><img src="https://img.shields.io/badge/discord-join-7289da?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/OpenJarvisAI"><img src="https://img.shields.io/badge/X-@OpenJarvisAI-black?logo=x&logoColor=white" alt="X / Twitter"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -26,15 +27,31 @@
|
||||
|
||||
Personal AI agents are exploding in popularity, but nearly all of them still route intelligence through cloud APIs. Your "personal" AI continues to depend on someone else's server. At the same time, our [Intelligence Per Watt](https://www.intelligence-per-watt.ai/) research showed that local language models already handle 88.7% of single-turn chat and reasoning queries, with intelligence efficiency improving 5.3× from 2023 to 2025. The models and hardware are increasingly ready. What has been missing is the software stack to make local-first personal AI practical.
|
||||
|
||||
OpenJarvis is that stack. It is an opinionated framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
OpenJarvis is that stack. It is a framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
|
||||
## Installation
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
|
||||
That's it. The installer handles everything: uv, the Python venv, Ollama, and pulling a small starter model. About 3 minutes on a typical broadband connection. Then:
|
||||
The installer handles everything for you — including [uv](https://docs.astral.sh/uv/), the Python venv, Ollama, and a small starter model. You don't need to install anything first.
|
||||
|
||||
**Windows:** the installer is a `bash` script and won't run in PowerShell or `cmd`. Pick one of:
|
||||
|
||||
- **WSL2 (recommended for the CLI / Python SDK)** — one-time setup in an admin PowerShell, then run the same `curl … | bash` inside Ubuntu:
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-24.04
|
||||
```
|
||||
Open the Ubuntu shell that gets installed, then follow [WSL2 install instructions](https://open-jarvis.github.io/OpenJarvis/getting-started/wsl2/).
|
||||
- **Desktop app** — download the [Windows installer (`.exe`)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe) from the latest [desktop release](https://github.com/open-jarvis/OpenJarvis/releases/tag/desktop-v1.0.2) (macOS `.dmg` and Linux `.deb`/`.rpm`/`.AppImage` are there too) for the GUI experience, no terminal required. **Prerequisite:** the desktop app expects [uv](https://docs.astral.sh/uv/) to be installed already — if it isn't, install it first in PowerShell, then launch the app:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
About 3 minutes on a typical broadband connection. Then:
|
||||
|
||||
```bash
|
||||
jarvis
|
||||
@@ -42,14 +59,14 @@ jarvis
|
||||
|
||||
The Rust extension and bigger models continue downloading in the background while you chat. Run `jarvis doctor` to see status.
|
||||
|
||||
**Platforms:** macOS (Intel + Apple Silicon), Linux, WSL2 on Windows.
|
||||
**Platforms:** macOS (Intel + Apple Silicon), Linux, WSL2 on Windows. Native Windows is not supported — use WSL2 or the desktop binary.
|
||||
|
||||
**Manual install / contributors:** see [docs/getting-started/install.md](docs/getting-started/install.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
jarvis
|
||||
```
|
||||
|
||||
@@ -112,6 +129,8 @@ See the [Skills User Guide](https://open-jarvis.github.io/OpenJarvis/user-guide/
|
||||
|
||||
### Built-in Agents
|
||||
|
||||
OpenJarvis ships with eight built-in agents across three execution modes (on-demand, scheduled, continuous):
|
||||
|
||||
| Agent | Type | What it does |
|
||||
|-------|------|-------------|
|
||||
| `morning_digest` | Scheduled | Daily briefing from email, calendar, health, news — with TTS audio |
|
||||
@@ -127,6 +146,13 @@ See the [User Guide](https://open-jarvis.github.io/OpenJarvis/user-guide/morning
|
||||
|
||||
Full documentation — including Docker deployment, cloud engines, development setup, and tutorials — at **[open-jarvis.github.io/OpenJarvis](https://open-jarvis.github.io/OpenJarvis/)**.
|
||||
|
||||
## Community
|
||||
|
||||
- **GitHub:** [github.com/open-jarvis/OpenJarvis](https://github.com/open-jarvis/OpenJarvis)
|
||||
- **Discord:** [discord.gg/YZZRxCAhmm](https://discord.gg/YZZRxCAhmm)
|
||||
- **X / Twitter:** [@OpenJarvisAI](https://x.com/OpenJarvisAI)
|
||||
- **Docs:** [open-jarvis.github.io/OpenJarvis](https://open-jarvis.github.io/OpenJarvis/)
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! See the [Contributing Guide](CONTRIBUTING.md) for incentives, contribution types, and the PR process.
|
||||
@@ -145,7 +171,7 @@ Browse the [Roadmap](https://open-jarvis.github.io/OpenJarvis/development/roadma
|
||||
|
||||
## About
|
||||
|
||||
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the efficiency of on-device AI systems. The project is developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
|
||||
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the intelligence efficiency of AI systems. The project is developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -161,11 +187,14 @@ OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.
|
||||
|
||||
## Citation
|
||||
```bibtex
|
||||
@misc{saadfalcon2026openjarvis,
|
||||
title={OpenJarvis: Personal AI, On Personal Devices},
|
||||
author={Jon Saad-Falcon and Avanika Narayan and Herumb Shandilya and Hakki Orhun Akengin and Robby Manihani and Gabriel Bo and John Hennessy and Christopher R\'{e} and Azalia Mirhoseini},
|
||||
year={2026},
|
||||
howpublished={\url{https://scalingintelligence.stanford.edu/blogs/openjarvis/}},
|
||||
@misc{saadfalcon2026openjarvispersonalaipersonal,
|
||||
title={OpenJarvis: Personal AI, On Personal Devices},
|
||||
author={Jon Saad-Falcon and Avanika Narayan and Robby Manihani and Tanvir Bhathal and Herumb Shandilya and Hakki Orhun Akengin and Gabriel Bo and Andrew Park and Matthew Hart and Caia Costello and Chuan Li and Christopher Ré and Azalia Mirhoseini},
|
||||
year={2026},
|
||||
eprint={2605.17172},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.LG},
|
||||
url={https://arxiv.org/abs/2605.17172},
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# posthog-hetzner-prep.sh — one-shot Hetzner Cloud server prep for
|
||||
# OpenJarvis's self-hosted PostHog analytics backend.
|
||||
#
|
||||
# Run this on a fresh Ubuntu 22.04+ box (Hetzner CCX23 in Ashburn, or
|
||||
# similar) after pointing the desired domain at it. Idempotent: safe
|
||||
# to re-run if a step fails partway.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash posthog-hetzner-prep.sh analytics.openjarvis.ai you@openjarvis.ai
|
||||
#
|
||||
# After it finishes:
|
||||
# 1. Visit https://<DOMAIN>/ and create the admin account.
|
||||
# 2. Create project "OpenJarvis".
|
||||
# 3. Settings → Project → grab the Project API Key (phc_…).
|
||||
# 4. Update src/openjarvis/core/config.py AnalyticsConfig defaults:
|
||||
# host = "https://<DOMAIN>"
|
||||
# key = "phc_<new>"
|
||||
# 5. Ship a release. Frontend + backend + install.sh all read those
|
||||
# same defaults via load_config().
|
||||
#
|
||||
# Cost: ~$35/mo on Hetzner CCX23 (4 dedicated vCPU / 16 GB / 240 GB
|
||||
# NVMe) in US-East. Add Hetzner Cloud Backups (+20%) for production.
|
||||
#
|
||||
# Retention: post-install, set 365 days in PostHog UI →
|
||||
# Settings → Data Management → Event ingestion → Data retention.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- args ----
|
||||
if [[ $# -lt 2 ]]; then
|
||||
cat >&2 <<'USAGE'
|
||||
posthog-hetzner-prep.sh: missing arguments.
|
||||
|
||||
Usage:
|
||||
sudo bash posthog-hetzner-prep.sh <domain> <admin_email>
|
||||
|
||||
Examples:
|
||||
sudo bash posthog-hetzner-prep.sh analytics.openjarvis.ai team@openjarvis.ai
|
||||
|
||||
The domain must already resolve to this box (DNS A record) before
|
||||
the script runs — Let's Encrypt needs to reach this server on port 80.
|
||||
USAGE
|
||||
exit 2
|
||||
fi
|
||||
|
||||
DOMAIN="$1"
|
||||
ADMIN_EMAIL="$2"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "posthog-hetzner-prep.sh: must be run as root (use sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- step 1: system prep ----
|
||||
echo "[1/5] apt update + base packages..."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ufw ca-certificates gnupg \
|
||||
apt-transport-https software-properties-common
|
||||
|
||||
# ---- step 2: firewall ----
|
||||
echo "[2/5] firewall (UFW): 22/80/443 only..."
|
||||
ufw --force reset
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow 22/tcp
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw --force enable
|
||||
|
||||
# ---- step 3: swap (helps ClickHouse under load spikes) ----
|
||||
echo "[3/5] swap..."
|
||||
if [[ ! -f /swapfile ]]; then
|
||||
fallocate -l 4G /swapfile
|
||||
chmod 600 /swapfile
|
||||
mkswap /swapfile
|
||||
swapon /swapfile
|
||||
if ! grep -q "/swapfile" /etc/fstab; then
|
||||
echo "/swapfile none swap sw 0 0" >> /etc/fstab
|
||||
fi
|
||||
else
|
||||
echo " /swapfile already present"
|
||||
fi
|
||||
|
||||
# ---- step 4: docker ----
|
||||
echo "[4/5] docker..."
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
else
|
||||
echo " docker already installed"
|
||||
fi
|
||||
systemctl enable --now docker
|
||||
|
||||
# ---- step 5: posthog hobby deploy ----
|
||||
echo "[5/5] PostHog Hobby Deploy..."
|
||||
echo
|
||||
echo " Domain: $DOMAIN"
|
||||
echo " Admin email: $ADMIN_EMAIL"
|
||||
echo " DNS A record: verify it points to $(curl -fsS -m 5 https://api.ipify.org 2>/dev/null || echo "<this server>")"
|
||||
echo
|
||||
|
||||
# PostHog's official one-liner. It writes a .env file with random
|
||||
# secrets, configures Caddy with Let's Encrypt TLS for the domain,
|
||||
# and brings up the full stack via docker-compose.
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/deploy-hobby)" -s -- \
|
||||
--domain "$DOMAIN" \
|
||||
--email "$ADMIN_EMAIL" || {
|
||||
echo
|
||||
echo "PostHog deploy script exited with an error. Common causes:"
|
||||
echo " - DNS for $DOMAIN doesn't resolve to this server yet (wait + retry)"
|
||||
echo " - Port 80 not reachable from the public internet (firewall / cloud SG)"
|
||||
echo " - Out of disk on /var/lib/docker (need 20+ GB free)"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
cat <<EOF
|
||||
|
||||
================================================================
|
||||
PostHog is up at https://$DOMAIN/
|
||||
|
||||
Next steps:
|
||||
1. Open https://$DOMAIN/ in a browser.
|
||||
2. Create the first admin account (any email, your password).
|
||||
3. Create project "OpenJarvis".
|
||||
4. Settings → Project → Project API Key — copy the phc_… value.
|
||||
5. Update src/openjarvis/core/config.py AnalyticsConfig defaults:
|
||||
host = "https://$DOMAIN"
|
||||
key = "phc_<the-new-key>"
|
||||
6. Settings → Data Management → set retention to 365 days.
|
||||
7. Settings → Recordings → confirm Session Replay is OFF (default).
|
||||
8. Ship a release of OpenJarvis with the new config defaults.
|
||||
|
||||
Operational notes:
|
||||
- Updates: bash <(curl -fsSL https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/upgrade-hobby)
|
||||
- Logs: docker compose -f /home/posthog/posthog/docker-compose.hobby.yml logs -f
|
||||
- Disk usage: df -h # bump VPS tier when /var/lib/docker > 70% full
|
||||
- Backups: enable Hetzner Cloud Backups in the Hetzner console
|
||||
================================================================
|
||||
EOF
|
||||
@@ -116,7 +116,7 @@ All providers produce the same output format consumed by agents:
|
||||
| **LM Studio** | `lmstudio` | OpenAI-compatible | 1234 | No (GPU optional) | Desktop GUI, easy model management |
|
||||
| **Exo** | `exo` | OpenAI-compatible | 52415 | No (distributed) | Distributed inference across heterogeneous devices |
|
||||
| **Nexa** | `nexa` | OpenAI-compatible | 18181 | No (CPU/GPU) | On-device inference with GGUF models |
|
||||
| **Lemonade** | `lemonade` | OpenAI-compatible | 8000 | AMD GPU/NPU | AMD consumer GPUs (RDNA), Ryzen AI NPUs |
|
||||
| **Lemonade** | `lemonade` | OpenAI-compatible | 13305 | AMD GPU/NPU | AMD consumer GPUs (RDNA), Ryzen AI NPUs |
|
||||
| **Uzu** | `uzu` | OpenAI-compatible | 8000 | Varies | Uzu inference runtime |
|
||||
| **Apple FM** | `apple_fm` | OpenAI-compatible | 8079 | Apple Silicon | Apple Foundation Model on-device inference |
|
||||
| **LiteLLM** | `litellm` | OpenAI-compatible | — | No | Unified proxy to 100+ LLM providers |
|
||||
@@ -135,7 +135,7 @@ The Ollama backend communicates via Ollama's native HTTP API at `/api/chat` and
|
||||
|
||||
The vLLM backend uses the OpenAI-compatible `/v1/chat/completions` API. It is recommended for datacenter GPUs (NVIDIA A100, H100, L40, A10, A30 and AMD MI300, MI325, MI350, MI355).
|
||||
|
||||
- **Default host:** `http://localhost:8000`
|
||||
- **Default host:** `http://localhost:13305`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Tool fallback:** If the server returns HTTP 400 when tools are included, the engine automatically retries without tools
|
||||
|
||||
@@ -204,7 +204,7 @@ The Nexa backend connects to the Nexa SDK on-device inference server via a FastA
|
||||
|
||||
The Lemonade backend connects to the [Lemonade](https://lemonade-server.ai/) inference server, which is optimized for AMD consumer GPUs (RDNA architecture) and Ryzen AI Neural Processing Units (NPUs). It uses the OpenAI-compatible `/v1/chat/completions` API.
|
||||
|
||||
- **Default host:** `http://localhost:8000`
|
||||
- **Default host:** `http://localhost:13305`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Install:** Visit [lemonade-server.ai](https://lemonade-server.ai/) for platform-specific installation instructions
|
||||
- **Best for:** Ryzen AI GPUs and NPUs, and AMD-based desktop and laptop systems
|
||||
@@ -357,7 +357,7 @@ host = "http://localhost:30000"
|
||||
# binary_path = ""
|
||||
|
||||
# [engine.lemonade]
|
||||
# host = "http://localhost:8000"
|
||||
# host = "http://localhost:13305"
|
||||
```
|
||||
|
||||
The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settings:
|
||||
@@ -370,7 +370,7 @@ The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settin
|
||||
| `SGLangEngineConfig` | `host` | `http://localhost:30000` | SGLang server URL |
|
||||
| `LlamaCppEngineConfig` | `host` | `http://localhost:8080` | llama.cpp server URL |
|
||||
| `LlamaCppEngineConfig` | `binary_path` | `""` | Path to llama.cpp binary (for managed mode) |
|
||||
| `LemonadeEngineConfig` | `host` | `http://localhost:8000` | Lemonade server URL |
|
||||
| `LemonadeEngineConfig` | `host` | `http://localhost:13305` | Lemonade server URL |
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old flat field names `ollama_host`, `vllm_host`, `llamacpp_host`, `llamacpp_path`, `sglang_host`, and `lemonade_host` under `[engine]` are still accepted as backward-compatible properties on `EngineConfig`. New configurations should use the nested sub-section format.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Desktop auto-update
|
||||
|
||||
The OpenJarvis desktop app ships with [Tauri's updater
|
||||
plugin](https://v2.tauri.app/plugin/updater/), which checks for new
|
||||
versions on launch and every 30 minutes. When a newer signed build is
|
||||
available, the app prompts the user to download and install it.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
on launch / every 30 min
|
||||
│
|
||||
▼
|
||||
GET https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/latest.json
|
||||
│
|
||||
▼
|
||||
Parse manifest: { "version": "X.Y.Z", "platforms": { ... } }
|
||||
│
|
||||
▼
|
||||
If manifest.version > installed_version:
|
||||
download signed .dmg / .deb / .msi from manifest.platforms[target].url
|
||||
verify against the minisign pubkey baked into the app
|
||||
prompt user to install
|
||||
```
|
||||
|
||||
The frontend code lives in
|
||||
[`frontend/src/components/Desktop/UpdateChecker.tsx`](../frontend/src/components/Desktop/UpdateChecker.tsx);
|
||||
the Tauri wiring is in
|
||||
[`frontend/src-tauri/tauri.conf.json`](../frontend/src-tauri/tauri.conf.json)
|
||||
under `plugins.updater`.
|
||||
|
||||
## How releases reach the update endpoint
|
||||
|
||||
The `Desktop Build & Release` GitHub Action
|
||||
([`.github/workflows/desktop.yml`](../.github/workflows/desktop.yml))
|
||||
builds signed binaries plus a `latest.json` manifest with the
|
||||
`tauri-action` step (`includeUpdaterJson: true` generates the manifest
|
||||
automatically). Where it publishes depends on the trigger.
|
||||
|
||||
Three release streams exist:
|
||||
|
||||
- **`desktop-latest`** (stable auto-update channel): **this is the
|
||||
channel the installed app polls.** It is *not* built directly —
|
||||
instead, when a stable `desktop-vX.Y.Z` release is published, the
|
||||
`refresh-stable-channel` job copies that release's `latest.json`
|
||||
into `desktop-latest`. So the app is only ever offered vetted stable
|
||||
builds, and `latest.json` here points at the current `desktop-v*`
|
||||
assets.
|
||||
- **`desktop-vX.Y.Z`** (tagged stable): created when someone pushes a
|
||||
`desktop-v*` git tag. The user-facing stable release with full
|
||||
installers; also the source of truth the stable channel mirrors.
|
||||
- **`desktop-edge`** (rolling pre-release): rebuilt on every push to
|
||||
`main` (via the `autotag` → `desktop.yml` dispatch) and on manual
|
||||
`workflow_dispatch`. Carries the most recent CI build for testers.
|
||||
The shipped app does **not** poll this stream, so dev builds never
|
||||
auto-install onto stable users.
|
||||
|
||||
This split means security and telemetry-policy fixes reach users on
|
||||
the next **stable** `desktop-v*` tag — cut one to ship an update.
|
||||
Edge builds are available for anyone who wants to test `main` ahead of
|
||||
a stable tag, without risking the stable population.
|
||||
|
||||
## Signing
|
||||
|
||||
Binaries are signed by `tauri-action` using the minisign key pair
|
||||
referenced via these GitHub Actions secrets:
|
||||
|
||||
| Secret | Purpose |
|
||||
|---|---|
|
||||
| `TAURI_SIGNING_PRIVATE_KEY` | Private key (PEM-formatted minisign) |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Passphrase for the private key |
|
||||
|
||||
The matching public key is baked into the app at
|
||||
`tauri.conf.json:plugins.updater.pubkey`. If you ever need to rotate
|
||||
the key, replace the public key in the JSON file *and* update both
|
||||
secrets atomically — mismatched keys cause every update download to
|
||||
fail signature verification with no recovery path other than a manual
|
||||
reinstall.
|
||||
|
||||
## Disabling the updater locally
|
||||
|
||||
For frontend development, set `VITE_OPENJARVIS_NO_UPDATER=1` in your
|
||||
shell before running `npm run tauri dev`. Vite injects any
|
||||
`VITE_`-prefixed env var into `import.meta.env`, and the
|
||||
`UpdateChecker.tsx` component honors it to skip the 30-minute poll.
|
||||
|
||||
```bash
|
||||
export VITE_OPENJARVIS_NO_UPDATER=1
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
This is purely a dev escape hatch — it has no effect on production
|
||||
builds (where `import.meta.env.VITE_OPENJARVIS_NO_UPDATER` will be
|
||||
`undefined` unless you explicitly set it at build time).
|
||||
|
||||
## Verifying a release manually
|
||||
|
||||
```bash
|
||||
# Download the latest manifest and confirm it parses cleanly
|
||||
curl -fsSL https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/latest.json | jq .
|
||||
|
||||
# Fields:
|
||||
# version — semver string, must match the tag (without leading "v")
|
||||
# notes — release notes string
|
||||
# pub_date — RFC3339 timestamp
|
||||
# platforms — map keyed by "<target>-<arch>" e.g. "darwin-aarch64"
|
||||
# each entry has { signature: "...", url: "..." }
|
||||
```
|
||||
|
||||
A 404 on the manifest URL means the most recent desktop CI run
|
||||
didn't complete or didn't have signing secrets — check the
|
||||
`Desktop Build & Release` workflow logs.
|
||||
+5
-5
@@ -25,11 +25,11 @@ processing happens on your local machine — the app connects to the backend you
|
||||
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_aarch64.dmg) | M1/M2/M3/M4 Macs |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe) | Windows 10+ |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb) | Ubuntu, Debian |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm) | Fedora, RHEL |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.AppImage) | Any distro |
|
||||
| macOS (Universal) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_universal.dmg) | Apple Silicon + Intel |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe) | Windows 10+ |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.deb) | Ubuntu, Debian |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis-1.0.1-1.x86_64.rpm) | Fedora, RHEL |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.AppImage) | Any distro |
|
||||
|
||||
!!! tip "All releases"
|
||||
Browse all versions on the [GitHub Releases](https://github.com/open-jarvis/OpenJarvis/releases) page.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Publish the canonical install.sh into the docs site.
|
||||
|
||||
Serves the installer at ``https://open-jarvis.github.io/OpenJarvis/install.sh``
|
||||
so users have an HTTPS-valid, project-controlled install URL that does not
|
||||
depend on the externally-hosted ``openjarvis.ai`` domain — whose TLS config
|
||||
broke and which the project does not control (issue #337).
|
||||
|
||||
Single source of truth: the script lives at ``scripts/install/install.sh``
|
||||
(also bundled into the wheel as ``_install_scripts/``). This copies it
|
||||
verbatim into the built site at ``install.sh`` on every ``mkdocs build``,
|
||||
so the published copy can never drift from the canonical one.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import mkdocs_gen_files
|
||||
|
||||
_SRC = Path("scripts/install/install.sh")
|
||||
|
||||
with mkdocs_gen_files.open("install.sh", "wb") as dst:
|
||||
dst.write(_SRC.read_bytes())
|
||||
@@ -3,9 +3,20 @@
|
||||
OpenJarvis ships a one-line installer for macOS, Linux, and WSL2.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
|
||||
The installer downloads everything for you — including [uv](https://docs.astral.sh/uv/)
|
||||
(the Python package manager), the Python venv, Ollama, and a small starter
|
||||
model. **You don't need to install uv or any other prerequisite first.**
|
||||
|
||||
!!! info "Install URL"
|
||||
This script is served straight from the project's own GitHub Pages site,
|
||||
so HTTPS always works. You may also see `https://openjarvis.ai/install.sh`
|
||||
referenced in older docs — that domain is community-operated and has had
|
||||
intermittent TLS issues ([#337](https://github.com/open-jarvis/OpenJarvis/issues/337)).
|
||||
The `open-jarvis.github.io` URL above is the canonical one.
|
||||
|
||||
About 3 minutes on a typical broadband connection. Type `jarvis` to start chatting.
|
||||
|
||||
## What the installer does
|
||||
|
||||
@@ -94,11 +94,11 @@ cd OpenJarvis
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_aarch64.dmg) |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe) |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb) |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm) |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.AppImage) |
|
||||
| macOS (Universal) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_universal.dmg) |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe) |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.deb) |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis-1.0.1-1.x86_64.rpm) |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.AppImage) |
|
||||
|
||||
The app connects to `http://localhost:8000` automatically.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Linux Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
|
||||
Tested on: Ubuntu 22.04 / 24.04, Fedora 40, Debian 12, Arch.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# macOS Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
|
||||
Works on Intel and Apple Silicon. The installer auto-detects your CPU/GPU.
|
||||
|
||||
@@ -15,7 +15,7 @@ Then open the Ubuntu (or Debian) shell that gets installed.
|
||||
## Install OpenJarvis
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
|
||||
About 3 minutes. Type `jarvis` to start.
|
||||
|
||||
+19
-12
@@ -20,7 +20,7 @@ Build personal AI that runs on your hardware. Cloud APIs are optional.
|
||||
|
||||
Personal AI agents are exploding in popularity, but nearly all of them still route intelligence through cloud APIs. Your "personal" AI continues to depend on someone else's server. At the same time, our [Intelligence Per Watt](https://www.intelligence-per-watt.ai/) research showed that local language models already handle 88.7% of single-turn chat and reasoning queries, with intelligence efficiency improving 5.3× from 2023 to 2025. The models and hardware are increasingly ready. What has been missing is the software stack to make local-first personal AI practical.
|
||||
|
||||
OpenJarvis is that stack. It is an opinionated framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
OpenJarvis is that stack. It is a framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
|
||||
---
|
||||
|
||||
@@ -54,13 +54,15 @@ OpenJarvis is that stack. It is an opinionated framework for local-first persona
|
||||
|
||||
**Step 2.** Download and open the desktop app:
|
||||
|
||||
[Download for macOS](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_universal.dmg){ .md-button .md-button--primary }
|
||||
[Download for macOS](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_universal.dmg){ .md-button .md-button--primary }
|
||||
|
||||
Also available for [Windows](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe), [Linux (DEB)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb), and [Linux (RPM)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm). See the [Downloads](downloads.md) page for details.
|
||||
Also available for [Windows](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe), [Linux (DEB)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.deb), and [Linux (RPM)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis-1.0.1-1.x86_64.rpm). See the [Downloads](downloads.md) page for details.
|
||||
|
||||
The app connects to `http://localhost:8000` automatically.
|
||||
|
||||
!!! warning "macOS: run `xattr -cr /Applications/OpenJarvis.app` if the app shows as \"damaged\"."
|
||||
!!! warning "macOS first launch"
|
||||
|
||||
Run `xattr -cr /Applications/OpenJarvis.app` if the app shows as "damaged".
|
||||
|
||||
=== "Python SDK"
|
||||
|
||||
@@ -104,9 +106,9 @@ OpenJarvis is that stack. It is an opinionated framework for local-first persona
|
||||
OpenJarvis is built around five composable layers. Each has a clean interface and can be swapped independently.
|
||||
|
||||
1. **Intelligence** — Pick a model, or let OpenJarvis pick one for your hardware. Manages the full catalog of local models across providers.
|
||||
2. **Agents** — Multi-step reasoning with tool use. Seven built-in agent types from simple chat to orchestrated workflows.
|
||||
3. **Tools** — Web search, calculator, file I/O, code interpreter, retrieval, and any external MCP server.
|
||||
4. **Engine** — The inference runtime: [Ollama](https://ollama.com), [vLLM](https://github.com/vllm-project/vllm), [SGLang](https://github.com/sgl-project/sglang), [llama.cpp](https://github.com/ggerganov/llama.cpp), cloud APIs, and more. Auto-detects your hardware and recommends the best fit.
|
||||
2. **Engine** — The inference runtime: [Ollama](https://ollama.com), [vLLM](https://github.com/vllm-project/vllm), [SGLang](https://github.com/sgl-project/sglang), [llama.cpp](https://github.com/ggerganov/llama.cpp), cloud APIs, and more. Auto-detects your hardware and recommends the best fit.
|
||||
3. **Agents** — Multi-step reasoning with tool use. Eight built-in agent types from simple chat to orchestrated workflows.
|
||||
4. **Tools & Memory** — Web search, calculator, file I/O, code interpreter, retrieval, persistent local state, and any external MCP server.
|
||||
5. **Learning** — Your AI gets better over time. Every interaction generates traces that drive automatic improvements to model weights, prompts, and agent behavior.
|
||||
|
||||
---
|
||||
@@ -206,11 +208,14 @@ Read the [blog post](https://scalingintelligence.stanford.edu/blogs/openjarvis/)
|
||||
## Citation
|
||||
|
||||
```bibtex
|
||||
@misc{saadfalcon2026openjarvis,
|
||||
title={OpenJarvis: Personal AI, On Personal Devices},
|
||||
author={Jon Saad-Falcon and Avanika Narayan and Herumb Shandilya and Hakki Orhun Akengin and Robby Manihani and Gabriel Bo and John Hennessy and Christopher R\'{e} and Azalia Mirhoseini},
|
||||
year={2026},
|
||||
howpublished={\url{https://scalingintelligence.stanford.edu/blogs/openjarvis/}},
|
||||
@misc{saadfalcon2026openjarvispersonalaipersonal,
|
||||
title={OpenJarvis: Personal AI, On Personal Devices},
|
||||
author={Jon Saad-Falcon and Avanika Narayan and Robby Manihani and Tanvir Bhathal and Herumb Shandilya and Hakki Orhun Akengin and Gabriel Bo and Andrew Park and Matthew Hart and Caia Costello and Chuan Li and Christopher Ré and Azalia Mirhoseini},
|
||||
year={2026},
|
||||
eprint={2605.17172},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.LG},
|
||||
url={https://arxiv.org/abs/2605.17172},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -225,3 +230,5 @@ Read the [blog post](https://scalingintelligence.stanford.edu/blogs/openjarvis/)
|
||||
<a href="https://research.ibm.com/">IBM Research</a> •
|
||||
<a href="https://hai.stanford.edu/">Stanford HAI</a>
|
||||
</p>
|
||||
|
||||
Follow [@OpenJarvisAI](https://x.com/OpenJarvisAI) on X for updates.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# ACE optimizer (Agentic Context Engineering)
|
||||
|
||||
OpenJarvis supports [ACE](https://github.com/ace-agent/ace) as a third
|
||||
optimizer alongside DSPy and GEPA. Where DSPy bootstraps few-shot
|
||||
examples and GEPA evolves prompts via reflective mutation, **ACE
|
||||
evolves a textual *playbook*** — annotated natural-language strategies
|
||||
the agent reads at inference time. The playbook is updated by a
|
||||
Generator / Reflector / Curator triad of LLM calls.
|
||||
|
||||
## When to pick ACE
|
||||
|
||||
| Task shape | DSPy | GEPA | ACE |
|
||||
|---|---|---|---|
|
||||
| Single-turn QA with crisp metric | strong | strong | weaker |
|
||||
| Long-running agent that should accumulate guidance | weak | medium | strong |
|
||||
| Open-domain where strategies matter more than templates | weak | medium | strong |
|
||||
| When you want to *read* what the optimizer learned | medium | medium | strong |
|
||||
|
||||
ACE's headline artifact is `final_playbook.txt` — a human-readable
|
||||
file like:
|
||||
|
||||
```
|
||||
## STRATEGIES & INSIGHTS
|
||||
[str-00001] helpful=5 harmful=0 :: When the user asks for unit
|
||||
conversion, prefer the exact
|
||||
rational form before rounding.
|
||||
[str-00002] helpful=3 harmful=1 :: Cite a primary source before
|
||||
stating a date claim.
|
||||
```
|
||||
|
||||
If reading those strategies feels like the form of "what learning
|
||||
should produce" for your task, ACE is the right choice.
|
||||
|
||||
## Setup
|
||||
|
||||
ACE is **not on PyPI** as of OpenJarvis v1.0.1, and the upstream
|
||||
repository is structured as a research codebase (multiple top-level
|
||||
directories) rather than a Python package. There's no `learning-ace`
|
||||
extra for that reason. Install ACE manually instead:
|
||||
|
||||
```bash
|
||||
# 1. Clone ACE somewhere outside your OpenJarvis checkout
|
||||
git clone https://github.com/ace-agent/ace.git ~/code/ace
|
||||
cd ~/code/ace
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh # if you don't have uv
|
||||
uv sync
|
||||
|
||||
# 2. Make ACE's src/ importable from your OpenJarvis venv
|
||||
echo "$HOME/code/ace/src" > \
|
||||
"$(python -c 'import site; print(site.getsitepackages()[0])')/ace.pth"
|
||||
|
||||
# 3. Set the API key for whichever provider ACE will call
|
||||
cp ~/code/ace/.env.example ~/code/ace/.env
|
||||
# Edit ~/code/ace/.env to set API_KEY for your chosen provider.
|
||||
|
||||
# 4. Verify the import resolves from OpenJarvis's venv
|
||||
python -c "from openjarvis.learning.agents.ace_optimizer import HAS_ACE; print(HAS_ACE)"
|
||||
# True
|
||||
```
|
||||
|
||||
If `HAS_ACE` prints `False`, the `.pth` file isn't being picked up —
|
||||
verify the path matches `site.getsitepackages()[0]` for the same
|
||||
Python interpreter you're using to run OpenJarvis.
|
||||
|
||||
## Configuration
|
||||
|
||||
ACE is configured under `[learning.agent.ace]` in your OpenJarvis
|
||||
config TOML:
|
||||
|
||||
```toml
|
||||
[learning.agent]
|
||||
policy = "ace"
|
||||
|
||||
[learning.agent.ace]
|
||||
# ACE's three roles. Empty = inherit from the intelligence primitive's
|
||||
# default cloud model.
|
||||
generator_model = "claude-opus-4-7"
|
||||
reflector_model = "claude-opus-4-7"
|
||||
curator_model = "claude-sonnet-4-6"
|
||||
|
||||
api_provider = "openai" # sambanova | together | openai | commonstack
|
||||
|
||||
num_epochs = 1
|
||||
max_num_rounds = 3
|
||||
playbook_token_budget = 80000
|
||||
max_tokens = 4096
|
||||
|
||||
task_name = "openjarvis"
|
||||
save_dir = "" # default: ~/.openjarvis/learning/ace/<task>/
|
||||
|
||||
min_traces = 20
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
Once configured, the same orchestrator that runs DSPy / GEPA also runs
|
||||
ACE — pick it via the `policy` field above. To force a one-shot run:
|
||||
|
||||
```bash
|
||||
jarvis optimize agent --policy ace
|
||||
```
|
||||
|
||||
ACE writes intermediate state and the final playbook to `save_dir`.
|
||||
The OpenJarvis runtime will pick up the playbook on next agent start
|
||||
(via the same sidecar overlay mechanism the Skills System uses).
|
||||
|
||||
## Trace adapter behavior
|
||||
|
||||
OpenJarvis traces are adapted into ACE's `train_samples` /
|
||||
`val_samples` / `test_samples` format via a 70 / 15 / 15 split
|
||||
(order-preserving for reproducibility). Each trace becomes a
|
||||
`{question: trace.query, ground_truth_answer: trace.result}` sample.
|
||||
Traces with empty `query` or `result` are dropped before splitting.
|
||||
|
||||
The `DataProcessor` ACE expects is built from `_TraceDataProcessor`
|
||||
in `src/openjarvis/learning/agents/ace_optimizer.py` — it does a
|
||||
case-insensitive substring match for `answer_is_correct` and averages
|
||||
that for aggregate accuracy. If you're optimizing for a domain where
|
||||
substring matching is the wrong correctness signal (math problems,
|
||||
code, structured outputs), subclass `_TraceDataProcessor` and pass it
|
||||
through your own callsite to `ACEAgentOptimizer.optimize()`.
|
||||
|
||||
## Limitations in v1.0.1
|
||||
|
||||
- **No automatic install.** Document above is the only path.
|
||||
- **The trace adapter uses substring correctness.** Override for
|
||||
domain-specific scoring.
|
||||
- **Single provider per run.** ACE assigns the same `api_provider` to
|
||||
all three roles. To mix providers, run ACE outside OpenJarvis and
|
||||
hand-deliver the resulting playbook into `save_dir`.
|
||||
|
||||
These will get revisited once ACE publishes a PyPI package or stable
|
||||
provider interface — track
|
||||
[ace-agent/ace#issues](https://github.com/ace-agent/ace/issues) for
|
||||
upstream changes that would let us tighten the wrapper.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Telemetry
|
||||
|
||||
OpenJarvis ships **anonymous usage telemetry** by default so the team can
|
||||
see where the product breaks, what features people actually use, and
|
||||
how to make it better. This page documents exactly what is and isn't
|
||||
collected, where the data goes, and how to opt out.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **On by default**, anonymous, no chat content.
|
||||
- **Anonymous** — one random UUID per install, no email, no name, no IP.
|
||||
- **No chat content, ever.** Only counts, timings, and feature names.
|
||||
- **Self-hosted backend** on the OpenJarvis team's PostHog instance —
|
||||
data is not sold or shared with third parties.
|
||||
- **365-day retention**, after which events are deleted automatically.
|
||||
|
||||
## What we collect
|
||||
|
||||
### Lifecycle events
|
||||
|
||||
| Event | Source | Why we send it |
|
||||
|---|---|---|
|
||||
| `install_started` | `install.sh` | Top of install funnel |
|
||||
| `install_stage_completed` | `install.sh` | Per-stage timing — where do people drop off? |
|
||||
| `install_completed` | `install.sh` | Did the install succeed? |
|
||||
| `install_failed` | `install.sh` | Which stage failed, and on what OS |
|
||||
| `app_opened` | Backend + frontend | DAU / WAU / MAU |
|
||||
| `setup_completed` | Frontend | First-run wizard finished |
|
||||
| `first_chat_sent` | Backend | First-ever message — activation |
|
||||
| `uninstall_started` | `uninstall.sh` (if user runs it) | Churn signal |
|
||||
|
||||
### Usage events
|
||||
|
||||
| Event | Why we send it |
|
||||
|---|---|
|
||||
| `chat_session_ended` | Aggregated per-session: turn count, tokens, latency, tool count |
|
||||
| `tool_first_used` | Which built-in tools are actually adopted |
|
||||
| `model_changed` | How often users switch models |
|
||||
| `feature_used` | Which features get traffic, which don't |
|
||||
| `connector_auth_completed` | Which connectors people set up |
|
||||
| `error_shown_to_user` | User-visible error class (not stack trace) |
|
||||
| `feedback_submitted` | Was a rating given? Was a comment included? |
|
||||
| `settings_changed` | Which settings get toggled |
|
||||
| `usage_daily_summary` | Once-per-day aggregated counts |
|
||||
|
||||
The canonical, authoritative list with every property name and its
|
||||
type validator lives in
|
||||
[`src/openjarvis/analytics/events.py`](../src/openjarvis/analytics/events.py).
|
||||
That file is the only place new events can be added — PR review is
|
||||
the gate.
|
||||
|
||||
## What we never collect
|
||||
|
||||
Hard guardrails, enforced by code:
|
||||
|
||||
- **Chat content** — prompts, model outputs, system messages, tool args.
|
||||
- **File paths** — anything matching `~/`, `$HOME`, `/Users/<name>`, `/home/<name>`, `file://`.
|
||||
- **Emails, names, phone numbers, addresses.**
|
||||
- **IP addresses** (IPv4 + IPv6). PostHog's IP geo lookup is disabled server-side too.
|
||||
- **MAC addresses, hardware serials, drive UUIDs.**
|
||||
- **Stack traces** — only error class enums.
|
||||
- **API keys, OAuth tokens, JWTs, bearer tokens, password assignments** —
|
||||
matched and dropped at value level.
|
||||
- **Hostnames** that look personal (e.g. `alice-macbook.local`).
|
||||
- **Lists, dicts, sets** — composite values are never sent so PII can't
|
||||
smuggle through inside containers.
|
||||
|
||||
Two independent filters run before every event leaves the machine:
|
||||
|
||||
1. [`src/openjarvis/analytics/redaction.py`](../src/openjarvis/analytics/redaction.py) — value-level pattern matching (20+ regexes for PII).
|
||||
2. [`src/openjarvis/analytics/events.py`](../src/openjarvis/analytics/events.py) — structural allowlist (event name + property name + type validator).
|
||||
|
||||
Any failure at either layer → the event or property is silently
|
||||
dropped. Tests covering the patterns: [`tests/analytics/test_redaction.py`](../tests/analytics/test_redaction.py).
|
||||
|
||||
## Where the data goes
|
||||
|
||||
- **Today** (alpha): PostHog Cloud (US region) free tier. Disclosed
|
||||
here for transparency.
|
||||
- **Production target**: A self-hosted PostHog instance at
|
||||
`analytics.openjarvis.ai`, Hetzner US-East. Single-tenant, operated
|
||||
by the OpenJarvis team.
|
||||
- **Never** sold, shared with advertisers, or used for anything other
|
||||
than improving OpenJarvis.
|
||||
|
||||
## Opting out
|
||||
|
||||
Three independent ways to disable analytics — any one is sufficient:
|
||||
|
||||
1. **Set an env var** (no config file edit needed):
|
||||
```bash
|
||||
export DO_NOT_TRACK=1 # W3C convention, honored by other tools too
|
||||
# or
|
||||
export OPENJARVIS_NO_ANALYTICS=1 # project-specific, leaves other DNT-aware tools unaffected
|
||||
```
|
||||
Both are checked at runtime; any truthy value (`1`, `true`, `yes`,
|
||||
`on`) disables analytics for that process. Truthy = anything other
|
||||
than empty, `0`, `false`, `no`, `off`.
|
||||
|
||||
2. **Edit `~/.openjarvis/config.toml`**:
|
||||
```toml
|
||||
[analytics]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
3. **Delete the anon ID** (`rm ~/.openjarvis/anon_id`) — events for
|
||||
the prior identity are orphaned, but a new identity will be
|
||||
created on the next run. Combine with #1 or #2 to fully stop.
|
||||
|
||||
Env-var opt-out takes precedence over the config file, so setting
|
||||
`DO_NOT_TRACK=1` overrides `enabled = true` in the config.
|
||||
|
||||
## Retention
|
||||
|
||||
- Default retention: **365 days**, then events are deleted by PostHog
|
||||
automatically.
|
||||
- `jarvis analytics reset-id` lets you orphan all of your past events
|
||||
by generating a fresh anonymous ID for future events.
|
||||
|
||||
## How identity works
|
||||
|
||||
A single UUID v4 is generated on first install and stored at
|
||||
`~/.openjarvis/anon_id`. The install script, backend, and frontend all
|
||||
read the same file so events across the full lifecycle tie to one
|
||||
person — without us ever knowing who that person is.
|
||||
|
||||
Delete the file (`rm ~/.openjarvis/anon_id`) and a fresh UUID will be
|
||||
generated next time the app runs. The previous UUID and its events
|
||||
are then orphaned.
|
||||
|
||||
## For researchers and contributors
|
||||
|
||||
- **Adding an event**: edit `src/openjarvis/analytics/events.py`,
|
||||
declare the spec, then update this page. PR review enforces both.
|
||||
- **Adding a PII pattern**: edit `src/openjarvis/analytics/redaction.py`
|
||||
and add a test case in `tests/analytics/test_redaction.py`.
|
||||
- **Inspecting what your install sends**: run with
|
||||
`OPENJARVIS_LOG_LEVEL=DEBUG` and grep for `Analytics`. You'll see
|
||||
every event name and (redacted) property dict before it ships.
|
||||
|
||||
## Related
|
||||
|
||||
- Local telemetry (FLOPs, energy, latency stored in
|
||||
`~/.openjarvis/telemetry.db`) is a **separate** subsystem documented
|
||||
in [`src/openjarvis/telemetry/`](../src/openjarvis/telemetry/). It
|
||||
never leaves the machine and is controlled by `[telemetry]` (not
|
||||
`[analytics]`) in `config.toml`.
|
||||
- The leaderboard / contest opt-in (`OptInModal.tsx`) is a separate,
|
||||
voluntary feature that publicly shares your energy and savings on
|
||||
the OpenJarvis leaderboard. It is **not** the same as analytics and
|
||||
requires explicit opt-in with a display name and email.
|
||||
@@ -148,6 +148,14 @@ jarvis skill sync openclaw --search "web3|crypto"
|
||||
jarvis skill install github:user/repo/path/to/skill --url https://github.com/user/repo
|
||||
```
|
||||
|
||||
For example, install the Hermes Tweet skill when you want an agent to search
|
||||
Twitter/X, read tweet replies, monitor tweets, export followers, and run
|
||||
gated post, reply, or DM workflows:
|
||||
|
||||
```bash
|
||||
jarvis skill install github:Xquik-dev/hermes-tweet/skills/hermes-tweet --url https://github.com/Xquik-dev/hermes-tweet
|
||||
```
|
||||
|
||||
### Config-Driven Auto Import
|
||||
|
||||
Add sources to `~/.openjarvis/config.toml` for automatic syncing:
|
||||
|
||||
Generated
+441
-1
@@ -24,6 +24,7 @@
|
||||
"katex": "^0.16.38",
|
||||
"lucide-react": "^0.576.0",
|
||||
"motion": "^12.38.0",
|
||||
"posthog-js": "^1.373.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -2643,6 +2644,331 @@
|
||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
|
||||
"integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/core": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
|
||||
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
|
||||
"integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/otlp-exporter-base": "0.208.0",
|
||||
"@opentelemetry/otlp-transformer": "0.208.0",
|
||||
"@opentelemetry/sdk-logs": "0.208.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-exporter-base": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
|
||||
"integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/otlp-transformer": "0.208.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-transformer": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
|
||||
"integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0",
|
||||
"@opentelemetry/sdk-logs": "0.208.0",
|
||||
"@opentelemetry/sdk-metrics": "2.2.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.2.0",
|
||||
"protobufjs": "^7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
|
||||
"integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
|
||||
"integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-logs": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
|
||||
"integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-metrics": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
|
||||
"integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
|
||||
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
|
||||
"integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.28.7",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.28.7.tgz",
|
||||
"integrity": "sha512-JmV2wN5sE7u2JWxwNNw6CBrPu5xDzIAMWR9zKBar8Pk/8TRrvbFPlXehap8xOtDslfnilY+/urpHeVHpbXMo4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "1.373.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.373.2",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.373.2.tgz",
|
||||
"integrity": "sha512-6o0AARB7OakxsrQiVeMow/m1QPnsI0Cdm7g0o5mNjVSLH/sU1MuTqckNQDLzImv++MzW0+Gyvq44cgwt3wP/Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
|
||||
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
|
||||
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1",
|
||||
"@protobufjs/inquire": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
|
||||
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
@@ -3855,6 +4181,15 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz",
|
||||
"integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@@ -3891,7 +4226,7 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
@@ -4690,6 +5025,17 @@
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
|
||||
@@ -5178,6 +5524,15 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
@@ -5790,6 +6145,12 @@
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||
@@ -7779,6 +8140,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
@@ -9453,6 +9820,27 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.373.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.373.2.tgz",
|
||||
"integrity": "sha512-wi9LjL+67iQsUPE4PtGp3SASWksYy0Nmo1F0Te9jDGn0wTAK5oIIFF+JxgM8II518wH5xJ2kSlyGqcrjcNFFAw==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.208.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
|
||||
"@opentelemetry/resources": "^2.2.0",
|
||||
"@opentelemetry/sdk-logs": "^0.208.0",
|
||||
"@posthog/core": "1.28.7",
|
||||
"@posthog/types": "1.373.2",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.28.2",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
||||
@@ -9465,6 +9853,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
|
||||
"integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-bytes": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
||||
@@ -9525,6 +9923,30 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.7",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.7.tgz",
|
||||
"integrity": "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.1",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -9563,6 +9985,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/query-selector-shadow-dom": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
|
||||
"integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -11307,6 +11735,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
|
||||
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
|
||||
@@ -11780,6 +12214,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
|
||||
"integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openjarvis-chat",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -30,6 +30,7 @@
|
||||
"katex": "^0.16.38",
|
||||
"lucide-react": "^0.576.0",
|
||||
"motion": "^12.38.0",
|
||||
"posthog-js": "^1.373.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
Generated
+93
@@ -759,6 +759,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -2601,6 +2603,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-process",
|
||||
@@ -3390,6 +3393,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4297,6 +4324,48 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.0.6+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.3.1"
|
||||
@@ -4716,6 +4785,21 @@ dependencies = [
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.0.4",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
@@ -4734,6 +4818,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.19.15"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openjarvis-desktop"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
description = "OpenJarvis Desktop — Native AI assistant with energy monitoring, trace debugging, and learning visualization"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
+182
-12
@@ -405,6 +405,57 @@ async fn pull_model(model: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uv sync error formatting (pure helpers — unit-tested, see #331)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Last `max_chars` characters of a `uv sync` stderr stream, trimmed.
|
||||
///
|
||||
/// uv's actionable diagnostic almost always lands at the tail of the
|
||||
/// stream, so when surfacing a failure to the user we show the end, not
|
||||
/// the (usually noisy progress-spinner) beginning. Operates on `char`
|
||||
/// boundaries so it never splits a multi-byte UTF-8 codepoint — important
|
||||
/// because Windows consoles emit non-ASCII (cp9xx) bytes.
|
||||
fn uv_sync_stderr_tail(stderr: &str, max_chars: usize) -> String {
|
||||
let total = stderr.chars().count();
|
||||
let skip = total.saturating_sub(max_chars);
|
||||
stderr.chars().skip(skip).collect::<String>().trim().to_string()
|
||||
}
|
||||
|
||||
/// Error message shown when `uv sync` runs but exits non-zero (#331).
|
||||
///
|
||||
/// `exit_code` is `None` when the process was terminated by a signal with
|
||||
/// no exit code (rendered as "unknown" rather than a misleading -1).
|
||||
fn format_uv_sync_failure(
|
||||
root: &std::path::Path,
|
||||
exit_code: Option<i32>,
|
||||
stderr: &str,
|
||||
) -> String {
|
||||
let code = exit_code
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
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.",
|
||||
root.display(),
|
||||
code,
|
||||
uv_sync_stderr_tail(stderr, 800),
|
||||
)
|
||||
}
|
||||
|
||||
/// Error message shown when `uv sync` can't even be spawned (#331) —
|
||||
/// e.g. the resolved `uv` binary doesn't exist or isn't executable.
|
||||
fn format_uv_sync_spawn_error(root: &std::path::Path, uv_bin: &str, err: &str) -> String {
|
||||
format!(
|
||||
"Could not run `uv sync`: {}. Verify uv is installed at \
|
||||
`{}` and the OpenJarvis repo is at `{}`.",
|
||||
err,
|
||||
uv_bin,
|
||||
root.display(),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend boot sequence (runs in background after app launch)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -495,14 +546,33 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
|
||||
let uv_bin = resolve_bin("uv");
|
||||
|
||||
// Verify uv is actually installed
|
||||
// Verify uv is actually installed. Concrete per-OS instructions —
|
||||
// the generic "install it from astral.sh" was the #1 source of
|
||||
// confusion on the Discord support thread; users couldn't tell whether
|
||||
// to use winget, scoop, pip, or the official installer.
|
||||
if !std::path::Path::new(&uv_bin).exists() && uv_bin == "uv" {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(
|
||||
"Could not find 'uv' (Python package manager). \
|
||||
Install it from https://astral.sh/uv then relaunch."
|
||||
.into(),
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
let msg = "Could not find 'uv' (Python package manager). \
|
||||
To install on Windows, open PowerShell and run:\n\n\
|
||||
powershell -ExecutionPolicy Bypass -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n\n\
|
||||
Then close and relaunch this app. \
|
||||
(If the install completes but the app still can't find uv, \
|
||||
you may need to log out and back in so PATH refreshes.)";
|
||||
#[cfg(target_os = "macos")]
|
||||
let msg = "Could not find 'uv' (Python package manager). \
|
||||
To install on macOS, open Terminal and run:\n\n\
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh\n\n\
|
||||
Then relaunch this app.";
|
||||
#[cfg(target_os = "linux")]
|
||||
let msg = "Could not find 'uv' (Python package manager). \
|
||||
To install on Linux, open a terminal and run:\n\n\
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh\n\n\
|
||||
Then relaunch this app.";
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
let msg = "Could not find 'uv' (Python package manager). \
|
||||
Install it from https://astral.sh/uv then relaunch.";
|
||||
s.error = Some(msg.into());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -643,12 +713,25 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
|
||||
let root = project_root.as_ref().unwrap();
|
||||
|
||||
// Install dependencies automatically (handles fresh clones)
|
||||
// Install dependencies automatically (handles fresh clones).
|
||||
//
|
||||
// Previously we ran `uv sync` with both stdout AND stderr piped to
|
||||
// /dev/null and discarded the exit code (`let _ = …`). When `uv sync`
|
||||
// failed — Windows path issues, network problems, lockfile conflicts —
|
||||
// the user saw no error, the boot continued, `uv run jarvis serve`
|
||||
// then ran in an under-provisioned venv, and the user waited the full
|
||||
// 600s health-check window before getting "Jarvis server did not
|
||||
// become healthy in time" with no actionable detail (issue #331).
|
||||
//
|
||||
// Now: capture stderr, check the exit status, surface a useful error
|
||||
// to the user BEFORE the long server-start wait. The status detail
|
||||
// message also indicates this can take a couple of minutes on first
|
||||
// boot so users don't restart the app thinking it's stuck.
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = "Installing dependencies...".into();
|
||||
s.detail = "Installing dependencies (uv sync — may take 1-2 min on first boot)...".into();
|
||||
}
|
||||
let _ = tokio::process::Command::new(&uv_bin)
|
||||
let sync_output = tokio::process::Command::new(&uv_bin)
|
||||
.args([
|
||||
"sync",
|
||||
"--extra", "server",
|
||||
@@ -656,10 +739,24 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
"--extra", "inference-google",
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(root)
|
||||
.status()
|
||||
.output()
|
||||
.await;
|
||||
match sync_output {
|
||||
Ok(out) if !out.status.success() => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format_uv_sync_failure(root, out.status.code(), &stderr));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format_uv_sync_spawn_error(root, &uv_bin, &e.to_string()));
|
||||
return;
|
||||
}
|
||||
Ok(_) => {} // success — fall through
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
@@ -1604,7 +1701,7 @@ pub fn run() {
|
||||
MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--hidden"]),
|
||||
))
|
||||
// .plugin(tauri_plugin_updater::Builder::new().build()) // disabled for local dev
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
@@ -1719,3 +1816,76 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_uv_sync_failure, format_uv_sync_spawn_error, uv_sync_stderr_tail};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn tail_returns_whole_string_when_shorter_than_limit() {
|
||||
assert_eq!(uv_sync_stderr_tail("short error", 800), "short error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_keeps_the_end_not_the_beginning() {
|
||||
// uv's actionable line is at the end; the spinner noise is at the start.
|
||||
let s = format!("{}ACTUAL ERROR HERE", "spinner-noise ".repeat(200));
|
||||
let tail = uv_sync_stderr_tail(&s, 40);
|
||||
assert!(tail.ends_with("ACTUAL ERROR HERE"), "tail was: {tail:?}");
|
||||
assert!(!tail.contains("spinner-noise spinner-noise spinner-noise"));
|
||||
assert!(tail.chars().count() <= 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_trims_surrounding_whitespace() {
|
||||
assert_eq!(uv_sync_stderr_tail(" \n padded \n ", 800), "padded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_never_splits_a_multibyte_codepoint() {
|
||||
// Each "é" is 2 bytes / 1 char. A byte-based slice could panic or
|
||||
// produce invalid UTF-8; the char-based tail must not.
|
||||
let s = "é".repeat(500);
|
||||
let tail = uv_sync_stderr_tail(&s, 100);
|
||||
assert_eq!(tail.chars().count(), 100);
|
||||
assert!(tail.chars().all(|c| c == 'é'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_message_includes_exit_code_and_tail_and_hint() {
|
||||
let msg = format_uv_sync_failure(
|
||||
Path::new("/home/u/.openjarvis/src"),
|
||||
Some(2),
|
||||
"error: failed to resolve numpy==2.1.3",
|
||||
);
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_message_renders_missing_exit_code_as_unknown() {
|
||||
// Process killed by signal → no exit code. Must not show a misleading -1.
|
||||
let msg = format_uv_sync_failure(Path::new("/x"), None, "boom");
|
||||
assert!(msg.contains("exit unknown"));
|
||||
assert!(!msg.contains("exit -1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_error_names_the_binary_and_root() {
|
||||
let msg = format_uv_sync_spawn_error(
|
||||
Path::new("/repo"),
|
||||
"C:\\Users\\me\\.local\\bin\\uv.exe",
|
||||
"No such file or directory (os error 2)",
|
||||
);
|
||||
assert!(msg.contains("C:\\Users\\me\\.local\\bin\\uv.exe"));
|
||||
assert!(msg.contains("/repo"));
|
||||
assert!(msg.contains("No such file or directory"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenJarvis",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"identifier": "com.openjarvis.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -58,9 +58,11 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": false,
|
||||
"active": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFFNzUzMzhEOEY2MjNEMDMKUldRRFBXS1BqVE4xSG8vK0lkUWN4WnZQYVIrbmc4RmpoOGlJWTBLTE15RlIya3JvQisvdUR3a0QK",
|
||||
"endpoints": []
|
||||
"endpoints": [
|
||||
"https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/latest.json"
|
||||
]
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
|
||||
+38
-29
@@ -14,10 +14,16 @@ import { Toaster } from './components/ui/sonner';
|
||||
import { useAppStore } from './lib/store';
|
||||
import { fetchModels, fetchServerInfo, fetchSavings, submitSavings, isTauri } from './lib/api';
|
||||
import { OptInModal } from './components/OptInModal';
|
||||
import { UpdateChecker } from './components/Desktop/UpdateChecker';
|
||||
import { track, hashId } from './lib/analytics';
|
||||
|
||||
export default function App() {
|
||||
const [setupDone, setSetupDone] = useState(!isTauri());
|
||||
const handleSetupReady = useCallback(() => setSetupDone(true), []);
|
||||
const handleSetupReady = useCallback(() => {
|
||||
setSetupDone(true);
|
||||
track('setup_completed', { preset: 'default' });
|
||||
}, []);
|
||||
const prevModelRef = useRef<string>('');
|
||||
const setModels = useAppStore((s) => s.setModels);
|
||||
const setModelsLoading = useAppStore((s) => s.setModelsLoading);
|
||||
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
|
||||
@@ -116,6 +122,36 @@ export default function App() {
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fire model_changed when the user switches models. First mount is
|
||||
// not a "change" — only emit when both prev and current are real and
|
||||
// differ.
|
||||
useEffect(() => {
|
||||
const prev = prevModelRef.current;
|
||||
const curr = selectedModel || '';
|
||||
prevModelRef.current = curr;
|
||||
if (!prev || !curr || prev === curr) return;
|
||||
void (async () => {
|
||||
const [fromHash, toHash] = await Promise.all([
|
||||
hashId(prev),
|
||||
hashId(curr),
|
||||
]);
|
||||
track('model_changed', {
|
||||
from_model_hash: fromHash,
|
||||
to_model_hash: toHash,
|
||||
});
|
||||
})();
|
||||
}, [selectedModel]);
|
||||
|
||||
// app_opened — one-shot per app launch, fires after analytics has had
|
||||
// a chance to initialize. platform + version are super-properties
|
||||
// registered in analytics.ts initAnalytics, so no per-call props needed.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
track('app_opened', {});
|
||||
}, 500);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
@@ -134,34 +170,6 @@ export default function App() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [commandPaletteOpen, setCommandPaletteOpen, toggleSystemPanel]);
|
||||
|
||||
// Desktop auto-update check — disabled during local development.
|
||||
// Re-enable for production releases by uncommenting below.
|
||||
// const updateChecked = useRef(false);
|
||||
// useEffect(() => {
|
||||
// if (!isTauri() || updateChecked.current) return;
|
||||
// updateChecked.current = true;
|
||||
// (async () => {
|
||||
// try {
|
||||
// const { check } = await import('@tauri-apps/plugin-updater');
|
||||
// const update = await check();
|
||||
// if (update) {
|
||||
// await update.downloadAndInstall();
|
||||
// const { toast } = await import('sonner');
|
||||
// toast.info('Update ready', {
|
||||
// description: 'A new version has been downloaded. Restart to apply.',
|
||||
// duration: Infinity,
|
||||
// action: {
|
||||
// label: 'Restart Now',
|
||||
// onClick: async () => {
|
||||
// const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
// await relaunch();
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// } catch {}
|
||||
// })();
|
||||
// }, []);
|
||||
|
||||
if (!setupDone) {
|
||||
return <SetupScreen onReady={handleSetupReady} />;
|
||||
@@ -169,6 +177,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<UpdateChecker />
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<ChatPage />} />
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Bell, CheckCircle, ChevronDown, ChevronUp, Clock, XCircle } from 'lucide-react';
|
||||
import { approveAction, denyAction, fetchPendingApprovals } from '../lib/api';
|
||||
import type { PendingApproval } from '../lib/api';
|
||||
|
||||
const TIER_STYLES: Record<string, { label: string; color: string; bg: string }> = {
|
||||
trivial: { label: 'Trivial', color: 'var(--color-text-secondary)', bg: 'color-mix(in srgb, var(--color-text-secondary) 10%, transparent)' },
|
||||
low: { label: 'Low', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' },
|
||||
medium: { label: 'Medium', color: 'var(--color-warning)', bg: 'color-mix(in srgb, var(--color-warning) 12%, transparent)' },
|
||||
high: { label: 'High', color: 'var(--color-error)', bg: 'color-mix(in srgb, var(--color-error) 12%, transparent)' },
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function ApprovalBell() {
|
||||
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [processing, setProcessing] = useState<Record<string, boolean>>({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setApprovals(await fetchPendingApprovals());
|
||||
} catch {
|
||||
// backend may not be running yet
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
setProcessing(p => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await approveAction(id);
|
||||
setApprovals(prev => prev.filter(a => a.id !== id));
|
||||
} finally {
|
||||
setProcessing(p => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async (id: string) => {
|
||||
setProcessing(p => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await denyAction(id);
|
||||
setApprovals(prev => prev.filter(a => a.id !== id));
|
||||
} finally {
|
||||
setProcessing(p => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const count = approvals.length;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fixed top-2 right-3 z-40">
|
||||
{/* Bell trigger */}
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="relative p-2 rounded-lg transition-colors cursor-pointer"
|
||||
title="Agent approvals"
|
||||
style={{
|
||||
color: count > 0 ? 'var(--color-text)' : 'var(--color-text-secondary)',
|
||||
background: open
|
||||
? 'var(--color-bg-tertiary)'
|
||||
: count > 0
|
||||
? 'color-mix(in srgb, var(--color-error) 8%, transparent)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Bell size={17} />
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 flex items-center justify-center rounded-full text-[10px] font-bold px-1 leading-none"
|
||||
style={{ background: 'var(--color-error)', color: '#fff' }}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-xl shadow-2xl overflow-hidden flex flex-col"
|
||||
style={{
|
||||
width: '340px',
|
||||
maxHeight: '500px',
|
||||
background: 'var(--color-bg-secondary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={13} style={{ color: 'var(--color-accent)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Agent Approvals
|
||||
</span>
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="text-[11px] font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||
color: 'var(--color-error)',
|
||||
}}
|
||||
>
|
||||
{count} pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{count === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<CheckCircle size={26} style={{ color: 'var(--color-text-secondary)', opacity: 0.35 }} />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
No pending approvals
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
approvals.map((action, idx) => {
|
||||
const tier = TIER_STYLES[action.tier] ?? TIER_STYLES.medium;
|
||||
const isExpanded = !!expanded[action.id];
|
||||
const isLoading = !!processing[action.id];
|
||||
const hasPayload = Object.keys(action.payload ?? {}).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="px-4 py-3"
|
||||
style={{
|
||||
borderBottom: idx < count - 1 ? '1px solid var(--color-border)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Row 1: action type + tier + time */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span
|
||||
className="text-[11px] font-mono font-semibold"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
>
|
||||
{action.action_type}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded"
|
||||
style={{ background: tier.bg, color: tier.color }}
|
||||
>
|
||||
{tier.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] flex items-center gap-0.5"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Clock size={9} />
|
||||
{timeAgo(action.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className="text-[13px] mb-2.5 leading-snug"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{action.description}
|
||||
</p>
|
||||
|
||||
{/* Expandable payload */}
|
||||
{hasPayload && (
|
||||
<button
|
||||
className="flex items-center gap-1 text-[11px] mb-2 cursor-pointer"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onClick={() =>
|
||||
setExpanded(e => ({ ...e, [action.id]: !e[action.id] }))
|
||||
}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
|
||||
{isExpanded ? 'Hide details' : 'View details'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<pre
|
||||
className="text-[10px] rounded-lg p-2.5 mb-2.5 overflow-x-auto"
|
||||
style={{
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(action.payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Approve / Deny */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleApprove(action.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs font-semibold transition-opacity cursor-pointer disabled:opacity-40"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
|
||||
color: 'var(--color-success)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-success) 22%, transparent)',
|
||||
}}
|
||||
>
|
||||
<CheckCircle size={12} />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeny(action.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs font-semibold transition-opacity cursor-pointer disabled:opacity-40"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||
color: 'var(--color-error)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-error) 22%, transparent)',
|
||||
}}
|
||||
>
|
||||
<XCircle size={12} />
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,14 +146,29 @@ export function ChatArea() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-[var(--chat-max-width)] mx-auto px-4 py-6">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{streamState.isStreaming && streamState.content === '' && (
|
||||
<div className="flex justify-start mb-4">
|
||||
<StreamingDots phase={streamState.phase} />
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => {
|
||||
const isLastAssistant =
|
||||
i === messages.length - 1 && msg.role === 'assistant';
|
||||
return (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLive={isLastAssistant && streamState.isStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
if (!streamState.isStreaming || streamState.content !== '') return null;
|
||||
// For research messages the ResearchTimeline handles its own
|
||||
// pre-content loading state — suppress the generic dots.
|
||||
const last = messages[messages.length - 1];
|
||||
if (last?.role === 'assistant' && last.isResearch) return null;
|
||||
return (
|
||||
<div className="flex justify-start mb-4">
|
||||
<StreamingDots phase={streamState.phase} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,77 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Send, Square, Paperclip } from 'lucide-react';
|
||||
import { Send, Square, Paperclip, Search } from 'lucide-react';
|
||||
import { useAppStore, generateId } from '../../lib/store';
|
||||
import { streamChat } from '../../lib/sse';
|
||||
import { streamChat, streamResearch } from '../../lib/sse';
|
||||
import { fetchSavings, getBase } from '../../lib/api';
|
||||
import { listConnectors, getSyncStatus } from '../../lib/connectors-api';
|
||||
import { MicButton } from './MicButton';
|
||||
import { useSpeech } from '../../hooks/useSpeech';
|
||||
import type { ChatMessage, ToolCallInfo, TokenUsage, MessageTelemetry } from '../../types';
|
||||
import type {
|
||||
ChatMessage,
|
||||
MessageTelemetry,
|
||||
ResearchSearchTrace,
|
||||
ResearchSource,
|
||||
TokenUsage,
|
||||
ToolCallInfo,
|
||||
} from '../../types';
|
||||
|
||||
// While Deep Research is toggled on, poll connected sources for sync
|
||||
// progress so we can surface "Searching over N items — sync in progress"
|
||||
// next to the toggle. Polling is gated on `enabled` so toggling DR off
|
||||
// stops the network chatter immediately.
|
||||
function useResearchCorpusSync(enabled: boolean): {
|
||||
syncing: boolean;
|
||||
itemsSynced: number;
|
||||
} {
|
||||
const [state, setState] = useState({ syncing: false, itemsSynced: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState({ syncing: false, itemsSynced: 0 });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const list = await listConnectors();
|
||||
const connected = list.filter((c) => c.connected);
|
||||
if (connected.length === 0) {
|
||||
if (!cancelled) setState({ syncing: false, itemsSynced: 0 });
|
||||
return;
|
||||
}
|
||||
const results = await Promise.all(
|
||||
connected.map(async (c) => {
|
||||
try {
|
||||
return await getSyncStatus(c.connector_id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
let syncing = false;
|
||||
let itemsSynced = 0;
|
||||
for (const r of results) {
|
||||
if (!r) continue;
|
||||
if (r.state === 'syncing') syncing = true;
|
||||
itemsSynced += r.items_synced ?? 0;
|
||||
}
|
||||
if (!cancelled) setState({ syncing, itemsSynced });
|
||||
} catch {
|
||||
// Network blip — leave previous state intact.
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function InputArea() {
|
||||
const [input, setInput] = useState('');
|
||||
@@ -26,6 +92,9 @@ export function InputArea() {
|
||||
const setStreamState = useAppStore((s) => s.setStreamState);
|
||||
const resetStream = useAppStore((s) => s.resetStream);
|
||||
const modelLoading = useAppStore((s) => s.modelLoading);
|
||||
const deepResearch = useAppStore((s) => s.deepResearch);
|
||||
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
|
||||
const corpusSync = useResearchCorpusSync(deepResearch);
|
||||
|
||||
const { state: speechState, available: speechAvailable, startRecording, stopRecording } = useSpeech();
|
||||
|
||||
@@ -114,6 +183,7 @@ export function InputArea() {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
isResearch: deepResearch || undefined,
|
||||
};
|
||||
addMessage(convId, assistantMsg);
|
||||
|
||||
@@ -131,12 +201,16 @@ export function InputArea() {
|
||||
let usage: TokenUsage | undefined;
|
||||
let complexity: { score: number; tier: string; suggested_max_tokens: number } | undefined;
|
||||
const toolCalls: ToolCallInfo[] = [];
|
||||
const researchTraces: ResearchSearchTrace[] = [];
|
||||
const researchSourcesByRef = new Map<number, ResearchSource>();
|
||||
const flushSources = () =>
|
||||
Array.from(researchSourcesByRef.values()).sort((a, b) => a.ref - b.ref);
|
||||
let lastFlush = 0;
|
||||
let ttftMs: number | undefined;
|
||||
|
||||
setStreamState({
|
||||
isStreaming: true,
|
||||
phase: 'Generating...',
|
||||
phase: deepResearch ? 'Researching...' : 'Generating...',
|
||||
elapsedMs: 0,
|
||||
activeToolCalls: [],
|
||||
content: '',
|
||||
@@ -145,10 +219,116 @@ export function InputArea() {
|
||||
timestamp: Date.now(),
|
||||
level: 'info',
|
||||
category: 'chat',
|
||||
message: `Request: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}" → ${selectedModel}`,
|
||||
message: deepResearch
|
||||
? `Research: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}"`
|
||||
: `Request: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}" → ${selectedModel}`,
|
||||
});
|
||||
|
||||
try {
|
||||
if (deepResearch) {
|
||||
for await (const ev of streamResearch(content, controller.signal)) {
|
||||
if (ev.type === 'search_call') {
|
||||
const trace: ResearchSearchTrace = {
|
||||
id: generateId(),
|
||||
query: ev.arguments?.query ?? '',
|
||||
person: ev.arguments?.person,
|
||||
timeRange: ev.arguments?.time_range,
|
||||
status: 'pending',
|
||||
};
|
||||
researchTraces.push(trace);
|
||||
setStreamState({ phase: `Searching: ${trace.query}` });
|
||||
updateLastAssistant(
|
||||
convId,
|
||||
accumulatedContent,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[...researchTraces],
|
||||
flushSources(),
|
||||
);
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(),
|
||||
level: 'info',
|
||||
category: 'tool',
|
||||
message: `Search: "${trace.query}"${trace.person ? ` (person: ${trace.person})` : ''}`,
|
||||
});
|
||||
} else if (ev.type === 'search_result') {
|
||||
const pending = [...researchTraces].reverse().find((t) => t.status === 'pending');
|
||||
if (pending) {
|
||||
pending.status = 'complete';
|
||||
pending.numHits = ev.num_hits;
|
||||
pending.topTitles = ev.top_titles;
|
||||
}
|
||||
if (ev.sources) {
|
||||
for (const src of ev.sources) {
|
||||
if (src && typeof src.ref === 'number' && !researchSourcesByRef.has(src.ref)) {
|
||||
researchSourcesByRef.set(src.ref, src);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateLastAssistant(
|
||||
convId,
|
||||
accumulatedContent,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[...researchTraces],
|
||||
flushSources(),
|
||||
);
|
||||
} else if (ev.type === 'synthesis') {
|
||||
if (!ttftMs) ttftMs = Date.now() - startTime;
|
||||
accumulatedContent += ev.text;
|
||||
setStreamState({ content: accumulatedContent, phase: '' });
|
||||
const now = Date.now();
|
||||
if (now - lastFlush >= 80) {
|
||||
updateLastAssistant(
|
||||
convId,
|
||||
accumulatedContent,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[...researchTraces],
|
||||
flushSources(),
|
||||
);
|
||||
lastFlush = now;
|
||||
}
|
||||
} else if (ev.type === 'system_metrics') {
|
||||
// Live GPU sample — feed straight to the System panel so Power
|
||||
// (W) and Energy (kJ) tick up in real time as the agent runs.
|
||||
useAppStore.getState().setLiveEnergy({
|
||||
power_w: ev.power_w,
|
||||
energy_j: ev.energy_j,
|
||||
duration_s: ev.duration_s,
|
||||
});
|
||||
} else if (ev.type === 'done') {
|
||||
if (ev.usage) {
|
||||
usage = {
|
||||
prompt_tokens: ev.usage.prompt_tokens ?? 0,
|
||||
completion_tokens: ev.usage.completion_tokens ?? 0,
|
||||
total_tokens:
|
||||
ev.usage.total_tokens ??
|
||||
(ev.usage.prompt_tokens ?? 0) +
|
||||
(ev.usage.completion_tokens ?? 0),
|
||||
};
|
||||
// Optimistically roll this research turn into the session
|
||||
// counters so the Session panel updates the moment the
|
||||
// stream finishes, regardless of how /v1/savings aggregates
|
||||
// research telemetry server-side.
|
||||
useAppStore.getState().incrementSavings(usage);
|
||||
}
|
||||
// Hold the final live numbers visible for a beat so the panel
|
||||
// doesn't flash to 0 between the SSE close and the next
|
||||
// /v1/telemetry/energy poll picking up the persisted record.
|
||||
window.setTimeout(() => {
|
||||
useAppStore.getState().setLiveEnergy(null);
|
||||
}, 1500);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for await (const sseEvent of streamChat(
|
||||
{ model: selectedModel, messages: apiMessages, stream: true, temperature, max_tokens: maxTokens },
|
||||
controller.signal,
|
||||
@@ -225,6 +405,7 @@ export function InputArea() {
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
// User cancelled or model switch — keep whatever was accumulated
|
||||
@@ -238,6 +419,9 @@ export function InputArea() {
|
||||
message: `Stream error: ${errMsg}`,
|
||||
});
|
||||
}
|
||||
// If we tore out mid-research, make sure the live System panel
|
||||
// numbers don't get stuck on the last sample.
|
||||
useAppStore.getState().setLiveEnergy(null);
|
||||
} finally {
|
||||
if (!accumulatedContent) {
|
||||
accumulatedContent = 'No response was generated. Please try again.';
|
||||
@@ -278,6 +462,8 @@ export function InputArea() {
|
||||
usage,
|
||||
telemetry,
|
||||
audioMeta,
|
||||
researchTraces.length > 0 ? researchTraces : undefined,
|
||||
researchSourcesByRef.size > 0 ? flushSources() : undefined,
|
||||
);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
@@ -290,9 +476,15 @@ export function InputArea() {
|
||||
});
|
||||
abortRef.current = null;
|
||||
|
||||
fetchSavings()
|
||||
.then((data) => useAppStore.getState().setSavings(data))
|
||||
.catch(() => {});
|
||||
// Research path updates session counters optimistically from the
|
||||
// `done` event's usage payload — re-fetching here would overwrite
|
||||
// it with a potentially stale snapshot if the server's research
|
||||
// telemetry hasn't been merged into /v1/savings yet.
|
||||
if (!deepResearch) {
|
||||
fetchSavings()
|
||||
.then((data) => useAppStore.getState().setSavings(data))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
input,
|
||||
@@ -304,6 +496,9 @@ export function InputArea() {
|
||||
updateLastAssistant,
|
||||
setStreamState,
|
||||
resetStream,
|
||||
deepResearch,
|
||||
temperature,
|
||||
maxTokens,
|
||||
]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -315,6 +510,38 @@ export function InputArea() {
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-2" style={{ maxWidth: 'var(--chat-max-width)', margin: '0 auto', width: '100%' }}>
|
||||
<div className="mb-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeepResearch(!deepResearch)}
|
||||
disabled={streamState.isStreaming}
|
||||
aria-pressed={deepResearch}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50"
|
||||
style={{
|
||||
background: deepResearch ? 'var(--color-accent-subtle)' : 'transparent',
|
||||
border: `1px solid ${deepResearch ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
color: deepResearch ? 'var(--color-accent)' : 'var(--color-text-tertiary)',
|
||||
}}
|
||||
title={deepResearch ? 'Deep Research: on' : 'Deep Research: off'}
|
||||
>
|
||||
<Search size={12} />
|
||||
Deep Research
|
||||
</button>
|
||||
</div>
|
||||
{deepResearch && corpusSync.syncing && corpusSync.itemsSynced > 0 && (
|
||||
<div
|
||||
className="text-[11px] leading-snug"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
Searching over{' '}
|
||||
<span key={corpusSync.itemsSynced} className="sync-bump" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{corpusSync.itemsSynced.toLocaleString()}
|
||||
</span>{' '}
|
||||
items — sync in progress, results will improve as more data is indexed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-2xl px-4 py-3 transition-shadow"
|
||||
style={{
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'katex/dist/katex.min.css';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { AudioPlayer } from './AudioPlayer';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { ResearchTimeline } from './ResearchTimeline';
|
||||
import { rehypeCitations } from '../../lib/rehype-citations';
|
||||
import { XRayFooter } from './XRayFooter';
|
||||
import type { ChatMessage } from '../../types';
|
||||
|
||||
@@ -19,6 +21,7 @@ function stripThinkTags(text: string): string {
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage;
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
function getTextContent(node: any): string {
|
||||
@@ -97,7 +100,7 @@ function CopyMessageButton({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: Props) {
|
||||
export function MessageBubble({ message, isLive = false }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
if (isUser) {
|
||||
@@ -121,8 +124,33 @@ export function MessageBubble({ message }: Props) {
|
||||
|
||||
const cleanContent = useMemo(() => stripThinkTags(message.content), [message.content]);
|
||||
|
||||
// Build a ref→source lookup once per render. Memoized so the rehype plugin
|
||||
// identity stays stable until the source list actually changes.
|
||||
const sourcesMap = useMemo(() => {
|
||||
const m = new Map<number, NonNullable<ChatMessage['researchSources']>[number]>();
|
||||
for (const s of message.researchSources ?? []) {
|
||||
if (typeof s.ref === 'number') m.set(s.ref, s);
|
||||
}
|
||||
return m;
|
||||
}, [message.researchSources]);
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const base: any[] = [[rehypeHighlight, { detect: true }], rehypeKatex];
|
||||
if (sourcesMap.size > 0) base.push([rehypeCitations, { sources: sourcesMap }]);
|
||||
return base;
|
||||
}, [sourcesMap]);
|
||||
|
||||
return (
|
||||
<div className="group mb-6">
|
||||
{/* Deep Research timeline (steps + status) */}
|
||||
{(message.isResearch || (message.researchTraces && message.researchTraces.length > 0)) && (
|
||||
<ResearchTimeline
|
||||
traces={message.researchTraces ?? []}
|
||||
isLive={isLive}
|
||||
hasContent={cleanContent.length > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="mb-3 flex flex-col gap-2">
|
||||
@@ -140,7 +168,7 @@ export function MessageBubble({ message }: Props) {
|
||||
<div className="prose max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true }], rehypeKatex]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={{
|
||||
pre: CodeBlockPre,
|
||||
}}
|
||||
@@ -154,7 +182,11 @@ export function MessageBubble({ message }: Props) {
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<CopyMessageButton content={cleanContent} />
|
||||
</div>
|
||||
<XRayFooter usage={message.usage} telemetry={message.telemetry} />
|
||||
<XRayFooter
|
||||
usage={message.usage}
|
||||
telemetry={message.telemetry}
|
||||
isResearch={message.isResearch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { ResearchSearchTrace, TimeRange } from '../../types';
|
||||
|
||||
interface Props {
|
||||
traces: ResearchSearchTrace[];
|
||||
isLive: boolean;
|
||||
hasContent: boolean;
|
||||
}
|
||||
|
||||
const SHORT_DATE = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
function fmtDate(iso: string): string | null {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return SHORT_DATE.format(d);
|
||||
}
|
||||
|
||||
function formatTimeRange(tr: TimeRange | string | undefined): string | null {
|
||||
if (!tr) return null;
|
||||
if (typeof tr === 'string') return tr.trim() || null;
|
||||
const start = tr.start ? fmtDate(tr.start) : null;
|
||||
const end = tr.end ? fmtDate(tr.end) : null;
|
||||
if (start && end) return start === end ? start : `${start} – ${end}`;
|
||||
if (start) return `after ${start}`;
|
||||
if (end) return `before ${end}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function summarizeTraces(traces: ResearchSearchTrace[]): string {
|
||||
const n = traces.length;
|
||||
const hits = traces.reduce((s, t) => s + (t.numHits ?? 0), 0);
|
||||
const searchLabel = `${n} ${n === 1 ? 'search' : 'searches'}`;
|
||||
if (hits === 0) return searchLabel;
|
||||
return `${searchLabel} · ${hits} ${hits === 1 ? 'result' : 'results'}`;
|
||||
}
|
||||
|
||||
function StatusLine({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="text-xs leading-relaxed animate-pulse"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{text}…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineStep({
|
||||
index,
|
||||
trace,
|
||||
isLive,
|
||||
}: {
|
||||
index: number;
|
||||
trace: ResearchSearchTrace;
|
||||
isLive: boolean;
|
||||
}) {
|
||||
const pending = trace.status === 'pending';
|
||||
const active = pending && isLive;
|
||||
const meta: string[] = [];
|
||||
if (trace.person) meta.push(`person: ${trace.person}`);
|
||||
const formattedTime = formatTimeRange(trace.timeRange);
|
||||
if (formattedTime) meta.push(`time: ${formattedTime}`);
|
||||
|
||||
return (
|
||||
<div className="relative pl-4">
|
||||
{/* Step indicator dot, sitting on the vertical rail */}
|
||||
<div
|
||||
className="absolute left-0 top-[7px] w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background: active
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--color-text-tertiary)',
|
||||
opacity: active ? 1 : 0.6,
|
||||
transform: 'translateX(-3px)',
|
||||
boxShadow: active ? '0 0 0 3px var(--color-accent-subtle)' : 'none',
|
||||
transition: 'background 200ms, box-shadow 200ms',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="text-[10px] uppercase tracking-[0.08em] mb-0.5"
|
||||
style={{
|
||||
color: active ? 'var(--color-accent)' : 'var(--color-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
Search {index}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--color-text)', fontWeight: 450 }}
|
||||
>
|
||||
“{trace.query}”
|
||||
</div>
|
||||
|
||||
{meta.length > 0 && (
|
||||
<div
|
||||
className="text-[11px] mt-0.5"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{meta.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active ? (
|
||||
<div
|
||||
className="mt-2 h-px overflow-hidden relative"
|
||||
style={{ background: 'var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className="research-shimmer absolute inset-y-0 w-1/4"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
/>
|
||||
</div>
|
||||
) : trace.numHits != null ? (
|
||||
<div
|
||||
className="text-[11px] mt-1"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{trace.numHits} {trace.numHits === 1 ? 'result' : 'results'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{trace.topTitles && trace.topTitles.length > 0 && (
|
||||
<div
|
||||
className="text-[11px] mt-0.5 truncate"
|
||||
style={{ color: 'var(--color-text-tertiary)', opacity: 0.75 }}
|
||||
>
|
||||
{trace.topTitles.slice(0, 2).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResearchTimeline({ traces, isLive, hasContent }: Props) {
|
||||
const showAnalyzing = isLive && traces.length === 0 && !hasContent;
|
||||
const allComplete =
|
||||
traces.length > 0 && traces.every((t) => t.status === 'complete');
|
||||
const showSynthesizing = isLive && allComplete && !hasContent;
|
||||
|
||||
// Auto-collapse the timeline the moment synthesis text begins streaming.
|
||||
// Subsequent user toggles win — we only fire the auto-collapse once per
|
||||
// false→true transition of hasContent.
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const prevHasContent = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasContent && !prevHasContent.current) {
|
||||
setExpanded(false);
|
||||
}
|
||||
prevHasContent.current = hasContent;
|
||||
}, [hasContent]);
|
||||
|
||||
if (!showAnalyzing && traces.length === 0) return null;
|
||||
|
||||
// No collapse affordance before any traces have arrived — just the
|
||||
// analyzing status sitting alone.
|
||||
if (traces.length === 0) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="relative pl-4">
|
||||
<div
|
||||
className="absolute left-0 top-[7px] w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background: 'var(--color-accent)',
|
||||
transform: 'translateX(-3px)',
|
||||
boxShadow: '0 0 0 3px var(--color-accent-subtle)',
|
||||
}}
|
||||
/>
|
||||
<StatusLine text="Analyzing query" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = summarizeTraces(traces);
|
||||
const Chevron = expanded ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex items-center gap-2 text-[11px] mb-2 cursor-pointer transition-colors"
|
||||
style={{
|
||||
color: 'var(--color-text-tertiary)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
}}
|
||||
title={expanded ? 'Collapse search trace' : 'Expand search trace'}
|
||||
>
|
||||
<span>{summary}</span>
|
||||
<Chevron size={12} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="relative">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute top-1 bottom-1 left-0 w-px"
|
||||
style={{ background: 'var(--color-border)' }}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
{traces.map((t, i) => (
|
||||
<TimelineStep
|
||||
key={t.id}
|
||||
index={i + 1}
|
||||
trace={t}
|
||||
isLive={isLive}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showSynthesizing && (
|
||||
<div className="relative pl-4">
|
||||
<div
|
||||
className="absolute left-0 top-[7px] w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background: 'var(--color-accent)',
|
||||
transform: 'translateX(-3px)',
|
||||
boxShadow: '0 0 0 3px var(--color-accent-subtle)',
|
||||
}}
|
||||
/>
|
||||
<StatusLine text="Synthesizing findings" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export function SystemPanel() {
|
||||
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
|
||||
const optInEnabled = useAppStore((s) => s.optInEnabled);
|
||||
const setOptInModalOpen = useAppStore((s) => s.setOptInModalOpen);
|
||||
const liveEnergy = useAppStore((s) => s.liveEnergy);
|
||||
const [energy, setEnergy] = useState<EnergyData | null>(null);
|
||||
const [telemetry, setTelemetry] = useState<TelemetryStats | null>(null);
|
||||
|
||||
@@ -129,13 +130,15 @@ export function SystemPanel() {
|
||||
<MiniStat
|
||||
icon={Zap}
|
||||
label="Power"
|
||||
value={(energy?.avg_power_w ?? 0).toFixed(1)}
|
||||
value={(liveEnergy?.power_w ?? energy?.avg_power_w ?? 0).toFixed(1)}
|
||||
unit="W"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={Activity}
|
||||
label="Energy"
|
||||
value={((energy?.total_energy_j ?? 0) / 1000).toFixed(1)}
|
||||
value={(
|
||||
((liveEnergy?.energy_j ?? energy?.total_energy_j ?? 0) / 1000)
|
||||
).toFixed(1)}
|
||||
unit="kJ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,26 @@ import type { TokenUsage, MessageTelemetry } from '../../types';
|
||||
interface Props {
|
||||
usage?: TokenUsage;
|
||||
telemetry?: MessageTelemetry;
|
||||
isResearch?: boolean;
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function XRayFooter({ usage, telemetry }: Props) {
|
||||
export function XRayFooter({ usage, telemetry, isResearch = false }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Build collapsed summary parts
|
||||
// Build collapsed summary parts. For Deep Research responses we hide the
|
||||
// chat-side engine/model (which doesn't reflect the planner that actually
|
||||
// produced the answer) and label the response with the mode instead.
|
||||
const parts: string[] = [];
|
||||
if (telemetry?.engine) parts.push(telemetry.engine);
|
||||
if (telemetry?.model_id) parts.push(telemetry.model_id);
|
||||
if (isResearch) {
|
||||
parts.push('Deep Research');
|
||||
} else {
|
||||
if (telemetry?.engine) parts.push(telemetry.engine);
|
||||
if (telemetry?.model_id) parts.push(telemetry.model_id);
|
||||
}
|
||||
if (telemetry?.complexity_tier) parts.push(telemetry.complexity_tier);
|
||||
if (telemetry?.total_ms) parts.push(formatMs(telemetry.total_ms));
|
||||
if (usage && (usage.prompt_tokens || usage.completion_tokens)) {
|
||||
@@ -32,7 +39,9 @@ export function XRayFooter({ usage, telemetry }: Props) {
|
||||
|
||||
// Build expanded rows
|
||||
const rows: Array<{ label: string; value: string; color?: string }> = [];
|
||||
if (telemetry?.engine) {
|
||||
if (isResearch) {
|
||||
rows.push({ label: 'Mode', value: 'Deep Research' });
|
||||
} else if (telemetry?.engine) {
|
||||
const modelDetail = telemetry.model_id || '';
|
||||
rows.push({ label: 'Engine', value: `${telemetry.engine}${modelDetail ? ` (${modelDetail})` : ''}` });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,25 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
type UpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const DISABLED_KEY = 'oj-auto-update-disabled';
|
||||
|
||||
export function isAutoUpdateDisabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DISABLED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutoUpdateDisabled(disabled: boolean): void {
|
||||
try {
|
||||
if (disabled) {
|
||||
localStorage.setItem(DISABLED_KEY, '1');
|
||||
} else {
|
||||
localStorage.removeItem(DISABLED_KEY);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function UpdateChecker() {
|
||||
const [state, setState] = useState<UpdateState>('idle');
|
||||
@@ -13,6 +32,7 @@ export function UpdateChecker() {
|
||||
const updateRef = useRef<any>(null);
|
||||
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
if (isAutoUpdateDisabled()) return;
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const update = await check();
|
||||
@@ -28,10 +48,20 @@ export function UpdateChecker() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're in a Tauri environment
|
||||
if (typeof window === 'undefined' || !(window as any).__TAURI_INTERNALS__) {
|
||||
return;
|
||||
}
|
||||
if (isAutoUpdateDisabled()) return;
|
||||
|
||||
// Local dev escape hatch: skip the auto-update poll if explicitly
|
||||
// disabled. Vite exposes any ``VITE_``-prefixed env var on
|
||||
// ``import.meta.env``, so a frontend dev can ``export
|
||||
// VITE_OPENJARVIS_NO_UPDATER=1`` before ``npm run tauri dev`` to
|
||||
// silence the 30-min poll. See docs/desktop-auto-update.md.
|
||||
const noUpdater = (import.meta as any).env?.VITE_OPENJARVIS_NO_UPDATER;
|
||||
if (noUpdater === '1' || noUpdater === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
checkForUpdate();
|
||||
const interval = setInterval(checkForUpdate, CHECK_INTERVAL_MS);
|
||||
@@ -50,9 +80,7 @@ export function UpdateChecker() {
|
||||
const contentLength = update.contentLength ?? 0;
|
||||
|
||||
await update.downloadAndInstall((event: any) => {
|
||||
if (event.event === 'Started' && event.data?.contentLength) {
|
||||
// Content length received
|
||||
} else if (event.event === 'Progress') {
|
||||
if (event.event === 'Progress') {
|
||||
downloaded += event.data?.chunkLength ?? 0;
|
||||
if (contentLength > 0) {
|
||||
setProgress(Math.min(100, Math.round((downloaded / contentLength) * 100)));
|
||||
@@ -75,13 +103,18 @@ export function UpdateChecker() {
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
await relaunch();
|
||||
} catch {
|
||||
// Fallback: inform user to restart manually
|
||||
setErrorMsg('Please restart the application manually');
|
||||
setState('error');
|
||||
setTimeout(() => setState('idle'), 5000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDisable = useCallback(() => {
|
||||
setAutoUpdateDisabled(true);
|
||||
setState('idle');
|
||||
setDismissed(false);
|
||||
}, []);
|
||||
|
||||
if (state === 'idle' || dismissed) return null;
|
||||
|
||||
return (
|
||||
@@ -91,7 +124,8 @@ export function UpdateChecker() {
|
||||
<span>Update available: <strong>v{version}</strong></span>
|
||||
<div style={styles.actions}>
|
||||
<button style={styles.primaryBtn} onClick={handleDownload}>Download</button>
|
||||
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Dismiss</button>
|
||||
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Later</button>
|
||||
<button style={styles.muteBtn} onClick={handleDisable}>Disable auto-updates</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -140,6 +174,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryBtn: {
|
||||
padding: '4px 14px',
|
||||
@@ -170,6 +205,15 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
muteBtn: {
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#585b70',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
progressBar: {
|
||||
flex: 1,
|
||||
maxWidth: '300px',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
import { ApprovalBell } from './ApprovalBell';
|
||||
import { Sidebar } from './Sidebar/Sidebar';
|
||||
import { SystemPulse } from './SystemPulse';
|
||||
import { useAppStore } from '../lib/store';
|
||||
@@ -27,6 +28,7 @@ export function Layout() {
|
||||
<div className="flex flex-col h-full w-full overflow-hidden relative" style={{ paddingTop: '3px' }}>
|
||||
<div className="hud-backdrop" aria-hidden="true" />
|
||||
<SystemPulse apiReachable={apiReachable} />
|
||||
<ApprovalBell />
|
||||
|
||||
{/* Health check banner */}
|
||||
{apiReachable === false && (
|
||||
|
||||
@@ -33,6 +33,7 @@ export function Sidebar() {
|
||||
const serverInfo = useAppStore((s) => s.serverInfo);
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
const modelLoading = useAppStore((s) => s.modelLoading);
|
||||
const deepResearch = useAppStore((s) => s.deepResearch);
|
||||
|
||||
const settings = useAppStore((s) => s.settings);
|
||||
const updateSettings = useAppStore((s) => s.updateSettings);
|
||||
@@ -143,8 +144,13 @@ export function Sidebar() {
|
||||
<Cpu size={14} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="truncate block text-left" style={{ color: 'var(--color-text)' }}>
|
||||
{selectedModel || serverInfo?.model || 'Select model'}
|
||||
<span
|
||||
className="truncate block text-left"
|
||||
style={{ color: deepResearch ? 'var(--color-accent)' : 'var(--color-text)' }}
|
||||
>
|
||||
{deepResearch
|
||||
? 'Deep Research'
|
||||
: selectedModel || serverInfo?.model || 'Select model'}
|
||||
</span>
|
||||
{modelLoading && (
|
||||
<span className="text-[10px] block text-left" style={{ color: 'var(--color-accent)' }}>
|
||||
|
||||
@@ -707,4 +707,50 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
/* Keep project radius tokens — don't override with shadcn calc values */
|
||||
}
|
||||
|
||||
/* Deep Research timeline — pending-step shimmer */
|
||||
@keyframes openjarvis-research-shimmer {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(420%); }
|
||||
}
|
||||
.research-shimmer {
|
||||
animation: openjarvis-research-shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Sync progress pulse — fires on each items_synced bump so the
|
||||
"12,450 / 99,840" label visibly breathes between polls. */
|
||||
@keyframes openjarvis-sync-bump {
|
||||
0% { opacity: 0.55; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.sync-bump {
|
||||
animation: openjarvis-sync-bump 400ms ease-out;
|
||||
}
|
||||
|
||||
/* Deep Research inline citation pills — small rounded chips that flow
|
||||
with the surrounding text and link out to the cited email. */
|
||||
.research-citation {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
margin: 0 1px;
|
||||
padding: 0 6px;
|
||||
min-width: 1.25em;
|
||||
text-align: center;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
border-radius: 9999px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 150ms, color 150ms;
|
||||
/* Keep the pill from inheriting prose link styles (underline, bold). */
|
||||
border-bottom: none;
|
||||
}
|
||||
.research-citation:hover {
|
||||
color: var(--color-on-accent, #ffffff);
|
||||
background: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Frontend analytics client.
|
||||
*
|
||||
* Thin wrapper around posthog-js that:
|
||||
* - Pulls its identity (anon_id, host, project key) from the backend
|
||||
* via GET /v1/analytics/identity, so all backend / install.sh /
|
||||
* frontend events tie to the same person.
|
||||
* - Initializes posthog-js with autocapture, session replay, and
|
||||
* pageviews disabled — we send only events we explicitly call out.
|
||||
* - Registers the app version as a super-property so every event
|
||||
* carries it uniformly (no per-event repetition needed).
|
||||
* - Fails silently when the backend isn't reachable, when analytics
|
||||
* is disabled, or when the SDK throws — must never break the UI.
|
||||
*
|
||||
* Opt-out matches the backend: if /identity returns enabled=false
|
||||
* (analytics disabled in config), the SDK is never
|
||||
* initialized and track() becomes a no-op.
|
||||
*/
|
||||
|
||||
import posthog from 'posthog-js';
|
||||
import { getBase } from './api';
|
||||
|
||||
/**
|
||||
* Mirror of the Python event catalog (src/openjarvis/analytics/events.py
|
||||
* REGISTRY). Anything not in this set is dropped with a console.warn at
|
||||
* track() time, so typos and unallowed events don't silently ship.
|
||||
*
|
||||
* KEEP IN SYNC with the Python REGISTRY. CI could enforce this later.
|
||||
*/
|
||||
const KNOWN_EVENTS = new Set<string>([
|
||||
'install_started',
|
||||
'install_stage_completed',
|
||||
'install_completed',
|
||||
'install_failed',
|
||||
'uninstall_started',
|
||||
'app_opened',
|
||||
'setup_completed',
|
||||
'first_chat_sent',
|
||||
'chat_session_ended',
|
||||
'tool_first_used',
|
||||
'model_changed',
|
||||
'connector_auth_completed',
|
||||
'feature_used',
|
||||
'feedback_submitted',
|
||||
'error_shown_to_user',
|
||||
'settings_changed',
|
||||
'usage_daily_summary',
|
||||
]);
|
||||
|
||||
// Hardcoded app version — should match the backend.
|
||||
// TODO: wire to Vite define() so this comes from package.json at build time.
|
||||
const APP_VERSION = '0.1.0';
|
||||
|
||||
interface AnalyticsIdentity {
|
||||
enabled: boolean;
|
||||
anon_id: string;
|
||||
host: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
let enabledState = false;
|
||||
let cachedAnonId = '';
|
||||
|
||||
/**
|
||||
* Fetch identity from the backend and initialize the SDK.
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export async function initAnalytics(): Promise<void> {
|
||||
if (initialized) return;
|
||||
initialized = true; // claim the slot even on failure paths
|
||||
|
||||
try {
|
||||
const base = getBase();
|
||||
if (!base) return;
|
||||
|
||||
const resp = await fetch(`${base}/v1/analytics/identity`);
|
||||
if (!resp.ok) return;
|
||||
|
||||
const identity: AnalyticsIdentity = await resp.json();
|
||||
if (!identity.enabled || !identity.key || !identity.anon_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
cachedAnonId = identity.anon_id;
|
||||
|
||||
posthog.init(identity.key, {
|
||||
api_host: identity.host,
|
||||
bootstrap: { distinctID: identity.anon_id },
|
||||
// No surprise data collection — only events we call out by name.
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
disable_session_recording: true,
|
||||
// No /decide call for feature flags (saves a request, we don't use them yet).
|
||||
advanced_disable_decide: true,
|
||||
// No PostHog person profiles — we're not making accounts, just sending events.
|
||||
person_profiles: 'never',
|
||||
// Don't try to load IP geolocation; backend disables this too.
|
||||
ip: false,
|
||||
// Best-effort sending only.
|
||||
loaded: () => {
|
||||
enabledState = true;
|
||||
},
|
||||
});
|
||||
|
||||
// Set distinct_id explicitly in case bootstrap raced.
|
||||
posthog.identify(identity.anon_id);
|
||||
|
||||
// Register version + platform as super-properties so they're attached
|
||||
// to every event automatically, without per-call-site repetition.
|
||||
posthog.register({
|
||||
version: APP_VERSION,
|
||||
platform: detectPlatform(),
|
||||
});
|
||||
} catch {
|
||||
// Any failure → analytics off, silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one event. Properties are passed through to PostHog; redaction
|
||||
* happens server-side for backend events. Unknown event names are
|
||||
* dropped here with a console.warn so typos surface in dev rather than
|
||||
* silently landing in the analytics warehouse.
|
||||
*/
|
||||
export function track(
|
||||
event: string,
|
||||
properties: Record<string, unknown> = {},
|
||||
): void {
|
||||
if (!enabledState) return;
|
||||
if (!KNOWN_EVENTS.has(event)) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
`[analytics] Unknown event "${event}" dropped. ` +
|
||||
`Add it to KNOWN_EVENTS in lib/analytics.ts and to the Python ` +
|
||||
`REGISTRY in src/openjarvis/analytics/events.py.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
posthog.capture(event, properties);
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-flush queued events. Call on visibilitychange / pagehide. */
|
||||
export function flush(): void {
|
||||
if (!enabledState) return;
|
||||
try {
|
||||
// posthog-js queues then flushes async; this is best-effort.
|
||||
// The SDK doesn't expose a direct flush, but the page lifecycle
|
||||
// hooks below cause a beacon-style flush automatically.
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
|
||||
export function isAnalyticsEnabled(): boolean {
|
||||
return enabledState;
|
||||
}
|
||||
|
||||
export function getAnonId(): string {
|
||||
return cachedAnonId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an identifier to a 16-char sha256 hex prefix.
|
||||
*
|
||||
* Used for model / tool names that we want to cohort on without
|
||||
* actually shipping the raw value (e.g. proprietary model names a
|
||||
* power user has configured). Mirror of the backend's
|
||||
* :func:`openjarvis.analytics.redaction.hash_id`.
|
||||
*/
|
||||
export async function hashId(s: string): Promise<string> {
|
||||
if (!s) return '';
|
||||
try {
|
||||
const data = new TextEncoder().encode(s);
|
||||
const buf = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.slice(0, 8)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect platform string conforming to the events.py allowlist:
|
||||
* "tauri-macos" | "tauri-linux" | "tauri-windows" | "web".
|
||||
*/
|
||||
export function detectPlatform(): string {
|
||||
const ua =
|
||||
typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '';
|
||||
const isTauri =
|
||||
typeof window !== 'undefined' &&
|
||||
!!(window as unknown as { __TAURI_INTERNALS__?: unknown })
|
||||
.__TAURI_INTERNALS__;
|
||||
if (isTauri) {
|
||||
if (ua.includes('mac')) return 'tauri-macos';
|
||||
if (ua.includes('windows')) return 'tauri-windows';
|
||||
return 'tauri-linux';
|
||||
}
|
||||
return 'web';
|
||||
}
|
||||
+53
-6
@@ -192,12 +192,26 @@ export async function checkHealth(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${getBase()}/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// In the browser, hit /health relative to the page origin so the request
|
||||
// flows through whatever path is already serving the SPA — the Vite
|
||||
// proxy in dev, FastAPI's static mount in prod. This avoids the
|
||||
// false-negative "Cannot reach backend" banner when getBase() points at
|
||||
// an absolute URL the browser can't reach directly.
|
||||
//
|
||||
// If /health itself fails for any reason (proxy quirk, stale service
|
||||
// worker, etc.) fall back to an arbitrary API endpoint we know the rest
|
||||
// of the app polls successfully. If THAT also fails we genuinely can't
|
||||
// reach the backend.
|
||||
const probe = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (await probe('/health')) return true;
|
||||
return probe('/v1/connectors');
|
||||
}
|
||||
|
||||
export async function fetchEnergy(): Promise<unknown> {
|
||||
@@ -917,3 +931,36 @@ export async function getMemoryConfig(): Promise<MemoryConfig> {
|
||||
if (!res.ok) throw new Error('Failed to fetch memory config');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approvals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PendingApproval {
|
||||
id: string;
|
||||
action_type: string;
|
||||
description: string;
|
||||
payload: Record<string, unknown>;
|
||||
permission_key: string;
|
||||
tier: 'trivial' | 'low' | 'medium' | 'high';
|
||||
status: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export async function fetchPendingApprovals(): Promise<PendingApproval[]> {
|
||||
const res = await fetch(`${getBase()}/v1/approvals/pending`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.actions || [];
|
||||
}
|
||||
|
||||
export async function approveAction(actionId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/approve`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function denyAction(actionId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/deny`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ResearchSource } from '../types';
|
||||
|
||||
interface HastNode {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
value?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
children?: HastNode[];
|
||||
}
|
||||
|
||||
// Tags whose descendants should NOT be searched for citations.
|
||||
// `<a>` is handled specially below (markdown link to a single number is
|
||||
// promoted to a pill); other links + code/pre/script/style are skipped.
|
||||
const SKIP_TAGS = new Set(['code', 'pre', 'script', 'style']);
|
||||
|
||||
// Matches `[N]` or `[N, M, ...]` — bracketed comma-separated digit lists.
|
||||
// Whitespace inside the brackets is tolerated. An optional trailing
|
||||
// whitespace run is consumed when followed by sentence punctuation, so we
|
||||
// can render `… San Francisco [1].` without a stray space before the period.
|
||||
const CITATION_RE =
|
||||
/\[(\s*\d+(?:\s*,\s*\d+)*\s*)\](\s+(?=[.,;:!?]))?/g;
|
||||
|
||||
const TOOLTIP_DATE = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
function formatTooltipDate(iso: string | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.trim() || null;
|
||||
return TOOLTIP_DATE.format(d);
|
||||
}
|
||||
|
||||
function buildTooltip(src: ResearchSource): string {
|
||||
const parts: string[] = [];
|
||||
if (src.title) parts.push(src.title);
|
||||
const meta: string[] = [];
|
||||
if (src.sender) meta.push(src.sender);
|
||||
const date = formatTooltipDate(src.date);
|
||||
if (date) meta.push(date);
|
||||
if (meta.length > 0) parts.push(meta.join(' · '));
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function buildPill(n: number, src: ResearchSource): HastNode {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'a',
|
||||
properties: {
|
||||
href: src.url,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className: ['research-citation'],
|
||||
'data-ref': String(n),
|
||||
title: buildTooltip(src) || `Source ${n}`,
|
||||
},
|
||||
children: [{ type: 'text', value: String(n) }],
|
||||
};
|
||||
}
|
||||
|
||||
function parseRefList(inner: string): number[] {
|
||||
return inner
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace bracketed citations in the rendered markdown with small inline
|
||||
* pill links to the underlying source.
|
||||
*
|
||||
* Patterns handled:
|
||||
* • `[1]` — single ref
|
||||
* • `[4, 7, 20]` — grouped refs → individual pills, no separators
|
||||
* • `[1](https://…)` — markdown link whose text is just a number; the
|
||||
* inline URL is discarded in favor of the source-map
|
||||
* URL so all citations stay routed through Gmail.
|
||||
*
|
||||
* Citations whose ref is missing from the source map are preserved as
|
||||
* literal text so we never silently lose information.
|
||||
*/
|
||||
export function rehypeCitations(options: {
|
||||
sources: Map<number, ResearchSource>;
|
||||
}) {
|
||||
const { sources } = options;
|
||||
if (sources.size === 0) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
function transformText(text: string): HastNode[] | null {
|
||||
if (!text.includes('[')) return null;
|
||||
|
||||
const pieces: HastNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
CITATION_RE.lastIndex = 0;
|
||||
let changed = false;
|
||||
|
||||
while ((m = CITATION_RE.exec(text)) !== null) {
|
||||
const refs = parseRefList(m[1]);
|
||||
const resolved = refs.map((n) => ({ n, src: sources.get(n) }));
|
||||
const allMatched =
|
||||
resolved.length > 0 && resolved.every((r) => r.src && r.src.url);
|
||||
|
||||
if (m.index > lastIndex) {
|
||||
pieces.push({ type: 'text', value: text.slice(lastIndex, m.index) });
|
||||
}
|
||||
|
||||
if (allMatched) {
|
||||
for (const r of resolved) {
|
||||
pieces.push(buildPill(r.n, r.src!));
|
||||
}
|
||||
changed = true;
|
||||
} else {
|
||||
// Conservative fallback: if any ref in this group is missing source
|
||||
// data, keep the whole bracketed expression as the model wrote it.
|
||||
pieces.push({ type: 'text', value: m[0] });
|
||||
}
|
||||
|
||||
lastIndex = m.index + m[0].length;
|
||||
}
|
||||
|
||||
if (!changed) return null;
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
pieces.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
function tryMarkdownCitationLink(node: HastNode): HastNode | null {
|
||||
// `[1](url)` → <a href="url">1</a>. If the link text is just a number
|
||||
// and we have a source for that ref, replace with our pill.
|
||||
if (
|
||||
node.type !== 'element' ||
|
||||
node.tagName !== 'a' ||
|
||||
!node.children ||
|
||||
node.children.length !== 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const child = node.children[0];
|
||||
if (child.type !== 'text' || typeof child.value !== 'string') return null;
|
||||
|
||||
const trimmed = child.value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
|
||||
const n = parseInt(trimmed, 10);
|
||||
const src = sources.get(n);
|
||||
if (!src || !src.url) return null;
|
||||
|
||||
return buildPill(n, src);
|
||||
}
|
||||
|
||||
function walk(node: HastNode): void {
|
||||
if (!node.children || node.children.length === 0) return;
|
||||
if (node.tagName && SKIP_TAGS.has(node.tagName)) return;
|
||||
|
||||
const out: HastNode[] = [];
|
||||
let changed = false;
|
||||
|
||||
for (const child of node.children) {
|
||||
// 1. Markdown citation link: [1](url)
|
||||
const promoted = tryMarkdownCitationLink(child);
|
||||
if (promoted) {
|
||||
out.push(promoted);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Other elements — recurse but don't transform inside <a>/code/etc.
|
||||
if (child.type === 'element') {
|
||||
if (child.tagName !== 'a') walk(child);
|
||||
out.push(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Text node — look for [N] and [N, M, …] patterns.
|
||||
if (child.type === 'text' && typeof child.value === 'string') {
|
||||
const replaced = transformText(child.value);
|
||||
if (replaced) {
|
||||
out.push(...replaced);
|
||||
changed = true;
|
||||
} else {
|
||||
out.push(child);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(child);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
node.children = out;
|
||||
}
|
||||
}
|
||||
|
||||
return (tree: HastNode) => {
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
+50
-1
@@ -1,4 +1,4 @@
|
||||
import type { SSEEvent } from '../types';
|
||||
import type { ResearchEvent, SSEEvent } from '../types';
|
||||
import { getBase } from './api';
|
||||
|
||||
export interface ChatRequest {
|
||||
@@ -57,3 +57,52 @@ export async function* streamChat(
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamResearch(
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<ResearchEvent> {
|
||||
// /api/research is mounted at the server root — strip any trailing /v1
|
||||
// from the base so configurations like "http://host:8000/v1" still resolve.
|
||||
const base = getBase().replace(/\/v1\/?$/, '');
|
||||
const response = await fetch(`${base}/api/research`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Research request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') return;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as ResearchEvent;
|
||||
yield parsed;
|
||||
if (parsed.type === 'done') return;
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import { create } from 'zustand';
|
||||
import type {
|
||||
Conversation,
|
||||
ChatMessage,
|
||||
LiveEnergyMetrics,
|
||||
LogEntry,
|
||||
ModelInfo,
|
||||
MessageTelemetry,
|
||||
ResearchSearchTrace,
|
||||
ResearchSource,
|
||||
SavingsData,
|
||||
ServerInfo,
|
||||
StreamState,
|
||||
@@ -158,16 +161,29 @@ interface AppState {
|
||||
usage?: TokenUsage,
|
||||
telemetry?: MessageTelemetry,
|
||||
audio?: { url: string },
|
||||
researchTraces?: ResearchSearchTrace[],
|
||||
researchSources?: ResearchSource[],
|
||||
) => void;
|
||||
setStreamState: (state: Partial<StreamState>) => void;
|
||||
resetStream: () => void;
|
||||
|
||||
// Deep Research toggle
|
||||
deepResearch: boolean;
|
||||
setDeepResearch: (on: boolean) => void;
|
||||
|
||||
// Actions: models & server
|
||||
setModels: (models: ModelInfo[]) => void;
|
||||
setModelsLoading: (loading: boolean) => void;
|
||||
setSelectedModel: (model: string) => void;
|
||||
setServerInfo: (info: ServerInfo | null) => void;
|
||||
setSavings: (data: SavingsData | null) => void;
|
||||
incrementSavings: (usage: TokenUsage) => void;
|
||||
|
||||
// Live GPU metrics — streamed from /api/research system_metrics events.
|
||||
// When non-null, the System panel renders this instead of polled values
|
||||
// so Power (W) and Energy (kJ) update in real time during a research run.
|
||||
liveEnergy: LiveEnergyMetrics | null;
|
||||
setLiveEnergy: (data: LiveEnergyMetrics | null) => void;
|
||||
|
||||
// Actions: settings
|
||||
updateSettings: (partial: Partial<Settings>) => void;
|
||||
@@ -270,6 +286,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
const existing = store.conversations[overlay.id];
|
||||
// Only update if the overlay has newer/more messages
|
||||
if (existing && existing.messages.length >= overlay.messages.length) return;
|
||||
// Track first use of overlay for this conversation
|
||||
if (!existing) {
|
||||
import('../lib/analytics').then(({ track }) => {
|
||||
track('feature_used', { feature_name: 'overlay' });
|
||||
});
|
||||
}
|
||||
store.conversations[overlay.id] = {
|
||||
id: overlay.id,
|
||||
title: overlay.title || 'Overlay chat',
|
||||
@@ -381,6 +403,8 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
usage?: TokenUsage,
|
||||
telemetry?: MessageTelemetry,
|
||||
audio?: { url: string },
|
||||
researchTraces?: ResearchSearchTrace[],
|
||||
researchSources?: ResearchSource[],
|
||||
) => {
|
||||
const store = loadConversations();
|
||||
const conv = store.conversations[conversationId];
|
||||
@@ -392,6 +416,8 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
if (usage) lastMsg.usage = usage;
|
||||
if (telemetry) lastMsg.telemetry = telemetry;
|
||||
if (audio) lastMsg.audio = audio;
|
||||
if (researchTraces) lastMsg.researchTraces = researchTraces;
|
||||
if (researchSources) lastMsg.researchSources = researchSources;
|
||||
conv.updatedAt = Date.now();
|
||||
saveConversations(store);
|
||||
set({ messages: [...conv.messages] });
|
||||
@@ -406,6 +432,10 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
set({ streamState: INITIAL_STREAM });
|
||||
},
|
||||
|
||||
// ── Deep Research ─────────────────────────────────────────────
|
||||
deepResearch: false,
|
||||
setDeepResearch: (on: boolean) => set({ deepResearch: on }),
|
||||
|
||||
// ── Models & server ────────────────────────────────────────────
|
||||
|
||||
setModels: (models: ModelInfo[]) => set({ models }),
|
||||
@@ -413,6 +443,26 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
setSelectedModel: (model: string) => set({ selectedModel: model }),
|
||||
setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
|
||||
setSavings: (data: SavingsData | null) => set({ savings: data }),
|
||||
incrementSavings: (usage: TokenUsage) => {
|
||||
const cur = get().savings;
|
||||
const prompt = usage.prompt_tokens ?? 0;
|
||||
const completion = usage.completion_tokens ?? 0;
|
||||
const total = usage.total_tokens ?? prompt + completion;
|
||||
set({
|
||||
savings: {
|
||||
total_calls: (cur?.total_calls ?? 0) + 1,
|
||||
total_prompt_tokens: (cur?.total_prompt_tokens ?? 0) + prompt,
|
||||
total_completion_tokens: (cur?.total_completion_tokens ?? 0) + completion,
|
||||
total_tokens: (cur?.total_tokens ?? 0) + total,
|
||||
local_cost: cur?.local_cost ?? 0,
|
||||
per_provider: cur?.per_provider ?? [],
|
||||
token_counting_version: cur?.token_counting_version,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
liveEnergy: null,
|
||||
setLiveEnergy: (data: LiveEnergyMetrics | null) => set({ liveEnergy: data }),
|
||||
|
||||
cachedConnectors: null,
|
||||
setCachedConnectors: (list) => set({ cachedConnectors: list }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import App from './App';
|
||||
import { initApiBase } from './lib/api';
|
||||
import { initAnalytics } from './lib/analytics';
|
||||
import './index.css';
|
||||
|
||||
function applyTheme() {
|
||||
@@ -27,6 +28,10 @@ applyTheme();
|
||||
// This ensures JARVIS_PORT is defined in one place (the Rust backend).
|
||||
// In non-Tauri environments this is a no-op.
|
||||
initApiBase().finally(() => {
|
||||
// Kick off analytics init in the background — it's never awaited so
|
||||
// a slow/failed identity fetch never delays UI render.
|
||||
void initAnalytics();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -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, getSyncStatus, triggerSync } from '../lib/connectors-api';
|
||||
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync } from '../lib/connectors-api';
|
||||
import type { SyncStatus } from '../types/connectors';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -312,11 +312,120 @@ const IconFor = ({ id, size = 18 }: { id: string; size?: number }) => {
|
||||
return <Ico size={size} />;
|
||||
};
|
||||
|
||||
// The Gmail card unifies the OAuth (`gmail`) and IMAP (`gmail_imap`) backend
|
||||
// connectors — both should resolve to the gmail_imap catalog entry so the
|
||||
// connected card shows the same name, unit label, and troubleshooting tips
|
||||
// regardless of which underlying flow the user picked.
|
||||
function metaFor(connectorId: string) {
|
||||
const id = connectorId === 'gmail' ? 'gmail_imap' : connectorId;
|
||||
return SOURCE_CATALOG.find((s) => s.connector_id === id);
|
||||
}
|
||||
|
||||
// Advanced OAuth disclosure for the unified Gmail card. Hidden by default;
|
||||
// expands to a Client ID + Client Secret form that POSTs to the OAuth
|
||||
// `gmail` backend connector. Lives here rather than in SOURCE_CATALOG
|
||||
// because the Gmail card is the only one with a dual-flow shape.
|
||||
function GmailOAuthAdvanced({
|
||||
loading,
|
||||
onConnect,
|
||||
}: {
|
||||
loading: boolean;
|
||||
onConnect: (req: ConnectRequest) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-tertiary)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{open ? 'Hide advanced' : 'Advanced: Connect with Google OAuth'}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 10,
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)', marginBottom: 8 }}>
|
||||
For developers with an existing Google Cloud project. Enable the
|
||||
Gmail API and create a Desktop OAuth client at{' '}
|
||||
<a
|
||||
href="https://console.cloud.google.com/apis/credentials"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)', textDecoration: 'underline' }}
|
||||
>
|
||||
Google Cloud Credentials →
|
||||
</a>{' '}
|
||||
then paste the Client ID and Client Secret below.
|
||||
</div>
|
||||
<InlineConnectForm
|
||||
fields={[
|
||||
{ name: 'email', placeholder: 'Client ID', type: 'text' },
|
||||
{ name: 'password', placeholder: 'Client Secret', type: 'password' },
|
||||
]}
|
||||
loading={loading}
|
||||
onSubmit={onConnect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Sources section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Sync status display component with progress bar
|
||||
function formatTimeAgo(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
const diffSec = (Date.now() - t) / 1000;
|
||||
if (diffSec < 30) return 'just now';
|
||||
if (diffSec < 60) return 'less than a min ago';
|
||||
if (diffSec < 3600) {
|
||||
const m = Math.round(diffSec / 60);
|
||||
return `${m} min${m === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
if (diffSec < 86400) {
|
||||
const h = Math.round(diffSec / 3600);
|
||||
return `${h} hr${h === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
const d = Math.round(diffSec / 86400);
|
||||
return `${d} day${d === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
/** Render how far back the corpus extends, given the oldest indexed
|
||||
* item's timestamp. Returns null when there isn't enough data yet. */
|
||||
function formatBacklogRange(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
const days = (Date.now() - t) / 86400_000;
|
||||
if (days < 7) return 'past few days';
|
||||
if (days < 30) return 'past month';
|
||||
if (days < 90) return 'past 3 months';
|
||||
if (days < 365) return 'past year';
|
||||
const years = Math.round(days / 365);
|
||||
return `past ${years} year${years === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function SyncStatusDisplay({
|
||||
chunks,
|
||||
sync,
|
||||
@@ -368,13 +477,65 @@ function SyncStatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// Done — has chunks
|
||||
if (chunks > 0) {
|
||||
// Treat the SyncEngine's checkpointed items_synced as the source of
|
||||
// truth for "total indexed" — `chunks` from listConnectors counts
|
||||
// embedding chunks (often != source items) and the checkpoint is what
|
||||
// both the syncing and idle branches need to display consistently.
|
||||
const totalIndexed = sync?.items_synced ?? chunks;
|
||||
const itemsTotal = sync?.items_total ?? 0;
|
||||
const backlogRange = formatBacklogRange(sync?.oldest_item_date);
|
||||
// "Complete inbox" — the user has indexed everything reachable. Only
|
||||
// surface this label when idle (during a sync we always show how far
|
||||
// back we've gotten so far).
|
||||
const isComplete =
|
||||
totalIndexed > 0 && itemsTotal > 0 && totalIndexed >= itemsTotal;
|
||||
|
||||
// Actively syncing — single status line + reassurance line.
|
||||
if (sync?.state === 'syncing' || syncing) {
|
||||
const rangeLabel = backlogRange ?? 'building corpus';
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-warning)', marginBottom: 4 }}>
|
||||
Indexed{' '}
|
||||
<span key={totalIndexed} className="sync-bump">
|
||||
{totalIndexed.toLocaleString()} {unitLabel}
|
||||
</span>{' '}
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
({rangeLabel})
|
||||
</span>{' '}
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
· Still indexing…
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--color-text-tertiary)' }}>
|
||||
Deep Research available now · results improve as more {unitLabel} are indexed
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle — already has indexed items: show the corpus size + range or
|
||||
// "complete inbox" label, plus how long ago we last refreshed it.
|
||||
if (totalIndexed > 0) {
|
||||
const lastSyncLabel = formatTimeAgo(sync?.last_sync);
|
||||
const rangeLabel = isComplete
|
||||
? 'complete inbox'
|
||||
: backlogRange;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-success)' }}>
|
||||
{chunks.toLocaleString()} {unitLabel}
|
||||
Indexed {totalIndexed.toLocaleString()} {unitLabel}
|
||||
{rangeLabel && (
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{' '}({rangeLabel})
|
||||
</span>
|
||||
)}
|
||||
{lastSyncLabel && (
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{' · '}Last synced {lastSyncLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
@@ -397,70 +558,14 @@ function SyncStatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// Actively syncing
|
||||
if (sync?.state === 'syncing' || syncing) {
|
||||
const pct = sync?.items_total && sync.items_total > 0
|
||||
? Math.round((sync.items_synced / sync.items_total) * 100)
|
||||
: null;
|
||||
const label = sync?.items_total && sync.items_total > 0
|
||||
? `${sync.items_synced.toLocaleString()} / ${sync.items_total.toLocaleString()}`
|
||||
: sync?.items_synced && sync.items_synced > 0
|
||||
? `${sync.items_synced.toLocaleString()} items so far`
|
||||
: 'Starting...';
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-warning)', marginBottom: 4 }}>
|
||||
Syncing — {label}
|
||||
</div>
|
||||
<div style={{
|
||||
height: 4, borderRadius: 2,
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 2,
|
||||
background: 'var(--color-warning)',
|
||||
width: pct != null ? `${pct}%` : '30%',
|
||||
transition: 'width 0.5s ease',
|
||||
animationName: pct == null ? 'pulse' : undefined,
|
||||
animationDuration: pct == null ? '1.5s' : undefined,
|
||||
animationIterationCount: pct == null ? 'infinite' : undefined,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle with items synced but no chunks yet (indexing)
|
||||
if (sync?.state === 'idle' && sync.items_synced > 0) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-warning)', marginBottom: 4 }}>
|
||||
Indexing {sync.items_synced.toLocaleString()} items...
|
||||
</div>
|
||||
<div style={{
|
||||
height: 4, borderRadius: 2,
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 2, background: 'var(--color-warning)',
|
||||
width: '60%',
|
||||
animationName: 'pulse', animationDuration: '1.5s', animationIterationCount: 'infinite',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected but no chunks yet
|
||||
// Connected but nothing ever ingested. Mirror the original copy.
|
||||
const hasSynced = sync?.last_sync != null;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
||||
{hasSynced
|
||||
? 'Synced — 0 items found'
|
||||
? `Synced — 0 ${unitLabel} found`
|
||||
: 'Connected — not synced yet'}
|
||||
</span>
|
||||
<button
|
||||
@@ -546,6 +651,21 @@ function DataSourcesSection() {
|
||||
const [connectingId, setConnectingId] = useState<string | null>(null);
|
||||
const [connectStage, setConnectStage] = useState<string>('');
|
||||
const [connectError, setConnectError] = useState<string>('');
|
||||
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
|
||||
|
||||
const handleDisconnect = async (id: string) => {
|
||||
if (disconnectingId) return;
|
||||
setDisconnectingId(id);
|
||||
try {
|
||||
await disconnectSource(id);
|
||||
loadConnectors();
|
||||
} catch {
|
||||
// Surface failures silently — the connector list will refresh on the
|
||||
// next poll and reflect the true state regardless.
|
||||
} finally {
|
||||
setDisconnectingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (id: string, req: ConnectRequest) => {
|
||||
setLoading(true);
|
||||
@@ -598,8 +718,32 @@ function DataSourcesSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const connected = connectors.filter((c) => c.connected);
|
||||
const notConnectedBase = connectors.filter((c) => !c.connected);
|
||||
// Merge the OAuth Gmail (`gmail`) and IMAP Gmail (`gmail_imap`) backend
|
||||
// connectors into a single user-facing Gmail card. IMAP is the default
|
||||
// flow (no Google Cloud setup needed); OAuth lives behind an "Advanced"
|
||||
// disclosure when the card is expanded. If both happen to be connected,
|
||||
// keep whichever has more indexed chunks so the active source still
|
||||
// surfaces its sync state.
|
||||
const unifiedConnectors = (() => {
|
||||
const gmail = connectors.find((c) => c.connector_id === 'gmail');
|
||||
const gmailImap = connectors.find((c) => c.connector_id === 'gmail_imap');
|
||||
if (!gmail || !gmailImap) return connectors;
|
||||
if (gmail.connected && !gmailImap.connected) {
|
||||
return connectors.filter((c) => c.connector_id !== 'gmail_imap');
|
||||
}
|
||||
if (gmailImap.connected && !gmail.connected) {
|
||||
return connectors.filter((c) => c.connector_id !== 'gmail');
|
||||
}
|
||||
if (gmail.connected && gmailImap.connected) {
|
||||
const dropId = gmail.chunks >= gmailImap.chunks ? 'gmail_imap' : 'gmail';
|
||||
return connectors.filter((c) => c.connector_id !== dropId);
|
||||
}
|
||||
// Neither connected — show only the IMAP card as the default flow.
|
||||
return connectors.filter((c) => c.connector_id !== 'gmail');
|
||||
})();
|
||||
|
||||
const connected = unifiedConnectors.filter((c) => c.connected);
|
||||
const notConnectedBase = unifiedConnectors.filter((c) => !c.connected);
|
||||
// Always show the upload card in the not-connected list (it has no backend connector)
|
||||
const uploadEntry = { connector_id: 'upload', display_name: 'Upload / Paste', connected: false, chunks: 0 };
|
||||
const notConnected = notConnectedBase.some((c) => c.connector_id === 'upload')
|
||||
@@ -642,10 +786,9 @@ function DataSourcesSection() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{connected.map((c) => {
|
||||
const meta = SOURCE_CATALOG.find(s => s.connector_id === c.connector_id);
|
||||
const meta = metaFor(c.connector_id);
|
||||
const unit = meta?.unitLabel || 'items';
|
||||
const sync = syncStatuses[c.connector_id];
|
||||
const isReconnecting = expandedId === c.connector_id;
|
||||
const hasError = !!sync?.error;
|
||||
return (
|
||||
<div
|
||||
@@ -663,7 +806,7 @@ function DataSourcesSection() {
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="font-semibold" style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{c.display_name}
|
||||
{meta?.display_name ?? c.display_name}
|
||||
</div>
|
||||
<SyncStatusDisplay
|
||||
chunks={c.chunks}
|
||||
@@ -674,60 +817,23 @@ function DataSourcesSection() {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpandedId(isReconnecting ? null : c.connector_id)}
|
||||
onClick={() => handleDisconnect(c.connector_id)}
|
||||
disabled={disconnectingId === c.connector_id}
|
||||
className="hud-label"
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 4, cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
cursor: disconnectingId === c.connector_id ? 'default' : 'pointer',
|
||||
letterSpacing: '0.15em',
|
||||
opacity: disconnectingId === c.connector_id ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{isReconnecting ? 'Cancel' : 'Reconnect'}
|
||||
{disconnectingId === c.connector_id ? 'Disconnecting…' : 'Disconnect'}
|
||||
</button>
|
||||
</div>
|
||||
{isReconnecting && meta?.steps && (
|
||||
<div style={{ borderTop: '1px solid var(--color-border)', padding: 12 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-warning)', marginBottom: 8 }}>
|
||||
Re-enter credentials to reconnect this source.
|
||||
</div>
|
||||
{meta.steps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6, padding: 10,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--color-accent-purple)', fontSize: 10, fontWeight: 600, marginBottom: 3 }}>
|
||||
STEP {i + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, marginBottom: step.url ? 4 : 0 }}>{step.label}</div>
|
||||
{step.url && (
|
||||
<a
|
||||
href={step.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)', fontSize: 11, textDecoration: 'underline' }}
|
||||
>
|
||||
{step.urlLabel || 'Open'} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{meta.inputFields && (
|
||||
<InlineConnectForm
|
||||
fields={meta.inputFields}
|
||||
loading={loading}
|
||||
onSubmit={(req) => handleConnect(c.connector_id, req)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -744,7 +850,7 @@ function DataSourcesSection() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{notConnected.map((c) => {
|
||||
const meta = SOURCE_CATALOG.find(s => s.connector_id === c.connector_id);
|
||||
const meta = metaFor(c.connector_id);
|
||||
const isExpanded = expandedId === c.connector_id;
|
||||
|
||||
return (
|
||||
@@ -767,10 +873,10 @@ function DataSourcesSection() {
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="font-semibold" style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{c.display_name}
|
||||
{meta?.display_name ?? c.display_name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)', marginTop: 2 }}>
|
||||
Not connected
|
||||
{meta?.description ?? 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12, fontWeight: 500 }}>
|
||||
@@ -822,6 +928,12 @@ function DataSourcesSection() {
|
||||
onSubmit={(req) => handleConnect(c.connector_id, req)}
|
||||
/>
|
||||
)}
|
||||
{c.connector_id === 'gmail_imap' && (
|
||||
<GmailOAuthAdvanced
|
||||
loading={loading && connectingId === 'gmail'}
|
||||
onConnect={(req) => handleConnect('gmail', req)}
|
||||
/>
|
||||
)}
|
||||
{meta?.troubleshooting && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-[11px] cursor-pointer" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Palette,
|
||||
Globe,
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
Key,
|
||||
Search,
|
||||
Brain,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore, type ThemeMode } from '../lib/store';
|
||||
import { checkHealth, fetchSpeechHealth, getMemoryStats } from '../lib/api';
|
||||
import { isAutoUpdateDisabled, setAutoUpdateDisabled } from '../components/Desktop/UpdateChecker';
|
||||
|
||||
function OllamaModelList() {
|
||||
const [models, setModels] = useState<Array<{ name: string; size: number }>>([]);
|
||||
@@ -122,6 +124,27 @@ export function SettingsPage() {
|
||||
const [speechBackendAvailable, setSpeechBackendAvailable] = useState<boolean | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(() => !isAutoUpdateDisabled());
|
||||
const [updateCheckState, setUpdateCheckState] = useState<'idle' | 'checking' | 'available' | 'latest'>('idle');
|
||||
|
||||
const handleAutoUpdateToggle = useCallback((enabled: boolean) => {
|
||||
setAutoUpdateEnabled(enabled);
|
||||
setAutoUpdateDisabled(!enabled);
|
||||
}, []);
|
||||
|
||||
const handleCheckNow = useCallback(async () => {
|
||||
if (!(window as any).__TAURI_INTERNALS__) return;
|
||||
setUpdateCheckState('checking');
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const update = await check();
|
||||
setUpdateCheckState(update ? 'available' : 'latest');
|
||||
setTimeout(() => setUpdateCheckState('idle'), 4000);
|
||||
} catch {
|
||||
setUpdateCheckState('idle');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [memoryStats, setMemoryStats] = useState<{ entries: number; backend: string } | null>(null);
|
||||
const [memoryEnabled, setMemoryEnabled] = useState(() => {
|
||||
try { return localStorage.getItem('openjarvis-memory-enabled') !== 'false'; } catch { return true; }
|
||||
@@ -551,6 +574,39 @@ export function SettingsPage() {
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Updates */}
|
||||
<Section title="Updates">
|
||||
<SettingRow label="Auto-update" description="Check for new desktop builds automatically every 30 minutes">
|
||||
<button
|
||||
onClick={() => handleAutoUpdateToggle(!autoUpdateEnabled)}
|
||||
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors"
|
||||
style={{ background: autoUpdateEnabled ? 'var(--color-accent)' : 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3.5 w-3.5 rounded-full transition-transform"
|
||||
style={{
|
||||
background: 'white',
|
||||
transform: autoUpdateEnabled ? 'translateX(18px)' : 'translateX(2px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</SettingRow>
|
||||
<SettingRow label="Check for updates" description="Manually check for a new version right now">
|
||||
<button
|
||||
onClick={handleCheckNow}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text)', cursor: 'pointer' }}
|
||||
disabled={updateCheckState === 'checking'}
|
||||
>
|
||||
<RefreshCw size={12} className={updateCheckState === 'checking' ? 'animate-spin' : ''} />
|
||||
{updateCheckState === 'checking' && 'Checking...'}
|
||||
{updateCheckState === 'available' && 'Update available — see banner above'}
|
||||
{updateCheckState === 'latest' && 'Already up to date'}
|
||||
{updateCheckState === 'idle' && 'Check now'}
|
||||
</button>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* About */}
|
||||
<Section title="About">
|
||||
<div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface SyncStatus {
|
||||
state: "idle" | "syncing" | "paused" | "error";
|
||||
items_synced: number;
|
||||
items_total: number;
|
||||
/** Items processed in the current (or most recent) run only. `null`
|
||||
* when no sync has been triggered through this server session yet. */
|
||||
new_items_synced?: number | null;
|
||||
/** ISO 8601 timestamp of the oldest indexed item, used to label how far
|
||||
* back the corpus reaches ("past 3 months", "past 5 years"). `null`
|
||||
* before anything is indexed. */
|
||||
oldest_item_date?: string | null;
|
||||
last_sync: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -73,6 +80,9 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
},
|
||||
// ── Communication ──────────────────────────────────────────────────
|
||||
{
|
||||
// Unified Gmail card. Defaults to the IMAP (app-password) flow because
|
||||
// it needs no Google Cloud setup; the OAuth path is offered as an
|
||||
// "Advanced" disclosure rendered in DataSourcesPage.
|
||||
connector_id: 'gmail_imap',
|
||||
display_name: 'Gmail',
|
||||
auth_type: 'oauth',
|
||||
@@ -83,23 +93,14 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
unitLabel: 'emails',
|
||||
steps: [
|
||||
{
|
||||
label: 'Go to your Google Account \u2192 Security \u2192 2-Step Verification. Make sure it\'s turned ON. App Passwords only work if 2-Step Verification is enabled.',
|
||||
url: 'https://myaccount.google.com/signinoptions/two-step-verification',
|
||||
urlLabel: 'Open Google Security \u2192',
|
||||
},
|
||||
{
|
||||
label: 'Go to App Passwords. Select app: "Mail", device: "Other" and type "OpenJarvis". Click Generate. This does NOT open a login popup \u2014 you\'ll get a 16-character password to copy (you won\'t see it again).',
|
||||
label: 'Make sure 2-Step Verification is enabled, then generate a 16-character App Password (Mail / Other / "OpenJarvis"). Paste it below \u2014 spaces are fine, and use the app password, not your regular Gmail password.',
|
||||
url: 'https://myaccount.google.com/apppasswords',
|
||||
urlLabel: 'Open App Passwords \u2192',
|
||||
},
|
||||
{
|
||||
label: 'Paste your Gmail address and the 16-character app password below (spaces are fine). Use the app password, NOT your regular Gmail password.',
|
||||
urlLabel: 'How to get an app password \u2192',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
"Don't see App Passwords? Make sure 2-Step Verification is enabled first.",
|
||||
"Google Workspace user? Your admin may need to enable App Passwords for your organization.",
|
||||
"Want OAuth instead? Use the Google Drive connector \u2014 it covers Gmail content too.",
|
||||
],
|
||||
inputFields: [
|
||||
{ name: 'email', placeholder: 'you@gmail.com', type: 'text' },
|
||||
@@ -113,7 +114,7 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
category: 'communication',
|
||||
icon: 'Hash',
|
||||
color: 'text-purple-400',
|
||||
description: 'Read messages from channels, DMs, and threads',
|
||||
description: 'Read messages from every channel, private channel, DM, and group DM you have access to',
|
||||
unitLabel: 'messages',
|
||||
steps: [
|
||||
{
|
||||
@@ -122,16 +123,16 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
urlLabel: 'Open Slack Apps',
|
||||
},
|
||||
{
|
||||
label: 'In the left sidebar, click "OAuth & Permissions". Scroll down to "Bot Token Scopes" and click "Add an OAuth Scope" to add EACH of these scopes one by one:',
|
||||
label: 'In the left sidebar, click "OAuth & Permissions". Scroll down to "User Token Scopes" (NOT "Bot Token Scopes"). Click "Add an OAuth Scope" and add EACH of these scopes one by one:',
|
||||
},
|
||||
{
|
||||
label: 'channels:read • channels:history • channels:join • groups:read • groups:history • im:read • im:history • mpim:read • mpim:history • chat:write • users:read • app_mentions:read',
|
||||
label: 'channels:history • channels:read • groups:history • groups:read • im:history • im:read • mpim:history • mpim:read • users:read',
|
||||
},
|
||||
{
|
||||
label: 'In the left sidebar, click "Install App" → click "Install to Workspace" → click "Allow". After installing, copy the "Bot User OAuth Token" that appears (starts with xoxb-)',
|
||||
label: 'In the left sidebar, click "Install App" → click "Install to Workspace" → click "Allow". After installing, copy the "User OAuth Token" that appears (starts with xoxp-, NOT xoxb-)',
|
||||
},
|
||||
{
|
||||
label: 'Paste the bot token below. After connecting, invite the bot to channels you want indexed by typing /invite @OpenJarvis in each channel',
|
||||
label: 'Paste the user token below. Sync indexes every channel, private channel, DM, and group DM you have access to — no need to invite anything to channels',
|
||||
},
|
||||
{
|
||||
label: '(Optional) Set the app icon: in the left sidebar click "Basic Information" → scroll to "Display Information" → upload the OpenJarvis logo',
|
||||
@@ -140,7 +141,7 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
},
|
||||
],
|
||||
inputFields: [
|
||||
{ name: 'token', placeholder: 'xoxb-...', type: 'password' },
|
||||
{ name: 'token', placeholder: 'xoxp-...', type: 'password' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,12 +61,69 @@ export interface MessageTelemetry {
|
||||
suggested_max_tokens?: number;
|
||||
}
|
||||
|
||||
export interface TimeRange {
|
||||
start?: string;
|
||||
end?: string;
|
||||
}
|
||||
|
||||
export interface ResearchSource {
|
||||
ref: number;
|
||||
title?: string;
|
||||
sender?: string;
|
||||
date?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface ResearchSearchTrace {
|
||||
id: string;
|
||||
query: string;
|
||||
person?: string;
|
||||
timeRange?: TimeRange | string;
|
||||
status: 'pending' | 'complete';
|
||||
numHits?: number;
|
||||
topTitles?: string[];
|
||||
}
|
||||
|
||||
export type ResearchEvent =
|
||||
| {
|
||||
type: 'search_call';
|
||||
arguments: {
|
||||
query: string;
|
||||
person?: string;
|
||||
time_range?: TimeRange | string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'search_result';
|
||||
num_hits: number;
|
||||
top_titles?: string[];
|
||||
sources?: ResearchSource[];
|
||||
}
|
||||
| { type: 'synthesis'; text: string }
|
||||
| {
|
||||
type: 'system_metrics';
|
||||
power_w: number;
|
||||
energy_j: number;
|
||||
duration_s: number;
|
||||
}
|
||||
| { type: 'done'; usage?: TokenUsage }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export interface LiveEnergyMetrics {
|
||||
power_w: number;
|
||||
energy_j: number;
|
||||
duration_s: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
researchTraces?: ResearchSearchTrace[];
|
||||
researchSources?: ResearchSource[];
|
||||
isResearch?: boolean;
|
||||
usage?: TokenUsage;
|
||||
telemetry?: MessageTelemetry;
|
||||
audio?: { url: string };
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/optinmodal.tsx","./src/components/setupscreen.tsx","./src/components/systempulse.tsx","./src/components/chat/audioplayer.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/micbutton.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/chat/xrayfooter.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/components/setup/ingestdashboard.tsx","./src/components/setup/readyscreen.tsx","./src/components/setup/setupwizard.tsx","./src/components/setup/sourceconnectflow.tsx","./src/components/setup/sourcepicker.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tooltip.tsx","./src/hooks/usespeech.ts","./src/lib/api.ts","./src/lib/connectors-api.ts","./src/lib/deep-link.ts","./src/lib/profanity.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/lib/utils.ts","./src/pages/agentspage.tsx","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/datasourcespage.tsx","./src/pages/getstartedpage.tsx","./src/pages/logspage.tsx","./src/pages/settingspage.tsx","./src/types/connectors.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/optinmodal.tsx","./src/components/setupscreen.tsx","./src/components/systempulse.tsx","./src/components/chat/audioplayer.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/micbutton.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/chat/xrayfooter.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/components/setup/ingestdashboard.tsx","./src/components/setup/readyscreen.tsx","./src/components/setup/setupwizard.tsx","./src/components/setup/sourceconnectflow.tsx","./src/components/setup/sourcepicker.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tooltip.tsx","./src/hooks/usespeech.ts","./src/lib/analytics.ts","./src/lib/api.ts","./src/lib/connectors-api.ts","./src/lib/deep-link.ts","./src/lib/profanity.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/lib/useagentevents.ts","./src/lib/utils.ts","./src/pages/agentspage.tsx","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/datasourcespage.tsx","./src/pages/getstartedpage.tsx","./src/pages/logspage.tsx","./src/pages/settingspage.tsx","./src/types/connectors.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
navigateFallbackDenylist: [/^\/v1\//, /^\/health/, /^\/dashboard/],
|
||||
navigateFallbackDenylist: [/^\/v1\//, /^\/health/, /^\/dashboard/, /^\/api\//],
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -53,6 +53,7 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/v1': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
'/health': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
'/api': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ plugins:
|
||||
- gen-files:
|
||||
scripts:
|
||||
- docs/gen_ref_pages.py
|
||||
- docs/gen_install_script.py
|
||||
- literate-nav:
|
||||
nav_file: SUMMARY.md
|
||||
- mkdocstrings:
|
||||
|
||||
+15
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "OpenJarvis"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
description = "OpenJarvis — modular AI assistant backend with composable intelligence primitives"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -29,6 +29,8 @@ dependencies = [
|
||||
"ddgs>=9.11.4",
|
||||
"httpx>=0.27",
|
||||
"openai>=1.30",
|
||||
"posthog>=3.0",
|
||||
"nvidia-ml-py>=12.560.30",
|
||||
"python-telegram-bot>=22.6",
|
||||
"rich>=13",
|
||||
"tomli>=2.0; python_version < '3.11'",
|
||||
@@ -78,13 +80,19 @@ server = [
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
|
||||
gpu-metrics = ["pynvml>=12.0"]
|
||||
gpu-metrics = ["nvidia-ml-py>=12.560.30"]
|
||||
energy-amd = ["amdsmi>=6.1"]
|
||||
energy-apple = ["zeus-ml[apple]"]
|
||||
energy-all = ["pynvml>=12.0", "amdsmi>=6.1", "zeus-ml[apple]"]
|
||||
energy-all = ["nvidia-ml-py>=12.560.30", "amdsmi>=6.1", "zeus-ml[apple]"]
|
||||
orchestrator-training = ["torch>=2.0", "transformers>=4.40"]
|
||||
learning-dspy = ["dspy>=2.6"]
|
||||
learning-gepa = ["gepa>=0.1"]
|
||||
# ACE (Agentic Context Engineering) is supported via
|
||||
# ``openjarvis.learning.agents.ace_optimizer`` but ACE upstream isn't on
|
||||
# PyPI and isn't structured as an installable Python package as of
|
||||
# v1.0.1, so there's no ``learning-ace`` extra. To use ACE, follow the
|
||||
# manual setup in docs/learning/ace.md (clone the upstream repo, add
|
||||
# its ``src/`` to PYTHONPATH).
|
||||
channel-telegram = ["python-telegram-bot>=21.0"]
|
||||
channel-discord = ["discord.py>=2.3"]
|
||||
channel-slack = ["slack-sdk>=3.27"]
|
||||
@@ -182,6 +190,10 @@ select = ["E", "F", "I", "W"]
|
||||
# hybrid/ is research code with long prompt strings and paradigm-specific
|
||||
# config dicts — same relaxation as evals research code above.
|
||||
"src/openjarvis/agents/hybrid/*.py" = ["E501"]
|
||||
# research_loop.py carries the multi-paragraph planner system prompt as
|
||||
# inline string literals; line-length wrapping would harm readability of
|
||||
# the prompt itself.
|
||||
"src/openjarvis/agents/research_loop.py" = ["E501"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -48,17 +48,81 @@ impl PySkillManifest {
|
||||
}
|
||||
|
||||
fn verify_signature(&self, public_key_hex: &str) -> bool {
|
||||
let key_bytes: Vec<u8> = (0..public_key_hex.len())
|
||||
.step_by(2)
|
||||
.filter_map(|i| u8::from_str_radix(&public_key_hex[i..i + 2], 16).ok())
|
||||
.collect();
|
||||
openjarvis_skills::verify_signature(&self.inner, &key_bytes)
|
||||
match parse_public_key_hex(public_key_hex) {
|
||||
Some(key_bytes) => openjarvis_skills::verify_signature(&self.inner, &key_bytes),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_public_key_hex(public_key_hex: &str) -> Option<Vec<u8>> {
|
||||
if !public_key_hex.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
if !public_key_hex.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut key_bytes = Vec::with_capacity(public_key_hex.len() / 2);
|
||||
for i in (0..public_key_hex.len()).step_by(2) {
|
||||
match u8::from_str_radix(&public_key_hex[i..i + 2], 16) {
|
||||
Ok(byte) => key_bytes.push(byte),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(key_bytes)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn load_skill(toml_str: &str) -> PyResult<PySkillManifest> {
|
||||
let manifest = openjarvis_skills::load_skill(toml_str)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e))?;
|
||||
Ok(PySkillManifest { inner: manifest })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_public_key_hex;
|
||||
|
||||
#[test]
|
||||
fn empty_input_returns_empty_vec() {
|
||||
assert_eq!(parse_public_key_hex(""), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_hex_decodes() {
|
||||
assert_eq!(
|
||||
parse_public_key_hex("0a1b2cFF"),
|
||||
Some(vec![0x0a, 0x1b, 0x2c, 0xff])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_length_rejected_without_panic() {
|
||||
// Regression: the pre-fix implementation sliced public_key_hex[i..i+2]
|
||||
// on an odd-length string, triggering an out-of-bounds panic and a
|
||||
// DoS vector when the input was attacker-controlled.
|
||||
assert_eq!(parse_public_key_hex("0"), None);
|
||||
assert_eq!(parse_public_key_hex("abc"), None);
|
||||
assert_eq!(parse_public_key_hex("0a1b2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_hex_chars_rejected() {
|
||||
// Pre-fix `filter_map` silently dropped non-hex chars and produced a
|
||||
// truncated key, which would also have caused verification surprises.
|
||||
assert_eq!(parse_public_key_hex("zz"), None);
|
||||
assert_eq!(parse_public_key_hex("0aZZ"), None);
|
||||
assert_eq!(parse_public_key_hex("gh"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_utf8_rejected_without_panic() {
|
||||
// Pre-fix indexing public_key_hex[i..i+2] on a non-ASCII string could
|
||||
// split a multi-byte UTF-8 codepoint and panic. The ASCII-only check
|
||||
// makes the rejection explicit instead of relying on from_str_radix's
|
||||
// post-slice error path.
|
||||
assert_eq!(parse_public_key_hex("é"), None);
|
||||
assert_eq!(parse_public_key_hex("aaé"), None);
|
||||
}
|
||||
}
|
||||
|
||||
+190
-1
@@ -2,7 +2,7 @@
|
||||
# install.sh — OpenJarvis curl-pipe-bash installer.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
# curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
#
|
||||
# Flags (only used in tests / power users):
|
||||
# --no-bg-orchestrator Skip the detached background orchestrator
|
||||
@@ -29,6 +29,38 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- non-WSL Windows refusal ----
|
||||
# Running the installer in Git Bash / MSYS2 / Cygwin on native Windows
|
||||
# (i.e. NOT inside WSL2) gets the user into a confusing failure state:
|
||||
# uv/git tooling installs to Windows paths the rest of OpenJarvis can't
|
||||
# reach, and Ollama integration silently breaks. The supported Windows
|
||||
# path is WSL2. Bail early with a clear next step rather than letting
|
||||
# users discover this 3 minutes into a doomed install.
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
cat >&2 <<'EOF'
|
||||
install.sh: native Windows (Git Bash / MSYS2 / Cygwin) is not supported.
|
||||
|
||||
OpenJarvis runs on Windows via WSL2. Two paths:
|
||||
|
||||
1. WSL2 (recommended for the CLI). One-time setup in an admin PowerShell:
|
||||
|
||||
wsl --install -d Ubuntu-24.04
|
||||
|
||||
Open the Ubuntu shell that gets installed, then re-run:
|
||||
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
|
||||
2. Desktop app — download the .exe from the Releases page:
|
||||
https://github.com/open-jarvis/OpenJarvis/releases
|
||||
|
||||
See the WSL2 install guide for the full walkthrough:
|
||||
https://open-jarvis.github.io/OpenJarvis/getting-started/wsl2/
|
||||
EOF
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---- root refusal ----
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
cat >&2 <<'EOF'
|
||||
@@ -77,6 +109,150 @@ elif [[ -f /proc/sys/kernel/osrelease ]] && grep -qi "microsoft" /proc/sys/kerne
|
||||
WSL=1
|
||||
fi
|
||||
|
||||
# ---- analytics beacon (anonymized install funnel) ----
|
||||
#
|
||||
# Posts a small JSON event to PostHog at each install stage so the
|
||||
# OpenJarvis team can see where users drop off during install.
|
||||
# No content, no IPs (handled by PostHog disable_geoip on server),
|
||||
# no hardware identifiers — just OS, arch, elapsed time, and stage name.
|
||||
#
|
||||
ANALYTICS_HOST="${OPENJARVIS_ANALYTICS_HOST:-https://34.231.106.201.sslip.io}"
|
||||
ANALYTICS_KEY="${OPENJARVIS_ANALYTICS_KEY:-phc_ysKu72QaxzYNmDpHFcesD2ZZAe68zkdWJEKoYYkc5e3n}"
|
||||
ANON_ID_FILE="$OPENJARVIS_HOME/anon_id"
|
||||
INSTALL_START_EPOCH="$(date +%s)"
|
||||
CURRENT_STAGE=""
|
||||
|
||||
analytics_enabled() {
|
||||
# Honor the same opt-out env vars as the Python analytics module
|
||||
# (``src/openjarvis/analytics/identity.py::is_analytics_enabled``).
|
||||
# ``DO_NOT_TRACK`` is W3C convention; ``OPENJARVIS_NO_ANALYTICS`` is
|
||||
# the project-specific override. Any truthy value disables.
|
||||
for var in DO_NOT_TRACK OPENJARVIS_NO_ANALYTICS; do
|
||||
val="${!var:-}"
|
||||
case "$(printf '%s' "$val" | tr '[:upper:]' '[:lower:]' | xargs)" in
|
||||
""|0|false|no|off) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Darwin) echo "darwin" ;;
|
||||
Linux) [[ "$WSL" -eq 1 ]] && echo "wsl" || echo "linux" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "x86_64" ;;
|
||||
arm64|aarch64) echo "arm64" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_anon_id() {
|
||||
if [[ -f "$ANON_ID_FILE" ]]; then
|
||||
cat "$ANON_ID_FILE"
|
||||
return
|
||||
fi
|
||||
local new_id
|
||||
new_id="$(python3 -c 'import uuid; print(uuid.uuid4())' 2>/dev/null || echo "")"
|
||||
if [[ -z "$new_id" ]]; then
|
||||
return
|
||||
fi
|
||||
echo "$new_id" > "$ANON_ID_FILE"
|
||||
echo "$new_id"
|
||||
}
|
||||
|
||||
stage_label() {
|
||||
case "$1" in
|
||||
install_uv) echo "uv" ;;
|
||||
clone_repo|copy_scripts) echo "deps" ;;
|
||||
create_venv) echo "venv" ;;
|
||||
editable_install) echo "package" ;;
|
||||
install_ollama|start_ollama) echo "ollama" ;;
|
||||
pull_default_model) echo "model_download" ;;
|
||||
write_config) echo "config" ;;
|
||||
install_symlinks|ensure_path|detach_bg_orchestrator) echo "verify" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
beacon() {
|
||||
# Args: event_name stage_label elapsed_ms exit_code
|
||||
local event="$1"
|
||||
local stage="${2:-}"
|
||||
local elapsed_ms="${3:-0}"
|
||||
local exit_code="${4:-0}"
|
||||
|
||||
if ! analytics_enabled; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local anon_id os arch
|
||||
anon_id="$(get_anon_id)"
|
||||
if [[ -z "$anon_id" ]]; then
|
||||
return 0
|
||||
fi
|
||||
os="$(detect_os)"
|
||||
arch="$(detect_arch)"
|
||||
|
||||
python3 - "$ANALYTICS_HOST" "$ANALYTICS_KEY" "$event" "$anon_id" \
|
||||
"$os" "$arch" "$stage" "$elapsed_ms" "$exit_code" \
|
||||
>/dev/null 2>&1 <<'PYEOF' || true
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
host, key, event, distinct_id, os_val, arch, stage, elapsed_ms, exit_code = sys.argv[1:10]
|
||||
props = {
|
||||
"os": os_val,
|
||||
"arch": arch,
|
||||
"installer_version": "0.1.1",
|
||||
}
|
||||
if stage:
|
||||
props["stage"] = stage
|
||||
if elapsed_ms and elapsed_ms != "0":
|
||||
try:
|
||||
props["elapsed_ms"] = int(elapsed_ms)
|
||||
except ValueError:
|
||||
pass
|
||||
if exit_code and exit_code != "0":
|
||||
try:
|
||||
props["exit_code"] = int(exit_code)
|
||||
except ValueError:
|
||||
pass
|
||||
if event == "install_completed":
|
||||
props["total_elapsed_ms"] = props.pop("elapsed_ms", 0)
|
||||
payload = {
|
||||
"api_key": key,
|
||||
"event": event,
|
||||
"distinct_id": distinct_id,
|
||||
"properties": props,
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
f"{host}/i/v0/e/",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
PYEOF
|
||||
}
|
||||
|
||||
_on_install_error() {
|
||||
local exit_code=$?
|
||||
beacon "install_failed" "$(stage_label "$CURRENT_STAGE")" 0 "$exit_code"
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap _on_install_error ERR
|
||||
|
||||
# ---- helpers ----
|
||||
state_done() {
|
||||
[[ -f "$STATE_FILE" ]] && grep -q "\"$1\":[[:space:]]*true" "$STATE_FILE"
|
||||
@@ -100,13 +276,18 @@ PYEOF
|
||||
|
||||
step() {
|
||||
local name="$1" desc="$2"; shift 2
|
||||
CURRENT_STAGE="$name"
|
||||
if [[ "$FORCE" -ne 1 ]] && state_done "$name"; then
|
||||
echo "[ok] $desc (already done)"
|
||||
return 0
|
||||
fi
|
||||
echo "[..] $desc"
|
||||
local stage_start_epoch stage_elapsed_ms
|
||||
stage_start_epoch="$(date +%s)"
|
||||
"$@"
|
||||
mark_done "$name"
|
||||
stage_elapsed_ms=$(( ( $(date +%s) - stage_start_epoch ) * 1000 ))
|
||||
beacon "install_stage_completed" "$(stage_label "$name")" "$stage_elapsed_ms"
|
||||
echo "[ok] $desc"
|
||||
}
|
||||
|
||||
@@ -240,6 +421,8 @@ echo " install dir: $OPENJARVIS_HOME"
|
||||
echo " WSL2: $WSL"
|
||||
echo
|
||||
|
||||
beacon "install_started"
|
||||
|
||||
step install_uv "Install uv" install_uv
|
||||
step clone_repo "Clone OpenJarvis repo" clone_repo
|
||||
step copy_scripts "Copy install scripts" copy_scripts
|
||||
@@ -253,6 +436,12 @@ step install_symlinks "Install symlinks" install_symlinks
|
||||
step ensure_path "Ensure PATH" ensure_path
|
||||
step detach_bg_orchestrator "Detach background work" detach_bg_orchestrator
|
||||
|
||||
# Total install duration → install_completed event.
|
||||
INSTALL_TOTAL_MS=$(( ( $(date +%s) - INSTALL_START_EPOCH ) * 1000 ))
|
||||
beacon "install_completed" "" "$INSTALL_TOTAL_MS"
|
||||
# Clear ERR trap — we succeeded; any later non-zero exit shouldn't beacon a failure.
|
||||
trap - ERR
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done. Type 'jarvis' to start chatting.
|
||||
|
||||
@@ -7,7 +7,7 @@ VENV="$OPENJARVIS_HOME/.venv"
|
||||
|
||||
if [[ ! -d "$VENV" ]]; then
|
||||
echo "jarvis: venv not found at $VENV" >&2
|
||||
echo "Re-run the installer: curl -fsSL https://openjarvis.ai/install.sh | bash" >&2
|
||||
echo "Re-run the installer: curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -79,6 +79,11 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.agents.proactive_agent # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Hybrid local+cloud paradigm agents (Minions, Conductor, Archon, Advisors,
|
||||
# SkillOrchestra, ToolOrchestra). Each module registers under its own name
|
||||
# via @AgentRegistry.register(). Optional deps may make some unavailable.
|
||||
|
||||
@@ -23,6 +23,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
|
||||
# Default model for monitor_operative / long-horizon agent ticks. qwen3:8b
|
||||
# emits tool_calls but, when given the full MonitorOperative system prompt
|
||||
# alongside a `think` no-op tool, reliably picks `think` instead of the real
|
||||
# action tools — producing tickless prose from training-data memory.
|
||||
# gemma4:31b follows the function-calling protocol with the same prompt and
|
||||
# actually invokes web_search / memory_retrieve. Explicit ``config["model"]``
|
||||
# on an agent still wins.
|
||||
_AGENT_TICK_DEFAULT_MODEL = "gemma4:31b"
|
||||
|
||||
|
||||
class AgentExecutor:
|
||||
"""Executes a single tick for a managed agent.
|
||||
@@ -93,20 +102,34 @@ class AgentExecutor:
|
||||
)
|
||||
return agent.run(input_text)
|
||||
|
||||
def execute_tick(self, agent_id: str) -> None:
|
||||
def execute_tick(
|
||||
self, agent_id: str, *, lock_already_held: bool = False
|
||||
) -> None:
|
||||
"""Run one tick for the given agent.
|
||||
|
||||
1. Acquire concurrency guard (start_tick)
|
||||
2. Invoke agent with retry logic
|
||||
3. Update stats
|
||||
4. Release guard (end_tick)
|
||||
|
||||
``lock_already_held`` is set by callers that took the start_tick()
|
||||
lock themselves before spawning the worker (e.g. the HTTP /run route
|
||||
guards against concurrent POSTs by acquiring before threading).
|
||||
Without this flag, the executor would re-acquire and trip its own
|
||||
guard — bailing out with no end_tick(), leaving the agent stuck in
|
||||
``status='running'`` forever.
|
||||
"""
|
||||
try:
|
||||
self._manager.start_tick(agent_id)
|
||||
if lock_already_held:
|
||||
self._set_activity(agent_id, "Preparing tick...")
|
||||
except ValueError:
|
||||
logger.warning("Agent %s already running, skipping tick", agent_id)
|
||||
return
|
||||
else:
|
||||
try:
|
||||
self._manager.start_tick(agent_id)
|
||||
self._set_activity(agent_id, "Preparing tick...")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Agent %s already running, skipping tick", agent_id
|
||||
)
|
||||
return
|
||||
|
||||
agent = self._manager.get_agent(agent_id)
|
||||
if agent is None:
|
||||
@@ -243,7 +266,11 @@ class AgentExecutor:
|
||||
engine = self._system.engine if self._system else None
|
||||
if engine is None:
|
||||
raise FatalError("No engine available in JarvisSystem")
|
||||
model = config.get("model") or (self._system.model if self._system else "")
|
||||
model = (
|
||||
config.get("model")
|
||||
or _AGENT_TICK_DEFAULT_MODEL
|
||||
or (self._system.model if self._system else "")
|
||||
)
|
||||
if not model:
|
||||
raise FatalError("No model configured for agent")
|
||||
|
||||
@@ -303,6 +330,24 @@ class AgentExecutor:
|
||||
tool_instances.append(tool)
|
||||
except Exception:
|
||||
logger.warning("Failed to instantiate tool %s", tname)
|
||||
|
||||
# Pull tools already discovered by SystemBuilder (e.g. external MCP
|
||||
# adapters) that aren't in the static ToolRegistry. Without this,
|
||||
# agents declaring MCP-discovered tools in their template would
|
||||
# silently fall back to natives only.
|
||||
if (
|
||||
self._system is not None
|
||||
and getattr(self._system, "tool_executor", None) is not None
|
||||
):
|
||||
mcp_pool = getattr(self._system.tool_executor, "_tools", {}) or {}
|
||||
existing = {t.spec.name for t in tool_instances}
|
||||
for tname in tool_names:
|
||||
if tname in existing:
|
||||
continue
|
||||
pooled = mcp_pool.get(tname)
|
||||
if pooled is not None:
|
||||
tool_instances.append(pooled)
|
||||
|
||||
if tool_instances:
|
||||
logger.info(
|
||||
"Agent %s: resolved %d/%d tools",
|
||||
@@ -318,23 +363,109 @@ class AgentExecutor:
|
||||
agent_kwargs["system_prompt"] = sys_prompt
|
||||
if getattr(agent_cls, "accepts_tools", False) and tool_instances:
|
||||
agent_kwargs["tools"] = tool_instances
|
||||
try:
|
||||
agent_instance = agent_cls(engine, model, **agent_kwargs)
|
||||
except TypeError:
|
||||
agent_instance = agent_cls(engine, model)
|
||||
# Hand the agent our EventBus so its ToolExecutor can publish
|
||||
# TOOL_CALL_START/END — without this, ToolExecutor's ``self._bus``
|
||||
# is None and every tool call executes silently, which is why
|
||||
# traces previously reported "0 steps" even when the model was
|
||||
# actively invoking web_search/memory_*/etc.
|
||||
if self._bus is not None:
|
||||
agent_kwargs["bus"] = self._bus
|
||||
# Propagate confirmation policy from the AgentExecutor down to the
|
||||
# agent's own ToolExecutor. Set by CLI paths like `jarvis agents ask`
|
||||
# so non-interactive runs can auto-approve tool execution.
|
||||
if getattr(self, "_confirm_callback", None) is not None:
|
||||
agent_kwargs["interactive"] = True
|
||||
agent_kwargs["confirm_callback"] = self._confirm_callback
|
||||
|
||||
# Build input from instruction + summary_memory + pending messages
|
||||
# Wire cross-tick state plumbing into agent classes that accept it.
|
||||
# Without this, MonitorOperative/Operative agents have no working
|
||||
# session_store or memory_backend and silently no-op their state
|
||||
# recall / persistence paths.
|
||||
import inspect
|
||||
|
||||
init_sig = inspect.signature(agent_cls.__init__)
|
||||
accepts_var_kw = any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD
|
||||
for p in init_sig.parameters.values()
|
||||
)
|
||||
|
||||
def _accepts(name: str) -> bool:
|
||||
return accepts_var_kw or name in init_sig.parameters
|
||||
|
||||
state_kwargs: dict[str, Any] = {}
|
||||
if _accepts("operator_id"):
|
||||
state_kwargs["operator_id"] = agent["id"]
|
||||
if self._system is not None:
|
||||
if _accepts("session_store"):
|
||||
state_kwargs["session_store"] = getattr(
|
||||
self._system, "session_store", None
|
||||
)
|
||||
if _accepts("memory_backend"):
|
||||
state_kwargs["memory_backend"] = getattr(
|
||||
self._system, "memory_backend", None
|
||||
)
|
||||
|
||||
try:
|
||||
agent_instance = agent_cls(
|
||||
engine, model, **agent_kwargs, **state_kwargs
|
||||
)
|
||||
except TypeError:
|
||||
try:
|
||||
agent_instance = agent_cls(engine, model, **agent_kwargs)
|
||||
except TypeError:
|
||||
agent_instance = agent_cls(engine, model)
|
||||
|
||||
# Inject the managed-agent UUID into the agent's ToolExecutor so
|
||||
# emitted TOOL_CALL_START/END events carry it; the trace subscriber
|
||||
# below filters by ``event.data["agent"] == agent_id`` and would
|
||||
# otherwise drop every tool call (the class-level agent_id like
|
||||
# "monitor_operative" doesn't match the runtime UUID).
|
||||
inner_executor = getattr(agent_instance, "_executor", None)
|
||||
if inner_executor is not None and hasattr(inner_executor, "_agent_id"):
|
||||
inner_executor._agent_id = agent["id"]
|
||||
|
||||
logger.info(
|
||||
"Agent %s: tool wiring — %d tools resolved (%s), agent class %s",
|
||||
agent["name"],
|
||||
len(tool_instances),
|
||||
", ".join(t.spec.name for t in tool_instances) or "none",
|
||||
agent_cls.__name__,
|
||||
)
|
||||
|
||||
# Build input from instruction + summary_memory + pending messages.
|
||||
# NB: we deliberately do NOT inject the full previous response back
|
||||
# in as "Previous context" — that caused the model to parrot its
|
||||
# own prior output verbatim. Cross-tick continuity now lives in the
|
||||
# agent's session_store / memory_backend; here we only surface a
|
||||
# short tick-boundary marker so the model knows time has passed.
|
||||
import datetime
|
||||
import re
|
||||
|
||||
today = datetime.date.today().strftime("%A, %B %d, %Y")
|
||||
instruction = config.get("instruction", "")
|
||||
memory = agent.get("summary_memory", "")
|
||||
memory = (agent.get("summary_memory") or "").strip()
|
||||
last_run_at = agent.get("last_run_at")
|
||||
|
||||
tick_note = ""
|
||||
if memory:
|
||||
first_sentence = re.split(r"(?<=[.!?])\s+", memory, maxsplit=1)[0]
|
||||
first_sentence = first_sentence.strip()[:200]
|
||||
if last_run_at:
|
||||
ts = datetime.datetime.fromtimestamp(last_run_at).strftime(
|
||||
"%Y-%m-%d %H:%M"
|
||||
)
|
||||
tick_note = f"Last tick at {ts}: {first_sentence}"
|
||||
else:
|
||||
tick_note = f"Previous tick: {first_sentence}"
|
||||
|
||||
if instruction:
|
||||
input_text = f"Current date: {today}\n\nStanding instruction: {instruction}"
|
||||
if memory:
|
||||
input_text += f"\n\nPrevious context: {memory}"
|
||||
input_text = (
|
||||
f"Current date: {today}\n\nStanding instruction: {instruction}"
|
||||
)
|
||||
if tick_note:
|
||||
input_text += f"\n\n{tick_note}"
|
||||
else:
|
||||
base = memory or "Continue your assigned task."
|
||||
base = tick_note or "Continue your assigned task."
|
||||
input_text = f"Current date: {today}\n\n{base}"
|
||||
pending = self._manager.get_pending_messages(agent["id"])
|
||||
if pending:
|
||||
|
||||
@@ -7,6 +7,7 @@ to the five existing primitives (Intelligence, Agent, Tools, Engine, Learning).
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
@@ -14,6 +15,8 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CREATE_AGENTS = """\
|
||||
CREATE TABLE IF NOT EXISTS managed_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -123,6 +126,33 @@ class AgentManager:
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
self._conn.commit()
|
||||
self._clear_stale_running_state()
|
||||
|
||||
def _clear_stale_running_state(self) -> None:
|
||||
"""Reset any agent stuck in ``status='running'`` on startup.
|
||||
|
||||
Tick worker threads are ``daemon=True`` — when the server process
|
||||
exits (SIGTERM, crash, restart), they die without running the
|
||||
``finally`` clause that calls :meth:`end_tick`, leaving the DB row
|
||||
in ``running`` forever. The :meth:`start_tick` guard then rejects
|
||||
every subsequent run with "Agent is already running".
|
||||
|
||||
A freshly-started process holds zero tick locks by definition, so
|
||||
any persisted ``running`` is a zombie. Sweep it back to ``idle``
|
||||
and clear the activity string so the UI doesn't show a stale
|
||||
"Preparing tick..." indicator.
|
||||
"""
|
||||
cur = self._conn.execute(
|
||||
"UPDATE managed_agents SET status = 'idle', current_activity = '',"
|
||||
" updated_at = ? WHERE status = 'running'",
|
||||
(time.time(),),
|
||||
)
|
||||
self._conn.commit()
|
||||
if cur.rowcount:
|
||||
logger.info(
|
||||
"AgentManager: cleared stale 'running' status on %d agent(s)",
|
||||
cur.rowcount,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
@@ -49,18 +49,18 @@ MONITOR_OPERATIVE_SYSTEM_PROMPT = """\
|
||||
You are a Monitor Operative Agent designed for long-horizon tasks.
|
||||
|
||||
## Capabilities
|
||||
1. TOOLS: Call any available tool via function calling
|
||||
2. STATE: Your previous findings and state are automatically restored
|
||||
3. MEMORY: Store important findings for future recall
|
||||
1. TOOLS: You have access to tools via native function calling. The list
|
||||
below shows what is available — invoke them through the function-calling
|
||||
API, not by writing tool names into your text response.
|
||||
2. STATE: Your previous findings and state are automatically restored from memory.
|
||||
3. MEMORY: Store important findings via memory_store; recall via memory_retrieve.
|
||||
|
||||
## How to use tools
|
||||
|
||||
To call a tool, write on its own lines:
|
||||
|
||||
Action: <tool_name>
|
||||
Action Input: <json_arguments>
|
||||
|
||||
You will receive the result, then continue your response.
|
||||
## Critical Operating Rule
|
||||
Your training data is frozen and out of date. For ANY question about recent,
|
||||
current, or evolving information, you MUST call a substantive retrieval tool
|
||||
(web_search, memory_retrieve, or an equivalent) BEFORE composing a response.
|
||||
Writing fact claims about recent events from memory alone produces
|
||||
hallucinations and is a failure mode.
|
||||
|
||||
## Strategy
|
||||
- Memory extraction: {memory_extraction}
|
||||
@@ -70,6 +70,9 @@ You will receive the result, then continue your response.
|
||||
|
||||
## Protocol
|
||||
- Break complex tasks into phases and track progress
|
||||
- Prefer substantive tools (web_search, memory_retrieve) over reasoning-only
|
||||
tools (think) — `think` does not gather new information, only reorganises
|
||||
what you already have
|
||||
- Store causal relationships and key findings in memory
|
||||
- Compress long tool outputs before adding to context
|
||||
- Self-evaluate retrieved context for relevance
|
||||
|
||||
@@ -202,6 +202,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")
|
||||
tts_call = ToolCall(
|
||||
id="digest-tts-1",
|
||||
name="text_to_speech",
|
||||
@@ -211,6 +212,7 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
"voice_id": self._voice_id,
|
||||
"backend": self._tts_backend,
|
||||
"speed": self._voice_speed,
|
||||
"output_dir": output_dir,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
# ruff: noqa: E501
|
||||
"""Proactive Agent — runs on a cron (default 5am local) to autonomously handle
|
||||
routine tasks based on learned user behavior.
|
||||
|
||||
Lifecycle per run
|
||||
-----------------
|
||||
1. Load USER.md + MEMORY.md for behavioral context.
|
||||
2. Collect overnight data from connected sources via ``digest_collect``.
|
||||
3. Use the LLM to classify each item and propose actions with a tier + permission key.
|
||||
4. For each proposed action:
|
||||
- TRIVIAL tier → queue + immediately approve
|
||||
- Known always_approve → queue + immediately approve
|
||||
- Known always_deny → skip silently
|
||||
- Everything else → queue as pending, notify user
|
||||
5. Execute all approved actions via ``execute_pending_actions``.
|
||||
6. Send the user a concise summary: what was done + numbered list of what needs approval.
|
||||
|
||||
Approval reply format (user replies to the notification message):
|
||||
``{action_id} yes`` approve one action
|
||||
``{action_id} no`` deny one action
|
||||
``always yes {action_id}`` approve + remember for this pattern
|
||||
``always no {action_id}`` deny + remember for this pattern
|
||||
``yes all`` / ``no all`` bulk decision
|
||||
|
||||
Wire up ``parse_approval_response`` from ``proactive_tools`` in your channel
|
||||
message handler to process replies without running the full agent.
|
||||
|
||||
Scheduling
|
||||
----------
|
||||
The agent self-registers a 5am daily cron task when ``register_cron`` is
|
||||
called from your app startup:
|
||||
|
||||
from openjarvis.agents.proactive_agent import register_cron
|
||||
register_cron(scheduler, notification_channel_id="your-channel-id")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
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.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
from openjarvis.tools.approval_store import (
|
||||
DECISION_ALWAYS_APPROVE,
|
||||
DECISION_ALWAYS_DENY,
|
||||
STATUS_APPROVED,
|
||||
TIER_MEDIUM,
|
||||
TIER_TRIVIAL,
|
||||
ApprovalStore,
|
||||
)
|
||||
from openjarvis.tools.proactive_tools import get_store
|
||||
|
||||
_SYSTEM_PROMPT = """You are a proactive personal assistant agent. You have already collected
|
||||
data from the user's connected sources (email, messages, calendar). Your job is to:
|
||||
|
||||
1. Analyze each item and decide what action (if any) should be taken.
|
||||
2. For each action, output a JSON object in your response inside a ```json ... ``` block.
|
||||
|
||||
Each action object must have these fields:
|
||||
- action_type: one of email_delete | email_archive | sms_send | sms_draft_reply |
|
||||
calendar_decline | calendar_accept | no_action
|
||||
- description: human-readable sentence explaining what you will do
|
||||
- payload: dict with the data needed to execute. ALWAYS include:
|
||||
- doc_id: copy the value of ``id=...`` from the digest line, EXACTLY
|
||||
as shown. Example: a digest line ``[gmail id=gmail:18f9abc] From:
|
||||
...`` means ``doc_id`` must be ``"gmail:18f9abc"``. NEVER invent
|
||||
ids like ``"gmail:wells_fargo"`` or ``"msg_1"`` — the executor
|
||||
will fail. If a digest line has no ``id=...`` segment, do not
|
||||
propose an action for that line.
|
||||
- For email actions: message_id MUST be the part of doc_id after
|
||||
the ``gmail:`` prefix (e.g. ``"18f9abc"``).
|
||||
- For sms actions: contact (phone/email), body (the message text)
|
||||
- For calendar actions: event_id (the part after ``gcalendar:`` in
|
||||
the digest's ``id=...``) and calendar_id (default "primary")
|
||||
- permission_key: pattern string like "email_delete:domain:noreply.github.com"
|
||||
- tier: one of trivial | low | medium | high
|
||||
- reasoning: one sentence why
|
||||
|
||||
Tier guidance:
|
||||
trivial — read-only or categorization only, no external effect
|
||||
low — reversible, routine (delete a known-spam sender, archive newsletter)
|
||||
medium — affects another party but is expected (reply to a simple scheduling text)
|
||||
high — sends a message in the user's voice for the first time, or irreversible
|
||||
|
||||
Output a JSON array of action objects inside a single ```json ... ``` block.
|
||||
Only include items where action_type is not 'no_action'.
|
||||
If nothing needs to be done, output an empty array: ```json [] ```
|
||||
|
||||
HARD LIMITS — these keep responses parseable:
|
||||
- Output AT MOST 8 action objects. Pick the highest-value ones (most
|
||||
clearly safe-to-delete or obviously useful to handle).
|
||||
- Keep each `reasoning` field to ONE short sentence (≤ 15 words).
|
||||
- Keep each `description` field to ONE short sentence (≤ 15 words).
|
||||
- No nested objects beyond what the schema requires.
|
||||
- Your entire visible response MUST be ONLY the fenced JSON block —
|
||||
no explanations, headers, or commentary before or after.
|
||||
|
||||
Example response when the digest has two newsletters and a calendar
|
||||
invite (use exactly this shape; substitute real ids from the digest):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action_type": "email_archive",
|
||||
"description": "Archive Substack newsletter from on+stories@substack.com",
|
||||
"payload": {"doc_id": "gmail:18f9...", "message_id": "18f9..."},
|
||||
"permission_key": "email_archive:from:on+stories@substack.com",
|
||||
"tier": "low",
|
||||
"reasoning": "Routine newsletter — safe to archive."
|
||||
},
|
||||
{
|
||||
"action_type": "email_delete",
|
||||
"description": "Delete Wells Fargo marketing email",
|
||||
"payload": {"doc_id": "gmail:18fa...", "message_id": "18fa..."},
|
||||
"permission_key": "email_delete:from:wf.com",
|
||||
"tier": "low",
|
||||
"reasoning": "Marketing email, user already has account."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Be generous about proposing low-tier actions for marketing emails,
|
||||
newsletters, transactional receipts the user has already seen, and
|
||||
calendar duplicates — these are the items the user wants triaged.
|
||||
|
||||
User context is provided below — use it to tailor decisions to their patterns.
|
||||
"""
|
||||
|
||||
|
||||
def _load_md_file(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
|
||||
|
||||
def _extract_json_block(text: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Extract a JSON array from LLM output.
|
||||
|
||||
Tries (in order):
|
||||
1. ```json ... ``` fenced block (preferred).
|
||||
2. ``` ... ``` fenced block with no language tag.
|
||||
3. First ``[ ... ]`` array in the raw text.
|
||||
|
||||
Returns the parsed list, or ``None`` if nothing parses.
|
||||
"""
|
||||
import re
|
||||
|
||||
candidates: List[str] = []
|
||||
|
||||
# 1. ```json ... ``` (case-insensitive)
|
||||
m = re.search(r"```(?:json|JSON)\s*(.*?)```", text, re.DOTALL)
|
||||
if m:
|
||||
candidates.append(m.group(1).strip())
|
||||
|
||||
# 2. Any ``` ... ``` block (model may omit the language tag)
|
||||
for m in re.finditer(r"```\s*(.*?)```", text, re.DOTALL):
|
||||
candidates.append(m.group(1).strip())
|
||||
|
||||
# 3. Raw top-level JSON array anywhere in the text (best-effort,
|
||||
# balanced-bracket walk so nested objects don't trip us up).
|
||||
start = text.find("[")
|
||||
while start != -1:
|
||||
depth = 0
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if ch == "[":
|
||||
depth += 1
|
||||
elif ch == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidates.append(text[start : i + 1])
|
||||
break
|
||||
next_start = text.find("[", start + 1)
|
||||
if next_start == start:
|
||||
break
|
||||
start = next_start
|
||||
|
||||
for raw in candidates:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, list):
|
||||
return parsed # type: ignore[return-value]
|
||||
if isinstance(parsed, dict):
|
||||
return [parsed]
|
||||
return None
|
||||
|
||||
|
||||
def _build_notification_channel(channel_spec: str) -> Optional[Any]:
|
||||
"""Parse a ``"type:identifier"`` string into a channel backend instance.
|
||||
|
||||
Supports:
|
||||
``imessage:+15551234567`` — sends via AppleScript directly
|
||||
``telegram:123456789`` — instantiates TelegramChannel
|
||||
``slack:D0123456789`` — instantiates registered Slack channel
|
||||
Any other type registered in ChannelRegistry
|
||||
|
||||
Returns ``None`` (silently) if the spec is empty or the channel can't
|
||||
be instantiated so the agent degrades gracefully to no notifications.
|
||||
"""
|
||||
if not channel_spec or ":" not in channel_spec:
|
||||
return None
|
||||
|
||||
channel_type, _, channel_id = channel_spec.partition(":")
|
||||
|
||||
# iMessage: wrap send_imessage() in a minimal BaseChannel-compatible shim
|
||||
if channel_type == "imessage":
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelStatus,
|
||||
)
|
||||
|
||||
class _IMessageShim(BaseChannel):
|
||||
channel_id = "imessage"
|
||||
|
||||
def __init__(self, handle: str) -> None:
|
||||
self._handle = handle
|
||||
|
||||
def connect(self) -> None:
|
||||
pass
|
||||
|
||||
def disconnect(self) -> None:
|
||||
pass
|
||||
|
||||
def send(
|
||||
self, channel: str, content: str, *, conversation_id: str = ""
|
||||
) -> bool:
|
||||
from openjarvis.channels.imessage_daemon import send_imessage
|
||||
|
||||
return send_imessage(self._handle, content)
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
return ChannelStatus.CONNECTED
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
return [self._handle]
|
||||
|
||||
def on_message(self, handler: Any) -> None:
|
||||
pass
|
||||
|
||||
return _IMessageShim(channel_id)
|
||||
|
||||
# All other channel types: look up in ChannelRegistry
|
||||
try:
|
||||
import openjarvis.channels # noqa: F401 trigger registration
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
if ChannelRegistry.contains(channel_type):
|
||||
channel_cls = ChannelRegistry.get(channel_type)
|
||||
instance = channel_cls()
|
||||
try:
|
||||
instance.connect()
|
||||
except Exception:
|
||||
pass
|
||||
return instance
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@AgentRegistry.register("proactive")
|
||||
class ProactiveAgent(ToolUsingAgent):
|
||||
"""Autonomous agent that handles routine tasks based on learned user behavior."""
|
||||
|
||||
agent_id = "proactive"
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
self._notification_channel_id: str = kwargs.pop("notification_channel_id", "")
|
||||
self._hours_back: int = kwargs.pop("hours_back", 24)
|
||||
self._approval_store: Optional[ApprovalStore] = kwargs.pop(
|
||||
"approval_store", None
|
||||
)
|
||||
self._timezone: str = kwargs.pop("timezone", "America/Los_Angeles")
|
||||
|
||||
# Read config defaults before super().__init__ so we can inject tools
|
||||
try:
|
||||
cfg = load_config()
|
||||
p = cfg.proactive
|
||||
if not self._notification_channel_id:
|
||||
self._notification_channel_id = p.notification_channel
|
||||
self._hours_back = p.hours_back
|
||||
self._timezone = p.timezone
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build the required tools and inject them into the executor.
|
||||
# This must happen before super().__init__ is called because
|
||||
# ToolUsingAgent builds the ToolExecutor from kwargs["tools"].
|
||||
store = self._approval_store or get_store()
|
||||
self._approval_store = store
|
||||
|
||||
notification_channel = _build_notification_channel(
|
||||
self._notification_channel_id
|
||||
)
|
||||
self._notification_channel = notification_channel
|
||||
|
||||
from openjarvis.tools.channel_tools import ChannelSendTool
|
||||
from openjarvis.tools.digest_collect import DigestCollectTool
|
||||
from openjarvis.tools.proactive_tools import (
|
||||
CheckPermissionTool,
|
||||
ExecutePendingActionsTool,
|
||||
GetPendingActionsTool,
|
||||
QueueActionTool,
|
||||
RecordDecisionTool,
|
||||
)
|
||||
|
||||
proactive_tools = [
|
||||
DigestCollectTool(),
|
||||
ExecutePendingActionsTool(store=store),
|
||||
ChannelSendTool(channel=notification_channel),
|
||||
CheckPermissionTool(store=store),
|
||||
QueueActionTool(store=store),
|
||||
GetPendingActionsTool(store=store),
|
||||
RecordDecisionTool(store=store),
|
||||
]
|
||||
|
||||
# Merge with any tools passed by the caller
|
||||
caller_tools: List[Any] = kwargs.pop("tools", None) or []
|
||||
kwargs["tools"] = proactive_tools + caller_tools
|
||||
|
||||
# The agent emits a JSON array of proposals — one entry per actionable
|
||||
# item — and a typical morning digest produces dozens. The default
|
||||
# max_tokens (often ~1024) truncates the array mid-element, which the
|
||||
# parser then rejects. Give it real room unless the caller overrode.
|
||||
kwargs.setdefault("max_tokens", 8192)
|
||||
# Deterministic-ish output makes the JSON shape more reliable.
|
||||
kwargs.setdefault("temperature", 0.2)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _get_already_seen_ids(self, store: ApprovalStore) -> Set[str]:
|
||||
return store.get_seen_ids()
|
||||
|
||||
def _store(self) -> ApprovalStore:
|
||||
if self._approval_store is None:
|
||||
self._approval_store = get_store()
|
||||
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")
|
||||
now = datetime.now()
|
||||
context_block = ""
|
||||
if user_md or memory_md:
|
||||
context_block = "\n\n---\nUSER CONTEXT:\n"
|
||||
if user_md:
|
||||
context_block += f"\n{user_md.strip()}\n"
|
||||
if memory_md:
|
||||
context_block += f"\n{memory_md.strip()}\n"
|
||||
return (
|
||||
_SYSTEM_PROMPT
|
||||
+ f"\nToday is {now.strftime('%A, %B %d, %Y')} ({self._timezone})."
|
||||
+ context_block
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str = "",
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
self._emit_turn_start(input or "proactive_run")
|
||||
|
||||
store = self._store()
|
||||
store.expire_stale()
|
||||
|
||||
# --- Step 1: Collect data — only items user hasn't acted on ---
|
||||
sources = ["gmail", "imessage", "gcalendar", "slack", "google_tasks"]
|
||||
seen_ids = self._get_already_seen_ids(store)
|
||||
collect_call = ToolCall(
|
||||
id="proactive-collect-1",
|
||||
name="digest_collect",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"sources": sources,
|
||||
"hours_back": self._hours_back,
|
||||
"unacted_only": True,
|
||||
"seen_ids": list(seen_ids),
|
||||
}
|
||||
),
|
||||
)
|
||||
collect_result = self._executor.execute(collect_call)
|
||||
if not collect_result.success or not collect_result.content.strip():
|
||||
self._emit_turn_end(turns=1)
|
||||
return AgentResult(
|
||||
content="No data collected from connectors — nothing to do.",
|
||||
turns=1,
|
||||
)
|
||||
|
||||
# --- Step 2: Ask LLM to classify items and propose actions ---
|
||||
messages = [
|
||||
Message(role=Role.SYSTEM, content=self._build_system_prompt()),
|
||||
Message(
|
||||
role=Role.USER,
|
||||
content=(
|
||||
f"Here is the data collected from the last {self._hours_back} hours:\n\n"
|
||||
f"{collect_result.content}\n\n"
|
||||
"Analyze each item and output the JSON array of proposed actions."
|
||||
),
|
||||
),
|
||||
]
|
||||
llm_result = self._generate(messages)
|
||||
raw_full = llm_result.get("content", "")
|
||||
raw_output = self._strip_think_tags(raw_full)
|
||||
proposed: List[Dict[str, Any]] = _extract_json_block(raw_output) or []
|
||||
|
||||
# Debug log — write the raw LLM output and what we parsed out so a
|
||||
# human can diagnose "Nothing to report" without re-running the
|
||||
# whole agent. Best-effort; never fail the run because of logging.
|
||||
try:
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
|
||||
log_dir = DEFAULT_CONFIG_DIR / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / "proactive_debug.log"
|
||||
with log_path.open("a", encoding="utf-8") as f:
|
||||
f.write(f"\n===== {datetime.now().isoformat()} =====\n")
|
||||
f.write(f"--- digest ({len(collect_result.content)} chars) ---\n")
|
||||
f.write(collect_result.content + "\n")
|
||||
f.write(f"--- llm raw ({len(raw_full)} chars) ---\n")
|
||||
f.write(raw_full + "\n")
|
||||
f.write(f"--- parsed proposals: {len(proposed)} ---\n")
|
||||
f.write(json.dumps(proposed, indent=2, default=str) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Step 3: Route each proposed action ---
|
||||
auto_approve_ids: List[str] = []
|
||||
pending_actions = []
|
||||
|
||||
for item in proposed:
|
||||
action_type = item.get("action_type", "")
|
||||
tier = item.get("tier", TIER_MEDIUM)
|
||||
permission_key = item.get("permission_key", f"{action_type}:default")
|
||||
description = item.get("description", "")
|
||||
payload = item.get("payload", {})
|
||||
|
||||
if not action_type or action_type == "no_action":
|
||||
continue
|
||||
|
||||
# Check remembered permission first
|
||||
rule = store.get_permission(permission_key)
|
||||
if rule and rule.decision == DECISION_ALWAYS_DENY:
|
||||
continue
|
||||
|
||||
# Queue the action
|
||||
action = store.queue_action(
|
||||
action_type=action_type,
|
||||
description=description,
|
||||
payload=payload,
|
||||
permission_key=permission_key,
|
||||
tier=tier,
|
||||
)
|
||||
|
||||
if tier == TIER_TRIVIAL or (
|
||||
rule and rule.decision == DECISION_ALWAYS_APPROVE
|
||||
):
|
||||
store.update_status(action.id, STATUS_APPROVED)
|
||||
auto_approve_ids.append(action.id)
|
||||
else:
|
||||
pending_actions.append(action)
|
||||
|
||||
# --- Step 4: Execute all auto-approved actions ---
|
||||
executed_results: List[Dict[str, Any]] = []
|
||||
if auto_approve_ids:
|
||||
exec_call = ToolCall(
|
||||
id="proactive-exec-1",
|
||||
name="execute_pending_actions",
|
||||
arguments=json.dumps({"action_ids": auto_approve_ids}),
|
||||
)
|
||||
exec_result = self._executor.execute(exec_call)
|
||||
if exec_result.success and exec_result.content:
|
||||
try:
|
||||
executed_results = json.loads(exec_result.content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# --- Step 5: Build and send notification ---
|
||||
notification = self._build_notification(executed_results, pending_actions)
|
||||
|
||||
if notification and self._notification_channel_id:
|
||||
send_call = ToolCall(
|
||||
id="proactive-notify-1",
|
||||
name="channel_send",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"channel": self._notification_channel_id,
|
||||
"content": notification,
|
||||
}
|
||||
),
|
||||
)
|
||||
self._executor.execute(send_call)
|
||||
for action in pending_actions:
|
||||
store.update_status(action.id, action.status, notification_sent=True)
|
||||
|
||||
self._emit_turn_end(turns=1)
|
||||
return AgentResult(
|
||||
content=notification or "Nothing to report.",
|
||||
turns=1,
|
||||
metadata={
|
||||
"auto_executed": len(executed_results),
|
||||
"pending_approval": len(pending_actions),
|
||||
},
|
||||
)
|
||||
|
||||
def _build_notification(
|
||||
self,
|
||||
executed: List[Dict[str, Any]],
|
||||
pending: List[Any],
|
||||
) -> str:
|
||||
lines: List[str] = []
|
||||
|
||||
if executed:
|
||||
successes = [r for r in executed if r.get("success")]
|
||||
failures = [r for r in executed if not r.get("success")]
|
||||
lines.append(f"Done automatically ({len(successes)} actions):")
|
||||
for r in successes:
|
||||
lines.append(f" ✓ {r['description']}")
|
||||
for r in failures:
|
||||
lines.append(f" ✗ {r['description']} — {r.get('message', 'error')}")
|
||||
|
||||
if pending:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append(f"Needs your approval ({len(pending)} actions):")
|
||||
for action in pending:
|
||||
tier_label = {
|
||||
"low": "low-risk",
|
||||
"medium": "medium",
|
||||
"high": "HIGH",
|
||||
}.get(action.tier, action.tier)
|
||||
lines.append(f" [{action.id}] ({tier_label}) {action.description}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Reply with: '{id} yes/no' to decide. "
|
||||
"Add 'always' to remember (e.g. 'always yes {id}'). "
|
||||
"'yes all' / 'no all' for bulk."
|
||||
)
|
||||
|
||||
if not lines:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience: register the 5am cron task
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register_cron(
|
||||
scheduler: Any,
|
||||
*,
|
||||
notification_channel_id: str = "",
|
||||
cron_expr: str = "",
|
||||
hours_back: int = 0,
|
||||
timezone: str = "",
|
||||
) -> Any:
|
||||
"""Register the proactive agent as a daily cron task.
|
||||
|
||||
All defaults are read from ``config.toml [proactive]`` when not explicitly
|
||||
passed. Call this once from app startup after the scheduler is started.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scheduler:
|
||||
A ``TaskScheduler`` instance.
|
||||
notification_channel_id:
|
||||
Override the channel ID from config. If empty, uses ``notification_channel``
|
||||
from ``[proactive]`` in config.toml.
|
||||
cron_expr:
|
||||
Override the cron schedule. Defaults to config value (``"0 5 * * *"``).
|
||||
hours_back:
|
||||
Override hours of data to scan. Defaults to config value (24).
|
||||
timezone:
|
||||
Override timezone string. Defaults to config value.
|
||||
"""
|
||||
try:
|
||||
cfg = load_config()
|
||||
p = cfg.proactive
|
||||
notification_channel_id = notification_channel_id or p.notification_channel
|
||||
cron_expr = cron_expr or p.schedule
|
||||
hours_back = hours_back or p.hours_back
|
||||
timezone = timezone or p.timezone
|
||||
except Exception:
|
||||
cron_expr = cron_expr or "0 5 * * *"
|
||||
hours_back = hours_back or 24
|
||||
timezone = timezone or "America/Los_Angeles"
|
||||
|
||||
return scheduler.create_task(
|
||||
prompt="Run the proactive agent: collect overnight data, execute approved actions, notify pending approvals.",
|
||||
schedule_type="cron",
|
||||
schedule_value=cron_expr,
|
||||
agent="proactive",
|
||||
context_mode="isolated",
|
||||
metadata={
|
||||
"notification_channel_id": notification_channel_id,
|
||||
"hours_back": hours_back,
|
||||
"timezone": timezone,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,867 @@
|
||||
"""Agentic research loop over the hybrid-search tool.
|
||||
|
||||
A small, self-contained planner-executor loop:
|
||||
|
||||
* the planner is a local Ollama chat model (default ``gemma4:31b``),
|
||||
* 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
|
||||
* the final reply must cite specific hits.
|
||||
|
||||
The loop is deliberately decoupled from the rest of the agent scaffolding
|
||||
(`ToolUsingAgent`, `EventBus`, `AgentContext`, etc.) so the surface stays
|
||||
small. Anything that wants tracing or registry integration can wrap it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from openjarvis.connectors.hybrid_search import HybridSearch, SearchHit
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
from openjarvis.engine._base import InferenceEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_PLANNER_MODEL = "gemma4:31b"
|
||||
|
||||
|
||||
CLARIFY_TOOL_SPEC: Dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "clarify",
|
||||
"description": (
|
||||
"Ask the user a clarifying question and wait for their answer. "
|
||||
"Only use AFTER at least one search has been attempted. Use when "
|
||||
"search results are ambiguous (e.g. three different people share "
|
||||
"a first name), search returned zero results and the query likely "
|
||||
"needs reframing, or the scope is too broad to synthesize "
|
||||
"meaningfully. Never use clarify before searching."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The clarifying question to ask the user. Be specific "
|
||||
"about what you need to know to make progress."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["question"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
SEARCH_TOOL_SPEC: Dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": (
|
||||
"Hybrid search over the user's personal knowledge corpus (emails, "
|
||||
"notes, calendar events, attachments). Combines BM25 lexical match "
|
||||
"with dense embedding similarity, ranked by reciprocal rank fusion. "
|
||||
"Use structured filters (person, time_range, sources) whenever the "
|
||||
"user names a specific person or time window. Each call returns up "
|
||||
"to 'limit' results with content snippets and thread context."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Natural-language query. Use the topic the user is asking "
|
||||
"about. Can be empty when filtering purely by person or "
|
||||
"time (e.g. 'list all mail from Kelly in May')."
|
||||
),
|
||||
},
|
||||
"person": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Filter to messages involving this person. Matches a "
|
||||
"substring of the name or email address — 'Kelly' or "
|
||||
"'@tldrnewsletter.com' both work."
|
||||
),
|
||||
},
|
||||
"time_range": {
|
||||
"type": "object",
|
||||
"description": "ISO 8601 datetime range. Either bound may be omitted.",
|
||||
"properties": {
|
||||
"start": {"type": "string", "description": "ISO 8601 start"},
|
||||
"end": {"type": "string", "description": "ISO 8601 end"},
|
||||
},
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Restrict the search to one or more connectors. Use this "
|
||||
"whenever the user names a data source (e.g. \"in my "
|
||||
"Granola notes\" → ['granola']; \"check Slack and Gmail\" "
|
||||
"→ ['slack', 'gmail']). Valid IDs include: gmail, slack, "
|
||||
"granola, notion, obsidian, gcalendar, gdrive, gmail_imap, "
|
||||
"outlook, imessage, whatsapp, apple_notes, apple_contacts, "
|
||||
"gcontacts, google_tasks, github_notifications."
|
||||
),
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results to return (default 20, cap 20).",
|
||||
"default": 20,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a research assistant with access to the user's personal knowledge corpus.
|
||||
|
||||
The user's corpus contains data from these sources only:
|
||||
{available_sources}
|
||||
|
||||
You answer questions by calling two tools:
|
||||
|
||||
search(query, person=None, time_range=None, sources=None, limit=20)
|
||||
clarify(question)
|
||||
|
||||
Strategy:
|
||||
1. If the user names a person, ALWAYS pass `person=` rather than relying on lexical match. Hybrid search will fuzzy-match name or address fragments.
|
||||
2. When the user mentions ANY time window — "this past week", "recently", "last month", "past few days", "yesterday" — you MUST translate it to a `time_range` parameter. Today is {today}.
|
||||
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.
|
||||
|
||||
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.
|
||||
- Quote sender / date / subject when relevant — the user wants attribution.
|
||||
- If the search returned nothing relevant, say so plainly. Do not invent results.
|
||||
- Only state facts that appear in the retrieved search results. Never supplement with your own knowledge or training data. If you are unsure whether a fact came from the search results, do not include it.
|
||||
|
||||
Today's date is {today}.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool-result shaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _trim_thread_context(ctx: List[Dict[str, Any]], cap: int) -> List[Dict[str, Any]]:
|
||||
"""Keep the first ``cap`` entries; mark elision when trimming."""
|
||||
if len(ctx) <= cap:
|
||||
return ctx
|
||||
trimmed = list(ctx[:cap])
|
||||
trimmed.append({"snippet": f"… {len(ctx) - cap} more chunks in thread …"})
|
||||
return trimmed
|
||||
|
||||
|
||||
def shape_results_for_model(
|
||||
hits: List[SearchHit],
|
||||
*,
|
||||
detailed_top: int = 5,
|
||||
thread_ctx_per_hit: int = 3,
|
||||
total_cap: int = 20,
|
||||
ref_offset: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Compact a hit list into a JSON payload the planner can chew through.
|
||||
|
||||
The first ``detailed_top`` rows keep their content snippet and trimmed
|
||||
thread context; the remainder are summarised to title + sender + date so
|
||||
the planner still sees the breadth of what's available without blowing the
|
||||
context window. Each hit gets a numeric ``ref`` (1-indexed, plus
|
||||
``ref_offset``) so the synthesis can cite it as ``[N]``. The offset lets
|
||||
multi-search runs hand the planner globally unique refs across calls so
|
||||
a later renumbering pass can dedupe by first appearance.
|
||||
"""
|
||||
out_hits: List[Dict[str, Any]] = []
|
||||
visible = hits[:total_cap]
|
||||
for i, h in enumerate(visible):
|
||||
sender = h.participants[0] if h.participants else ""
|
||||
base = {
|
||||
"ref": i + 1 + ref_offset,
|
||||
"title": h.title,
|
||||
"sender": sender,
|
||||
"timestamp": h.timestamp,
|
||||
"source": h.source,
|
||||
"score": round(h.score, 4),
|
||||
}
|
||||
if i < detailed_top:
|
||||
base["snippet"] = h.content_snippet
|
||||
if h.thread_context:
|
||||
base["thread"] = _trim_thread_context(h.thread_context, thread_ctx_per_hit)
|
||||
out_hits.append(base)
|
||||
return {
|
||||
"num_results": len(hits),
|
||||
"shown": len(visible),
|
||||
"truncated": len(hits) > total_cap,
|
||||
"hits": out_hits,
|
||||
}
|
||||
|
||||
|
||||
def _hit_date(timestamp: str) -> str:
|
||||
"""Pull a ``YYYY-MM-DD`` date out of a SearchHit timestamp (best effort)."""
|
||||
if not timestamp:
|
||||
return ""
|
||||
try:
|
||||
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).date().isoformat()
|
||||
except (ValueError, AttributeError):
|
||||
return str(timestamp)[:10]
|
||||
|
||||
|
||||
def _bare_doc_id(source: str, document_id: str) -> str:
|
||||
"""Strip the connector prefix from a stored ``doc_id``.
|
||||
|
||||
Gmail ingest writes ``doc_id="gmail:<hex_message_id>"`` so that ids stay
|
||||
unique across connectors. The Gmail web UI only resolves the bare hex
|
||||
message id; passing the full prefixed form 404s and bounces the user
|
||||
back to the inbox. Other connectors can be added here as we link out
|
||||
to them.
|
||||
"""
|
||||
if not document_id:
|
||||
return ""
|
||||
prefix = f"{source}:"
|
||||
if source and document_id.startswith(prefix):
|
||||
return document_id[len(prefix):]
|
||||
return document_id
|
||||
|
||||
|
||||
def _hit_url(source: str, document_id: str) -> str:
|
||||
"""Reconstruct a clickable URL from a hit's ``doc_id`` alone.
|
||||
|
||||
Used as a *fallback* when the connector didn't persist a URL on the
|
||||
chunk (``SearchHit.url`` is empty). Reconstruction only works for
|
||||
sources whose doc_id encodes everything the permalink needs — Gmail
|
||||
and Slack today. Sources whose doc_id is just an opaque ID (e.g.
|
||||
Granola, where the web URL uses a different UUID than the API note_id)
|
||||
must populate ``Document.url`` at ingest time; we can't make a working
|
||||
link from the doc_id alone.
|
||||
|
||||
Gmail ids land here in two flavors:
|
||||
|
||||
- **Hex message id** (``19dfa2ccbeff78b0``) — what the OAuth Gmail
|
||||
connector stores. Resolves directly via ``#all/<id>`` permalink.
|
||||
- **RFC822 Message-ID** (``<CABCD@mail.gmail.com>``) — what the IMAP
|
||||
connector stores, since IMAP doesn't expose Gmail's internal hex
|
||||
id. The permalink form would 404; instead route through Gmail's
|
||||
search URL with the ``rfc822msgid:`` operator, which lands the user
|
||||
on the specific message.
|
||||
|
||||
Slack doc_ids encode workspace + channel + timestamp as
|
||||
``slack:{team_domain}:{channel_id}:{ts}`` so the permalink
|
||||
``https://{team_domain}.slack.com/archives/{channel_id}/p{ts}`` can
|
||||
be reconstructed without a side lookup. Legacy two-segment ids
|
||||
(``slack:{channel_id}:{ts}`` from earlier ingests) fall back to the
|
||||
workspace-less ``slack.com/archives/...`` form.
|
||||
"""
|
||||
if source == "gmail" and document_id:
|
||||
msg_id = _bare_doc_id(source, document_id)
|
||||
if not msg_id:
|
||||
return ""
|
||||
if "@" in msg_id or "<" in msg_id or ">" in msg_id:
|
||||
rfc_id = msg_id.strip("<>")
|
||||
return f"https://mail.google.com/mail/u/0/#search/rfc822msgid:{rfc_id}"
|
||||
return f"https://mail.google.com/mail/u/0/#all/{msg_id}"
|
||||
if source == "slack" and document_id:
|
||||
bare = _bare_doc_id(source, document_id)
|
||||
if not bare:
|
||||
return ""
|
||||
parts = bare.split(":")
|
||||
if len(parts) >= 3:
|
||||
team_domain, channel_id, ts = parts[0], parts[1], ":".join(parts[2:])
|
||||
elif len(parts) == 2:
|
||||
team_domain = ""
|
||||
channel_id, ts = parts
|
||||
else:
|
||||
return ""
|
||||
if not channel_id or not ts:
|
||||
return ""
|
||||
ts_clean = ts.replace(".", "")
|
||||
if team_domain:
|
||||
return f"https://{team_domain}.slack.com/archives/{channel_id}/p{ts_clean}"
|
||||
return f"https://slack.com/archives/{channel_id}/p{ts_clean}"
|
||||
return ""
|
||||
|
||||
|
||||
_CITE_RE = re.compile(r"\[(\d+)\]")
|
||||
|
||||
|
||||
def renumber_citations(
|
||||
text: str,
|
||||
ref_to_source: Dict[int, Dict[str, Any]],
|
||||
) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
"""Renumber ``[N]`` citations in ``text`` by first-appearance order.
|
||||
|
||||
The planner sees globally-offset refs across multiple search calls
|
||||
(search 1 returns 1..20, search 2 returns 21..40, …). When the
|
||||
synthesis arrives, the first ref the model actually cited becomes
|
||||
``[1]``, the second unique one becomes ``[2]``, and so on. Repeats
|
||||
map to the same new ref. Refs the synthesis never cites are dropped
|
||||
from the returned ``sources`` list — only the ones the user can
|
||||
actually click on get carried through.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text:
|
||||
Synthesis text containing inline ``[N]`` references.
|
||||
ref_to_source:
|
||||
Mapping from the original (offset) ref to the source dict that
|
||||
``build_sources_for_client`` produced for that hit.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(new_text, ordered_sources)
|
||||
``new_text`` has every cited ``[N]`` rewritten to its new
|
||||
sequence number. ``ordered_sources`` is the deduped list of
|
||||
source dicts in the order they appear in the synthesis, each
|
||||
with its ``ref`` field set to the new sequence number.
|
||||
"""
|
||||
old_to_new: Dict[int, int] = {}
|
||||
ordered: List[Dict[str, Any]] = []
|
||||
for m in _CITE_RE.finditer(text):
|
||||
try:
|
||||
old = int(m.group(1))
|
||||
except ValueError:
|
||||
continue
|
||||
if old in old_to_new:
|
||||
continue
|
||||
src = ref_to_source.get(old)
|
||||
if src is None:
|
||||
# Synthesis cited a ref that doesn't exist in the corpus —
|
||||
# leave the literal text alone, drop the source entry.
|
||||
continue
|
||||
new_ref = len(ordered) + 1
|
||||
old_to_new[old] = new_ref
|
||||
renumbered_src = dict(src)
|
||||
renumbered_src["ref"] = new_ref
|
||||
ordered.append(renumbered_src)
|
||||
|
||||
def _replace(match: "re.Match[str]") -> str:
|
||||
try:
|
||||
old = int(match.group(1))
|
||||
except ValueError:
|
||||
return match.group(0)
|
||||
new = old_to_new.get(old)
|
||||
return f"[{new}]" if new is not None else match.group(0)
|
||||
|
||||
new_text = _CITE_RE.sub(_replace, text)
|
||||
return new_text, ordered
|
||||
|
||||
|
||||
def build_sources_for_client(
|
||||
hits: List[SearchHit],
|
||||
*,
|
||||
total_cap: int = 20,
|
||||
ref_offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Produce the citation-friendly sources list streamed to the frontend.
|
||||
|
||||
One entry per hit, in the same order the planner sees them — so a
|
||||
``[N]`` citation in the synthesis maps to ``sources[N - 1]`` on the
|
||||
client. We don't deduplicate by ``document_id``: separate chunks of the
|
||||
same email each get their own citation slot since the planner may quote
|
||||
different parts.
|
||||
"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, h in enumerate(hits[:total_cap]):
|
||||
sender = h.participants[0] if h.participants else ""
|
||||
# Prefer the URL the connector stored at ingest time (Granola's
|
||||
# ``web_url``, Notion's page URL, etc.) — it's the only reliable
|
||||
# link for sources whose web URL doesn't derive from the doc_id.
|
||||
# Fall back to the doc_id-based reconstruction for sources where
|
||||
# that still works (Slack, Gmail).
|
||||
url = h.url or _hit_url(h.source, h.document_id)
|
||||
out.append(
|
||||
{
|
||||
"ref": i + 1 + ref_offset,
|
||||
"title": h.title,
|
||||
"sender": sender,
|
||||
"date": _hit_date(h.timestamp),
|
||||
"source": h.source,
|
||||
"source_id": _bare_doc_id(h.source, h.document_id),
|
||||
"url": url,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolInvocation:
|
||||
"""One tool call together with what the planner asked for and got.
|
||||
|
||||
``tool_name`` is ``"search"`` or ``"clarify"``. For search calls,
|
||||
``num_results``, ``top_titles`` and ``raw_hits`` are populated; for
|
||||
clarify calls, ``response`` holds the user's answer.
|
||||
"""
|
||||
|
||||
arguments: Dict[str, Any]
|
||||
num_results: int = 0
|
||||
top_titles: List[str] = field(default_factory=list)
|
||||
raw_hits: List[SearchHit] = field(default_factory=list)
|
||||
tool_name: str = "search"
|
||||
response: str = ""
|
||||
|
||||
|
||||
def _default_clarify_handler(question: str) -> str:
|
||||
"""Prompt the user on stdout and read a one-line answer from stdin.
|
||||
|
||||
Empty answers are echoed back as a sentinel so the planner doesn't think
|
||||
the user was silent because of an upstream error.
|
||||
"""
|
||||
print(file=sys.stderr)
|
||||
print(f"\033[1m🤔 Clarification needed:\033[0m {question}", file=sys.stderr)
|
||||
try:
|
||||
answer = input("> ").strip()
|
||||
except EOFError:
|
||||
return "(no answer provided)"
|
||||
return answer or "(user did not provide a clarification)"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResearchResult:
|
||||
answer: str
|
||||
iterations: int
|
||||
tool_calls: List[ToolInvocation]
|
||||
usage: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ResearchAgent:
|
||||
"""Planner + executor loop over a single hybrid-search tool.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
An ``InferenceEngine`` that supports OpenAI-style ``tools`` in
|
||||
``generate`` (Ollama with a tool-capable model).
|
||||
search:
|
||||
The HybridSearch instance the planner can call.
|
||||
model:
|
||||
Planner model tag (default ``gemma4:31b``).
|
||||
max_iterations:
|
||||
Hard ceiling on tool calls before the loop is forced into synthesis.
|
||||
temperature, max_tokens, num_ctx:
|
||||
Generation parameters passed through to ``engine.generate``.
|
||||
on_event:
|
||||
Optional callback fired at loop milestones so callers (e.g. the SSE
|
||||
research router) can stream progress without rewriting the loop.
|
||||
Receives a dict in one of these shapes:
|
||||
- ``{"type": "search_call", "arguments": {...}}`` — about to call search
|
||||
- ``{"type": "search_result", "num_hits": N, "top_titles": [...], "sources": [{"ref": 1, "title": ..., "sender": ..., "date": ..., "source_id": ..., "url": ...}, ...]}`` — search returned
|
||||
- ``{"type": "clarify_call", "question": "..."}`` — about to ask for clarification
|
||||
- ``{"type": "clarify_response", "response": "..."}`` — clarification received
|
||||
- ``{"type": "final_answer", "text": "..."}`` — synthesis ready
|
||||
The callback runs on the same thread as ``run`` and must be non-blocking.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
search: HybridSearch,
|
||||
*,
|
||||
model: str = DEFAULT_PLANNER_MODEL,
|
||||
max_iterations: int = 5,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 1500,
|
||||
num_ctx: int = 16384,
|
||||
clarify_handler: Optional[Callable[[str], str]] = None,
|
||||
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
available_sources: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
self._engine = engine
|
||||
self._search = search
|
||||
self._model = model
|
||||
self._max_iterations = int(max_iterations)
|
||||
self._temperature = float(temperature)
|
||||
self._max_tokens = int(max_tokens)
|
||||
self._num_ctx = int(num_ctx)
|
||||
self._clarify_handler = clarify_handler or _default_clarify_handler
|
||||
self._on_event = on_event
|
||||
# Explicit list wins; otherwise we'll discover sources from the
|
||||
# KnowledgeStore on each run() call so the prompt stays accurate
|
||||
# even as the user connects new connectors mid-session.
|
||||
self._available_sources_override = available_sources
|
||||
|
||||
def _emit(self, event: Dict[str, Any]) -> None:
|
||||
"""Fire ``self._on_event`` if set; swallow callback errors."""
|
||||
if self._on_event is None:
|
||||
return
|
||||
try:
|
||||
self._on_event(event)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("on_event callback raised %s — ignoring", exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_time_range(raw: Any):
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
def _maybe(v):
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(v).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
start = _maybe(raw.get("start"))
|
||||
end = _maybe(raw.get("end"))
|
||||
if start is None and end is None:
|
||||
return None
|
||||
return (start, end)
|
||||
|
||||
def _execute_search(self, args: Dict[str, Any]) -> ToolInvocation:
|
||||
query = str(args.get("query", "") or "")
|
||||
person = args.get("person") or None
|
||||
time_range = self._parse_time_range(args.get("time_range"))
|
||||
sources = args.get("sources") or None
|
||||
if sources and not isinstance(sources, list):
|
||||
sources = [str(sources)]
|
||||
limit = int(args.get("limit", 20) or 20)
|
||||
limit = max(1, min(limit, 20))
|
||||
|
||||
hits = self._search.search(
|
||||
query,
|
||||
person=person,
|
||||
time_range=time_range,
|
||||
sources=sources,
|
||||
limit=limit,
|
||||
)
|
||||
titles = [h.title or (h.content_snippet[:60] + "…") for h in hits[:5]]
|
||||
return ToolInvocation(
|
||||
tool_name="search",
|
||||
arguments={
|
||||
"query": query,
|
||||
"person": person,
|
||||
"time_range": (
|
||||
{"start": time_range[0].isoformat() if time_range and time_range[0] else None,
|
||||
"end": time_range[1].isoformat() if time_range and time_range[1] else None}
|
||||
if time_range else None
|
||||
),
|
||||
"sources": sources,
|
||||
"limit": limit,
|
||||
},
|
||||
num_results=len(hits),
|
||||
top_titles=titles,
|
||||
raw_hits=hits,
|
||||
)
|
||||
|
||||
def _execute_clarify(self, args: Dict[str, Any]) -> ToolInvocation:
|
||||
question = str(args.get("question", "") or "").strip()
|
||||
if not question:
|
||||
return ToolInvocation(
|
||||
tool_name="clarify",
|
||||
arguments={"question": ""},
|
||||
response="(no question provided by agent — skipping clarify)",
|
||||
)
|
||||
answer = self._clarify_handler(question)
|
||||
return ToolInvocation(
|
||||
tool_name="clarify",
|
||||
arguments={"question": question},
|
||||
response=answer,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_available_sources(self) -> List[str]:
|
||||
"""Return the source IDs the user actually has data for.
|
||||
|
||||
Override > live query of the KnowledgeStore. Failure to read the
|
||||
store (e.g. no _store attribute on the search backend) returns
|
||||
``[]`` so the prompt still formats — better empty than crashing.
|
||||
"""
|
||||
if self._available_sources_override is not None:
|
||||
return list(self._available_sources_override)
|
||||
store = getattr(self._search, "_store", None)
|
||||
if store is None:
|
||||
return []
|
||||
try:
|
||||
return list(store.distinct_sources())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("distinct_sources() failed: %s", exc)
|
||||
return []
|
||||
|
||||
def run(self, query: str) -> ResearchResult:
|
||||
"""Run the loop end-to-end and return the synthesis plus a trace."""
|
||||
sources_list = self._resolve_available_sources()
|
||||
if sources_list:
|
||||
sources_blurb = ", ".join(sources_list)
|
||||
else:
|
||||
sources_blurb = (
|
||||
"(no connected sources — tell the user to connect a "
|
||||
"connector before searching)"
|
||||
)
|
||||
sys_msg = Message(
|
||||
role=Role.SYSTEM,
|
||||
content=SYSTEM_PROMPT.format(
|
||||
today=datetime.now().isoformat(timespec="minutes"),
|
||||
available_sources=sources_blurb,
|
||||
),
|
||||
)
|
||||
messages: List[Message] = [sys_msg, Message(role=Role.USER, content=query)]
|
||||
|
||||
invocations: List[ToolInvocation] = []
|
||||
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
|
||||
# Global ref counter: each search increments by the number of hits
|
||||
# it returned so the planner sees unique refs across calls. The
|
||||
# accumulator lets us renumber whatever the synthesis cites at the
|
||||
# end into a single deduped client-facing sources list.
|
||||
next_ref: int = 1
|
||||
ref_to_source: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
def _finalize(text: str) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
return renumber_citations(text, ref_to_source)
|
||||
|
||||
iterations = 0
|
||||
for _ in range(self._max_iterations + 1):
|
||||
iterations += 1
|
||||
tools_arg = (
|
||||
[SEARCH_TOOL_SPEC, CLARIFY_TOOL_SPEC]
|
||||
if len(invocations) < self._max_iterations
|
||||
else None
|
||||
)
|
||||
result = self._engine.generate(
|
||||
messages,
|
||||
model=self._model,
|
||||
temperature=self._temperature,
|
||||
max_tokens=self._max_tokens,
|
||||
num_ctx=self._num_ctx,
|
||||
tools=tools_arg,
|
||||
)
|
||||
for k in total_usage:
|
||||
total_usage[k] += int(result.get("usage", {}).get(k, 0))
|
||||
|
||||
content = result.get("content", "") or ""
|
||||
tool_calls_raw = result.get("tool_calls", []) or []
|
||||
|
||||
if not tool_calls_raw:
|
||||
if content.strip():
|
||||
answer, final_sources = _finalize(content.strip())
|
||||
self._emit(
|
||||
{
|
||||
"type": "final_answer",
|
||||
"text": answer,
|
||||
"sources": final_sources,
|
||||
}
|
||||
)
|
||||
return ResearchResult(
|
||||
answer=answer,
|
||||
iterations=iterations,
|
||||
tool_calls=invocations,
|
||||
usage=total_usage,
|
||||
)
|
||||
# Empty content with no tool call — push a synthesis prod
|
||||
if invocations:
|
||||
messages.append(Message(role=Role.ASSISTANT, content=content))
|
||||
messages.append(
|
||||
Message(
|
||||
role=Role.USER,
|
||||
content=(
|
||||
"Write your final answer now based on the search "
|
||||
"results above. Cite sources as [1], [2], etc."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
fallback = "(model returned no content and no tool calls)"
|
||||
self._emit(
|
||||
{"type": "final_answer", "text": fallback, "sources": []}
|
||||
)
|
||||
return ResearchResult(
|
||||
answer=fallback,
|
||||
iterations=iterations,
|
||||
tool_calls=invocations,
|
||||
usage=total_usage,
|
||||
)
|
||||
|
||||
assistant_msg = Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=content,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id=tc.get("id", f"call_{i}"),
|
||||
name=tc.get("name", "search"),
|
||||
arguments=tc.get("arguments", "{}") or "{}",
|
||||
)
|
||||
for i, tc in enumerate(tool_calls_raw)
|
||||
],
|
||||
)
|
||||
messages.append(assistant_msg)
|
||||
|
||||
for tc in tool_calls_raw:
|
||||
name = tc.get("name", "")
|
||||
raw_args = tc.get("arguments", "{}") or "{}"
|
||||
try:
|
||||
args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
if name == "search":
|
||||
# Guard against the planner pre-empting clarify before any
|
||||
# search has run — silently accept; the rule lives in the
|
||||
# system prompt as guidance, not enforcement.
|
||||
self._emit({"type": "search_call", "arguments": args})
|
||||
inv = self._execute_search(args)
|
||||
invocations.append(inv)
|
||||
offset = next_ref - 1
|
||||
sources_for_search = build_sources_for_client(
|
||||
inv.raw_hits, ref_offset=offset
|
||||
)
|
||||
self._emit(
|
||||
{
|
||||
"type": "search_result",
|
||||
"num_hits": inv.num_results,
|
||||
"top_titles": inv.top_titles,
|
||||
"sources": sources_for_search,
|
||||
}
|
||||
)
|
||||
for src in sources_for_search:
|
||||
ref_to_source[int(src["ref"])] = src
|
||||
next_ref += len(sources_for_search)
|
||||
tool_output = json.dumps(
|
||||
shape_results_for_model(inv.raw_hits, ref_offset=offset),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
elif name == "clarify":
|
||||
# Enforce the "search first" rule at runtime so we don't
|
||||
# surprise the user with a clarification before showing any
|
||||
# work. If the planner jumps to clarify with no searches
|
||||
# behind it, return an error and let the loop try again.
|
||||
if not any(i.tool_name == "search" for i in invocations):
|
||||
tool_output = json.dumps(
|
||||
{
|
||||
"error": (
|
||||
"clarify is only available after at least "
|
||||
"one search call. Run search first, then "
|
||||
"use clarify if the results are ambiguous "
|
||||
"or empty."
|
||||
)
|
||||
}
|
||||
)
|
||||
else:
|
||||
self._emit(
|
||||
{"type": "clarify_call", "question": str(args.get("question", ""))}
|
||||
)
|
||||
inv = self._execute_clarify(args)
|
||||
invocations.append(inv)
|
||||
self._emit(
|
||||
{"type": "clarify_response", "response": inv.response}
|
||||
)
|
||||
tool_output = json.dumps(
|
||||
{
|
||||
"question": inv.arguments.get("question", ""),
|
||||
"user_response": inv.response,
|
||||
}
|
||||
)
|
||||
else:
|
||||
tool_output = json.dumps(
|
||||
{
|
||||
"error": (
|
||||
f"unknown tool {name!r}; available tools are "
|
||||
"'search' and 'clarify'"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
messages.append(
|
||||
Message(
|
||||
role=Role.TOOL,
|
||||
content=tool_output,
|
||||
tool_call_id=tc.get("id", ""),
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
|
||||
if len(invocations) >= self._max_iterations:
|
||||
messages.append(
|
||||
Message(
|
||||
role=Role.USER,
|
||||
content=(
|
||||
"You have used your tool-call budget (search + "
|
||||
"clarify combined). Write the final synthesis now "
|
||||
"using only the search results and clarifications "
|
||||
"above. Cite sources as [1], [2], etc."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Loop fell through without the model producing a text response.
|
||||
# Force one final tool-less synthesis call so the caller always gets
|
||||
# an answer — bailing out with a sentinel string is never useful to
|
||||
# the user, who already paid for the searches.
|
||||
messages.append(
|
||||
Message(
|
||||
role=Role.USER,
|
||||
content=(
|
||||
"You've used all your search attempts. Synthesize your "
|
||||
"findings now from whatever you've found so far. Do not "
|
||||
"request more tool calls — write the final answer as "
|
||||
"plain text, citing sources as [1], [2], etc. where you can. "
|
||||
"If the searches returned nothing usable, say so plainly."
|
||||
),
|
||||
)
|
||||
)
|
||||
iterations += 1
|
||||
final = self._engine.generate(
|
||||
messages,
|
||||
model=self._model,
|
||||
temperature=self._temperature,
|
||||
max_tokens=self._max_tokens,
|
||||
num_ctx=self._num_ctx,
|
||||
tools=None,
|
||||
)
|
||||
for k in total_usage:
|
||||
total_usage[k] += int(final.get("usage", {}).get(k, 0))
|
||||
answer = (final.get("content", "") or "").strip()
|
||||
if not answer:
|
||||
answer = (
|
||||
"(no synthesis available — the search budget was exhausted "
|
||||
"and the model returned no text response)"
|
||||
)
|
||||
answer, final_sources = _finalize(answer)
|
||||
self._emit(
|
||||
{"type": "final_answer", "text": answer, "sources": final_sources}
|
||||
)
|
||||
return ResearchResult(
|
||||
answer=answer,
|
||||
iterations=iterations,
|
||||
tool_calls=invocations,
|
||||
usage=total_usage,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ResearchAgent",
|
||||
"ResearchResult",
|
||||
"ToolInvocation",
|
||||
"SEARCH_TOOL_SPEC",
|
||||
"CLARIFY_TOOL_SPEC",
|
||||
"SYSTEM_PROMPT",
|
||||
"DEFAULT_PLANNER_MODEL",
|
||||
"shape_results_for_model",
|
||||
"build_sources_for_client",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""External anonymous usage analytics.
|
||||
|
||||
Sends anonymized events to PostHog so the OpenJarvis team can measure
|
||||
setup success, retention, feature usage, and churn — without ever
|
||||
collecting chat content, prompts, file paths, emails, IPs, or hardware
|
||||
identifiers.
|
||||
|
||||
Distinct from :mod:`openjarvis.telemetry`, which stores local FLOPs and
|
||||
energy metrics in a SQLite DB and never leaves the machine.
|
||||
|
||||
Disable: set ``[analytics] enabled = false`` in ``~/.openjarvis/config.toml``.
|
||||
"""
|
||||
|
||||
from openjarvis.analytics.aggregator import SessionAggregator
|
||||
from openjarvis.analytics.bridge import EventBridge
|
||||
from openjarvis.analytics.client import AnalyticsClient
|
||||
from openjarvis.analytics.identity import (
|
||||
get_or_create_anon_id,
|
||||
is_analytics_enabled,
|
||||
reset_anon_id,
|
||||
)
|
||||
from openjarvis.analytics.redaction import hash_id, redact
|
||||
|
||||
__all__ = [
|
||||
"AnalyticsClient",
|
||||
"EventBridge",
|
||||
"SessionAggregator",
|
||||
"get_or_create_anon_id",
|
||||
"is_analytics_enabled",
|
||||
"reset_anon_id",
|
||||
"redact",
|
||||
"hash_id",
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Per-session aggregator — turns many internal events into one analytics event.
|
||||
|
||||
Without this, a single chat (50 inferences, 10 tool calls) would
|
||||
produce ~60 PostHog events. With it, the same chat produces one
|
||||
``chat_session_ended`` event with summary properties — a ~60× reduction
|
||||
that keeps per-DAU event volume in the target zone (~40 events/day).
|
||||
|
||||
The aggregator buffers per-session counts in memory, emits on
|
||||
explicit session end, and also emits stale sessions on a background
|
||||
flusher thread (so abandoned sessions don't accumulate forever).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.analytics.client import AnalyticsClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long before an idle session is force-flushed (5 minutes is a
|
||||
# typical chat-app session timeout). 30 second flusher tick.
|
||||
_IDLE_TIMEOUT_S = 300
|
||||
_FLUSHER_TICK_S = 30
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SessionStats:
|
||||
started_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
inference_count: int = 0
|
||||
tokens_in: int = 0
|
||||
tokens_out: int = 0
|
||||
tool_count: int = 0
|
||||
error_count: int = 0
|
||||
latencies_ms: list[float] = field(default_factory=list)
|
||||
models: set[str] = field(default_factory=set)
|
||||
tools: set[str] = field(default_factory=set)
|
||||
engines: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
def _percentile(values: list[float], pct: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
sorted_vals = sorted(values)
|
||||
k = (len(sorted_vals) - 1) * pct
|
||||
f = int(k)
|
||||
c = min(f + 1, len(sorted_vals) - 1)
|
||||
if f == c:
|
||||
return sorted_vals[f]
|
||||
return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f)
|
||||
|
||||
|
||||
class SessionAggregator:
|
||||
"""Buffers per-session counts; emits ``chat_session_ended`` on close."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: "AnalyticsClient",
|
||||
*,
|
||||
idle_timeout_s: float = _IDLE_TIMEOUT_S,
|
||||
flusher_tick_s: float = _FLUSHER_TICK_S,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.idle_timeout_s = idle_timeout_s
|
||||
self._sessions: dict[str, _SessionStats] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._shutdown = threading.Event()
|
||||
self._flusher = threading.Thread(
|
||||
target=self._flush_idle_loop,
|
||||
args=(flusher_tick_s,),
|
||||
daemon=True,
|
||||
name="analytics-aggregator-flusher",
|
||||
)
|
||||
self._flusher.start()
|
||||
|
||||
# -- recording -------------------------------------------------------
|
||||
|
||||
def _touch(self, session_id: str) -> _SessionStats:
|
||||
s = self._sessions.get(session_id)
|
||||
if s is None:
|
||||
s = _SessionStats()
|
||||
self._sessions[session_id] = s
|
||||
s.last_activity = time.time()
|
||||
return s
|
||||
|
||||
def record_inference(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
tokens_in: int = 0,
|
||||
tokens_out: int = 0,
|
||||
latency_ms: float = 0.0,
|
||||
model_hash: str = "",
|
||||
engine: str = "",
|
||||
) -> None:
|
||||
with self._lock:
|
||||
s = self._touch(session_id)
|
||||
s.inference_count += 1
|
||||
s.tokens_in += max(0, tokens_in)
|
||||
s.tokens_out += max(0, tokens_out)
|
||||
if latency_ms > 0:
|
||||
s.latencies_ms.append(latency_ms)
|
||||
if model_hash:
|
||||
s.models.add(model_hash)
|
||||
if engine:
|
||||
s.engines.add(engine)
|
||||
|
||||
def record_tool(self, session_id: str, *, tool_name: str = "") -> None:
|
||||
with self._lock:
|
||||
s = self._touch(session_id)
|
||||
s.tool_count += 1
|
||||
if tool_name:
|
||||
s.tools.add(tool_name)
|
||||
|
||||
def record_error(self, session_id: str) -> None:
|
||||
with self._lock:
|
||||
s = self._touch(session_id)
|
||||
s.error_count += 1
|
||||
|
||||
# -- lifecycle -------------------------------------------------------
|
||||
|
||||
def end_session(self, session_id: str) -> None:
|
||||
"""Emit ``chat_session_ended`` for one session and drop the buffer."""
|
||||
with self._lock:
|
||||
stats = self._sessions.pop(session_id, None)
|
||||
if stats is None or stats.inference_count == 0:
|
||||
# Nothing meaningful happened — don't emit a no-op event.
|
||||
return
|
||||
self._emit(stats)
|
||||
|
||||
def _emit(self, stats: _SessionStats) -> None:
|
||||
props: dict[str, object] = {
|
||||
"turn_count": stats.inference_count,
|
||||
"tokens_in": stats.tokens_in,
|
||||
"tokens_out": stats.tokens_out,
|
||||
"latency_ms_p50": _percentile(stats.latencies_ms, 0.50),
|
||||
"latency_ms_p95": _percentile(stats.latencies_ms, 0.95),
|
||||
"tool_count": stats.tool_count,
|
||||
"unique_tools": len(stats.tools),
|
||||
"unique_models": len(stats.models),
|
||||
"error_count": stats.error_count,
|
||||
"duration_ms": int((stats.last_activity - stats.started_at) * 1000),
|
||||
}
|
||||
# Only emit model/engine when unambiguous (one used in session).
|
||||
if len(stats.models) == 1:
|
||||
props["model_hash"] = next(iter(stats.models))
|
||||
if len(stats.engines) == 1:
|
||||
props["engine"] = next(iter(stats.engines))
|
||||
self.client.capture("chat_session_ended", props)
|
||||
|
||||
def _flush_idle_loop(self, tick_s: float) -> None:
|
||||
while not self._shutdown.wait(tick_s):
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
stale_ids = [
|
||||
sid
|
||||
for sid, s in self._sessions.items()
|
||||
if now - s.last_activity > self.idle_timeout_s
|
||||
]
|
||||
for sid in stale_ids:
|
||||
try:
|
||||
self.end_session(sid)
|
||||
except Exception as exc:
|
||||
logger.debug("Aggregator idle flush failed: %s", exc)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Flush every buffered session and stop the flusher thread."""
|
||||
self._shutdown.set()
|
||||
with self._lock:
|
||||
stats_list = list(self._sessions.values())
|
||||
self._sessions.clear()
|
||||
for stats in stats_list:
|
||||
try:
|
||||
if stats.inference_count > 0:
|
||||
self._emit(stats)
|
||||
except Exception as exc:
|
||||
logger.debug("Aggregator shutdown flush failed: %s", exc)
|
||||
|
||||
|
||||
__all__ = ["SessionAggregator"]
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Bridge between the internal event bus and the analytics client.
|
||||
|
||||
The internal bus (:mod:`openjarvis.core.events`) carries dozens of
|
||||
event types — most are too granular or too internal to ship as
|
||||
analytics. The bridge:
|
||||
|
||||
- Subscribes to a focused subset of EventTypes.
|
||||
- Aggregates high-frequency events (INFERENCE_END, TOOL_CALL_END)
|
||||
via :class:`SessionAggregator` so we only ship one event per chat
|
||||
session, not one per inference.
|
||||
- Forwards user-meaningful low-frequency events directly
|
||||
(FEEDBACK_RECEIVED, SECURITY_ALERT).
|
||||
- Tracks first-uses in-process so ``tool_first_used`` fires once per
|
||||
(anon_id, tool) per process. (First-use *across* processes is
|
||||
not promised — that would require disk state and isn't worth the
|
||||
complexity for v1.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openjarvis.analytics.aggregator import SessionAggregator
|
||||
from openjarvis.analytics.redaction import hash_id
|
||||
from openjarvis.core.events import Event, EventType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.analytics.client import AnalyticsClient
|
||||
from openjarvis.core.events import EventBus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Allowlist of known engines — anything else gets normalised to "unknown"
|
||||
# so we don't leak custom engine names through the engine property.
|
||||
_KNOWN_ENGINES = frozenset(
|
||||
{
|
||||
"ollama",
|
||||
"vllm",
|
||||
"mlx",
|
||||
"llama_cpp",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
}
|
||||
)
|
||||
|
||||
# Default session id used when an internal event doesn't carry one.
|
||||
_DEFAULT_SESSION = "default"
|
||||
|
||||
|
||||
class EventBridge:
|
||||
"""Subscribes to the internal bus and routes to the analytics client."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: "EventBus",
|
||||
client: "AnalyticsClient",
|
||||
aggregator: SessionAggregator | None = None,
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
self.client = client
|
||||
self.aggregator = aggregator or SessionAggregator(client)
|
||||
self._first_tool_uses: set[str] = set()
|
||||
self._first_chat_emitted = False
|
||||
self._lock = threading.Lock()
|
||||
self._subscribed = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Attach subscribers to the bus. Idempotent."""
|
||||
if self._subscribed:
|
||||
return
|
||||
self.bus.subscribe(EventType.INFERENCE_END, self._on_inference_end)
|
||||
self.bus.subscribe(EventType.TOOL_CALL_END, self._on_tool_end)
|
||||
self.bus.subscribe(EventType.SESSION_END, self._on_session_end)
|
||||
self.bus.subscribe(EventType.AGENT_TURN_END, self._on_agent_turn_end)
|
||||
self.bus.subscribe(EventType.FEEDBACK_RECEIVED, self._on_feedback)
|
||||
self.bus.subscribe(EventType.SECURITY_ALERT, self._on_security_alert)
|
||||
self._subscribed = True
|
||||
logger.debug("Analytics bridge subscribed to internal event bus")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Detach subscribers and flush buffered sessions."""
|
||||
if self._subscribed:
|
||||
try:
|
||||
self.bus.unsubscribe(EventType.INFERENCE_END, self._on_inference_end)
|
||||
self.bus.unsubscribe(EventType.TOOL_CALL_END, self._on_tool_end)
|
||||
self.bus.unsubscribe(EventType.SESSION_END, self._on_session_end)
|
||||
self.bus.unsubscribe(EventType.AGENT_TURN_END, self._on_agent_turn_end)
|
||||
self.bus.unsubscribe(EventType.FEEDBACK_RECEIVED, self._on_feedback)
|
||||
self.bus.unsubscribe(EventType.SECURITY_ALERT, self._on_security_alert)
|
||||
except Exception as exc:
|
||||
logger.debug("Analytics bridge unsubscribe error: %s", exc)
|
||||
self._subscribed = False
|
||||
self.aggregator.shutdown()
|
||||
|
||||
# -- handlers --------------------------------------------------------
|
||||
|
||||
def _session_id(self, data: dict) -> str:
|
||||
sid = data.get("session_id") or data.get("session") or data.get("trace_id")
|
||||
return str(sid) if sid else _DEFAULT_SESSION
|
||||
|
||||
def _normalise_engine(self, raw: object) -> str:
|
||||
if not isinstance(raw, str):
|
||||
return ""
|
||||
e = raw.lower()
|
||||
return e if e in _KNOWN_ENGINES else "unknown"
|
||||
|
||||
def _on_inference_end(self, event: Event) -> None:
|
||||
try:
|
||||
data = event.data or {}
|
||||
self.aggregator.record_inference(
|
||||
session_id=self._session_id(data),
|
||||
tokens_in=int(
|
||||
data.get("input_tokens") or data.get("prompt_tokens") or 0
|
||||
),
|
||||
tokens_out=int(
|
||||
data.get("output_tokens") or data.get("completion_tokens") or 0
|
||||
),
|
||||
latency_ms=float(
|
||||
data.get("latency_ms")
|
||||
or (data.get("latency_seconds", 0) or 0) * 1000.0
|
||||
),
|
||||
model_hash=hash_id(str(data.get("model", ""))),
|
||||
engine=self._normalise_engine(data.get("engine")),
|
||||
)
|
||||
|
||||
# One-shot first_chat_sent per process lifetime.
|
||||
# Note: we don't set "platform" here — the backend can't reliably
|
||||
# tell whether the call came from CLI, desktop, or web. The
|
||||
# frontend owns platform-aware events; this one is just the
|
||||
# activation marker.
|
||||
with self._lock:
|
||||
if not self._first_chat_emitted:
|
||||
self._first_chat_emitted = True
|
||||
self.client.capture("first_chat_sent", {})
|
||||
except Exception as exc:
|
||||
logger.debug("Bridge _on_inference_end error: %s", exc)
|
||||
|
||||
def _on_tool_end(self, event: Event) -> None:
|
||||
try:
|
||||
data = event.data or {}
|
||||
session_id = self._session_id(data)
|
||||
tool_name_raw = str(data.get("tool_name") or data.get("tool") or "")
|
||||
self.aggregator.record_tool(session_id, tool_name=hash_id(tool_name_raw))
|
||||
|
||||
# First-use per (process, tool). Use the raw name's hash to
|
||||
# de-duplicate without leaking the literal name.
|
||||
tool_key = hash_id(tool_name_raw)
|
||||
with self._lock:
|
||||
first = tool_key and tool_key not in self._first_tool_uses
|
||||
if first:
|
||||
self._first_tool_uses.add(tool_key)
|
||||
if first:
|
||||
# tool_name property must be in the analytics allowlist;
|
||||
# if the raw name isn't recognised, we send "custom_tool".
|
||||
from openjarvis.analytics.events import KNOWN_TOOL_NAMES
|
||||
|
||||
shipped_name = (
|
||||
tool_name_raw
|
||||
if tool_name_raw in KNOWN_TOOL_NAMES
|
||||
else "custom_tool"
|
||||
)
|
||||
self.client.capture("tool_first_used", {"tool_name": shipped_name})
|
||||
except Exception as exc:
|
||||
logger.debug("Bridge _on_tool_end error: %s", exc)
|
||||
|
||||
def _on_session_end(self, event: Event) -> None:
|
||||
try:
|
||||
data = event.data or {}
|
||||
self.aggregator.end_session(self._session_id(data))
|
||||
except Exception as exc:
|
||||
logger.debug("Bridge _on_session_end error: %s", exc)
|
||||
|
||||
def _on_agent_turn_end(self, event: Event) -> None:
|
||||
# AGENT_TURN_END can mark the end of a logical chat exchange.
|
||||
# If there's no explicit SESSION_END, treat this as an idle hint
|
||||
# rather than a hard close — the aggregator will flush on idle
|
||||
# if no more events arrive.
|
||||
try:
|
||||
data = event.data or {}
|
||||
if data.get("error"):
|
||||
self.aggregator.record_error(self._session_id(data))
|
||||
except Exception as exc:
|
||||
logger.debug("Bridge _on_agent_turn_end error: %s", exc)
|
||||
|
||||
def _on_feedback(self, event: Event) -> None:
|
||||
try:
|
||||
data = event.data or {}
|
||||
rating = data.get("rating")
|
||||
has_comment = bool(data.get("comment") or data.get("text"))
|
||||
self.client.capture(
|
||||
"feedback_submitted",
|
||||
{
|
||||
"rating": int(rating) if isinstance(rating, (int, float)) else 0,
|
||||
"has_comment": has_comment,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Bridge _on_feedback error: %s", exc)
|
||||
|
||||
def _on_security_alert(self, event: Event) -> None:
|
||||
try:
|
||||
data = event.data or {}
|
||||
self.client.capture(
|
||||
"error_shown_to_user",
|
||||
{
|
||||
"error_class": "permission_denied",
|
||||
"platform": str(data.get("platform", "cli")),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Bridge _on_security_alert error: %s", exc)
|
||||
|
||||
|
||||
__all__ = ["EventBridge"]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""PostHog client wrapper.
|
||||
|
||||
A thin adapter over the official ``posthog`` SDK that:
|
||||
- Initialises lazily and only if analytics is enabled.
|
||||
- Pipes every capture through :mod:`redaction` and :mod:`events` for
|
||||
PII stripping and structural validation.
|
||||
- Fails silently — analytics must never break the host application.
|
||||
|
||||
The underlying SDK handles batching, async send on a background
|
||||
thread, retries, and silent failure on network errors. We layer
|
||||
"never crash the app" on top by wrapping every call in try/except.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from openjarvis.analytics.events import validate_event
|
||||
from openjarvis.analytics.identity import (
|
||||
get_or_create_anon_id,
|
||||
is_analytics_enabled,
|
||||
)
|
||||
from openjarvis.analytics.redaction import redact
|
||||
from openjarvis.core.config import AnalyticsConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalyticsClient:
|
||||
"""Send anonymized usage events to PostHog.
|
||||
|
||||
Construct once at server / CLI startup, share for the process
|
||||
lifetime, call :meth:`shutdown` on exit to flush pending events.
|
||||
"""
|
||||
|
||||
def __init__(self, config: AnalyticsConfig, anon_id: str | None = None) -> None:
|
||||
self.config = config
|
||||
self.anon_id = anon_id or get_or_create_anon_id(config.anon_id_path)
|
||||
self._lock = threading.Lock()
|
||||
self._posthog: Any = None
|
||||
self._enabled = is_analytics_enabled(config)
|
||||
if self._enabled:
|
||||
self._init_sdk()
|
||||
|
||||
def _init_sdk(self) -> None:
|
||||
try:
|
||||
from posthog import Posthog
|
||||
|
||||
self._posthog = Posthog(
|
||||
project_api_key=self.config.key,
|
||||
host=self.config.host,
|
||||
# The SDK queues events and flushes on a background
|
||||
# thread; these knobs just tune the batch size/cadence.
|
||||
max_queue_size=10_000,
|
||||
flush_at=self.config.flush_at_size,
|
||||
flush_interval=self.config.flush_interval_seconds,
|
||||
# Don't sample or auto-capture anything beyond what we
|
||||
# explicitly send.
|
||||
disable_geoip=True,
|
||||
)
|
||||
logger.debug(
|
||||
"PostHog analytics initialised host=%s anon_id=%s…",
|
||||
self.config.host,
|
||||
self.anon_id[:8],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Analytics SDK init failed (%s); analytics disabled", exc)
|
||||
self._posthog = None
|
||||
self._enabled = False
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled and self._posthog is not None
|
||||
|
||||
def capture(
|
||||
self,
|
||||
event_name: str,
|
||||
properties: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Send one event. Unknown events are silently dropped.
|
||||
|
||||
Runs through redaction → event-spec validation → SDK capture.
|
||||
Failures at any stage are swallowed; analytics is best-effort.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
try:
|
||||
raw = properties or {}
|
||||
cleaned = redact(raw)
|
||||
validated = validate_event(event_name, cleaned)
|
||||
if validated is None:
|
||||
logger.debug("Dropped unknown analytics event: %s", event_name)
|
||||
return
|
||||
self._posthog.capture(
|
||||
distinct_id=self.anon_id,
|
||||
event=event_name,
|
||||
properties=validated,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Analytics capture failed for %s: %s", event_name, exc)
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Force-flush queued events. Safe to call repeatedly."""
|
||||
if self._posthog is None:
|
||||
return
|
||||
try:
|
||||
self._posthog.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Flush and close the SDK. Call once on process exit."""
|
||||
with self._lock:
|
||||
if self._posthog is None:
|
||||
return
|
||||
try:
|
||||
self._posthog.flush()
|
||||
self._posthog.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self._posthog = None
|
||||
self._enabled = False
|
||||
|
||||
|
||||
__all__ = ["AnalyticsClient"]
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Canonical registry of external analytics events.
|
||||
|
||||
Single source of truth for every event name and property the OpenJarvis
|
||||
analytics module is allowed to send. Any event not declared here is
|
||||
dropped at send time. Any property not declared on a known event is
|
||||
also dropped. This is the fail-closed half of the PII guardrail —
|
||||
see :mod:`openjarvis.analytics.redaction` for the value-level filters.
|
||||
|
||||
Keeping the catalog in code (not config) means PR review is the gate
|
||||
for adding a new event, and ``docs/telemetry.md`` can render from this
|
||||
module as the source of truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
PropertyValidator = Callable[[Any], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EventSpec:
|
||||
"""Declaration for one analytics event."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
properties: dict[str, PropertyValidator]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reusable validators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_bool(v: Any) -> bool:
|
||||
return isinstance(v, bool)
|
||||
|
||||
|
||||
def _is_int_nonneg(v: Any) -> bool:
|
||||
return isinstance(v, int) and not isinstance(v, bool) and v >= 0
|
||||
|
||||
|
||||
def _is_number_nonneg(v: Any) -> bool:
|
||||
return isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 0
|
||||
|
||||
|
||||
def _is_short_str(v: Any) -> bool:
|
||||
return isinstance(v, str) and 0 < len(v) <= 64
|
||||
|
||||
|
||||
def _is_med_str(v: Any) -> bool:
|
||||
return isinstance(v, str) and 0 < len(v) <= 200
|
||||
|
||||
|
||||
def _is_hash16(v: Any) -> bool:
|
||||
"""sha256 prefix, 16 hex chars — used for hashed model/tool identifiers."""
|
||||
if not isinstance(v, str) or len(v) != 16:
|
||||
return False
|
||||
return all(c in "0123456789abcdef" for c in v)
|
||||
|
||||
|
||||
def _is_one_of(*allowed: str) -> PropertyValidator:
|
||||
allowed_set = frozenset(allowed)
|
||||
|
||||
def check(v: Any) -> bool:
|
||||
return isinstance(v, str) and v in allowed_set
|
||||
|
||||
return check
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Closed enums (allowlists for free-form-looking strings)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OS_VALUES = ("darwin", "linux", "wsl", "windows", "unknown")
|
||||
_ARCH_VALUES = ("x86_64", "arm64", "aarch64", "unknown")
|
||||
_PLATFORM_VALUES = (
|
||||
"cli",
|
||||
"macos",
|
||||
"linux",
|
||||
"windows",
|
||||
"tauri-macos",
|
||||
"tauri-linux",
|
||||
"tauri-windows",
|
||||
"web",
|
||||
)
|
||||
_INSTALL_STAGES = (
|
||||
"deps",
|
||||
"uv",
|
||||
"python",
|
||||
"venv",
|
||||
"package",
|
||||
"ollama",
|
||||
"model_download",
|
||||
"config",
|
||||
"verify",
|
||||
"complete",
|
||||
)
|
||||
_SETUP_PRESETS = (
|
||||
"morning-digest-mac",
|
||||
"deep-research",
|
||||
"code-assistant",
|
||||
"minimal",
|
||||
"custom",
|
||||
"default",
|
||||
)
|
||||
_ERROR_CLASSES = (
|
||||
"network_error",
|
||||
"engine_unreachable",
|
||||
"model_not_found",
|
||||
"tool_timeout",
|
||||
"tool_error",
|
||||
"rate_limit",
|
||||
"auth_failure",
|
||||
"permission_denied",
|
||||
"validation_error",
|
||||
"config_error",
|
||||
"unknown_error",
|
||||
)
|
||||
_FEATURE_NAMES = (
|
||||
"chat",
|
||||
"voice",
|
||||
"agents",
|
||||
"skills",
|
||||
"memory",
|
||||
"tools",
|
||||
"connectors",
|
||||
"scheduler",
|
||||
"workflows",
|
||||
"digest",
|
||||
"research",
|
||||
"evals",
|
||||
"feedback",
|
||||
"telemetry_dashboard",
|
||||
"settings",
|
||||
)
|
||||
_TOOL_NAMES = (
|
||||
"http_request",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"shell",
|
||||
"search_web",
|
||||
"calculator",
|
||||
"memory_search",
|
||||
"memory_store",
|
||||
"code_exec",
|
||||
"image_view",
|
||||
"gmail",
|
||||
"calendar",
|
||||
"drive",
|
||||
"github",
|
||||
"hackernews",
|
||||
"weather",
|
||||
"notion",
|
||||
"strava",
|
||||
"custom_tool",
|
||||
)
|
||||
_CONNECTOR_NAMES = (
|
||||
"google",
|
||||
"gmail",
|
||||
"calendar",
|
||||
"drive",
|
||||
"github",
|
||||
"notion",
|
||||
"strava",
|
||||
"weather",
|
||||
"hackernews",
|
||||
"slack",
|
||||
"telegram",
|
||||
"discord",
|
||||
"webhook",
|
||||
"sendblue",
|
||||
"whatsapp",
|
||||
"signal",
|
||||
"custom",
|
||||
)
|
||||
_ENGINE_NAMES = (
|
||||
"ollama",
|
||||
"vllm",
|
||||
"mlx",
|
||||
"llama_cpp",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"unknown",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event specifications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SPECS: tuple[EventSpec, ...] = (
|
||||
# -- Install funnel (sent from install.sh) ------------------------------
|
||||
EventSpec(
|
||||
name="install_started",
|
||||
description="Install script began executing (curl | bash entry).",
|
||||
properties={
|
||||
"os": _is_one_of(*_OS_VALUES),
|
||||
"arch": _is_one_of(*_ARCH_VALUES),
|
||||
"installer_version": _is_short_str,
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="install_stage_completed",
|
||||
description="One install stage finished successfully.",
|
||||
properties={
|
||||
"stage": _is_one_of(*_INSTALL_STAGES),
|
||||
"elapsed_ms": _is_int_nonneg,
|
||||
"os": _is_one_of(*_OS_VALUES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="install_completed",
|
||||
description="Full install finished successfully.",
|
||||
properties={
|
||||
"total_elapsed_ms": _is_int_nonneg,
|
||||
"os": _is_one_of(*_OS_VALUES),
|
||||
"arch": _is_one_of(*_ARCH_VALUES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="install_failed",
|
||||
description="Install script exited non-zero at a known stage.",
|
||||
properties={
|
||||
"stage": _is_one_of(*_INSTALL_STAGES),
|
||||
"exit_code": _is_int_nonneg,
|
||||
"os": _is_one_of(*_OS_VALUES),
|
||||
"arch": _is_one_of(*_ARCH_VALUES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="uninstall_started",
|
||||
description="Uninstall script began executing.",
|
||||
properties={
|
||||
"days_since_install": _is_int_nonneg,
|
||||
"os": _is_one_of(*_OS_VALUES),
|
||||
},
|
||||
),
|
||||
# -- App lifecycle ------------------------------------------------------
|
||||
EventSpec(
|
||||
name="app_opened",
|
||||
description="App boot — every launch of CLI or desktop UI.",
|
||||
properties={
|
||||
"version": _is_short_str,
|
||||
"platform": _is_one_of(*_PLATFORM_VALUES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="setup_completed",
|
||||
description="`jarvis init` finished and config.toml was written.",
|
||||
properties={
|
||||
"preset": _is_one_of(*_SETUP_PRESETS),
|
||||
"model_hash": _is_hash16,
|
||||
"engine": _is_one_of(*_ENGINE_NAMES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="first_chat_sent",
|
||||
description="First chat message ever sent on this anon_id.",
|
||||
properties={
|
||||
"platform": _is_one_of(*_PLATFORM_VALUES),
|
||||
},
|
||||
),
|
||||
# -- Usage (per session, aggregated) -----------------------------------
|
||||
EventSpec(
|
||||
name="chat_session_ended",
|
||||
description=(
|
||||
"A chat session closed (explicit close or idle timeout). "
|
||||
"Aggregated counts only — no content."
|
||||
),
|
||||
properties={
|
||||
"turn_count": _is_int_nonneg,
|
||||
"tokens_in": _is_int_nonneg,
|
||||
"tokens_out": _is_int_nonneg,
|
||||
"latency_ms_p50": _is_number_nonneg,
|
||||
"latency_ms_p95": _is_number_nonneg,
|
||||
"tool_count": _is_int_nonneg,
|
||||
"unique_tools": _is_int_nonneg,
|
||||
"unique_models": _is_int_nonneg,
|
||||
"error_count": _is_int_nonneg,
|
||||
"model_hash": _is_hash16,
|
||||
"engine": _is_one_of(*_ENGINE_NAMES),
|
||||
"duration_ms": _is_int_nonneg,
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="tool_first_used",
|
||||
description="First time this anon_id used a given tool.",
|
||||
properties={
|
||||
"tool_name": _is_one_of(*_TOOL_NAMES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="model_changed",
|
||||
description="User explicitly switched the active model.",
|
||||
properties={
|
||||
"from_model_hash": _is_hash16,
|
||||
"to_model_hash": _is_hash16,
|
||||
"engine": _is_one_of(*_ENGINE_NAMES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="connector_auth_completed",
|
||||
description="OAuth flow finished successfully for a connector.",
|
||||
properties={
|
||||
"connector_name": _is_one_of(*_CONNECTOR_NAMES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="feature_used",
|
||||
description="A top-level feature was invoked.",
|
||||
properties={
|
||||
"feature_name": _is_one_of(*_FEATURE_NAMES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="feedback_submitted",
|
||||
description="User submitted feedback. No comment content sent.",
|
||||
properties={
|
||||
"rating": _is_int_nonneg,
|
||||
"has_comment": _is_bool,
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="error_shown_to_user",
|
||||
description="A user-visible error was rendered. Error class only.",
|
||||
properties={
|
||||
"error_class": _is_one_of(*_ERROR_CLASSES),
|
||||
"platform": _is_one_of(*_PLATFORM_VALUES),
|
||||
},
|
||||
),
|
||||
EventSpec(
|
||||
name="settings_changed",
|
||||
description="User toggled or modified a setting.",
|
||||
properties={
|
||||
"setting_key": _is_short_str,
|
||||
},
|
||||
),
|
||||
# -- Daily rollup ------------------------------------------------------
|
||||
EventSpec(
|
||||
name="usage_daily_summary",
|
||||
description=(
|
||||
"Once-per-day aggregated counts. Cheaper than per-event "
|
||||
"for high-frequency operations."
|
||||
),
|
||||
properties={
|
||||
"sessions": _is_int_nonneg,
|
||||
"total_tokens": _is_int_nonneg,
|
||||
"total_inferences": _is_int_nonneg,
|
||||
"unique_tools": _is_int_nonneg,
|
||||
"unique_models": _is_int_nonneg,
|
||||
"total_errors": _is_int_nonneg,
|
||||
"total_duration_ms": _is_int_nonneg,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
REGISTRY: dict[str, EventSpec] = {spec.name: spec for spec in _SPECS}
|
||||
|
||||
|
||||
def validate_event(name: str, properties: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Return cleaned properties or ``None`` if the event name is unknown.
|
||||
|
||||
Unknown properties are silently dropped. Properties whose values
|
||||
fail the spec's validator are silently dropped. Empty result is
|
||||
valid (the event itself is still recorded).
|
||||
"""
|
||||
spec = REGISTRY.get(name)
|
||||
if spec is None:
|
||||
return None
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in properties.items():
|
||||
validator = spec.properties.get(key)
|
||||
if validator is None:
|
||||
continue
|
||||
if not validator(value):
|
||||
continue
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
|
||||
def known_event_names() -> tuple[str, ...]:
|
||||
"""All event names declared in the catalog (for docs and tests)."""
|
||||
return tuple(REGISTRY.keys())
|
||||
|
||||
|
||||
# Public re-exports of the allowlists for cross-module use (bridge.py).
|
||||
KNOWN_TOOL_NAMES = frozenset(_TOOL_NAMES)
|
||||
KNOWN_CONNECTORS = frozenset(_CONNECTOR_NAMES)
|
||||
KNOWN_FEATURES = frozenset(_FEATURE_NAMES)
|
||||
KNOWN_ENGINES = frozenset(_ENGINE_NAMES)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EventSpec",
|
||||
"REGISTRY",
|
||||
"PropertyValidator",
|
||||
"validate_event",
|
||||
"known_event_names",
|
||||
"KNOWN_TOOL_NAMES",
|
||||
"KNOWN_CONNECTORS",
|
||||
"KNOWN_FEATURES",
|
||||
"KNOWN_ENGINES",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Anonymous identity for external analytics.
|
||||
|
||||
One UUID v4 per install, persisted to disk on first use. The same file
|
||||
is referenced by ``scripts/install/install.sh`` so install-time beacon
|
||||
events tie back to the same person across the install→first-run funnel.
|
||||
|
||||
No email, no name, no hardware fingerprint — just an opaque UUID.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.config import AnalyticsConfig
|
||||
|
||||
# Env vars that disable analytics regardless of config-file setting.
|
||||
# ``DO_NOT_TRACK`` follows the W3C convention (https://www.eff.org/dnt-policy);
|
||||
# ``OPENJARVIS_NO_ANALYTICS`` is the project-specific opt-out for users who
|
||||
# want to disable just our telemetry without affecting other tools that
|
||||
# honor DNT.
|
||||
_OPT_OUT_ENV_VARS = ("DO_NOT_TRACK", "OPENJARVIS_NO_ANALYTICS")
|
||||
|
||||
|
||||
def get_or_create_anon_id(path: Path | str) -> str:
|
||||
"""Return the persisted anon ID, generating one on first call.
|
||||
|
||||
Idempotent across processes — if the file already exists with a
|
||||
non-empty value, return it; otherwise generate a fresh UUID v4 and
|
||||
write atomically (rename-after-write so a crashed write leaves no
|
||||
half-file).
|
||||
"""
|
||||
p = Path(path)
|
||||
if p.exists():
|
||||
existing = p.read_text(encoding="utf-8").strip()
|
||||
if existing:
|
||||
return existing
|
||||
new_id = str(uuid.uuid4())
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||
tmp.write_text(new_id + "\n", encoding="utf-8")
|
||||
tmp.replace(p)
|
||||
return new_id
|
||||
|
||||
|
||||
def reset_anon_id(path: Path | str) -> str:
|
||||
"""Delete the persisted ID and generate a fresh one (privacy reset)."""
|
||||
p = Path(path)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
return get_or_create_anon_id(p)
|
||||
|
||||
|
||||
def _env_opt_out() -> bool:
|
||||
"""Return True if any opt-out env var is set to a truthy value.
|
||||
|
||||
Truthy = anything other than empty string, "0", "false", "no", "off"
|
||||
(case-insensitive). Lets `DO_NOT_TRACK=1`, `=true`, `=yes` all work.
|
||||
"""
|
||||
for name in _OPT_OUT_ENV_VARS:
|
||||
raw = os.environ.get(name)
|
||||
if raw and raw.strip().lower() not in ("", "0", "false", "no", "off"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_analytics_enabled(cfg: AnalyticsConfig) -> bool:
|
||||
"""Return True if analytics is enabled.
|
||||
|
||||
Disabled in three cases (any one is sufficient):
|
||||
|
||||
1. Running under pytest. The PostHog SDK registers an ``atexit``
|
||||
hook that synchronously joins its consumer thread; if the host
|
||||
is unreachable (CI runners can't reach the analytics endpoint),
|
||||
each queued batch retries for ``timeout * max_retries`` seconds
|
||||
and the interpreter never exits. Detect pytest via
|
||||
``PYTEST_CURRENT_TEST`` (set per test) and ``"pytest" in
|
||||
sys.modules`` (covers the collection phase before the first
|
||||
test runs).
|
||||
2. An opt-out env var is set: ``DO_NOT_TRACK=1`` (W3C convention)
|
||||
or ``OPENJARVIS_NO_ANALYTICS=1`` (project-specific). Both take
|
||||
precedence over the config so users can opt out without
|
||||
editing ``~/.openjarvis/config.toml``.
|
||||
3. The ``[analytics] enabled = false`` config-file setting.
|
||||
"""
|
||||
if os.environ.get("PYTEST_CURRENT_TEST") or "pytest" in sys.modules:
|
||||
return False
|
||||
if _env_opt_out():
|
||||
return False
|
||||
return cfg.enabled
|
||||
@@ -0,0 +1,106 @@
|
||||
"""PII redaction for analytics property values.
|
||||
|
||||
This is the value-level half of the guardrail
|
||||
(:mod:`openjarvis.analytics.events` is the structural half).
|
||||
|
||||
For each property value we ship, we:
|
||||
1. Drop strings longer than ``MAX_STR_LEN`` (no chunks of chat content).
|
||||
2. Drop strings that match any known PII pattern (emails, IPs, MACs,
|
||||
$HOME paths, API keys, JWTs, bearer tokens, etc.).
|
||||
3. Otherwise pass through unchanged.
|
||||
|
||||
Fail-closed: when in doubt, drop. Combined with the event-spec
|
||||
allowlist this gives two independent layers of protection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MAX_STR_LEN = 200
|
||||
|
||||
# Patterns that, if found anywhere inside a string value, cause that
|
||||
# value to be dropped. Order is by likelihood for short-circuit speed.
|
||||
_PII_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
# Email
|
||||
re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
|
||||
# IPv4 (any 4-octet decimal pattern)
|
||||
re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"),
|
||||
# IPv6 (loose — any colon-separated hex)
|
||||
re.compile(r"\b(?:[0-9A-Fa-f]{1,4}:){2,}[0-9A-Fa-f]{0,4}\b"),
|
||||
# MAC address
|
||||
re.compile(r"\b[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}\b"),
|
||||
# Home paths
|
||||
re.compile(r"/Users/[^/\s]+"),
|
||||
re.compile(r"/home/[^/\s]+"),
|
||||
re.compile(r"\$HOME|~/"),
|
||||
# File URLs
|
||||
re.compile(r"file://"),
|
||||
# Common API key prefixes
|
||||
re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), # OpenAI / Anthropic
|
||||
re.compile(r"\bxoxb-[A-Za-z0-9-]{8,}"), # Slack bot
|
||||
re.compile(r"\bxoxp-[A-Za-z0-9-]{8,}"), # Slack user
|
||||
re.compile(r"\bghp_[A-Za-z0-9]{20,}"), # GitHub personal
|
||||
re.compile(r"\bgho_[A-Za-z0-9]{20,}"), # GitHub OAuth
|
||||
re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key
|
||||
re.compile(r"\bAIza[0-9A-Za-z_-]{20,}"), # Google API key
|
||||
re.compile(r"\bya29\.[0-9A-Za-z_-]+"), # Google OAuth access token
|
||||
# JWT (three base64url chunks separated by dots, header starts with eyJ)
|
||||
re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"),
|
||||
# Bearer authorization headers
|
||||
re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._-]+"),
|
||||
# Password / secret assignment patterns
|
||||
re.compile(r"(?i)\b(password|secret|api_key|token)\s*[:=]\s*\S+"),
|
||||
# Hostnames that look like personal machines (e.g. johns-macbook.local)
|
||||
re.compile(r"\b[A-Za-z0-9-]+\.local\b"),
|
||||
)
|
||||
|
||||
|
||||
def looks_like_pii(s: str) -> bool:
|
||||
"""Return True if any PII pattern matches anywhere in ``s``."""
|
||||
for pattern in _PII_PATTERNS:
|
||||
if pattern.search(s):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def redact(properties: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy of ``properties`` with PII-bearing string values dropped.
|
||||
|
||||
Non-string values pass through unchanged. Strings exceeding
|
||||
``MAX_STR_LEN`` are dropped. Strings matching any PII pattern are
|
||||
dropped. The event-spec validator (in :mod:`events`) runs after
|
||||
this and provides a second layer of structural enforcement.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in properties.items():
|
||||
if isinstance(value, str):
|
||||
if not value:
|
||||
# empty string is uninformative — drop
|
||||
continue
|
||||
if len(value) > MAX_STR_LEN:
|
||||
continue
|
||||
if looks_like_pii(value):
|
||||
continue
|
||||
elif isinstance(value, (list, dict, set, tuple)):
|
||||
# Composite values are never sent — keeps the surface tiny.
|
||||
continue
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def hash_id(s: str) -> str:
|
||||
"""Return a 16-char sha256 prefix of ``s``.
|
||||
|
||||
Used for model / tool / connector names that aren't on the public
|
||||
allowlist — we still want to see "uses-a-custom-model-X" cohorting
|
||||
without ever learning which model.
|
||||
"""
|
||||
if not s:
|
||||
return ""
|
||||
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
__all__ = ["MAX_STR_LEN", "redact", "looks_like_pii", "hash_id"]
|
||||
@@ -58,27 +58,47 @@ def poll_new_messages(
|
||||
|
||||
|
||||
def send_imessage(chat_identifier: str, message: str) -> bool:
|
||||
"""Send an iMessage via AppleScript."""
|
||||
"""Send an iMessage via AppleScript.
|
||||
|
||||
``chat_identifier`` is the recipient handle:
|
||||
- phone number in E.164 format (e.g. ``+15551234567``)
|
||||
- or email address registered with iMessage
|
||||
|
||||
Internally addresses the recipient via the iMessage service's
|
||||
``participant`` lookup — the previous ``chat id "..."`` form
|
||||
expected an internal chat handle (e.g. ``iMessage;-;+1555...``)
|
||||
and silently failed on raw phone numbers, returning success while
|
||||
no message was actually sent.
|
||||
"""
|
||||
escaped = message.replace("\\", "\\\\").replace('"', '\\"')
|
||||
script = (
|
||||
f'tell application "Messages"\n'
|
||||
f" set targetChat to a reference to "
|
||||
f'chat id "{chat_identifier}"\n'
|
||||
f' send "{escaped}" to targetChat\n'
|
||||
f"end tell"
|
||||
'tell application "Messages"\n'
|
||||
" set targetService to 1st account whose service type = iMessage\n"
|
||||
f' set targetBuddy to participant "{chat_identifier}" of targetService\n'
|
||||
f' send "{escaped}" to targetBuddy\n'
|
||||
"end tell"
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
return True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
logger.error("Failed to send iMessage via AppleScript")
|
||||
logger.error("Failed to invoke osascript for iMessage send")
|
||||
return False
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(
|
||||
"AppleScript iMessage send failed (rc=%s): %s",
|
||||
result.returncode,
|
||||
(result.stderr or "").strip(),
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_daemon(
|
||||
*,
|
||||
|
||||
@@ -35,6 +35,7 @@ from openjarvis.cli.quickstart_cmd import quickstart
|
||||
from openjarvis.cli.registry_cmd import registry
|
||||
from openjarvis.cli.scan_cmd import scan
|
||||
from openjarvis.cli.scheduler_cmd import scheduler
|
||||
from openjarvis.cli.self_update_cmd import self_update
|
||||
from openjarvis.cli.serve import serve
|
||||
from openjarvis.cli.skill_cmd import skill
|
||||
from openjarvis.cli.telemetry_cmd import telemetry
|
||||
@@ -60,8 +61,14 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None:
|
||||
ctx.obj["quiet"] = quiet
|
||||
setup_logging(verbose=verbose, quiet=quiet)
|
||||
|
||||
# Check for updates on interactive commands
|
||||
if not quiet and ctx.invoked_subcommand:
|
||||
# Check for updates on interactive commands. The banner is noise in
|
||||
# demo recordings of ``jarvis ask --research``, so skip it whenever
|
||||
# the research flag is in argv (cheap argv sniff — Click hasn't
|
||||
# parsed the subcommand's args yet at this point).
|
||||
import sys
|
||||
|
||||
research_mode_active = "--research" in sys.argv
|
||||
if not quiet and ctx.invoked_subcommand and not research_mode_active:
|
||||
from openjarvis.cli._version_check import check_for_updates
|
||||
|
||||
check_for_updates(ctx.invoked_subcommand)
|
||||
@@ -112,6 +119,7 @@ cli.add_command(connect, "connect")
|
||||
cli.add_command(digest, "digest")
|
||||
cli.add_command(deep_research_setup, "deep-research-setup")
|
||||
cli.add_command(deep_research_setup, "research")
|
||||
cli.add_command(self_update, "self-update")
|
||||
cli.add_command(bootstrap_cmd, "_bootstrap")
|
||||
|
||||
# Gateway CLI commands (lazy import to avoid pulling starlette)
|
||||
@@ -132,6 +140,15 @@ except ImportError:
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point registered as ``jarvis`` console script."""
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
cli()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Startup banner — arc-reactor logo + OpenJarvis wordmark."""
|
||||
|
||||
# ruff: noqa: E501 — Rich markup tags inflate source-line length; the rendered
|
||||
# banner stays under 80 displayed columns.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Reactor on the left (5 lines, ~12 cols) + small ASCII wordmark on the right.
|
||||
# Last line carries the tagline so the whole block stays under 8 lines / 80 cols.
|
||||
_BANNER_LINES = (
|
||||
r" [blue]╭─────╮[/blue] [bold bright_blue] ___ _ _ [/]",
|
||||
r" [blue]╱[/blue] [bright_blue]╭───╮[/bright_blue] [blue]╲[/blue] [bold bright_blue]/ _ \ _ __ ___ _ _ | | __ _ _ ___ _(_)___[/]",
|
||||
r" [blue]│[/blue] [bright_blue]│[/bright_blue] [bold bright_white]◉[/] [bright_blue]│[/bright_blue] [blue]│[/blue] [bold bright_blue]| | | | '_ \/ -_) ' \ | |/ _` | '_\ V /| (_-<[/]",
|
||||
r" [blue]╲[/blue] [bright_blue]╰───╯[/bright_blue] [blue]╱[/blue] [bold bright_blue]\___/| .__/\___|_||_||_|\__,_|_| \_/ |_/__/[/]",
|
||||
r" [blue]╰─────╯[/blue] [dim]|_|[/dim] [cyan]Private AI on your machine[/cyan]",
|
||||
)
|
||||
|
||||
_PLAIN_BANNER = (
|
||||
r" ╭─────╮ ___ _ _ ",
|
||||
r" ╱ ╭───╮ ╲ / _ \ _ __ ___ _ _ | | __ _ _ ___ _(_)___ ",
|
||||
r" │ │ ◉ │ │ | | | | '_ \/ -_) ' \ | |/ _` | '_\ V /| (_-< ",
|
||||
r" ╲ ╰───╯ ╱ \___/| .__/\___|_||_||_|\__,_|_| \_/ |_/__/ ",
|
||||
r" ╰─────╯ |_| Private AI on your machine ",
|
||||
)
|
||||
|
||||
|
||||
def print_banner(quiet: bool = False) -> None:
|
||||
"""Print the OpenJarvis startup banner. No-op when quiet."""
|
||||
if quiet:
|
||||
return
|
||||
try:
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
for line in _BANNER_LINES:
|
||||
console.print(line, highlight=False)
|
||||
console.print()
|
||||
except ImportError:
|
||||
for line in _PLAIN_BANNER:
|
||||
print(line)
|
||||
print()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Detect how OpenJarvis was installed so we can show the right upgrade
|
||||
command (and run the right upgrade command for ``jarvis self-update``).
|
||||
|
||||
Three install paths are supported today:
|
||||
|
||||
- **PyPI** (``pip install openjarvis``). The package lives somewhere
|
||||
inside ``site-packages``. Upgrade with ``pip install --upgrade openjarvis``.
|
||||
- **uv tool** (``uv tool install openjarvis``). Lives in a uv-managed
|
||||
isolated venv under ``~/.local/share/uv/tools/``. Upgrade with
|
||||
``uv tool upgrade openjarvis``.
|
||||
- **Editable git checkout** (``uv sync`` / ``pip install -e .`` from a
|
||||
cloned repo). The package's ``__file__`` is inside a working tree
|
||||
with a ``.git`` directory at the repo root. Upgrade with
|
||||
``git pull && uv sync`` from the checkout.
|
||||
|
||||
We detect by inspecting ``openjarvis.__file__``. If we can't tell with
|
||||
confidence we fall back to the PyPI command — that's the most common
|
||||
case and the worst outcome is a no-op for a user who has nothing to
|
||||
pull from PyPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstallInfo:
|
||||
"""How OpenJarvis was installed."""
|
||||
|
||||
kind: str # "pypi" | "uv-tool" | "editable-git" | "unknown"
|
||||
upgrade_command: str
|
||||
repo_root: Optional[Path] = None # only set for editable-git
|
||||
|
||||
|
||||
def detect_install() -> InstallInfo:
|
||||
"""Return an :class:`InstallInfo` for the running interpreter.
|
||||
|
||||
Cheap: just walks the parent directories of ``openjarvis.__file__``
|
||||
once and checks for marker directories. No subprocess calls.
|
||||
"""
|
||||
try:
|
||||
import openjarvis
|
||||
|
||||
pkg_file = Path(openjarvis.__file__).resolve()
|
||||
except Exception:
|
||||
return InstallInfo(
|
||||
kind="unknown",
|
||||
upgrade_command="pip install --upgrade openjarvis",
|
||||
)
|
||||
|
||||
parts = [p.lower() for p in pkg_file.parts]
|
||||
|
||||
if "uv" in parts and "tools" in parts:
|
||||
return InstallInfo(
|
||||
kind="uv-tool",
|
||||
upgrade_command="uv tool upgrade openjarvis",
|
||||
)
|
||||
|
||||
# Editable install: a ``.git`` dir within a few parents of the
|
||||
# package source. Walk up at most ~8 levels — enough for typical
|
||||
# ``<repo>/src/openjarvis/__init__.py`` layouts plus headroom, but
|
||||
# not so deep we wander into home or root.
|
||||
candidate = pkg_file.parent
|
||||
for _ in range(8):
|
||||
if (candidate / ".git").exists() and (candidate / "pyproject.toml").exists():
|
||||
return InstallInfo(
|
||||
kind="editable-git",
|
||||
upgrade_command=f"cd {candidate} && git pull && uv sync",
|
||||
repo_root=candidate,
|
||||
)
|
||||
if candidate.parent == candidate:
|
||||
break
|
||||
candidate = candidate.parent
|
||||
|
||||
if "site-packages" in parts:
|
||||
return InstallInfo(
|
||||
kind="pypi",
|
||||
upgrade_command="pip install --upgrade openjarvis",
|
||||
)
|
||||
|
||||
return InstallInfo(
|
||||
kind="unknown",
|
||||
upgrade_command="pip install --upgrade openjarvis",
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Check for newer OpenJarvis releases on GitHub."""
|
||||
"""Check for newer OpenJarvis releases on PyPI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -12,14 +13,100 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_PATH = Path("~/.openjarvis/version-check.json").expanduser()
|
||||
_CACHE_TTL = 86400 # 24 hours
|
||||
_GITHUB_API = "https://api.github.com/repos/open-jarvis/OpenJarvis/releases/latest"
|
||||
_CHECK_COMMANDS = {"ask", "chat", "serve"}
|
||||
_PYPI_API = "https://pypi.org/pypi/openjarvis/json"
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
"""Resolve the config path, honoring ``OPENJARVIS_CONFIG`` like core.config."""
|
||||
override = os.environ.get("OPENJARVIS_CONFIG")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return Path("~/.openjarvis/config.toml").expanduser()
|
||||
|
||||
|
||||
# Commands that surface the "new version available" nudge. We deliberately
|
||||
# cast a wide net for interactive commands (anything a human runs at a
|
||||
# terminal and would benefit from knowing about an update), and skip
|
||||
# automation-facing ones (``_bootstrap``, ``daemon``, ``host``) so we
|
||||
# don't add noise to background processes or CI.
|
||||
_CHECK_COMMANDS = {
|
||||
"ask",
|
||||
"chat",
|
||||
"serve",
|
||||
"doctor",
|
||||
"init",
|
||||
"quickstart",
|
||||
"model",
|
||||
"agents",
|
||||
"skill",
|
||||
"memory",
|
||||
"bench",
|
||||
"telemetry",
|
||||
"config",
|
||||
"eval",
|
||||
"optimize",
|
||||
}
|
||||
|
||||
# Environment opt-outs (any truthy value disables the check):
|
||||
# - ``OPENJARVIS_NO_UPDATE_CHECK=1`` — project-specific
|
||||
# - ``CI=true`` — set by every major CI provider, suppresses by default
|
||||
_OPT_OUT_ENV_VARS = ("OPENJARVIS_NO_UPDATE_CHECK",)
|
||||
|
||||
|
||||
def _check_disabled() -> bool:
|
||||
"""Return True when the user has opted out of update checks."""
|
||||
for name in _OPT_OUT_ENV_VARS:
|
||||
raw = os.environ.get(name, "")
|
||||
if raw and raw.strip().lower() not in ("", "0", "false", "no", "off"):
|
||||
return True
|
||||
# CI defaults to skipping. Users in CI can override with
|
||||
# ``OPENJARVIS_NO_UPDATE_CHECK=0`` if they want the nudge anyway.
|
||||
if os.environ.get("CI", "").strip().lower() in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
return _config_disabled()
|
||||
|
||||
|
||||
def _config_disabled() -> bool:
|
||||
"""Return True if config.toml has ``[updates] auto_update = false``.
|
||||
|
||||
On a malformed config we conservatively return ``True`` — if the user
|
||||
tried to express an opt-out and the file has a typo, we should not
|
||||
silently flip back to auto-checking against their intent.
|
||||
"""
|
||||
path = _config_path()
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # Python 3.10
|
||||
try:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
except ImportError:
|
||||
logger.debug("tomli not available, skipping config opt-out check")
|
||||
return False
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
config = tomllib.load(f)
|
||||
except OSError as exc:
|
||||
logger.debug("config read failed: %s", exc)
|
||||
return False
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
logger.debug("config malformed at %s: %s — treating as opt-out", path, exc)
|
||||
return True
|
||||
return not config.get("updates", {}).get("auto_update", True)
|
||||
|
||||
|
||||
def check_for_updates(command_name: str) -> None:
|
||||
"""Print a message if a newer version is available. Best-effort, never raises."""
|
||||
"""Print a message if a newer version is available. Best-effort, never raises.
|
||||
|
||||
Honors ``OPENJARVIS_NO_UPDATE_CHECK=1`` and ``CI=true`` — any
|
||||
truthy value (``1``, ``true``, ``yes``, ``on``) disables both the
|
||||
PyPI poll and the banner. See ``_check_disabled`` for the full list.
|
||||
"""
|
||||
if command_name not in _CHECK_COMMANDS:
|
||||
return
|
||||
if _check_disabled():
|
||||
return
|
||||
try:
|
||||
_do_check()
|
||||
except Exception:
|
||||
@@ -38,38 +125,39 @@ def _do_check() -> None:
|
||||
|
||||
try:
|
||||
if Version(latest) > Version(current):
|
||||
from openjarvis.cli._install_detect import detect_install
|
||||
|
||||
cmd = detect_install().upgrade_command
|
||||
sys.stderr.write(
|
||||
f"\033[33mA new version of OpenJarvis is available "
|
||||
f"(v{current} \u2192 v{latest})\n"
|
||||
f"Update: cd ~/OpenJarvis && git pull && uv sync\033[0m\n\n"
|
||||
f"(v{current} → v{latest})\n"
|
||||
f"Update: {cmd}\n"
|
||||
f"Or run: jarvis self-update\033[0m\n\n"
|
||||
)
|
||||
except InvalidVersion:
|
||||
pass
|
||||
|
||||
|
||||
def _get_latest_version(current: str) -> str | None:
|
||||
"""Return latest version string from cache or GitHub API."""
|
||||
"""Return the latest non-prerelease version string from cache or PyPI.
|
||||
|
||||
Returns ``None`` on network/parse failures rather than caching a stale
|
||||
or empty result. Dev/pre-release versions (``.devN``, ``aN``, ``bN``,
|
||||
``rcN``) are filtered out so users on a stable release are not nudged
|
||||
to a rolling autotag build — they can still opt in via ``--pre``.
|
||||
"""
|
||||
try:
|
||||
if _CACHE_PATH.exists():
|
||||
data = json.loads(_CACHE_PATH.read_text())
|
||||
last_check = data.get("last_check", 0)
|
||||
if time.time() - last_check < _CACHE_TTL:
|
||||
return data.get("latest_version")
|
||||
cached = data.get("latest_version")
|
||||
return cached or None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
_GITHUB_API,
|
||||
headers={"Accept": "application/vnd.github.v3+json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
data = json.loads(resp.read())
|
||||
tag = data.get("tag_name", "")
|
||||
latest = tag.lstrip("v")
|
||||
except Exception:
|
||||
latest = _fetch_latest_stable()
|
||||
if not latest:
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -87,3 +175,40 @@ def _get_latest_version(current: str) -> str | None:
|
||||
pass
|
||||
|
||||
return latest
|
||||
|
||||
|
||||
def _fetch_latest_stable() -> str | None:
|
||||
"""Query PyPI and return the highest non-prerelease version, or ``None``."""
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
with urllib.request.urlopen(_PYPI_API, timeout=3) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except Exception as exc:
|
||||
logger.debug("PyPI poll failed: %s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
from packaging.version import InvalidVersion, Version
|
||||
except ImportError:
|
||||
# Fall back to the raw info.version if packaging isn't installed.
|
||||
return data.get("info", {}).get("version") or None
|
||||
|
||||
releases = data.get("releases", {})
|
||||
stable: list[Version] = []
|
||||
for raw in releases.keys():
|
||||
try:
|
||||
v = Version(raw)
|
||||
except InvalidVersion:
|
||||
continue
|
||||
if v.is_prerelease or v.is_devrelease:
|
||||
continue
|
||||
stable.append(v)
|
||||
|
||||
if stable:
|
||||
return str(max(stable))
|
||||
|
||||
# No stable releases yet — fall back to info.version (handles brand-new
|
||||
# projects that have only published dev releases).
|
||||
info_version = data.get("info", {}).get("version")
|
||||
return info_version or None
|
||||
|
||||
@@ -702,16 +702,34 @@ def errors():
|
||||
@agent.command("ask")
|
||||
@click.argument("agent_id")
|
||||
@click.argument("message")
|
||||
def ask(agent_id, message):
|
||||
@click.option(
|
||||
"--yes/--no-yes",
|
||||
"auto_approve",
|
||||
default=True,
|
||||
help="Auto-approve tool execution that would otherwise need confirmation. "
|
||||
"Default: on (suits non-interactive CLI use). Pass --no-yes to require a "
|
||||
"TTY prompt for tools whose ToolSpec sets requires_confirmation=True.",
|
||||
)
|
||||
def ask(agent_id, message, auto_approve):
|
||||
"""Ask an agent a question (immediate response)."""
|
||||
manager = _get_manager()
|
||||
agent_id = _resolve_agent_id(manager, agent_id)
|
||||
manager.send_message(agent_id, message, mode="immediate")
|
||||
click.echo("Asking agent...")
|
||||
_, executor, _ = _get_scheduler_and_executor()
|
||||
_, executor, system = _get_scheduler_and_executor()
|
||||
if executor is None:
|
||||
click.echo("Executor not available", err=True)
|
||||
raise SystemExit(1)
|
||||
# Wire a confirmation callback so the agent's own ToolExecutor can actually
|
||||
# run tools whose ToolSpec sets requires_confirmation=True (e.g. shell_exec,
|
||||
# git_*). `executor` is the AgentExecutor; the callback is read in
|
||||
# _invoke_agent and propagated to the constructed agent via agent_kwargs.
|
||||
if auto_approve:
|
||||
executor._confirm_callback = lambda _prompt: True
|
||||
else:
|
||||
executor._confirm_callback = (
|
||||
lambda prompt: click.confirm(f"\n{prompt}", default=False)
|
||||
)
|
||||
executor.execute_tick(agent_id)
|
||||
msgs = manager.list_messages(agent_id)
|
||||
responses = [m for m in msgs if m["direction"] == "agent_to_user"]
|
||||
|
||||
+253
-3
@@ -11,6 +11,7 @@ import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from openjarvis.cli._banner import print_banner
|
||||
from openjarvis.cli._tool_names import resolve_tool_names
|
||||
from openjarvis.cli.hints import hint_no_engine
|
||||
from openjarvis.core.config import load_config
|
||||
@@ -32,6 +33,187 @@ from openjarvis.telemetry.store import TelemetryStore
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_research(
|
||||
*,
|
||||
query_text: str,
|
||||
engine,
|
||||
model_name: str | None,
|
||||
knowledge_db: str | None,
|
||||
output_json: bool,
|
||||
console: Console,
|
||||
) -> None:
|
||||
"""Run the hybrid-search research loop and print the result to the console.
|
||||
|
||||
Lazy imports keep the cost of this branch off the cold-path of plain
|
||||
``jarvis ask`` calls.
|
||||
"""
|
||||
import re
|
||||
|
||||
from rich.markdown import Markdown
|
||||
from rich.theme import Theme
|
||||
|
||||
from openjarvis.agents.research_loop import DEFAULT_PLANNER_MODEL, ResearchAgent
|
||||
from openjarvis.connectors.embeddings import OllamaEmbedder
|
||||
from openjarvis.connectors.hybrid_search import HybridSearch
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
from openjarvis.engine.ollama import OllamaEngine
|
||||
|
||||
store_kwargs: dict = {}
|
||||
if knowledge_db:
|
||||
store_kwargs["db_path"] = knowledge_db
|
||||
store = KnowledgeStore(**store_kwargs)
|
||||
|
||||
# Research mode is wired specifically to Ollama: the planner prompt
|
||||
# (gemma4:31b) and the function-call schema for search/clarify both
|
||||
# assume Ollama's /api/chat tool semantics. Using the engine returned
|
||||
# by get_engine() here is a foot-gun — discovery can pick any
|
||||
# OpenAI-compatible engine registered on the same port as our own
|
||||
# API server. research_router.py hardcodes OllamaEngine() for the
|
||||
# same reason; mirror that here so CLI and HTTP behave identically.
|
||||
engine = OllamaEngine()
|
||||
|
||||
chunk_count = store._conn.execute(
|
||||
"SELECT COUNT(*) FROM knowledge_chunks"
|
||||
).fetchone()[0]
|
||||
logger.debug(
|
||||
"research: engine=%s.%s db=%s chunks=%d",
|
||||
type(engine).__module__,
|
||||
type(engine).__name__,
|
||||
store._db_path,
|
||||
chunk_count,
|
||||
)
|
||||
|
||||
embedder = OllamaEmbedder()
|
||||
embedder_available = embedder.is_available()
|
||||
logger.debug("research: embedder available=%s", embedder_available)
|
||||
if not embedder_available:
|
||||
console.print(
|
||||
"[yellow]Ollama embedder unavailable — falling back to BM25-only "
|
||||
"retrieval. Run `ollama pull nomic-embed-text` for hybrid scoring.[/yellow]"
|
||||
)
|
||||
embedder = None
|
||||
|
||||
planner_model = model_name or DEFAULT_PLANNER_MODEL
|
||||
logger.debug("research: planner_model=%s", planner_model)
|
||||
|
||||
# ---- Output styling --------------------------------------------------
|
||||
# Two consoles by design: traces and the timing footer go to stderr
|
||||
# (so ``jarvis ask --research "..." > out.md`` still gives a clean
|
||||
# markdown file), while the rendered synthesis goes to stdout. The
|
||||
# ``markdown.code`` theme override is the cyan-citation hack — see
|
||||
# ``_style_citations`` below.
|
||||
trace = Console(stderr=True, soft_wrap=True, highlight=False)
|
||||
answer_console = Console(
|
||||
theme=Theme({"markdown.code": "cyan bold not italic"}),
|
||||
soft_wrap=True,
|
||||
highlight=False,
|
||||
)
|
||||
|
||||
def _style_citations(text: str) -> str:
|
||||
"""Wrap each ``[N]`` in inline code so it renders cyan.
|
||||
|
||||
Rich's Markdown class doesn't expose any hook for styling
|
||||
arbitrary text spans, so we cheat: convert the citation tokens
|
||||
into inline-code markdown (`[1]` → `` `[1]` ``) and override
|
||||
the ``markdown.code`` theme entry above to colour them. Trims
|
||||
the implicit monospace background that some terminal themes
|
||||
give inline code so the result reads as text, not as code.
|
||||
"""
|
||||
return re.sub(r"(\[\d+\])", r"`\1`", text)
|
||||
|
||||
def _format_search_call(args: dict) -> str:
|
||||
q = args.get("query", "") or ""
|
||||
extras: list[str] = []
|
||||
person = args.get("person")
|
||||
if person:
|
||||
extras.append(f"person: {person}")
|
||||
time_range = args.get("time_range")
|
||||
if isinstance(time_range, dict):
|
||||
start = time_range.get("start") or ""
|
||||
end = time_range.get("end") or ""
|
||||
if start or end:
|
||||
bounds = f"{start or '…'} → {end or '…'}"
|
||||
extras.append(f"when: {bounds}")
|
||||
suffix = f" ({', '.join(extras)})" if extras else ""
|
||||
return f"'{q}'{suffix}"
|
||||
|
||||
def on_event(event: dict) -> None:
|
||||
etype = event.get("type")
|
||||
if etype == "search_call":
|
||||
args = event.get("arguments", {})
|
||||
trace.print(
|
||||
f" [dim]↳ Searching:[/dim] "
|
||||
f"[dim italic]{_format_search_call(args)}[/dim italic]"
|
||||
)
|
||||
elif etype == "search_result":
|
||||
n = event.get("num_hits", 0)
|
||||
label = "result" if n == 1 else "results"
|
||||
trace.print(f" [dim]↳ Found {n} {label}[/dim]")
|
||||
elif etype == "clarify_call":
|
||||
q = event.get("question", "") or ""
|
||||
trace.print(
|
||||
f" [dim]↳ Clarifying:[/dim] [dim italic]{q}[/dim italic]"
|
||||
)
|
||||
# final_answer and clarify_response are handled outside the loop.
|
||||
|
||||
agent = ResearchAgent(
|
||||
engine=engine,
|
||||
search=HybridSearch(store, embedder),
|
||||
model=planner_model,
|
||||
on_event=on_event,
|
||||
)
|
||||
|
||||
started = time.monotonic()
|
||||
result = agent.run(query_text)
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
logger.debug(
|
||||
"research: iterations=%d tool_calls=%d usage=%s",
|
||||
result.iterations,
|
||||
len(result.tool_calls),
|
||||
result.usage,
|
||||
)
|
||||
|
||||
if output_json:
|
||||
click.echo(
|
||||
json_mod.dumps(
|
||||
{
|
||||
"answer": result.answer,
|
||||
"iterations": result.iterations,
|
||||
"usage": result.usage,
|
||||
"tool_calls": [
|
||||
{
|
||||
"arguments": inv.arguments,
|
||||
"num_results": inv.num_results,
|
||||
"top_titles": inv.top_titles,
|
||||
}
|
||||
for inv in result.tool_calls
|
||||
],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Visual break between live traces and the synthesis.
|
||||
trace.print()
|
||||
|
||||
# Render the synthesis as Markdown with inline citations coloured cyan.
|
||||
answer_console.print(Markdown(_style_citations(result.answer)))
|
||||
|
||||
# Footer: how long it took + how many distinct sources the model
|
||||
# actually cited. Empty answers (rare; only if the model went silent)
|
||||
# skip the footer entirely.
|
||||
if result.answer:
|
||||
cited = {int(n) for n in re.findall(r"\[(\d+)\]", result.answer)}
|
||||
src_word = "source" if len(cited) == 1 else "sources"
|
||||
trace.print()
|
||||
trace.print(
|
||||
f"[dim]Deep Research · {elapsed:.1f}s · "
|
||||
f"{len(cited)} {src_word} cited[/dim]"
|
||||
)
|
||||
|
||||
|
||||
def _get_memory_backend(config):
|
||||
"""Try to instantiate the memory backend.
|
||||
|
||||
@@ -176,6 +358,22 @@ def _run_agent(
|
||||
if capability_policy is not None:
|
||||
agent_kwargs["capability_policy"] = capability_policy
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md persona
|
||||
# files actually reach the model. Only passed to agents whose __init__
|
||||
# accepts a `prompt_builder` kwarg (BaseAgent does; agents that override
|
||||
# __init__ without forwarding it, e.g. OrchestratorAgent, opt out
|
||||
# automatically and keep their existing system-prompt machinery).
|
||||
import inspect as _inspect
|
||||
|
||||
if "prompt_builder" in _inspect.signature(agent_cls.__init__).parameters:
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
agent_kwargs["prompt_builder"] = SystemPromptBuilder(
|
||||
agent_template=config.agent.default_system_prompt or "",
|
||||
memory_files_config=config.memory_files,
|
||||
system_prompt_config=config.system_prompt,
|
||||
)
|
||||
|
||||
agent = agent_cls(engine, model_name, **agent_kwargs)
|
||||
ctx = AgentContext()
|
||||
|
||||
@@ -354,7 +552,11 @@ def _print_profile(
|
||||
"--agent",
|
||||
"agent_name",
|
||||
default=None,
|
||||
help="Agent to use (simple, orchestrator).",
|
||||
help=(
|
||||
"Agent to use (simple, orchestrator, ...). "
|
||||
"When omitted, falls back to ``agent.default_agent`` from config. "
|
||||
"Pass ``--agent ''`` to force direct-to-engine mode (no agent)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--tools",
|
||||
@@ -368,7 +570,27 @@ def _print_profile(
|
||||
is_flag=True,
|
||||
help="Print inference telemetry profile (latency, tokens, energy, IPW).",
|
||||
)
|
||||
@click.option(
|
||||
"--research",
|
||||
"research_mode",
|
||||
is_flag=True,
|
||||
help=(
|
||||
"Route the query through the hybrid-search research agent over the "
|
||||
"personal knowledge store (BM25 + dense embeddings, max 5 tool calls)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--knowledge-db",
|
||||
"knowledge_db",
|
||||
default=None,
|
||||
help=(
|
||||
"Override the KnowledgeStore path used by --research "
|
||||
"(default: ~/.openjarvis/knowledge.db)."
|
||||
),
|
||||
)
|
||||
@click.pass_context
|
||||
def ask(
|
||||
ctx: click.Context,
|
||||
query: tuple[str, ...],
|
||||
model_name: str | None,
|
||||
engine_key: str | None,
|
||||
@@ -380,8 +602,12 @@ def ask(
|
||||
agent_name: str | None,
|
||||
tool_names: str | None,
|
||||
enable_profile: bool,
|
||||
research_mode: bool,
|
||||
knowledge_db: str | None,
|
||||
) -> None:
|
||||
"""Ask Jarvis a question."""
|
||||
quiet = (ctx.obj or {}).get("quiet", False) or output_json
|
||||
print_banner(quiet=quiet)
|
||||
console = Console(stderr=True)
|
||||
query_text = " ".join(query)
|
||||
|
||||
@@ -390,6 +616,16 @@ def ask(
|
||||
# Load config
|
||||
config = load_config()
|
||||
|
||||
# Honor `agent.default_agent` from config when --agent was not explicitly
|
||||
# passed. Pass `--agent ""` to opt out and use direct-to-engine mode.
|
||||
# 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 "..."`).
|
||||
if agent_name is None:
|
||||
configured_default = (config.agent.default_agent or "").strip()
|
||||
if configured_default:
|
||||
agent_name = configured_default
|
||||
|
||||
# Track whether the user explicitly set --max-tokens
|
||||
user_set_max_tokens = max_tokens is not None
|
||||
|
||||
@@ -445,6 +681,20 @@ def ask(
|
||||
|
||||
engine_name, engine = resolved
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Research mode — hybrid search + agentic loop over the knowledge store
|
||||
# ------------------------------------------------------------------
|
||||
if research_mode:
|
||||
_run_research(
|
||||
query_text=query_text,
|
||||
engine=engine,
|
||||
model_name=model_name,
|
||||
knowledge_db=knowledge_db,
|
||||
output_json=output_json,
|
||||
console=console,
|
||||
)
|
||||
return
|
||||
|
||||
# Apply security guardrails
|
||||
from openjarvis.security import setup_security
|
||||
|
||||
@@ -500,8 +750,8 @@ def ask(
|
||||
model_name,
|
||||
)
|
||||
|
||||
# Agent mode
|
||||
if agent_name is not None:
|
||||
# Agent mode (treat empty-string `--agent ""` as explicit opt-out)
|
||||
if agent_name:
|
||||
parsed_tools = resolve_tool_names(
|
||||
tool_names,
|
||||
getattr(config.tools, "enabled", None),
|
||||
|
||||
@@ -184,10 +184,36 @@ def digest(
|
||||
from openjarvis.sdk import Jarvis
|
||||
|
||||
with Jarvis() as j:
|
||||
result = j.ask("Generate my morning digest", agent="morning_digest")
|
||||
console.print(Markdown(result))
|
||||
j.ask("Generate my morning digest", agent="morning_digest")
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Failed to generate digest: {exc}[/red]")
|
||||
store.close()
|
||||
return
|
||||
|
||||
# Reload the freshly-generated digest from the store and play audio
|
||||
store.close()
|
||||
store = DigestStore(db_path=db_path) if db_path else DigestStore()
|
||||
artifact = store.get_latest()
|
||||
if artifact is None:
|
||||
console.print("[red]Digest was not saved.[/red]")
|
||||
store.close()
|
||||
return
|
||||
|
||||
audio_path = str(artifact.audio_path)
|
||||
console.print(f"[dim]Audio path: '{audio_path}'[/dim]")
|
||||
has_audio = bool(audio_path) and artifact.audio_path.exists()
|
||||
console.print(f"[dim]Audio available: {has_audio}[/dim]")
|
||||
if has_audio:
|
||||
audio_thread = threading.Thread(
|
||||
target=_play_audio, args=(audio_path,), daemon=True
|
||||
)
|
||||
audio_thread.start()
|
||||
console.print("[dim]Playing audio...[/dim]")
|
||||
else:
|
||||
console.print("[yellow]Audio unavailable — TTS failed.[/yellow]")
|
||||
console.print("[yellow]Check OPENAI_API_KEY is set.[/yellow]")
|
||||
|
||||
console.print(Markdown(artifact.text))
|
||||
store.close()
|
||||
return
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.panel import Panel
|
||||
|
||||
from openjarvis.cli._banner import print_banner
|
||||
from openjarvis.cli._bootstrap import detect_cloud_keys
|
||||
from openjarvis.cli.model import find_model_spec, hf_download, ollama_pull
|
||||
from openjarvis.cli.scan_cmd import PrivacyScanner
|
||||
@@ -290,7 +291,9 @@ def _do_download(engine: str, model: str, spec, console: Console) -> None:
|
||||
hidden=True,
|
||||
help="Run init non-interactively; called by the bare-jarvis first-run guard.",
|
||||
)
|
||||
@click.pass_context
|
||||
def init(
|
||||
ctx: click.Context,
|
||||
force: bool,
|
||||
config: Optional[Path],
|
||||
full_config: bool = False,
|
||||
@@ -303,6 +306,7 @@ def init(
|
||||
from_bare_jarvis: bool = False,
|
||||
) -> None:
|
||||
"""Detect hardware and generate ~/.openjarvis/config.toml."""
|
||||
print_banner(quiet=(ctx.obj or {}).get("quiet", False))
|
||||
console = Console()
|
||||
|
||||
# Cloud auto-detect — inform user if a key is in env.
|
||||
|
||||
@@ -218,6 +218,83 @@ def scheduler_logs(task_id: str, limit: int) -> None:
|
||||
store.close()
|
||||
|
||||
|
||||
@scheduler.command("run-task")
|
||||
@click.argument("agent_name")
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print what would run without executing.",
|
||||
)
|
||||
def scheduler_run_task(agent_name: str, dry_run: bool) -> None:
|
||||
"""Immediately execute the active task for AGENT_NAME.
|
||||
|
||||
Finds the first active scheduled task whose agent matches AGENT_NAME
|
||||
and runs it right now — useful for testing and for launchd invocation
|
||||
when OpenJarvis is not running as a persistent daemon.
|
||||
|
||||
Example (launchd plist ProgramArguments):
|
||||
jarvis scheduler run-task proactive
|
||||
"""
|
||||
console = Console()
|
||||
store = _get_store()
|
||||
try:
|
||||
sched = _get_scheduler(store)
|
||||
tasks = sched.list_tasks(status="active")
|
||||
match = next((t for t in tasks if t.agent == agent_name), None)
|
||||
|
||||
if match is None:
|
||||
console.print(
|
||||
f"[yellow]No active task found for agent '{agent_name}'. "
|
||||
"Register it first with 'jarvis scheduler create'.[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
console.print("[dim]Dry run — would execute:[/dim]")
|
||||
console.print(f" Task : {match.id}")
|
||||
console.print(f" Agent: {match.agent}")
|
||||
console.print(f" Prompt: {match.prompt[:80]}")
|
||||
return
|
||||
|
||||
console.print(f"Running task [cyan]{match.id}[/cyan] (agent: {match.agent})…")
|
||||
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.system import SystemBuilder
|
||||
|
||||
system = SystemBuilder(load_config()).build()
|
||||
result = system.ask(match.prompt, agent=match.agent)
|
||||
|
||||
# Log the run result in the scheduler store
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if isinstance(result, (dict, list)):
|
||||
import json as _json
|
||||
|
||||
result_str = _json.dumps(result, default=str)
|
||||
else:
|
||||
result_str = str(result) if result is not None else ""
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
store.log_run(
|
||||
task_id=match.id,
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
success=True,
|
||||
result=result_str,
|
||||
error="",
|
||||
)
|
||||
|
||||
console.print("[green]Done.[/green]")
|
||||
if result:
|
||||
console.print(result)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
@scheduler.command("start")
|
||||
@click.option(
|
||||
"--poll-interval",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""`jarvis self-update` — upgrade OpenJarvis to the latest release.
|
||||
|
||||
Runs the right upgrade command for how the user installed OpenJarvis:
|
||||
|
||||
- PyPI installs get ``pip install --upgrade openjarvis``.
|
||||
- uv-tool installs get ``uv tool upgrade openjarvis``.
|
||||
- Editable git checkouts get ``git pull && uv sync`` in the checkout.
|
||||
|
||||
The detection logic is shared with the post-command "new version
|
||||
available" hint in ``_version_check.py`` so both surfaces stay in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
import openjarvis
|
||||
from openjarvis.cli._install_detect import detect_install
|
||||
|
||||
|
||||
@click.command(
|
||||
"self-update",
|
||||
help=(
|
||||
"Upgrade OpenJarvis to the latest release. Detects how you "
|
||||
"installed (pip, uv tool, editable git) and runs the right "
|
||||
"command. Use --check to only print the upgrade command "
|
||||
"without running it."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--check",
|
||||
is_flag=True,
|
||||
help="Print the upgrade command that would run, without executing it.",
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
"-y",
|
||||
is_flag=True,
|
||||
help="Skip the interactive confirmation prompt.",
|
||||
)
|
||||
def self_update(check: bool, yes: bool) -> None:
|
||||
info = detect_install()
|
||||
current = openjarvis.__version__
|
||||
|
||||
click.echo(f"Current OpenJarvis version: v{current}")
|
||||
click.echo(f"Install method: {info.kind}")
|
||||
click.echo(f"Upgrade command: {info.upgrade_command}")
|
||||
|
||||
if check:
|
||||
return
|
||||
|
||||
if info.kind == "unknown":
|
||||
click.echo(
|
||||
"\nCould not determine install method with confidence. The "
|
||||
"command above is a best guess; verify it matches how you "
|
||||
"installed before running.",
|
||||
err=True,
|
||||
)
|
||||
|
||||
if not yes:
|
||||
if not click.confirm("\nRun the upgrade command now?", default=True):
|
||||
click.echo("Aborted.")
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(f"\n→ {info.upgrade_command}\n")
|
||||
|
||||
# ``editable-git`` uses shell features (``&&``); the others are
|
||||
# simple argv-style commands. Use ``shell=True`` only for the
|
||||
# editable case to keep the surface small. The command itself is
|
||||
# constructed from a trusted, locally-detected path — no user
|
||||
# input flows into it.
|
||||
if info.kind == "editable-git":
|
||||
result = subprocess.run(info.upgrade_command, shell=True)
|
||||
else:
|
||||
result = subprocess.run(shlex.split(info.upgrade_command))
|
||||
|
||||
if result.returncode != 0:
|
||||
click.echo(
|
||||
f"\nUpgrade command exited with code {result.returncode}. "
|
||||
"Inspect the output above for the failure mode.",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
click.echo("\nUpgrade complete. Re-run `jarvis --version` to confirm.")
|
||||
@@ -8,6 +8,7 @@ import sys
|
||||
import click
|
||||
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.engine import (
|
||||
@@ -40,7 +41,9 @@ logger = logging.getLogger(__name__)
|
||||
default=None,
|
||||
help="Agent for non-streaming requests (simple, orchestrator, react, openhands).",
|
||||
)
|
||||
@click.pass_context
|
||||
def serve(
|
||||
ctx: click.Context,
|
||||
host: str | None,
|
||||
port: int | None,
|
||||
engine_key: str | None,
|
||||
@@ -48,6 +51,7 @@ def serve(
|
||||
agent_name: str | None,
|
||||
) -> None:
|
||||
"""Start the OpenAI-compatible API server."""
|
||||
print_banner(quiet=(ctx.obj or {}).get("quiet", False))
|
||||
console = Console(stderr=True)
|
||||
|
||||
# Check for server dependencies
|
||||
|
||||
@@ -26,6 +26,11 @@ class Document:
|
||||
"""Universal schema for data from any connector.
|
||||
|
||||
All connectors normalize their output to this format before ingestion.
|
||||
|
||||
v1 schema fields (``source_id``, ``participants_raw``, ``channel``) default
|
||||
to empty so existing connectors compile without modification; new
|
||||
connectors should populate them. The pipeline derives ``source_id`` from
|
||||
``doc_id`` by stripping the ``{source}:`` prefix when not set explicitly.
|
||||
"""
|
||||
|
||||
doc_id: str
|
||||
@@ -40,6 +45,10 @@ class Document:
|
||||
url: Optional[str] = None
|
||||
attachments: List[Attachment] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
# v1 schema additions: defaulted empty so legacy connectors keep working.
|
||||
source_id: str = ""
|
||||
participants_raw: List[str] = field(default_factory=list)
|
||||
channel: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Type-aware semantic chunker for Deep Research ingestion.
|
||||
|
||||
Splits text based on document type, never splitting mid-sentence.
|
||||
Returns ``ChunkResult`` dataclass objects with section metadata and
|
||||
inherited parent metadata.
|
||||
Splits text by paragraph → sentence → token/character boundaries while
|
||||
enforcing a hard size cap and adding a fixed-size overlap between
|
||||
consecutive chunks. The cap is enforced on BOTH token count
|
||||
(whitespace-split) AND character count, since marketing-style emails
|
||||
frequently contain dense runs without whitespace (zero-width joiners,
|
||||
HTML residue) that defeat token-based limits alone.
|
||||
|
||||
Splitting strategy by doc_type
|
||||
-------------------------------
|
||||
- ``event``, ``contact`` : Always a single chunk; never split.
|
||||
- ``event``, ``contact`` : Always a single chunk; never split, never capped.
|
||||
- ``email`` : Split on reply boundaries (``On … wrote:``),
|
||||
then sentence-split within each part.
|
||||
- ``message`` : Split on double-newline boundaries, accumulate
|
||||
into chunks up to *max_tokens*.
|
||||
then paragraphs, then sentences, then force-split.
|
||||
- ``message`` : Split on double-newline boundaries, then sentences,
|
||||
then force-split.
|
||||
- ``document``, ``note``,
|
||||
anything else : Split on ``## Heading`` section boundaries →
|
||||
paragraph boundaries (``\\n\\n``) within sections →
|
||||
sentence boundaries as a last resort.
|
||||
paragraph boundaries → sentences → force-split.
|
||||
|
||||
Token counting uses whitespace splitting: ``len(text.split())``.
|
||||
"""
|
||||
@@ -23,15 +25,21 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public types
|
||||
# Regexes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SENTENCE_SPLIT_RE = re.compile(r'(?<=[.!?])\s+(?=[A-Z"])')
|
||||
_SECTION_RE = re.compile(r"(?m)^##\s+(.+)$")
|
||||
_REPLY_BOUNDARY_RE = re.compile(r"(?m)^On .+wrote:\s*$")
|
||||
_SENTENCE_END_RE = re.compile(r"[.!?](?:\s|$)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -54,65 +62,118 @@ def _count_tokens(text: str) -> int:
|
||||
|
||||
|
||||
def _split_sentences(text: str) -> List[str]:
|
||||
"""Split *text* into sentences using the canonical regex.
|
||||
|
||||
The regex splits after sentence-ending punctuation (``.``, ``!``, ``?``)
|
||||
followed by whitespace and a capital letter or a double-quote.
|
||||
"""
|
||||
parts = _SENTENCE_SPLIT_RE.split(text)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def _accumulate(
|
||||
def _accumulate_capped(
|
||||
segments: List[str],
|
||||
*,
|
||||
max_tokens: int,
|
||||
max_chars: int,
|
||||
sep: str = " ",
|
||||
) -> List[str]:
|
||||
"""Greedily merge *segments* into chunks up to *max_tokens* tokens.
|
||||
"""Greedy-merge segments into chunks bounded by token AND char counts.
|
||||
|
||||
A segment that is already larger than *max_tokens* is placed in its own
|
||||
chunk; it is never split further by this function.
|
||||
Segments larger than the bounds pass through as their own chunk —
|
||||
the caller force-splits those in a separate pass.
|
||||
"""
|
||||
chunks: List[str] = []
|
||||
current_parts: List[str] = []
|
||||
current_tokens = 0
|
||||
current: List[str] = []
|
||||
cur_tokens = 0
|
||||
cur_chars = 0
|
||||
|
||||
for seg in segments:
|
||||
seg_tokens = _count_tokens(seg)
|
||||
if current_parts and current_tokens + seg_tokens > max_tokens:
|
||||
chunks.append(sep.join(current_parts))
|
||||
current_parts = [seg]
|
||||
current_tokens = seg_tokens
|
||||
seg_chars = len(seg)
|
||||
sep_chars = len(sep) if current else 0
|
||||
|
||||
would_overflow = current and (
|
||||
cur_tokens + seg_tokens > max_tokens
|
||||
or cur_chars + sep_chars + seg_chars > max_chars
|
||||
)
|
||||
if would_overflow:
|
||||
chunks.append(sep.join(current))
|
||||
current = [seg]
|
||||
cur_tokens = seg_tokens
|
||||
cur_chars = seg_chars
|
||||
else:
|
||||
current_parts.append(seg)
|
||||
current_tokens += seg_tokens
|
||||
|
||||
if current_parts:
|
||||
chunks.append(sep.join(current_parts))
|
||||
current.append(seg)
|
||||
cur_tokens += seg_tokens
|
||||
cur_chars += sep_chars + seg_chars
|
||||
|
||||
if current:
|
||||
chunks.append(sep.join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
def _sentence_chunks(text: str, *, max_tokens: int) -> List[str]:
|
||||
"""Split *text* by sentences and accumulate into max_tokens chunks."""
|
||||
sentences = _split_sentences(text)
|
||||
if not sentences:
|
||||
stripped = text.strip()
|
||||
return [stripped] if stripped else []
|
||||
return _accumulate(sentences, max_tokens=max_tokens, sep=" ")
|
||||
def _best_cut_index(window: str) -> int:
|
||||
"""Pick a cut point inside ``window``: sentence end → space → hard cut."""
|
||||
matches = list(_SENTENCE_END_RE.finditer(window))
|
||||
if matches:
|
||||
return matches[-1].end()
|
||||
midpoint = len(window) // 2
|
||||
space = window.rfind(" ", midpoint)
|
||||
if space > 0:
|
||||
return space + 1
|
||||
return len(window)
|
||||
|
||||
|
||||
def _paragraph_chunks(text: str, *, max_tokens: int) -> List[str]:
|
||||
"""Split *text* on paragraph breaks (``\\n\\n``), then by sentences if needed."""
|
||||
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||||
result: List[str] = []
|
||||
for para in paragraphs:
|
||||
if _count_tokens(para) <= max_tokens:
|
||||
result.append(para)
|
||||
else:
|
||||
result.extend(_sentence_chunks(para, max_tokens=max_tokens))
|
||||
return result
|
||||
def _force_split(text: str, *, max_chars: int, max_tokens: int) -> List[str]:
|
||||
"""Split an oversized run into pieces that fit both caps.
|
||||
|
||||
Walks the text greedily: takes a window up to ``max_chars``, shrinks
|
||||
until the token count fits, then snaps the cut to the last sentence
|
||||
boundary, falling back to a word boundary, then a hard cut.
|
||||
"""
|
||||
rest = text.strip()
|
||||
out: List[str] = []
|
||||
while rest:
|
||||
if len(rest) <= max_chars and _count_tokens(rest) <= max_tokens:
|
||||
out.append(rest)
|
||||
break
|
||||
|
||||
end = min(max_chars, len(rest))
|
||||
while end > 1 and _count_tokens(rest[:end]) > max_tokens:
|
||||
end = max(1, int(end * 0.9))
|
||||
|
||||
window = rest[:end]
|
||||
cut = _best_cut_index(window)
|
||||
piece = rest[:cut].strip()
|
||||
if not piece:
|
||||
# Window contained only whitespace before any cuttable boundary.
|
||||
# Force advance to avoid an infinite loop.
|
||||
cut = max(cut + 1, end)
|
||||
piece = rest[:cut].strip()
|
||||
if piece:
|
||||
out.append(piece)
|
||||
rest = rest[cut:].strip()
|
||||
return out
|
||||
|
||||
|
||||
def _apply_overlap(chunks: List[str], *, overlap_tokens: int) -> List[str]:
|
||||
"""Prepend the last ``overlap_tokens`` tokens of each chunk to the next.
|
||||
|
||||
The tail is taken from the *original* preceding chunk (not the
|
||||
already-augmented output) so overlap doesn't compound, and is also
|
||||
capped by character count — a single whitespace-split "token" can be
|
||||
a 200+ char URL, so a pure-token cap would let tails balloon on
|
||||
content with long opaque strings.
|
||||
"""
|
||||
if overlap_tokens <= 0 or len(chunks) < 2:
|
||||
return list(chunks)
|
||||
overlap_chars_cap = overlap_tokens * 4
|
||||
out = [chunks[0]]
|
||||
for i in range(1, len(chunks)):
|
||||
prev_tokens = chunks[i - 1].split()
|
||||
if not prev_tokens:
|
||||
out.append(chunks[i])
|
||||
continue
|
||||
tail = " ".join(prev_tokens[-overlap_tokens:])
|
||||
if len(tail) > overlap_chars_cap:
|
||||
tail = tail[-overlap_chars_cap:]
|
||||
out.append(f"{tail} {chunks[i]}".strip())
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -121,18 +182,55 @@ def _paragraph_chunks(text: str, *, max_tokens: int) -> List[str]:
|
||||
|
||||
|
||||
class SemanticChunker:
|
||||
"""Split text based on document type without breaking mid-sentence.
|
||||
"""Split text by document type with a hard size cap and overlap.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
max_tokens:
|
||||
Soft upper limit on chunk size measured in whitespace-delimited tokens
|
||||
(i.e. ``len(text.split())``). Single unsplittable segments may exceed
|
||||
this limit.
|
||||
Hard upper bound on chunk size in whitespace-delimited tokens.
|
||||
No emitted chunk exceeds this.
|
||||
max_chars:
|
||||
Hard upper bound on chunk size in characters. Defaults to
|
||||
``max_tokens * 4`` (a typical English chars-per-token estimate).
|
||||
No emitted chunk exceeds this — the chunker force-splits any
|
||||
run that does, even when token count alone would say it fits.
|
||||
overlap_tokens:
|
||||
Token tail copied from each chunk into the head of the next so
|
||||
downstream retrieval doesn't miss context that straddles a chunk
|
||||
boundary. Defaults to ``min(100, max_tokens // 5)`` and is clamped
|
||||
to ``[0, max_tokens - 1]``. Set to ``0`` to disable.
|
||||
"""
|
||||
|
||||
def __init__(self, max_tokens: int = 512) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
max_tokens: int = 512,
|
||||
*,
|
||||
max_chars: Optional[int] = None,
|
||||
overlap_tokens: Optional[int] = None,
|
||||
) -> None:
|
||||
self.max_tokens = max_tokens
|
||||
self.max_chars = max_chars if max_chars is not None else max_tokens * 4
|
||||
if overlap_tokens is None:
|
||||
overlap_tokens = min(100, max(1, max_tokens // 5))
|
||||
self.overlap_tokens = max(0, min(overlap_tokens, max(0, max_tokens - 1)))
|
||||
|
||||
# Content budget per chunk leaves room for the overlap prefix
|
||||
# that gets added in the final pass, so tokens stay within
|
||||
# max_tokens. Char budget intentionally does NOT subtract overlap
|
||||
# — we accept up to ~overlap_tokens*4 chars of headroom over
|
||||
# max_chars after overlap, which still sits under any reasonable
|
||||
# hard ceiling and avoids spurious force-splits of well-behaved
|
||||
# sentences in configurations where the char cap is tight.
|
||||
self._content_tokens = max(1, self.max_tokens - self.overlap_tokens)
|
||||
self._content_chars = self.max_chars
|
||||
# Soft target used to decide when a paragraph is "long enough to
|
||||
# sub-split", and to size sentence-accumulated sub-chunks. At
|
||||
# roughly half the hard cap, typical paragraphs ship as a single
|
||||
# chunk and only the unusually long ones get further split,
|
||||
# which keeps semantic units intact while still pulling the
|
||||
# chunk-length tail down.
|
||||
self._target_tokens = max(1, self._content_tokens // 2)
|
||||
self._target_chars = max(1, self._content_chars // 2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -147,16 +245,10 @@ class SemanticChunker:
|
||||
) -> List[ChunkResult]:
|
||||
"""Split *text* into ``ChunkResult`` objects.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text: The raw text to split.
|
||||
doc_type: Controls the splitting strategy (see module docstring).
|
||||
metadata: Parent metadata dict; copied into every chunk's ``metadata``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
A list of ``ChunkResult`` objects with sequential 0-based ``index``
|
||||
values. Returns an empty list if *text* is empty or whitespace-only.
|
||||
Returns an empty list if *text* is empty or whitespace-only.
|
||||
Events and contacts are always returned as a single chunk
|
||||
regardless of size; all other types respect the size caps and
|
||||
receive overlap between consecutive chunks.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
@@ -164,88 +256,115 @@ class SemanticChunker:
|
||||
parent_meta: Dict[str, Any] = dict(metadata or {})
|
||||
|
||||
if doc_type in ("event", "contact"):
|
||||
raw_chunks = self._chunk_atomic(text)
|
||||
elif doc_type == "email":
|
||||
return [ChunkResult(content=text, index=0, metadata=parent_meta)]
|
||||
|
||||
if doc_type == "email":
|
||||
raw_chunks = self._chunk_email(text)
|
||||
elif doc_type == "message":
|
||||
raw_chunks = self._chunk_message(text)
|
||||
else:
|
||||
# "document", "note", or any unknown type
|
||||
raw_chunks = self._chunk_document(text)
|
||||
|
||||
# Apply overlap globally between consecutive chunks.
|
||||
contents = [c for c, _ in raw_chunks]
|
||||
metas = [m for _, m in raw_chunks]
|
||||
overlapped = _apply_overlap(contents, overlap_tokens=self.overlap_tokens)
|
||||
|
||||
results: List[ChunkResult] = []
|
||||
for idx, (content, extra_meta) in enumerate(raw_chunks):
|
||||
for idx, (content, extra_meta) in enumerate(zip(overlapped, metas)):
|
||||
merged: Dict[str, Any] = dict(parent_meta)
|
||||
merged.update(extra_meta)
|
||||
results.append(ChunkResult(content=content, index=idx, metadata=merged))
|
||||
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Strategy implementations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _chunk_atomic(self, text: str) -> List[tuple[str, Dict[str, Any]]]:
|
||||
"""Return the entire text as a single chunk (event / contact)."""
|
||||
return [(text, {})]
|
||||
def _pack_text(self, text: str) -> List[str]:
|
||||
"""Emit one chunk per paragraph; sub-split paragraphs over the soft target.
|
||||
|
||||
def _chunk_email(self, text: str) -> List[tuple[str, Dict[str, Any]]]:
|
||||
"""Split on reply boundaries; sentence-split each part."""
|
||||
# Split the email into parts on "On ... wrote:" lines.
|
||||
# re.split with a capturing group keeps the boundary in results,
|
||||
# so we re-attach the header to the following segment.
|
||||
Paragraphs are the natural chunk unit. A paragraph that fits the
|
||||
soft target ships as one chunk. A paragraph that doesn't is
|
||||
sentence-split and the sentences accumulated up to the soft
|
||||
target; sentences that exceed the hard cap (rare — opaque content
|
||||
with no whitespace) are force-split.
|
||||
"""
|
||||
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||||
if not paragraphs:
|
||||
stripped = text.strip()
|
||||
return [stripped] if stripped else []
|
||||
|
||||
out: List[str] = []
|
||||
for para in paragraphs:
|
||||
if (
|
||||
len(para) <= self._target_chars
|
||||
and _count_tokens(para) <= self._target_tokens
|
||||
):
|
||||
out.append(para)
|
||||
continue
|
||||
|
||||
sents = _split_sentences(para)
|
||||
if not sents:
|
||||
sents = [para]
|
||||
|
||||
accumulated = _accumulate_capped(
|
||||
sents,
|
||||
max_tokens=self._target_tokens,
|
||||
max_chars=self._target_chars,
|
||||
sep=" ",
|
||||
)
|
||||
for c in accumulated:
|
||||
if (
|
||||
len(c) <= self._content_chars
|
||||
and _count_tokens(c) <= self._content_tokens
|
||||
):
|
||||
out.append(c)
|
||||
else:
|
||||
out.extend(
|
||||
_force_split(
|
||||
c,
|
||||
max_chars=self._content_chars,
|
||||
max_tokens=self._content_tokens,
|
||||
)
|
||||
)
|
||||
return [c for c in out if c]
|
||||
|
||||
def _chunk_email(self, text: str) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
"""Split on reply boundaries; pack each part."""
|
||||
boundaries = _REPLY_BOUNDARY_RE.split(text)
|
||||
headers = _REPLY_BOUNDARY_RE.findall(text)
|
||||
|
||||
# Each boundary match is a separator; reassemble so the "On … wrote:"
|
||||
# line stays with the content that follows it (the quoted block).
|
||||
raw_parts: List[str] = []
|
||||
if boundaries:
|
||||
# The first element is the text before the first boundary (the
|
||||
# main reply body).
|
||||
raw_parts.append(boundaries[0])
|
||||
# Subsequent elements alternate: matched boundary, then text after.
|
||||
# Because we used split() (not findall), the boundaries themselves
|
||||
# are not in the list — only the text segments between them.
|
||||
# So boundaries[1:] are the segments after each matched header.
|
||||
# We need to re-find the headers to reassemble.
|
||||
headers = _REPLY_BOUNDARY_RE.findall(text)
|
||||
for header, body in zip(headers, boundaries[1:]):
|
||||
# We found the header text via findall; reconstruct the part.
|
||||
part = (header.strip() + "\n" + body).strip()
|
||||
raw_parts.append(part)
|
||||
|
||||
chunks: List[tuple[str, Dict[str, Any]]] = []
|
||||
chunks: List[Tuple[str, Dict[str, Any]]] = []
|
||||
for part in raw_parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if _count_tokens(part) <= self.max_tokens:
|
||||
chunks.append((part, {}))
|
||||
else:
|
||||
for sub in _sentence_chunks(part, max_tokens=self.max_tokens):
|
||||
if sub:
|
||||
chunks.append((sub, {}))
|
||||
for c in self._pack_text(part):
|
||||
if c:
|
||||
chunks.append((c, {}))
|
||||
|
||||
return chunks if chunks else [(text.strip(), {})]
|
||||
|
||||
def _chunk_message(self, text: str) -> List[tuple[str, Dict[str, Any]]]:
|
||||
"""Split on double-newline boundaries and accumulate up to max_tokens."""
|
||||
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||||
raw_chunks = _accumulate(paragraphs, max_tokens=self.max_tokens, sep="\n\n")
|
||||
return [(c, {}) for c in raw_chunks if c]
|
||||
def _chunk_message(self, text: str) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
"""Pack a message via paragraphs → sentences → force-split."""
|
||||
return [(c, {}) for c in self._pack_text(text)]
|
||||
|
||||
def _chunk_document(self, text: str) -> List[tuple[str, Dict[str, Any]]]:
|
||||
"""Split on ## headings → paragraphs → sentences."""
|
||||
# Find all ## heading positions
|
||||
def _chunk_document(self, text: str) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
"""Split on ## headings → paragraphs → sentences → force-split."""
|
||||
section_matches = list(_SECTION_RE.finditer(text))
|
||||
|
||||
if not section_matches:
|
||||
# No headings — fall back to paragraph/sentence splitting
|
||||
raw_chunks = _paragraph_chunks(text, max_tokens=self.max_tokens)
|
||||
return [(c, {}) for c in raw_chunks if c]
|
||||
return [(c, {}) for c in self._pack_text(text)]
|
||||
|
||||
# Build (title, body_text) pairs for each section
|
||||
sections: List[tuple[str, str]] = []
|
||||
sections: List[Tuple[str, str]] = []
|
||||
for i, m in enumerate(section_matches):
|
||||
title = m.group(1).strip()
|
||||
body_start = m.end()
|
||||
@@ -257,24 +376,19 @@ class SemanticChunker:
|
||||
body = text[body_start:body_end].strip()
|
||||
sections.append((title, body))
|
||||
|
||||
# Check for preamble text before the first heading
|
||||
result: List[Tuple[str, Dict[str, Any]]] = []
|
||||
preamble = text[: section_matches[0].start()].strip()
|
||||
result: List[tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
if preamble:
|
||||
for c in _paragraph_chunks(preamble, max_tokens=self.max_tokens):
|
||||
for c in self._pack_text(preamble):
|
||||
if c:
|
||||
result.append((c, {}))
|
||||
|
||||
for title, body in sections:
|
||||
section_meta: Dict[str, Any] = {"section": title}
|
||||
if not body:
|
||||
# Empty section — emit a placeholder chunk with just the title
|
||||
result.append((title, section_meta))
|
||||
continue
|
||||
|
||||
para_chunks = _paragraph_chunks(body, max_tokens=self.max_tokens)
|
||||
for c in para_chunks:
|
||||
for c in self._pack_text(body):
|
||||
if c:
|
||||
result.append((c, dict(section_meta)))
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Dense embedding clients for the IngestionPipeline.
|
||||
|
||||
A thin HTTP wrapper around a local `Ollama <https://ollama.com>`_ daemon
|
||||
running an embedding model (default ``nomic-embed-text``, 768-dim). Embeddings
|
||||
are serialised as float32 ``bytes`` for storage in the ``embedding`` BLOB
|
||||
column of ``knowledge_chunks``.
|
||||
|
||||
The client degrades gracefully when the daemon is unreachable: ``embed``
|
||||
returns ``None`` and ``is_available()`` reports ``False`` instead of raising,
|
||||
so ingestion never fails because a sidecar service is down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_OLLAMA_HOST = "http://localhost:11434"
|
||||
DEFAULT_EMBED_MODEL = "nomic-embed-text"
|
||||
|
||||
|
||||
class OllamaEmbedder:
|
||||
"""Embed text via a local Ollama daemon.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
model:
|
||||
Ollama model tag (e.g. ``nomic-embed-text``, ``mxbai-embed-large``).
|
||||
host:
|
||||
Base URL for the Ollama HTTP API. Defaults to ``http://localhost:11434``.
|
||||
timeout:
|
||||
Per-request timeout in seconds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str = DEFAULT_EMBED_MODEL,
|
||||
host: str = DEFAULT_OLLAMA_HOST,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self._model = model
|
||||
self._host = host.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._dim: Optional[int] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Identity / capability checks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def model_version(self) -> str:
|
||||
"""Stable identifier persisted alongside each embedding row."""
|
||||
return f"ollama:{self._model}"
|
||||
|
||||
@property
|
||||
def dim(self) -> Optional[int]:
|
||||
"""Embedding dimensionality, learned after the first successful call."""
|
||||
return self._dim
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True iff the daemon answers and the model is installed."""
|
||||
try:
|
||||
resp = requests.get(f"{self._host}/api/tags", timeout=2.0)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
try:
|
||||
names = {m.get("name", "") for m in resp.json().get("models", [])}
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# Ollama tags include the ":latest" suffix; match either form.
|
||||
return self._model in names or f"{self._model}:latest" in names
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Embedding
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def embed(self, text: str) -> Optional[bytes]:
|
||||
"""Embed a single string. Returns float32 bytes or ``None`` on failure."""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self._host}/api/embeddings",
|
||||
json={"model": self._model, "prompt": text},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except requests.RequestException as exc:
|
||||
logger.warning("OllamaEmbedder.embed: request failed (%s)", exc)
|
||||
return None
|
||||
except ValueError as exc:
|
||||
logger.warning("OllamaEmbedder.embed: bad JSON (%s)", exc)
|
||||
return None
|
||||
|
||||
vec = payload.get("embedding")
|
||||
if not vec:
|
||||
logger.warning(
|
||||
"OllamaEmbedder.embed: empty embedding for %d chars", len(text)
|
||||
)
|
||||
return None
|
||||
|
||||
arr = np.asarray(vec, dtype=np.float32)
|
||||
if self._dim is None:
|
||||
self._dim = int(arr.shape[0])
|
||||
elif arr.shape[0] != self._dim:
|
||||
logger.warning(
|
||||
"OllamaEmbedder.embed: dim drift (expected %d, got %d)",
|
||||
self._dim, arr.shape[0],
|
||||
)
|
||||
return None
|
||||
return arr.tobytes()
|
||||
|
||||
def embed_batch(self, texts: List[str]) -> List[Optional[bytes]]:
|
||||
"""Embed a list of strings sequentially.
|
||||
|
||||
Ollama's HTTP API serves one prompt per call; on the same host the
|
||||
round-trip overhead is negligible relative to model inference.
|
||||
"""
|
||||
return [self.embed(t) for t in texts]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deserialisation helper (used by verification + future retrieval code)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def decode_embedding(
|
||||
blob: Optional[bytes], *, dtype: type = np.float32
|
||||
) -> Optional[np.ndarray]:
|
||||
"""Reconstruct a 1-D vector from a BLOB written by ``OllamaEmbedder.embed``.
|
||||
|
||||
Returns ``None`` when the input is missing or zero-length so callers can
|
||||
treat absent embeddings uniformly.
|
||||
"""
|
||||
if not blob:
|
||||
return None
|
||||
return np.frombuffer(blob, dtype=dtype)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OllamaEmbedder",
|
||||
"decode_embedding",
|
||||
"DEFAULT_EMBED_MODEL",
|
||||
"DEFAULT_OLLAMA_HOST",
|
||||
]
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Dict, Iterator, List, Optional
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
|
||||
from openjarvis.connectors.google_auth import call_with_refresh
|
||||
from openjarvis.connectors.oauth import (
|
||||
GOOGLE_ALL_SCOPES,
|
||||
build_google_auth_url,
|
||||
@@ -39,6 +40,48 @@ _DEFAULT_CREDENTIALS_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "gcalendar.j
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gcal_api_user_email(token: str) -> str:
|
||||
"""Return the authenticated user's email via the Google userinfo endpoint."""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("email", "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _gcal_api_event_get(token: str, calendar_id: str, event_id: str) -> Dict[str, Any]:
|
||||
"""Fetch a single calendar event resource."""
|
||||
resp = httpx.get(
|
||||
f"{_GCAL_API_BASE}/calendars/{calendar_id}/events/{event_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _gcal_api_event_patch(
|
||||
token: str,
|
||||
calendar_id: str,
|
||||
event_id: str,
|
||||
body: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Patch a calendar event with a partial update body."""
|
||||
resp = httpx.patch(
|
||||
f"{_GCAL_API_BASE}/calendars/{calendar_id}/events/{event_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json=body,
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _gcal_api_calendars_list(token: str) -> Dict[str, Any]:
|
||||
"""Call the Calendar ``calendarList.list`` endpoint.
|
||||
|
||||
@@ -302,13 +345,15 @@ class GCalendarConnector(BaseConnector):
|
||||
tokens = load_tokens(self._credentials_path)
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
token: str = tokens.get("access_token", tokens.get("token", ""))
|
||||
if not token:
|
||||
if not tokens.get("access_token") and not tokens.get("token"):
|
||||
return
|
||||
|
||||
# Fetch list of calendars
|
||||
calendars_resp = _gcal_api_calendars_list(token)
|
||||
# Fetch list of calendars. call_with_refresh wraps the token read so
|
||||
# an expired access_token triggers a one-shot refresh + retry instead
|
||||
# of bubbling up a 401.
|
||||
calendars_resp = call_with_refresh(
|
||||
_gcal_api_calendars_list, self._credentials_path
|
||||
)
|
||||
calendars: List[Dict[str, Any]] = calendars_resp.get("items", [])
|
||||
|
||||
# Default to 24 hours ago so we don't dump the entire calendar history
|
||||
@@ -327,8 +372,9 @@ class GCalendarConnector(BaseConnector):
|
||||
|
||||
while True:
|
||||
try:
|
||||
events_resp = _gcal_api_events_list(
|
||||
token,
|
||||
events_resp = call_with_refresh(
|
||||
_gcal_api_events_list,
|
||||
self._credentials_path,
|
||||
calendar_id,
|
||||
page_token=page_token,
|
||||
time_min=time_min,
|
||||
@@ -354,6 +400,13 @@ class GCalendarConnector(BaseConnector):
|
||||
|
||||
content = _format_event(event)
|
||||
|
||||
# Find the self-attendee's response status
|
||||
self_status = ""
|
||||
for att in attendees:
|
||||
if att.get("self"):
|
||||
self_status = att.get("responseStatus", "")
|
||||
break
|
||||
|
||||
doc = Document(
|
||||
doc_id=f"gcalendar:{evt_id}",
|
||||
source="gcalendar",
|
||||
@@ -367,6 +420,7 @@ class GCalendarConnector(BaseConnector):
|
||||
metadata={
|
||||
"calendar_id": calendar_id,
|
||||
"event_id": evt_id,
|
||||
"response_status": self_status,
|
||||
},
|
||||
)
|
||||
synced += 1
|
||||
@@ -382,6 +436,49 @@ class GCalendarConnector(BaseConnector):
|
||||
self._items_synced = synced
|
||||
self._last_sync = datetime.now()
|
||||
|
||||
def _get_token(self) -> str:
|
||||
tokens = load_tokens(self._credentials_path)
|
||||
if not tokens:
|
||||
raise RuntimeError("Google Calendar not authenticated")
|
||||
token = tokens.get("access_token", tokens.get("token", ""))
|
||||
if not token:
|
||||
raise RuntimeError("Google Calendar token missing")
|
||||
return token
|
||||
|
||||
def accept_event(self, event_id: str, calendar_id: str = "primary") -> None:
|
||||
"""Accept a calendar invite by setting responseStatus to 'accepted'."""
|
||||
token = self._get_token()
|
||||
user_email = _gcal_api_user_email(token)
|
||||
event = _gcal_api_event_get(token, calendar_id, event_id)
|
||||
attendees = event.get("attendees", [])
|
||||
updated = []
|
||||
found = False
|
||||
for att in attendees:
|
||||
if att.get("self") or (user_email and att.get("email") == user_email):
|
||||
att = {**att, "responseStatus": "accepted"}
|
||||
found = True
|
||||
updated.append(att)
|
||||
if not found and user_email:
|
||||
updated.append({"email": user_email, "responseStatus": "accepted"})
|
||||
_gcal_api_event_patch(token, calendar_id, event_id, {"attendees": updated})
|
||||
|
||||
def decline_event(self, event_id: str, calendar_id: str = "primary") -> None:
|
||||
"""Decline a calendar invite by setting responseStatus to 'declined'."""
|
||||
token = self._get_token()
|
||||
user_email = _gcal_api_user_email(token)
|
||||
event = _gcal_api_event_get(token, calendar_id, event_id)
|
||||
attendees = event.get("attendees", [])
|
||||
updated = []
|
||||
found = False
|
||||
for att in attendees:
|
||||
if att.get("self") or (user_email and att.get("email") == user_email):
|
||||
att = {**att, "responseStatus": "declined"}
|
||||
found = True
|
||||
updated.append(att)
|
||||
if not found and user_email:
|
||||
updated.append({"email": user_email, "responseStatus": "declined"})
|
||||
_gcal_api_event_patch(token, calendar_id, event_id, {"attendees": updated})
|
||||
|
||||
def sync_status(self) -> SyncStatus:
|
||||
"""Return sync progress from the most recent :meth:`sync` call."""
|
||||
return SyncStatus(
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Dict, Iterator, List, Optional
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
|
||||
from openjarvis.connectors.google_auth import call_with_refresh
|
||||
from openjarvis.connectors.oauth import (
|
||||
GOOGLE_ALL_SCOPES,
|
||||
build_google_auth_url,
|
||||
@@ -249,16 +250,16 @@ class GContactsConnector(BaseConnector):
|
||||
tokens = load_tokens(self._credentials_path)
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
token: str = tokens.get("access_token", tokens.get("token", ""))
|
||||
if not token:
|
||||
if not tokens.get("access_token") and not tokens.get("token"):
|
||||
return
|
||||
|
||||
page_token: Optional[str] = cursor
|
||||
synced = 0
|
||||
|
||||
while True:
|
||||
list_resp = _gcontacts_api_list(token, page_token=page_token)
|
||||
list_resp = call_with_refresh(
|
||||
_gcontacts_api_list, self._credentials_path, page_token=page_token
|
||||
)
|
||||
connections: List[Dict[str, Any]] = list_resp.get("connections", [])
|
||||
|
||||
for person in connections:
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Dict, Iterator, List, Optional
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
|
||||
from openjarvis.connectors.google_auth import call_with_refresh
|
||||
from openjarvis.connectors.oauth import (
|
||||
GOOGLE_ALL_SCOPES,
|
||||
build_google_auth_url,
|
||||
@@ -234,16 +235,16 @@ class GDriveConnector(BaseConnector):
|
||||
tokens = load_tokens(self._credentials_path)
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
token: str = tokens.get("access_token", tokens.get("token", ""))
|
||||
if not token:
|
||||
if not tokens.get("access_token") and not tokens.get("token"):
|
||||
return
|
||||
|
||||
page_token: Optional[str] = cursor
|
||||
synced = 0
|
||||
|
||||
while True:
|
||||
list_resp = _gdrive_api_list_files(token, page_token=page_token)
|
||||
list_resp = call_with_refresh(
|
||||
_gdrive_api_list_files, self._credentials_path, page_token=page_token
|
||||
)
|
||||
files: List[Dict[str, Any]] = list_resp.get("files", [])
|
||||
|
||||
for file_meta in files:
|
||||
@@ -263,7 +264,12 @@ class GDriveConnector(BaseConnector):
|
||||
export_mime = _EXPORT_MIME_MAP.get(mime_type)
|
||||
if export_mime is not None:
|
||||
try:
|
||||
content = _gdrive_api_export(token, file_id, export_mime)
|
||||
content = call_with_refresh(
|
||||
_gdrive_api_export,
|
||||
self._credentials_path,
|
||||
file_id,
|
||||
export_mime,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
content = f"[File: {name}] ({mime_type})"
|
||||
else:
|
||||
|
||||
@@ -9,17 +9,27 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import email.utils
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
|
||||
from openjarvis.connectors.google_auth import (
|
||||
GoogleAuthError,
|
||||
)
|
||||
from openjarvis.connectors.google_auth import (
|
||||
call_with_refresh as _call_with_refresh,
|
||||
)
|
||||
from openjarvis.connectors.oauth import (
|
||||
GOOGLE_ALL_SCOPES,
|
||||
build_google_auth_url,
|
||||
delete_tokens,
|
||||
load_tokens,
|
||||
refresh_google_token,
|
||||
resolve_google_credentials,
|
||||
save_tokens,
|
||||
)
|
||||
@@ -27,6 +37,8 @@ from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
from openjarvis.tools._stubs import ToolSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -35,6 +47,13 @@ _GMAIL_API_BASE = "https://gmail.googleapis.com/gmail/v1/users/me"
|
||||
_GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
_DEFAULT_CREDENTIALS_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "gmail.json")
|
||||
|
||||
# Token refresh is delegated to the shared google_auth helper so Calendar,
|
||||
# Contacts, Drive, and Tasks get the same one-shot 401 retry. ``GmailAuthError``
|
||||
# is preserved as a module-level alias for tests/callers that imported it
|
||||
# under the historical name.
|
||||
GmailAuthError = GoogleAuthError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level API functions (easy to patch in tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -79,6 +98,38 @@ def _gmail_api_list_messages(
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _gmail_api_trash_message(token: str, msg_id: str) -> None:
|
||||
"""Move a Gmail message to Trash via the ``messages.trash`` endpoint."""
|
||||
resp = httpx.post(
|
||||
f"{_GMAIL_API_BASE}/messages/{msg_id}/trash",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _gmail_api_modify_message(
|
||||
token: str,
|
||||
msg_id: str,
|
||||
*,
|
||||
add_labels: Optional[List[str]] = None,
|
||||
remove_labels: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Modify labels on a Gmail message via the ``messages.modify`` endpoint."""
|
||||
body: Dict[str, Any] = {}
|
||||
if add_labels:
|
||||
body["addLabelIds"] = add_labels
|
||||
if remove_labels:
|
||||
body["removeLabelIds"] = remove_labels
|
||||
resp = httpx.post(
|
||||
f"{_GMAIL_API_BASE}/messages/{msg_id}/modify",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json=body,
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _gmail_api_get_message(token: str, msg_id: str) -> Dict[str, Any]:
|
||||
"""Fetch a single Gmail message by ID (``full`` format).
|
||||
|
||||
@@ -118,21 +169,103 @@ def _extract_header(headers: List[Dict[str, str]], name: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class _HTMLTextExtractor(HTMLParser):
|
||||
"""Strip HTML tags and return readable text using stdlib only.
|
||||
|
||||
Skips <script>, <style>, and <head> contents entirely so CSS rules and
|
||||
JS payloads don't pollute the text. Inserts a newline at each block-level
|
||||
tag boundary so the downstream chunker still has paragraph-ish breaks
|
||||
to split on; without this, a single <div>-wrapped marketing email
|
||||
becomes one giant unsplittable chunk.
|
||||
"""
|
||||
|
||||
_SKIP_TAGS = {"script", "style", "head", "title", "meta", "link"}
|
||||
_BLOCK_TAGS = {
|
||||
"p",
|
||||
"div",
|
||||
"br",
|
||||
"li",
|
||||
"ul",
|
||||
"ol",
|
||||
"tr",
|
||||
"td",
|
||||
"table",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"blockquote",
|
||||
"hr",
|
||||
"article",
|
||||
"section",
|
||||
"header",
|
||||
"footer",
|
||||
"pre",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self._parts: List[str] = []
|
||||
self._skip_depth = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
if tag in self._SKIP_TAGS:
|
||||
self._skip_depth += 1
|
||||
elif tag in self._BLOCK_TAGS and self._skip_depth == 0:
|
||||
self._parts.append("\n")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in self._SKIP_TAGS:
|
||||
self._skip_depth = max(0, self._skip_depth - 1)
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._skip_depth == 0:
|
||||
self._parts.append(data)
|
||||
|
||||
def get_text(self) -> str:
|
||||
text = "".join(self._parts)
|
||||
# Collapse runs of horizontal whitespace and excess blank lines so
|
||||
# the chunker sees clean paragraphs rather than walls of \n.
|
||||
text = re.sub(r"[ \t\r\f\v]+", " ", text)
|
||||
text = re.sub(r"\n[ \t]*", "\n", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
"""Convert HTML to plain text. Malformed input returns best-effort output."""
|
||||
extractor = _HTMLTextExtractor()
|
||||
try:
|
||||
extractor.feed(html)
|
||||
extractor.close()
|
||||
except Exception: # noqa: BLE001
|
||||
# Parser exceptions still leave partial output in self._parts.
|
||||
pass
|
||||
return extractor.get_text()
|
||||
|
||||
|
||||
def _decode_body(payload: Dict[str, Any]) -> str:
|
||||
"""Decode the message body from a Gmail payload dict.
|
||||
|
||||
Handles both simple payloads (``body.data``) and multipart messages
|
||||
by recursively searching for a ``text/plain`` part.
|
||||
Multipart messages prefer text/plain. When only text/html is available
|
||||
(common for marketing emails), the HTML is stripped to plain text so
|
||||
downstream chunkers and embeddings don't ingest raw markup.
|
||||
"""
|
||||
mime_type: str = payload.get("mimeType", "")
|
||||
|
||||
if mime_type.startswith("multipart/"):
|
||||
# Search parts for text/plain first, then any text/* fallback
|
||||
parts: List[Dict[str, Any]] = payload.get("parts", [])
|
||||
# Prefer text/plain when both alternatives are present.
|
||||
for part in parts:
|
||||
if part.get("mimeType", "").startswith("text/plain"):
|
||||
return _decode_body(part)
|
||||
# Fallback: recurse into first part
|
||||
# Fall back to text/html (which gets stripped at the leaf branch).
|
||||
for part in parts:
|
||||
if part.get("mimeType", "").startswith("text/html"):
|
||||
return _decode_body(part)
|
||||
# Last resort: recurse into the first part regardless of type.
|
||||
if parts:
|
||||
return _decode_body(parts[0])
|
||||
return ""
|
||||
@@ -144,10 +277,14 @@ def _decode_body(payload: Dict[str, Any]) -> str:
|
||||
# Gmail uses URL-safe base64 without padding
|
||||
padded = body_data + "=" * (-len(body_data) % 4)
|
||||
try:
|
||||
return base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
|
||||
decoded = base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
if mime_type.startswith("text/html"):
|
||||
return _html_to_text(decoded)
|
||||
return decoded
|
||||
|
||||
|
||||
def _parse_date(date_str: str) -> datetime:
|
||||
"""Parse an RFC 2822 email date string into a :class:`~datetime.datetime`.
|
||||
@@ -162,6 +299,31 @@ def _parse_date(date_str: str) -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _normalize_addresses(raw: str) -> List[str]:
|
||||
"""Extract lowercase email addresses from a comma-separated header value.
|
||||
|
||||
Uses :func:`email.utils.getaddresses` so multi-recipient ``To``/``Cc``
|
||||
headers are handled correctly. Addresses that fail to parse are dropped.
|
||||
"""
|
||||
if not raw:
|
||||
return []
|
||||
return [addr.lower() for _, addr in email.utils.getaddresses([raw]) if addr]
|
||||
|
||||
|
||||
# Order matters: a message tagged both SENT and INBOX (rare) reads as SENT,
|
||||
# the more specific origin. INBOX is last so it acts as the default.
|
||||
_PRIMARY_LABELS = ("SENT", "DRAFT", "SPAM", "TRASH", "INBOX")
|
||||
|
||||
|
||||
def _select_channel(label_ids: List[str]) -> Optional[str]:
|
||||
"""Pick the most specific Gmail system folder this message lives in."""
|
||||
label_set = set(label_ids)
|
||||
for label in _PRIMARY_LABELS:
|
||||
if label in label_set:
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GmailConnector
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -199,20 +361,24 @@ class GmailConnector(BaseConnector):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Return ``True`` if a credentials file with a valid token exists."""
|
||||
"""Return ``True`` if a credentials file with a valid access token exists.
|
||||
|
||||
The previous "any non-empty dict counts" check returned True for
|
||||
files containing only client_id/client_secret (no actual OAuth
|
||||
token), which made `jarvis connect gmail` short-circuit with
|
||||
"already connected" before any OAuth flow ran.
|
||||
"""
|
||||
tokens = load_tokens(self._credentials_path)
|
||||
if tokens is None:
|
||||
return False
|
||||
# Accept any non-empty dict that contains at least one key
|
||||
# (simplified: real impl would also check expiry / refresh token)
|
||||
return bool(tokens)
|
||||
return bool(tokens.get("access_token") or tokens.get("token"))
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Delete the stored credentials file."""
|
||||
delete_tokens(self._credentials_path)
|
||||
|
||||
def auth_url(self) -> str:
|
||||
"""Return a Google OAuth consent URL requesting ``gmail.readonly`` scope."""
|
||||
"""Return a Google OAuth consent URL for the shared Google scopes."""
|
||||
return build_google_auth_url(
|
||||
client_id="", # placeholder — real client_id from config
|
||||
scopes=GOOGLE_ALL_SCOPES,
|
||||
@@ -231,6 +397,7 @@ class GmailConnector(BaseConnector):
|
||||
*,
|
||||
since: Optional[datetime] = None,
|
||||
cursor: Optional[str] = None,
|
||||
query_extra: str = "",
|
||||
) -> Iterator[Document]:
|
||||
"""Yield :class:`Document` objects for Gmail messages.
|
||||
|
||||
@@ -244,27 +411,38 @@ class GmailConnector(BaseConnector):
|
||||
returned. Translated to a Gmail ``after:<epoch>`` search query.
|
||||
cursor:
|
||||
``nextPageToken`` from a previous sync to resume pagination.
|
||||
query_extra:
|
||||
Additional Gmail search operators appended to the base query,
|
||||
e.g. ``"is:unread"`` to restrict to unread messages only.
|
||||
"""
|
||||
# Existence check only — the actual access token is reloaded on every
|
||||
# API call by _call_with_refresh so a mid-sync refresh is picked up
|
||||
# transparently.
|
||||
tokens = load_tokens(self._credentials_path)
|
||||
if not tokens:
|
||||
if not tokens or not (tokens.get("token") or tokens.get("access_token")):
|
||||
return
|
||||
|
||||
token: str = tokens.get("token", tokens.get("access_token", ""))
|
||||
if not token:
|
||||
return
|
||||
|
||||
query = "category:primary"
|
||||
# Default to no filter so SENT, labeled, and category-tabbed mail
|
||||
# all flow in. The previous "category:primary" default excluded
|
||||
# ~95% of a typical mailbox (sent mail, Promotions, Updates, etc.)
|
||||
# which made any C2-style "what did I say to X" query impossible.
|
||||
query_parts: List[str] = []
|
||||
if since is not None:
|
||||
# Gmail's after: operator accepts Unix epoch seconds.
|
||||
epoch = int(since.timestamp())
|
||||
query = f"category:primary after:{epoch}"
|
||||
query_parts.append(f"after:{int(since.timestamp())}")
|
||||
if query_extra:
|
||||
query_parts.append(query_extra)
|
||||
query = " ".join(query_parts)
|
||||
|
||||
page_token: Optional[str] = cursor
|
||||
synced = 0
|
||||
|
||||
while True:
|
||||
list_resp = _gmail_api_list_messages(
|
||||
token, page_token=page_token, query=query
|
||||
list_resp = _call_with_refresh(
|
||||
_gmail_api_list_messages,
|
||||
self._credentials_path,
|
||||
page_token=page_token,
|
||||
query=query,
|
||||
)
|
||||
messages: List[Dict[str, Any]] = list_resp.get("messages", [])
|
||||
|
||||
@@ -273,39 +451,64 @@ class GmailConnector(BaseConnector):
|
||||
if not msg_id:
|
||||
continue
|
||||
|
||||
msg = _gmail_api_get_message(token, msg_id)
|
||||
msg = _call_with_refresh(
|
||||
_gmail_api_get_message,
|
||||
self._credentials_path,
|
||||
msg_id,
|
||||
)
|
||||
payload: Dict[str, Any] = msg.get("payload", {})
|
||||
headers: List[Dict[str, str]] = payload.get("headers", [])
|
||||
|
||||
from_header = _extract_header(headers, "From")
|
||||
to_header = _extract_header(headers, "To")
|
||||
cc_header = _extract_header(headers, "Cc")
|
||||
subject = _extract_header(headers, "Subject")
|
||||
date_str = _extract_header(headers, "Date")
|
||||
to_header = _extract_header(headers, "To")
|
||||
rfc_message_id = _extract_header(headers, "Message-ID")
|
||||
|
||||
body = _decode_body(payload)
|
||||
timestamp = _parse_date(date_str)
|
||||
|
||||
# Raw header values, exactly as Gmail returned them — preserved
|
||||
# so re-normalisation against an updated alias map doesn't need
|
||||
# a re-fetch from the API.
|
||||
participants_raw: List[str] = [
|
||||
h for h in (from_header, to_header, cc_header) if h
|
||||
]
|
||||
# Lowercase email addresses, multi-recipient-aware.
|
||||
participants: List[str] = []
|
||||
if from_header:
|
||||
participants.append(from_header)
|
||||
if to_header:
|
||||
participants.append(to_header)
|
||||
for header in (from_header, to_header, cc_header):
|
||||
participants.extend(_normalize_addresses(header))
|
||||
|
||||
label_ids: List[str] = msg.get("labelIds", [])
|
||||
channel = _select_channel(label_ids)
|
||||
thread_id: Optional[str] = msg.get("threadId")
|
||||
|
||||
doc = Document(
|
||||
doc_id=f"gmail:{msg_id}",
|
||||
source="gmail",
|
||||
source_id=msg_id,
|
||||
doc_type="email",
|
||||
content=body,
|
||||
title=subject,
|
||||
author=from_header,
|
||||
participants=participants,
|
||||
participants_raw=participants_raw,
|
||||
timestamp=timestamp,
|
||||
thread_id=thread_id,
|
||||
channel=channel,
|
||||
# Deep-link straight to the message. ``msg_id`` is Gmail's
|
||||
# internal hex id, which the ``#all/<id>`` permalink
|
||||
# resolves directly — so citations have a working URL
|
||||
# without relying on _hit_url reconstruction at query time.
|
||||
url=f"https://mail.google.com/mail/u/0/#all/{msg_id}",
|
||||
metadata={
|
||||
"message_id": msg_id,
|
||||
"labels": msg.get("labelIds", []),
|
||||
"rfc_message_id": rfc_message_id,
|
||||
"labels": label_ids,
|
||||
"snippet": msg.get("snippet", ""),
|
||||
"history_id": msg.get("historyId", ""),
|
||||
"size_estimate": msg.get("sizeEstimate", 0),
|
||||
},
|
||||
)
|
||||
synced += 1
|
||||
@@ -321,6 +524,54 @@ class GmailConnector(BaseConnector):
|
||||
self._items_synced = synced
|
||||
self._last_sync = datetime.now()
|
||||
|
||||
def _current_token(self) -> str:
|
||||
"""Return the cached access token (may be expired)."""
|
||||
tokens = load_tokens(self._credentials_path)
|
||||
if not tokens:
|
||||
raise RuntimeError("Gmail not authenticated")
|
||||
return tokens.get("token") or tokens.get("access_token") or ""
|
||||
|
||||
def _refresh_token(self) -> str:
|
||||
"""Refresh the access token using the stored refresh token.
|
||||
|
||||
Raises ``RuntimeError`` if refresh fails (typically because the
|
||||
refresh token has been revoked — user must re-authorise).
|
||||
"""
|
||||
new = refresh_google_token(self._credentials_path)
|
||||
if not new:
|
||||
raise RuntimeError(
|
||||
"Gmail token refresh failed — re-run `jarvis connect gmail`"
|
||||
)
|
||||
return new
|
||||
|
||||
def _call_with_refresh(self, fn: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Invoke a ``_gmail_api_*`` function with auto-refresh on 401.
|
||||
|
||||
Tries with the cached token first. If the call raises an
|
||||
``httpx.HTTPStatusError`` with a 401, refresh the access token
|
||||
once and retry. Any other failure propagates unchanged.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
token = self._current_token()
|
||||
try:
|
||||
return fn(token, *args, **kwargs)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code != 401:
|
||||
raise
|
||||
token = self._refresh_token()
|
||||
return fn(token, *args, **kwargs)
|
||||
|
||||
def delete_message(self, msg_id: str) -> None:
|
||||
"""Move a message to Trash (recoverable for 30 days)."""
|
||||
self._call_with_refresh(_gmail_api_trash_message, msg_id)
|
||||
|
||||
def archive_message(self, msg_id: str) -> None:
|
||||
"""Archive a message by removing the INBOX label."""
|
||||
self._call_with_refresh(
|
||||
_gmail_api_modify_message, msg_id, remove_labels=["INBOX"]
|
||||
)
|
||||
|
||||
def sync_status(self) -> SyncStatus:
|
||||
"""Return sync progress from the most recent :meth:`sync` call."""
|
||||
return SyncStatus(
|
||||
|
||||
@@ -92,12 +92,14 @@ class GmailIMAPConnector(BaseConnector):
|
||||
credentials_path: str = "",
|
||||
*,
|
||||
imap_host: str = "",
|
||||
max_messages: int = 5000,
|
||||
max_messages: Optional[int] = None,
|
||||
) -> None:
|
||||
self._email = email_address
|
||||
self._password = app_password
|
||||
self._credentials_path = credentials_path or _DEFAULT_CREDENTIALS_PATH
|
||||
self._imap_host = imap_host or self._default_imap_host
|
||||
# ``None`` means "no cap" — the full inbox is indexed. A positive
|
||||
# value is still honored for tests that want a bounded scan.
|
||||
self._max_messages = max_messages
|
||||
self._items_synced = 0
|
||||
self._items_total = 0
|
||||
@@ -156,21 +158,27 @@ class GmailIMAPConnector(BaseConnector):
|
||||
|
||||
imap.select("INBOX", readonly=True)
|
||||
|
||||
# Build search criteria
|
||||
if since:
|
||||
date_str = since.strftime("%d-%b-%Y")
|
||||
_, data = imap.search(None, f"SINCE {date_str}")
|
||||
else:
|
||||
_, data = imap.search(None, "ALL")
|
||||
|
||||
# Always SEARCH ALL. IMAP has no native cursor that survives a
|
||||
# server restart, so applying the SyncEngine's ``since`` filter
|
||||
# during a partial backfill would silently skip the older
|
||||
# unprocessed messages. The pipeline-level dedup (_seen_doc_ids
|
||||
# set + INSERT OR IGNORE in KnowledgeStore) makes re-scanning
|
||||
# already-indexed messages cheap, so resume is correct as long
|
||||
# as we keep enumerating the full inbox.
|
||||
_, data = imap.search(None, "ALL")
|
||||
msg_ids = data[0].split()
|
||||
self._items_total = len(msg_ids)
|
||||
|
||||
# Take the most recent N messages
|
||||
recent = msg_ids[-self._max_messages :]
|
||||
# Newest-first iteration so Deep Research becomes useful while
|
||||
# the long tail of older mail finishes indexing in the
|
||||
# background. IMAP returns sequence numbers in arrival order
|
||||
# (oldest -> newest), so reversing puts the most recent first.
|
||||
ordered = list(reversed(msg_ids))
|
||||
if self._max_messages is not None and self._max_messages > 0:
|
||||
ordered = ordered[: self._max_messages]
|
||||
synced = 0
|
||||
|
||||
for mid in recent:
|
||||
for mid in ordered:
|
||||
try:
|
||||
_, msg_data = imap.fetch(mid, "(RFC822)")
|
||||
raw = msg_data[0][1]
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Shared Google OAuth helpers: access token read + one-shot 401 refresh.
|
||||
|
||||
All Google connectors (Gmail, Calendar, Contacts, Drive, Tasks) authenticate
|
||||
with the same OAuth flow and store identical token payloads at
|
||||
``~/.openjarvis/connectors/*.json`` — typically a shared ``google.json`` file
|
||||
plus per-product copies. They all need the same refresh-on-401 behavior, so
|
||||
the wrapper lives here instead of being duplicated per connector.
|
||||
|
||||
Use ``call_with_refresh(api_fn, credentials_path, *args, **kwargs)`` around
|
||||
any token-taking API helper. On a 401 the wrapper exchanges the stored
|
||||
``refresh_token`` for a new ``access_token``, updates the credentials file,
|
||||
and retries the call once. All other status codes propagate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors.oauth import load_tokens, save_tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
|
||||
|
||||
|
||||
class GoogleAuthError(RuntimeError):
|
||||
"""Raised when Google credentials are missing or refresh-token grant fails."""
|
||||
|
||||
|
||||
def current_access_token(credentials_path: str) -> str:
|
||||
"""Return the current access token from the credentials file (empty if absent)."""
|
||||
tokens = load_tokens(credentials_path) or {}
|
||||
return tokens.get("access_token", tokens.get("token", ""))
|
||||
|
||||
|
||||
def refresh_access_token(credentials_path: str) -> str:
|
||||
"""Exchange the stored refresh_token for a fresh access_token and persist it.
|
||||
|
||||
Returns the new access_token. Raises :class:`GoogleAuthError` when the
|
||||
credentials file is missing, lacks a refresh_token / client credentials,
|
||||
or when Google rejects the refresh grant (e.g. the refresh_token has been
|
||||
revoked and the user needs to re-authenticate).
|
||||
"""
|
||||
tokens = load_tokens(credentials_path)
|
||||
if not tokens:
|
||||
raise GoogleAuthError(
|
||||
f"No credentials at {credentials_path}; re-run the connector OAuth flow."
|
||||
)
|
||||
refresh_token = tokens.get("refresh_token", "")
|
||||
client_id = tokens.get("client_id", "")
|
||||
client_secret = tokens.get("client_secret", "")
|
||||
if not (refresh_token and client_id and client_secret):
|
||||
raise GoogleAuthError(
|
||||
"Stored Google credentials are missing refresh_token / client_id / "
|
||||
"client_secret; re-run the connector OAuth flow to mint a full token."
|
||||
)
|
||||
|
||||
resp = httpx.post(
|
||||
_GOOGLE_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"refresh_token": refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise GoogleAuthError(
|
||||
f"Google token refresh failed ({resp.status_code}): {resp.text[:200]}"
|
||||
)
|
||||
payload = resp.json()
|
||||
new_token = payload.get("access_token", "")
|
||||
if not new_token:
|
||||
raise GoogleAuthError(
|
||||
"Google token refresh returned 200 but no access_token in payload."
|
||||
)
|
||||
|
||||
tokens["access_token"] = new_token
|
||||
# Keep the legacy "token" key in sync for older code paths that read it.
|
||||
tokens["token"] = new_token
|
||||
if "expires_in" in payload:
|
||||
tokens["expires_in"] = payload["expires_in"]
|
||||
save_tokens(credentials_path, tokens)
|
||||
logger.info(
|
||||
"Refreshed Google access token (expires_in=%s)", payload.get("expires_in")
|
||||
)
|
||||
return new_token
|
||||
|
||||
|
||||
def call_with_refresh(
|
||||
api_fn: Callable[..., Dict[str, Any]],
|
||||
credentials_path: str,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Invoke ``api_fn(token, *args, **kwargs)`` with one-shot 401 auto-refresh.
|
||||
|
||||
Loads the current access token from disk, calls the helper, and if Google
|
||||
returns 401 (the access token has expired or been revoked) uses the stored
|
||||
refresh_token to mint a new access_token, updates the credentials file,
|
||||
and retries the call exactly once.
|
||||
|
||||
Any other ``HTTPStatusError`` is re-raised unchanged — auth-related retries
|
||||
end here; transient 5xx / timeout retries belong further up the stack.
|
||||
"""
|
||||
token = current_access_token(credentials_path)
|
||||
try:
|
||||
return api_fn(token, *args, **kwargs)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response is None or exc.response.status_code != 401:
|
||||
raise
|
||||
logger.info(
|
||||
"Google returned 401 on %s — refreshing access token and retrying.",
|
||||
getattr(api_fn, "__name__", "<api_fn>"),
|
||||
)
|
||||
new_token = refresh_access_token(credentials_path)
|
||||
return api_fn(new_token, *args, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GoogleAuthError",
|
||||
"current_access_token",
|
||||
"refresh_access_token",
|
||||
"call_with_refresh",
|
||||
]
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, Iterator, Optional
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
|
||||
from openjarvis.connectors.google_auth import call_with_refresh
|
||||
from openjarvis.connectors.oauth import load_tokens, resolve_google_credentials
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
@@ -53,7 +54,15 @@ class GoogleTasksConnector(BaseConnector):
|
||||
return tokens.get("access_token") or tokens.get("token", "")
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return self._credentials_path.exists()
|
||||
"""Return ``True`` if the credentials file has a real access token.
|
||||
|
||||
File existence alone is not enough — the shared ``google.json``
|
||||
may contain only client_id/client_secret without OAuth tokens.
|
||||
"""
|
||||
tokens = load_tokens(str(self._credentials_path))
|
||||
if tokens is None:
|
||||
return False
|
||||
return bool(tokens.get("access_token") or tokens.get("token"))
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._credentials_path.exists():
|
||||
@@ -62,10 +71,10 @@ class GoogleTasksConnector(BaseConnector):
|
||||
def sync(
|
||||
self, *, since: Optional[datetime] = None, cursor: Optional[str] = None
|
||||
) -> Iterator[Document]:
|
||||
token = self._get_access_token()
|
||||
|
||||
# List all task lists first
|
||||
task_lists = _tasks_api_get(token, "users/@me/lists")
|
||||
# call_with_refresh handles the access-token read + one-shot 401 retry.
|
||||
task_lists = call_with_refresh(
|
||||
_tasks_api_get, str(self._credentials_path), "users/@me/lists"
|
||||
)
|
||||
|
||||
for tl in task_lists.get("items", []):
|
||||
tl_id = tl["id"]
|
||||
@@ -79,7 +88,12 @@ class GoogleTasksConnector(BaseConnector):
|
||||
if since:
|
||||
params["updatedMin"] = since.isoformat() + "Z"
|
||||
|
||||
tasks = _tasks_api_get(token, f"lists/{tl_id}/tasks", params=params)
|
||||
tasks = call_with_refresh(
|
||||
_tasks_api_get,
|
||||
str(self._credentials_path),
|
||||
f"lists/{tl_id}/tasks",
|
||||
params=params,
|
||||
)
|
||||
|
||||
for task in tasks.get("items", []):
|
||||
due = task.get("due", "")
|
||||
|
||||
@@ -10,6 +10,7 @@ Settings → API (requires Business or Enterprise plan).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
@@ -21,6 +22,8 @@ from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
from openjarvis.tools._stubs import ToolSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -73,6 +76,41 @@ def _granola_api_list_notes(
|
||||
return resp.json()
|
||||
|
||||
|
||||
class GranolaKeyError(ValueError):
|
||||
"""Raised when a Granola API key is missing or rejected by the API.
|
||||
|
||||
Surfaced through the ``/connect`` endpoint as an HTTP 400 so the user
|
||||
sees why the key was refused instead of a silent failed sync later.
|
||||
"""
|
||||
|
||||
|
||||
def _granola_api_validate_key(api_key: str) -> None:
|
||||
"""Verify an API key with a minimal ``GET /v1/notes?limit=1`` probe.
|
||||
|
||||
Raises :class:`GranolaKeyError` when the key is empty or the API
|
||||
responds 401/403, so an invalid key never overwrites a working
|
||||
credential on disk. Other HTTP errors propagate via ``raise_for_status``.
|
||||
"""
|
||||
if not api_key:
|
||||
raise GranolaKeyError("Granola API key is empty.")
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{_GRANOLA_API_BASE}/v1/notes",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
params={"limit": 1},
|
||||
timeout=30.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise GranolaKeyError(
|
||||
f"Could not reach Granola to verify the key: {exc}"
|
||||
) from exc
|
||||
if resp.status_code in (401, 403):
|
||||
raise GranolaKeyError(
|
||||
"Invalid API key. Check your key in Granola Settings → API."
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _granola_api_get_note(api_key: str, note_id: str) -> Dict[str, Any]:
|
||||
"""Fetch a single Granola note by ID (includes transcript).
|
||||
|
||||
@@ -239,10 +277,15 @@ class GranolaConnector(BaseConnector):
|
||||
)
|
||||
|
||||
def handle_callback(self, code: str) -> None:
|
||||
"""Persist the API key to the credentials file.
|
||||
"""Validate and persist the API key.
|
||||
|
||||
The *code* parameter holds the raw API key string provided by the user.
|
||||
The *code* parameter holds the raw API key string provided by the
|
||||
user. The key is verified with a live ``GET /v1/notes?limit=1``
|
||||
probe *before* it is written, so an invalid key can never overwrite
|
||||
a working credential on disk (raises :class:`GranolaKeyError` on a
|
||||
401/403).
|
||||
"""
|
||||
_granola_api_validate_key(code)
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
|
||||
def sync(
|
||||
@@ -286,6 +329,7 @@ class GranolaConnector(BaseConnector):
|
||||
created_after=created_after,
|
||||
)
|
||||
notes: List[Dict[str, Any]] = list_resp.get("notes", [])
|
||||
logger.info("Granola: Found %d notes on this page", len(notes))
|
||||
|
||||
for note_summary in notes:
|
||||
note_id: str = note_summary.get("id", "")
|
||||
@@ -297,11 +341,18 @@ class GranolaConnector(BaseConnector):
|
||||
|
||||
title: str = note.get("title", "")
|
||||
owner: Dict[str, Any] = note.get("owner") or {}
|
||||
author: str = owner.get("email", "")
|
||||
author: str = (owner.get("email") or "").lower()
|
||||
|
||||
attendees: List[Dict[str, Any]] = note.get("attendees") or []
|
||||
participants: List[str] = [
|
||||
a.get("email", "") for a in attendees if a.get("email")
|
||||
(a.get("email") or "").lower()
|
||||
for a in attendees
|
||||
if a.get("email")
|
||||
]
|
||||
participants_raw: List[str] = [
|
||||
a.get("name") or a.get("email") or ""
|
||||
for a in attendees
|
||||
if a.get("name") or a.get("email")
|
||||
]
|
||||
|
||||
created_at_str: str = note.get("created_at", "")
|
||||
@@ -309,11 +360,14 @@ class GranolaConnector(BaseConnector):
|
||||
|
||||
content = _format_note_content(note)
|
||||
|
||||
# Build URL from calendar event if available, else None
|
||||
cal_event: Optional[Dict[str, Any]] = note.get("calendar_event")
|
||||
url: Optional[str] = None
|
||||
if cal_event:
|
||||
url = note.get("url")
|
||||
cal_event: Dict[str, Any] = note.get("calendar_event") or {}
|
||||
channel: str = cal_event.get("event_title") or "meeting"
|
||||
|
||||
# ``web_url`` (e.g. https://notes.granola.ai/d/{uuid}) is the
|
||||
# only reliable way to deep-link to a Granola note — the API
|
||||
# ``note_id`` and the web UUID are different, so we must store
|
||||
# what the API gives us here.
|
||||
web_url: Optional[str] = note.get("web_url") or None
|
||||
|
||||
doc = Document(
|
||||
doc_id=f"granola:{note_id}",
|
||||
@@ -323,8 +377,11 @@ class GranolaConnector(BaseConnector):
|
||||
title=title,
|
||||
author=author,
|
||||
participants=participants,
|
||||
participants_raw=participants_raw,
|
||||
channel=channel,
|
||||
thread_id=note_id,
|
||||
timestamp=timestamp,
|
||||
url=url,
|
||||
url=web_url,
|
||||
metadata={
|
||||
"note_id": note_id,
|
||||
"owner_name": owner.get("name", ""),
|
||||
@@ -343,6 +400,7 @@ class GranolaConnector(BaseConnector):
|
||||
|
||||
self._items_synced = synced
|
||||
self._last_sync = datetime.now(tz=timezone.utc)
|
||||
logger.info("Granola: Sync complete, %d notes total", synced)
|
||||
|
||||
def sync_status(self) -> SyncStatus:
|
||||
"""Return sync progress from the most recent :meth:`sync` call."""
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Hybrid retrieval over the KnowledgeStore: metadata filter + BM25 + vector cosine.
|
||||
|
||||
A single ``search`` entrypoint that the agentic research loop calls as a tool.
|
||||
Structured WHERE-clause filters (person, time range, sources) narrow the
|
||||
candidate set, then BM25 (FTS5) and dense cosine similarity score the
|
||||
survivors. The two ranks are fused with Reciprocal Rank Fusion, which is
|
||||
robust to the very different score scales the two signals produce
|
||||
(BM25 ~ [0, 20], cosine ~ [0.4, 0.9] for nomic-embed-text).
|
||||
|
||||
Each result is enriched with its thread context: when a hit belongs to a
|
||||
``thread_id``, the surrounding chunks are attached so the synthesis model
|
||||
sees the conversation, not an isolated fragment.
|
||||
|
||||
Brute-force vector scan is fine at the current corpus size (~5k chunks ×
|
||||
768 dims fits in ~15 MB and matmuls in <50 ms). Swap in an ANN index when
|
||||
that stops being true.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openjarvis.connectors.embeddings import OllamaEmbedder, decode_embedding
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchHit:
|
||||
"""A single hybrid-search result with enough context for citation."""
|
||||
|
||||
chunk_id: str
|
||||
document_id: str
|
||||
chunk_idx: int
|
||||
title: str
|
||||
content_snippet: str
|
||||
source: str
|
||||
timestamp: str
|
||||
participants: List[str]
|
||||
score: float
|
||||
bm25_score: float
|
||||
vector_score: float
|
||||
thread_id: str = ""
|
||||
thread_context: List[Dict[str, Any]] = field(default_factory=list)
|
||||
# ``url`` is the connector-provided deep-link, persisted on
|
||||
# ``knowledge_chunks.url``. Empty when the source didn't supply one — in
|
||||
# that case callers may fall back to a doc_id-based reconstruction (Slack,
|
||||
# Gmail), or render the citation as non-clickable.
|
||||
url: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"title": self.title,
|
||||
"content_snippet": self.content_snippet,
|
||||
"source": self.source,
|
||||
"timestamp": self.timestamp,
|
||||
"participants": self.participants,
|
||||
"score": round(self.score, 4),
|
||||
"document_id": self.document_id,
|
||||
"chunk_idx": self.chunk_idx,
|
||||
"thread_id": self.thread_id,
|
||||
"thread_context": self.thread_context,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _iso(ts: Optional[datetime | str]) -> Optional[str]:
|
||||
if ts is None:
|
||||
return None
|
||||
if isinstance(ts, datetime):
|
||||
return ts.isoformat()
|
||||
return str(ts)
|
||||
|
||||
|
||||
def _quote_fts(query: str) -> str:
|
||||
"""Make a plain user query safe for FTS5 MATCH.
|
||||
|
||||
FTS5 treats characters like ``-``, ``:``, ``"`` as operators; the simplest
|
||||
way to avoid syntax errors on arbitrary user input is to quote each
|
||||
whitespace-delimited token and OR them together.
|
||||
"""
|
||||
tokens = [t for t in query.split() if t]
|
||||
if not tokens:
|
||||
return ""
|
||||
return " OR ".join(f'"{t.replace(chr(34), "")}"' for t in tokens)
|
||||
|
||||
|
||||
def _parse_participants(raw: Any) -> List[str]:
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [str(x) for x in raw]
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
return [str(x) for x in parsed] if isinstance(parsed, list) else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
def _snippet(content: str, max_chars: int = 500) -> str:
|
||||
flat = content.strip()
|
||||
if len(flat) <= max_chars:
|
||||
return flat
|
||||
return flat[:max_chars].rstrip() + "…"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HybridSearch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HybridSearch:
|
||||
"""Hybrid BM25 + dense-cosine retrieval over a ``KnowledgeStore``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
store:
|
||||
The store to query.
|
||||
embedder:
|
||||
Embedding client used to encode the query. When ``None``, search
|
||||
falls back to BM25 only and reports ``vector_score=0``.
|
||||
bm25_weight, vector_weight:
|
||||
Weights on the two RRF terms. Defaults to 0.5 / 0.5; raise either to
|
||||
bias retrieval toward lexical or semantic matches.
|
||||
rrf_k:
|
||||
RRF damping constant. Larger values flatten the contribution of
|
||||
deeper ranks; 60 is the canonical value from the original paper.
|
||||
recall_k:
|
||||
How deep each individual ranker recalls before fusion. Should be at
|
||||
least a few times ``limit`` so the fuser has overlap to work with.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: KnowledgeStore,
|
||||
embedder: Optional[OllamaEmbedder] = None,
|
||||
*,
|
||||
bm25_weight: float = 0.5,
|
||||
vector_weight: float = 0.5,
|
||||
rrf_k: int = 60,
|
||||
recall_k: int = 200,
|
||||
thread_context_cap: int = 20,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._embedder = embedder
|
||||
self._bm25_weight = float(bm25_weight)
|
||||
self._vector_weight = float(vector_weight)
|
||||
self._rrf_k = int(rrf_k)
|
||||
self._recall_k = int(recall_k)
|
||||
self._thread_context_cap = int(thread_context_cap)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Filter SQL construction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_filters(
|
||||
self,
|
||||
*,
|
||||
person: Optional[str],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
sources: Optional[Sequence[str]],
|
||||
alias: str = "",
|
||||
) -> Tuple[str, List[Any]]:
|
||||
"""Return ``(where_fragment, params)`` for the structured filters.
|
||||
|
||||
``person`` is matched against the participants_raw JSON via LIKE so a
|
||||
substring of a name or email address is enough — handy when the user
|
||||
says "Kelly" rather than "kelly@example.com".
|
||||
|
||||
``alias`` qualifies every column reference (e.g. ``kc.`` when joining
|
||||
against the FTS virtual table which also has ``author`` and ``title``
|
||||
columns and would otherwise produce an "ambiguous column" error).
|
||||
"""
|
||||
prefix = f"{alias}." if alias else ""
|
||||
clauses: List[str] = [f"{prefix}deleted_at IS NULL"]
|
||||
params: List[Any] = []
|
||||
|
||||
if person:
|
||||
clauses.append(
|
||||
f"({prefix}participants_raw LIKE ? OR {prefix}participants LIKE ? "
|
||||
f"OR {prefix}author LIKE ?)"
|
||||
)
|
||||
needle = f"%{person}%"
|
||||
params.extend([needle, needle, needle])
|
||||
|
||||
if time_range:
|
||||
start, end = time_range
|
||||
if start is not None:
|
||||
clauses.append(f"{prefix}timestamp >= ?")
|
||||
params.append(_iso(start))
|
||||
if end is not None:
|
||||
clauses.append(f"{prefix}timestamp <= ?")
|
||||
params.append(_iso(end))
|
||||
|
||||
if sources:
|
||||
placeholders = ",".join("?" for _ in sources)
|
||||
clauses.append(f"{prefix}source IN ({placeholders})")
|
||||
params.extend(sources)
|
||||
|
||||
return " AND ".join(clauses), params
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BM25 leg
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _bm25_recall(
|
||||
self, query: str, filter_sql: str, filter_params: List[Any]
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""Return ``[(chunk_id, bm25_score), ...]`` from FTS5."""
|
||||
fts_query = _quote_fts(query)
|
||||
if not fts_query:
|
||||
return []
|
||||
sql = f"""
|
||||
SELECT kc.id, abs(bm25(knowledge_fts)) AS score
|
||||
FROM knowledge_fts
|
||||
JOIN knowledge_chunks kc ON knowledge_fts.rowid = kc.rowid
|
||||
WHERE knowledge_fts MATCH ?
|
||||
AND {filter_sql}
|
||||
ORDER BY score DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
try:
|
||||
rows = self._store._conn.execute(
|
||||
sql, [fts_query, *filter_params, self._recall_k]
|
||||
).fetchall()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("hybrid_search: BM25 leg failed (%s)", exc)
|
||||
return []
|
||||
return [(row["id"], float(row["score"])) for row in rows]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Vector leg
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _vector_recall(
|
||||
self, query: str, filter_sql: str, filter_params: List[Any]
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""Return ``[(chunk_id, cosine_score), ...]`` from a brute-force scan."""
|
||||
if self._embedder is None:
|
||||
return []
|
||||
q_blob = self._embedder.embed(query)
|
||||
q_vec = decode_embedding(q_blob)
|
||||
if q_vec is None or q_vec.size == 0:
|
||||
return []
|
||||
q_norm = float(np.linalg.norm(q_vec))
|
||||
if q_norm == 0.0:
|
||||
return []
|
||||
q_unit = q_vec / q_norm
|
||||
|
||||
sql = f"""
|
||||
SELECT id, embedding
|
||||
FROM knowledge_chunks
|
||||
WHERE embedding IS NOT NULL AND {filter_sql}
|
||||
"""
|
||||
rows = self._store._conn.execute(sql, filter_params).fetchall()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
ids: List[str] = []
|
||||
vecs: List[np.ndarray] = []
|
||||
for row in rows:
|
||||
vec = decode_embedding(row["embedding"])
|
||||
if vec is None or vec.size != q_unit.size:
|
||||
continue
|
||||
ids.append(row["id"])
|
||||
vecs.append(vec)
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
mat = np.vstack(vecs).astype(np.float32, copy=False)
|
||||
norms = np.linalg.norm(mat, axis=1)
|
||||
norms[norms == 0.0] = 1.0
|
||||
mat = mat / norms[:, None]
|
||||
scores = mat @ q_unit
|
||||
# Top-recall_k
|
||||
if len(scores) > self._recall_k:
|
||||
top_idx = np.argpartition(-scores, self._recall_k)[: self._recall_k]
|
||||
top_idx = top_idx[np.argsort(-scores[top_idx])]
|
||||
else:
|
||||
top_idx = np.argsort(-scores)
|
||||
return [(ids[int(i)], float(scores[int(i)])) for i in top_idx]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fusion
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fuse(
|
||||
self,
|
||||
bm25: List[Tuple[str, float]],
|
||||
vector: List[Tuple[str, float]],
|
||||
) -> List[Tuple[str, float, float, float]]:
|
||||
"""Reciprocal Rank Fusion across the two rankers.
|
||||
|
||||
Returns ``[(chunk_id, fused, bm25_score, vector_score), ...]``
|
||||
sorted by ``fused`` descending.
|
||||
"""
|
||||
bm25_rank = {cid: i + 1 for i, (cid, _) in enumerate(bm25)}
|
||||
vec_rank = {cid: i + 1 for i, (cid, _) in enumerate(vector)}
|
||||
bm25_scores = {cid: s for cid, s in bm25}
|
||||
vec_scores = {cid: s for cid, s in vector}
|
||||
|
||||
candidates = set(bm25_rank) | set(vec_rank)
|
||||
out: List[Tuple[str, float, float, float]] = []
|
||||
for cid in candidates:
|
||||
fused = 0.0
|
||||
if cid in bm25_rank:
|
||||
fused += self._bm25_weight / (self._rrf_k + bm25_rank[cid])
|
||||
if cid in vec_rank:
|
||||
fused += self._vector_weight / (self._rrf_k + vec_rank[cid])
|
||||
out.append(
|
||||
(cid, fused, bm25_scores.get(cid, 0.0), vec_scores.get(cid, 0.0))
|
||||
)
|
||||
out.sort(key=lambda r: -r[1])
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Thread enrichment
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _thread_context(
|
||||
self, thread_id: str, anchor_chunk_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch sibling chunks for ``thread_id`` (capped at ``thread_context_cap``).
|
||||
|
||||
When the thread is longer than the cap, return a centred window around
|
||||
the anchor so the most relevant chunk is always present.
|
||||
"""
|
||||
if not thread_id:
|
||||
return []
|
||||
rows = self._store._conn.execute(
|
||||
"""
|
||||
SELECT id, chunk_index, content, timestamp, author
|
||||
FROM knowledge_chunks
|
||||
WHERE thread_id = ? AND deleted_at IS NULL
|
||||
ORDER BY timestamp ASC, chunk_index ASC
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return []
|
||||
cap = self._thread_context_cap
|
||||
if len(rows) > cap:
|
||||
anchor_idx = next(
|
||||
(i for i, r in enumerate(rows) if r["id"] == anchor_chunk_id),
|
||||
len(rows) // 2,
|
||||
)
|
||||
half = cap // 2
|
||||
lo = max(0, anchor_idx - half)
|
||||
hi = min(len(rows), lo + cap)
|
||||
lo = max(0, hi - cap)
|
||||
rows = rows[lo:hi]
|
||||
return [
|
||||
{
|
||||
"chunk_idx": int(r["chunk_index"]),
|
||||
"timestamp": r["timestamp"] or "",
|
||||
"author": r["author"] or "",
|
||||
"snippet": _snippet(r["content"], 240),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
person: Optional[str] = None,
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]] = None,
|
||||
sources: Optional[Sequence[str]] = None,
|
||||
limit: int = 20,
|
||||
) -> List[SearchHit]:
|
||||
"""Run the hybrid pipeline and return up to ``limit`` hits.
|
||||
|
||||
See module docstring for ranking semantics. ``query`` may be empty
|
||||
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.
|
||||
"""
|
||||
bm25_filter_sql, bm25_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources, alias="kc"
|
||||
)
|
||||
unaliased_filter_sql, unaliased_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources
|
||||
)
|
||||
|
||||
bm25 = (
|
||||
self._bm25_recall(query, bm25_filter_sql, bm25_filter_params)
|
||||
if query.strip()
|
||||
else []
|
||||
)
|
||||
vector = (
|
||||
self._vector_recall(query, unaliased_filter_sql, unaliased_filter_params)
|
||||
if query.strip()
|
||||
else []
|
||||
)
|
||||
fused = self._fuse(bm25, vector)
|
||||
|
||||
# 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.
|
||||
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]
|
||||
|
||||
# Materialise the top-N rows in one IN-clause round trip.
|
||||
top = fused[:limit]
|
||||
if not top:
|
||||
return []
|
||||
ids = [cid for cid, *_ in top]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
meta_rows = self._store._conn.execute(
|
||||
f"""
|
||||
SELECT id, doc_id, content, source, title, author, participants,
|
||||
timestamp, thread_id, chunk_index, url
|
||||
FROM knowledge_chunks
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
ids,
|
||||
).fetchall()
|
||||
by_id = {r["id"]: r for r in meta_rows}
|
||||
|
||||
hits: List[SearchHit] = []
|
||||
for chunk_id, fused_score, bm25_score, vec_score in top:
|
||||
r = by_id.get(chunk_id)
|
||||
if r is None:
|
||||
continue
|
||||
hits.append(
|
||||
SearchHit(
|
||||
chunk_id=chunk_id,
|
||||
document_id=r["doc_id"],
|
||||
chunk_idx=int(r["chunk_index"]),
|
||||
title=r["title"] or "",
|
||||
content_snippet=_snippet(r["content"]),
|
||||
source=r["source"] or "",
|
||||
timestamp=r["timestamp"] or "",
|
||||
participants=_parse_participants(r["participants"]),
|
||||
score=fused_score,
|
||||
bm25_score=bm25_score,
|
||||
vector_score=vec_score,
|
||||
thread_id=r["thread_id"] or "",
|
||||
thread_context=self._thread_context(r["thread_id"] or "", chunk_id),
|
||||
url=r["url"] or "",
|
||||
)
|
||||
)
|
||||
return hits
|
||||
|
||||
|
||||
__all__ = ["HybridSearch", "SearchHit"]
|
||||
@@ -58,9 +58,12 @@ GOOGLE_ALL_SCOPES: List[str] = [
|
||||
"email",
|
||||
"profile",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
# calendar (not .readonly) so the proactive agent can accept/decline events.
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
# gmail.modify (a superset of gmail.readonly) so the proactive agent
|
||||
# can trash and label-modify (archive) emails after user approval.
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/tasks.readonly",
|
||||
]
|
||||
|
||||
@@ -271,6 +274,62 @@ def delete_tokens(path: str) -> None:
|
||||
p.unlink()
|
||||
|
||||
|
||||
def refresh_google_token(path: str) -> Optional[str]:
|
||||
"""Refresh a Google access token using the stored refresh token.
|
||||
|
||||
Reads the credentials file at *path*, exchanges its ``refresh_token``
|
||||
(plus ``client_id``/``client_secret``) for a new ``access_token``
|
||||
against Google's OAuth token endpoint, persists the refreshed payload
|
||||
back to *path*, and returns the new access token.
|
||||
|
||||
Returns ``None`` if any required field is missing or the refresh call
|
||||
fails (network error or Google returns a non-2xx response — typically
|
||||
``invalid_grant`` when the refresh token has been revoked).
|
||||
"""
|
||||
import httpx
|
||||
|
||||
tokens = load_tokens(path)
|
||||
if not tokens:
|
||||
return None
|
||||
refresh_token = tokens.get("refresh_token")
|
||||
client_id = tokens.get("client_id")
|
||||
client_secret = tokens.get("client_secret")
|
||||
if not (refresh_token and client_id and client_secret):
|
||||
return None
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"refresh_token": refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
if resp.status_code >= 400:
|
||||
return None
|
||||
|
||||
body = resp.json()
|
||||
new_access = body.get("access_token")
|
||||
if not new_access:
|
||||
return None
|
||||
|
||||
tokens.update(
|
||||
{
|
||||
"access_token": new_access,
|
||||
"token": new_access, # legacy key used by some connectors
|
||||
"token_type": body.get("token_type", tokens.get("token_type", "Bearer")),
|
||||
"expires_in": body.get("expires_in", tokens.get("expires_in", 3600)),
|
||||
}
|
||||
)
|
||||
save_tokens(path, tokens)
|
||||
return new_access
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token exchange & full OAuth flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,12 +13,49 @@ Typical usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Iterable, Optional
|
||||
|
||||
from openjarvis.connectors._stubs import Attachment, Document
|
||||
from openjarvis.connectors.chunker import SemanticChunker
|
||||
from openjarvis.connectors.embeddings import OllamaEmbedder
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
|
||||
|
||||
def _namespace_thread_id(source: str, thread_id: Optional[str]) -> Optional[str]:
|
||||
"""Prefix ``thread_id`` with ``{source}:`` so it can't collide across sources.
|
||||
|
||||
Idempotent: if the input already starts with ``{source}:`` it is returned
|
||||
unchanged. Centralised here (rather than per-connector) so a new connector
|
||||
author can't forget to namespace.
|
||||
"""
|
||||
if not thread_id:
|
||||
return None
|
||||
prefix = f"{source}:"
|
||||
if thread_id.startswith(prefix):
|
||||
return thread_id
|
||||
return f"{prefix}{thread_id}"
|
||||
|
||||
|
||||
def _derive_source_id(doc: Document) -> str:
|
||||
"""Return the connector-set ``source_id`` or extract it from ``doc_id``.
|
||||
|
||||
Many existing connectors compose ``doc_id = f"{source}:{native_id}"``;
|
||||
this strips the prefix so storage can index the native ID directly.
|
||||
"""
|
||||
if doc.source_id:
|
||||
return doc.source_id
|
||||
prefix = f"{doc.source}:"
|
||||
if doc.doc_id.startswith(prefix):
|
||||
return doc.doc_id[len(prefix):]
|
||||
return doc.doc_id
|
||||
|
||||
|
||||
def _content_hash(text: str) -> str:
|
||||
"""SHA-256 hex digest of UTF-8-encoded chunk content."""
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.connectors.attachment_store import AttachmentStore
|
||||
|
||||
@@ -36,6 +73,12 @@ class IngestionPipeline:
|
||||
Optional ``AttachmentStore`` for persisting attachment blobs and
|
||||
extracting text from supported MIME types (PDF, plain text, etc.).
|
||||
When ``None`` (default) attachments are silently ignored.
|
||||
embedder:
|
||||
Optional embedding client (e.g. ``OllamaEmbedder``). When provided,
|
||||
every chunk is embedded at ingest time and the resulting float32
|
||||
vector is written to the ``embedding`` BLOB column alongside
|
||||
``embedding_model_version``. ``None`` (default) skips embedding so
|
||||
in-memory tests and offline runs don't depend on a sidecar daemon.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -44,10 +87,12 @@ class IngestionPipeline:
|
||||
*,
|
||||
max_tokens: int = 512,
|
||||
attachment_store: Optional[AttachmentStore] = None,
|
||||
embedder: Optional[OllamaEmbedder] = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._chunker = SemanticChunker(max_tokens=max_tokens)
|
||||
self._attachment_store = attachment_store
|
||||
self._embedder = embedder
|
||||
self._seen_doc_ids: set[str] = set()
|
||||
self._load_existing_doc_ids()
|
||||
|
||||
@@ -62,6 +107,20 @@ class IngestionPipeline:
|
||||
).fetchall()
|
||||
self._seen_doc_ids = {r[0] for r in rows}
|
||||
|
||||
def _embed_chunk(self, content: str) -> tuple[Optional[bytes], str]:
|
||||
"""Return ``(embedding_bytes, model_version)`` for a chunk.
|
||||
|
||||
Returns ``(None, "")`` when no embedder is configured or the embedder
|
||||
fails — ingestion continues with the lexical-only row, so a flaky
|
||||
local daemon never blocks a sync.
|
||||
"""
|
||||
if self._embedder is None:
|
||||
return None, ""
|
||||
emb = self._embedder.embed(content)
|
||||
if emb is None:
|
||||
return None, ""
|
||||
return emb, self._embedder.model_version
|
||||
|
||||
def _extract_attachment_text(self, att: Attachment) -> str:
|
||||
"""Extract text from an attachment.
|
||||
|
||||
@@ -109,15 +168,22 @@ class IngestionPipeline:
|
||||
if doc.doc_id in self._seen_doc_ids:
|
||||
continue
|
||||
|
||||
# Compute v1 provenance fields once per document.
|
||||
namespaced_thread = _namespace_thread_id(doc.source, doc.thread_id)
|
||||
source_id = _derive_source_id(doc)
|
||||
ingest_epoch = time.time()
|
||||
|
||||
# Build the parent metadata dict that will be inherited by every
|
||||
# chunk produced from this document.
|
||||
parent_meta = {
|
||||
"title": doc.title,
|
||||
"author": doc.author,
|
||||
"source": doc.source,
|
||||
"source_id": source_id,
|
||||
"doc_type": doc.doc_type,
|
||||
"url": doc.url or "",
|
||||
"thread_id": doc.thread_id or "",
|
||||
"thread_id": namespaced_thread or "",
|
||||
"channel": doc.channel or "",
|
||||
}
|
||||
# Merge any extra connector-level metadata (without overwriting
|
||||
# the standard provenance fields set above).
|
||||
@@ -137,19 +203,27 @@ class IngestionPipeline:
|
||||
)
|
||||
|
||||
for chunk in chunks:
|
||||
embedding_bytes, embedding_version = self._embed_chunk(chunk.content)
|
||||
self._store.store(
|
||||
content=chunk.content,
|
||||
source=doc.source,
|
||||
source_id=source_id,
|
||||
doc_type=doc.doc_type,
|
||||
doc_id=doc.doc_id,
|
||||
title=doc.title,
|
||||
author=doc.author,
|
||||
participants=doc.participants,
|
||||
participants_raw=doc.participants_raw,
|
||||
timestamp=timestamp_str,
|
||||
thread_id=doc.thread_id,
|
||||
thread_id=namespaced_thread,
|
||||
channel=doc.channel,
|
||||
url=doc.url,
|
||||
metadata=chunk.metadata,
|
||||
chunk_index=chunk.index,
|
||||
content_hash=_content_hash(chunk.content),
|
||||
embedding=embedding_bytes,
|
||||
embedding_model_version=embedding_version,
|
||||
last_synced=ingest_epoch,
|
||||
)
|
||||
chunks_stored += 1
|
||||
|
||||
@@ -179,20 +253,35 @@ class IngestionPipeline:
|
||||
"sha256": sha,
|
||||
},
|
||||
)
|
||||
# Synthetic source_id keeps attachment chunks distinct
|
||||
# from body chunks under the UNIQUE(source, source_id,
|
||||
# chunk_index) constraint while still letting them share
|
||||
# a parent doc_id for dedup and blob linkage.
|
||||
att_source_id = f"{source_id}#{att.filename}"
|
||||
for chunk in att_chunks:
|
||||
embedding_bytes, embedding_version = self._embed_chunk(
|
||||
chunk.content
|
||||
)
|
||||
self._store.store(
|
||||
content=chunk.content,
|
||||
source=doc.source,
|
||||
source_id=att_source_id,
|
||||
doc_type=doc.doc_type,
|
||||
doc_id=doc.doc_id,
|
||||
title=f"{doc.title} [{att.filename}]",
|
||||
author=doc.author,
|
||||
participants=doc.participants,
|
||||
participants_raw=doc.participants_raw,
|
||||
timestamp=timestamp_str,
|
||||
thread_id=doc.thread_id,
|
||||
thread_id=namespaced_thread,
|
||||
channel=doc.channel,
|
||||
url=doc.url,
|
||||
metadata=chunk.metadata,
|
||||
chunk_index=chunk.index,
|
||||
content_hash=_content_hash(chunk.content),
|
||||
embedding=embedding_bytes,
|
||||
embedding_model_version=embedding_version,
|
||||
last_synced=ingest_epoch,
|
||||
)
|
||||
chunks_stored += 1
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Slack connector — bulk channel message sync via the Slack Web API.
|
||||
|
||||
Uses OAuth tokens stored locally (see :mod:`openjarvis.connectors.oauth`).
|
||||
All network calls are isolated in module-level functions (``_slack_api_*``)
|
||||
to make them trivially mockable in tests.
|
||||
Uses a Slack **user** OAuth token (``xoxp-...``) stored locally so the
|
||||
sync sees everything the user can see — including their 1:1 DMs and
|
||||
multi-person DMs with other humans. A bot token (``xoxb-``) cannot see
|
||||
user-to-user DMs (Slack platform constraint), so this connector
|
||||
explicitly rejects bot tokens with a clear error at connect time.
|
||||
|
||||
All network calls are isolated in module-level functions
|
||||
(``_slack_api_*``) to make them trivially mockable in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
@@ -19,13 +25,18 @@ from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
from openjarvis.tools._stubs import ToolSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SLACK_API_BASE = "https://slack.com/api"
|
||||
_SLACK_AUTH_ENDPOINT = "https://slack.com/oauth/v2/authorize"
|
||||
_SLACK_SCOPES = (
|
||||
# User Token Scopes — Slack distinguishes user_scope (xoxp-) from scope
|
||||
# (xoxb-) on the OAuth authorize URL. We only request user scopes; passed
|
||||
# via ``user_scope`` so the install grants a User OAuth Token.
|
||||
_SLACK_USER_SCOPES = (
|
||||
"channels:read,channels:history,groups:read,groups:history,"
|
||||
"im:read,im:history,mpim:read,mpim:history,users:read"
|
||||
)
|
||||
@@ -55,8 +66,12 @@ def _slack_api_conversations_list(
|
||||
dict
|
||||
Raw API response containing ``channels`` list and ``response_metadata``.
|
||||
"""
|
||||
# Include every conversation type the bot can list — public + private
|
||||
# channels, multi-person DMs, and 1:1 DMs — so a "connect and sync"
|
||||
# flow indexes everything the token has access to without the user
|
||||
# picking channels (matches Gmail's connect-and-go behavior).
|
||||
params: Dict[str, str] = {
|
||||
"types": "public_channel,private_channel",
|
||||
"types": "public_channel,private_channel,mpim,im",
|
||||
"exclude_archived": "true",
|
||||
}
|
||||
if cursor:
|
||||
@@ -110,6 +125,16 @@ def _slack_api_users_list(token: str) -> Dict[str, Any]:
|
||||
return _slack_api_with_retry("users.list", token)
|
||||
|
||||
|
||||
def _slack_api_auth_test(token: str) -> Dict[str, Any]:
|
||||
"""Call the Slack ``auth.test`` endpoint.
|
||||
|
||||
Returns workspace context (``team_id``, ``team``, ``url``) used to
|
||||
construct message permalinks — the workspace subdomain isn't carried
|
||||
by any other endpoint we already call.
|
||||
"""
|
||||
return _slack_api_with_retry("auth.test", token)
|
||||
|
||||
|
||||
def _slack_api_with_retry(
|
||||
method: str,
|
||||
token: str,
|
||||
@@ -155,6 +180,41 @@ def _slack_api_with_retry(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SlackTokenError(ValueError):
|
||||
"""Raised when the stored Slack token isn't a usable user token.
|
||||
|
||||
Surfaced via :class:`SyncStatus.error` so the UI can render the actual
|
||||
reason (e.g. ``xoxb-`` bot token rejected) instead of an empty sync.
|
||||
"""
|
||||
|
||||
|
||||
_USER_TOKEN_PREFIX = "xoxp-"
|
||||
_BOT_TOKEN_PREFIX = "xoxb-"
|
||||
|
||||
|
||||
def _validate_user_token(token: str) -> None:
|
||||
"""Raise :class:`SlackTokenError` unless *token* looks like a user token.
|
||||
|
||||
Slack user tokens start with ``xoxp-``. We refuse ``xoxb-`` bot tokens
|
||||
explicitly because a bot can only see DMs *to/from itself* — paste a
|
||||
bot token and your sync silently misses every human-to-human DM. The
|
||||
error message tells the user exactly which token type to provide and
|
||||
where to find it.
|
||||
"""
|
||||
if not token:
|
||||
raise SlackTokenError("Slack token is empty.")
|
||||
if token.startswith(_BOT_TOKEN_PREFIX):
|
||||
raise SlackTokenError(
|
||||
"Bot tokens (xoxb-) can't read DMs. "
|
||||
"Use a User OAuth Token (xoxp-) instead."
|
||||
)
|
||||
if not token.startswith(_USER_TOKEN_PREFIX):
|
||||
raise SlackTokenError(
|
||||
"Invalid token format. Expected a Slack User OAuth Token "
|
||||
"starting with xoxp-"
|
||||
)
|
||||
|
||||
|
||||
def _build_user_map(members: List[Dict[str, Any]]) -> Dict[str, Dict[str, str]]:
|
||||
"""Build a user_id → {name, email} map from a ``users.list`` members list."""
|
||||
user_map: Dict[str, Dict[str, str]] = {}
|
||||
@@ -180,9 +240,32 @@ def _ts_to_datetime(ts: str) -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _slack_archive_url(team_id: str, channel_id: str, ts: str) -> str:
|
||||
"""Build a Slack message archive URL from team, channel, and timestamp."""
|
||||
def _team_domain_from_auth(auth_resp: Dict[str, Any]) -> str:
|
||||
"""Derive the workspace subdomain ('acme' from 'https://acme.slack.com/').
|
||||
|
||||
Falls back to the ``team_id`` so doc_ids stay non-empty when the
|
||||
workspace ``url`` is missing — losing the workspace breaks permalinks
|
||||
but lets ingestion continue.
|
||||
"""
|
||||
workspace_url: str = (auth_resp.get("url") or "").rstrip("/")
|
||||
if workspace_url:
|
||||
host = workspace_url.split("//", 1)[-1].split("/", 1)[0]
|
||||
suffix = ".slack.com"
|
||||
if host.endswith(suffix):
|
||||
return host[: -len(suffix)]
|
||||
return auth_resp.get("team_id", "") or ""
|
||||
|
||||
|
||||
def _slack_archive_url(team_domain: str, channel_id: str, ts: str) -> str:
|
||||
"""Build a Slack message permalink for ``team_domain``/``channel``/``ts``.
|
||||
|
||||
With a workspace subdomain the link resolves directly; without one we
|
||||
fall back to ``slack.com/archives/...`` which Slack redirects only for
|
||||
logged-in members of that workspace.
|
||||
"""
|
||||
ts_clean = ts.replace(".", "")
|
||||
if team_domain:
|
||||
return f"https://{team_domain}.slack.com/archives/{channel_id}/p{ts_clean}"
|
||||
return f"https://slack.com/archives/{channel_id}/p{ts_clean}"
|
||||
|
||||
|
||||
@@ -215,6 +298,10 @@ class SlackConnector(BaseConnector):
|
||||
self._items_total: int = 0
|
||||
self._last_sync: Optional[datetime] = None
|
||||
self._last_cursor: Optional[str] = None
|
||||
# Surfaced via sync_status().error so the UI can render the actual
|
||||
# failure reason (typically a bot-token-rejection) instead of a
|
||||
# silent "synced 0 messages".
|
||||
self._last_error: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseConnector interface
|
||||
@@ -232,21 +319,51 @@ class SlackConnector(BaseConnector):
|
||||
delete_tokens(self._credentials_path)
|
||||
|
||||
def auth_url(self) -> str:
|
||||
"""Return a Slack OAuth consent URL requesting channel history scopes."""
|
||||
"""Return a Slack OAuth consent URL requesting user-token scopes.
|
||||
|
||||
Uses ``user_scope`` (not ``scope``) so the install grants a User
|
||||
OAuth Token (``xoxp-``) — bot tokens (``xoxb-``) can't see human-
|
||||
to-human DMs and are rejected by :func:`handle_callback`.
|
||||
"""
|
||||
params = {
|
||||
"client_id": "", # placeholder — real client_id from config
|
||||
"scope": _SLACK_SCOPES,
|
||||
"user_scope": _SLACK_USER_SCOPES,
|
||||
"redirect_uri": "http://localhost:8789/callback",
|
||||
}
|
||||
return f"{_SLACK_AUTH_ENDPOINT}?{urlencode(params)}"
|
||||
|
||||
def handle_callback(self, code: str) -> None:
|
||||
"""Handle the OAuth callback by persisting the authorization code.
|
||||
"""Validate and persist a supplied User OAuth Token.
|
||||
|
||||
In a full implementation this would exchange the code for tokens.
|
||||
For now the code is saved directly as the token value.
|
||||
The connector ``/connect`` endpoint funnels manually-pasted tokens
|
||||
through this method (the parameter is named ``code`` for OAuth-flow
|
||||
compatibility). The token is checked two ways before it is allowed
|
||||
to touch disk, so an invalid credential never overwrites a working
|
||||
one:
|
||||
|
||||
1. **Shape** — must start with ``xoxp-`` (``xoxb-`` bot tokens and
|
||||
any other prefix are rejected via :func:`_validate_user_token`).
|
||||
2. **Liveness** — a live ``auth.test`` call must return ``ok`` so an
|
||||
expired or revoked token is caught at connect time.
|
||||
"""
|
||||
_validate_user_token(code)
|
||||
|
||||
# Verify the token actually works against Slack before persisting.
|
||||
try:
|
||||
auth_resp = _slack_api_auth_test(code)
|
||||
except Exception as exc: # noqa: BLE001 — surface as a token error
|
||||
raise SlackTokenError(
|
||||
f"Could not verify the Slack token (auth.test failed: {exc})."
|
||||
) from exc
|
||||
if not auth_resp.get("ok", False):
|
||||
err = str(auth_resp.get("error", "auth_failed"))
|
||||
raise SlackTokenError(
|
||||
f"Slack rejected the token (auth.test: {err}). "
|
||||
"Check that it is a current User OAuth Token."
|
||||
)
|
||||
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
self._last_error = None
|
||||
|
||||
def sync(
|
||||
self,
|
||||
@@ -254,10 +371,13 @@ class SlackConnector(BaseConnector):
|
||||
since: Optional[datetime] = None, # noqa: ARG002 — reserved for future use
|
||||
cursor: Optional[str] = None, # noqa: ARG002 — reserved for future use
|
||||
) -> Iterator[Document]:
|
||||
"""Yield :class:`Document` objects for Slack channel messages.
|
||||
"""Yield :class:`Document` objects for every accessible Slack message.
|
||||
|
||||
Builds a user map, then paginates through channels and retrieves
|
||||
message history for each channel.
|
||||
With a user OAuth token (``xoxp-``) the listing returned by
|
||||
``conversations.list`` already reflects what the user can see —
|
||||
no ``conversations.join`` step, no membership filtering. We
|
||||
enumerate every conversation first (so the per-type count can
|
||||
be logged up front), then stream history for each one.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -274,118 +394,225 @@ class SlackConnector(BaseConnector):
|
||||
if not token:
|
||||
return
|
||||
|
||||
# Step 1: build user map
|
||||
# Reject bot tokens up front so the user sees the actual reason
|
||||
# for an empty sync instead of every API call coming back
|
||||
# missing_scope / 0 messages.
|
||||
try:
|
||||
_validate_user_token(token)
|
||||
except SlackTokenError as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.warning("Slack sync rejected token: %s", exc)
|
||||
return
|
||||
|
||||
# Step 0: resolve workspace context — the subdomain is needed for
|
||||
# message permalinks and is the only piece of state that doesn't
|
||||
# come back from conversations.history. Done once per sync.
|
||||
try:
|
||||
auth_resp = _slack_api_auth_test(token)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Slack auth.test failed: %s", exc)
|
||||
auth_resp = {}
|
||||
if not auth_resp.get("ok", True):
|
||||
err = str(auth_resp.get("error", "auth_failed"))
|
||||
self._last_error = f"Slack auth.test failed: {err}"
|
||||
logger.warning("Slack auth.test returned not-ok: %s", err)
|
||||
return
|
||||
|
||||
team_domain: str = _team_domain_from_auth(auth_resp)
|
||||
team_id: str = auth_resp.get("team_id", "") or ""
|
||||
workspace_name: str = auth_resp.get("team", "") or ""
|
||||
workspace_url: str = auth_resp.get("url", "") or ""
|
||||
|
||||
# Step 1: build user map (so DMs render with peer names, not IDs)
|
||||
users_resp = _slack_api_users_list(token)
|
||||
members: List[Dict[str, Any]] = users_resp.get("members", [])
|
||||
user_map = _build_user_map(members)
|
||||
|
||||
synced = 0
|
||||
# Step 2: enumerate every conversation up front so we can log a
|
||||
# per-type summary before fetching history. Pagination over
|
||||
# conversations.list is cheap relative to history fetch and gives
|
||||
# the user (and the logs) immediate signal about what the token
|
||||
# can actually see.
|
||||
all_channels: List[Dict[str, Any]] = []
|
||||
channels_cursor = ""
|
||||
|
||||
# Step 2: paginate through channels
|
||||
while True:
|
||||
channels_resp = _slack_api_conversations_list(token, cursor=channels_cursor)
|
||||
channels: List[Dict[str, Any]] = channels_resp.get("channels", [])
|
||||
channels_resp = _slack_api_conversations_list(
|
||||
token, cursor=channels_cursor
|
||||
)
|
||||
if not channels_resp.get("ok", True):
|
||||
err = str(channels_resp.get("error", "list_failed"))
|
||||
self._last_error = f"Slack conversations.list failed: {err}"
|
||||
logger.warning("Slack conversations.list returned not-ok: %s", err)
|
||||
return
|
||||
all_channels.extend(channels_resp.get("channels", []))
|
||||
channels_cursor = (
|
||||
channels_resp.get("response_metadata", {}).get("next_cursor", "")
|
||||
or ""
|
||||
)
|
||||
if not channels_cursor:
|
||||
break
|
||||
|
||||
for channel in channels:
|
||||
chan_id: str = channel.get("id", "")
|
||||
chan_name: str = channel.get("name", chan_id)
|
||||
is_member: bool = channel.get("is_member", False)
|
||||
is_private: bool = channel.get("is_private", False)
|
||||
if not chan_id:
|
||||
continue
|
||||
counts = {"public_channel": 0, "private_channel": 0, "im": 0, "mpim": 0}
|
||||
for c in all_channels:
|
||||
if c.get("is_im"):
|
||||
counts["im"] += 1
|
||||
elif c.get("is_mpim"):
|
||||
counts["mpim"] += 1
|
||||
elif c.get("is_private"):
|
||||
counts["private_channel"] += 1
|
||||
else:
|
||||
counts["public_channel"] += 1
|
||||
logger.info(
|
||||
"Slack: Found %d public channels, %d private channels, "
|
||||
"%d DMs, %d group DMs",
|
||||
counts["public_channel"],
|
||||
counts["private_channel"],
|
||||
counts["im"],
|
||||
counts["mpim"],
|
||||
)
|
||||
|
||||
# Auto-join public channels; skip private channels the bot isn't in
|
||||
if not is_member:
|
||||
if is_private:
|
||||
continue # Can't join private channels without invite
|
||||
# Try to join the public channel
|
||||
try:
|
||||
join_resp = _slack_api_with_retry(
|
||||
"conversations.join",
|
||||
token,
|
||||
{"channel": chan_id},
|
||||
http_method="POST",
|
||||
)
|
||||
if not join_resp.get("ok"):
|
||||
continue
|
||||
except Exception:
|
||||
# Step 3: fetch history per channel and yield Documents.
|
||||
synced = 0
|
||||
for channel in all_channels:
|
||||
chan_id: str = channel.get("id", "")
|
||||
is_private: bool = channel.get("is_private", False)
|
||||
is_im: bool = channel.get("is_im", False)
|
||||
is_mpim: bool = channel.get("is_mpim", False)
|
||||
if not chan_id:
|
||||
continue
|
||||
|
||||
# Display name: IMs have no ``name`` field — render as
|
||||
# ``dm-<peer-name>`` so result chips show something readable.
|
||||
raw_name: str = channel.get("name", "") or ""
|
||||
if is_im:
|
||||
peer_id: str = channel.get("user", "") or ""
|
||||
peer_info = user_map.get(peer_id, {})
|
||||
peer_label = peer_info.get("name") or peer_id or "user"
|
||||
chan_name = f"dm-{peer_label}"
|
||||
else:
|
||||
chan_name = raw_name or chan_id
|
||||
|
||||
channel_type = (
|
||||
"im"
|
||||
if is_im
|
||||
else "mpim"
|
||||
if is_mpim
|
||||
else "private_channel"
|
||||
if is_private
|
||||
else "public_channel"
|
||||
)
|
||||
|
||||
history_cursor = ""
|
||||
while True:
|
||||
try:
|
||||
history_resp = _slack_api_conversations_history(
|
||||
token, chan_id, cursor=history_cursor
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(
|
||||
"Slack history fetch failed for %s (%s): %s",
|
||||
chan_name,
|
||||
chan_id,
|
||||
exc,
|
||||
)
|
||||
break
|
||||
if not history_resp.get("ok", True):
|
||||
# User token shouldn't hit not_in_channel, but if a
|
||||
# scope was revoked mid-sync we skip the channel rather
|
||||
# than abort the whole sync.
|
||||
logger.debug(
|
||||
"Slack history not-ok for %s (%s): %s",
|
||||
chan_name,
|
||||
chan_id,
|
||||
history_resp.get("error"),
|
||||
)
|
||||
break
|
||||
messages: List[Dict[str, Any]] = history_resp.get("messages", [])
|
||||
|
||||
for msg in messages:
|
||||
# Skip bot messages and non-content subtypes
|
||||
if msg.get("bot_id") or msg.get("subtype") in (
|
||||
"message_changed",
|
||||
"message_deleted",
|
||||
"bot_message",
|
||||
"channel_join",
|
||||
"channel_leave",
|
||||
):
|
||||
continue
|
||||
|
||||
# Step 3: paginate through message history
|
||||
history_cursor = ""
|
||||
while True:
|
||||
try:
|
||||
history_resp = _slack_api_conversations_history(
|
||||
token, chan_id, cursor=history_cursor
|
||||
)
|
||||
except Exception:
|
||||
break # Skip channels we can't read
|
||||
if not history_resp.get("ok", True):
|
||||
break # not_in_channel or other error
|
||||
messages: List[Dict[str, Any]] = history_resp.get("messages", [])
|
||||
ts: str = msg.get("ts", "")
|
||||
user_id: str = msg.get("user", "")
|
||||
text: str = msg.get("text", "")
|
||||
thread_ts: Optional[str] = msg.get("thread_ts")
|
||||
|
||||
for msg in messages:
|
||||
# Skip bot messages and non-content subtypes
|
||||
if msg.get("bot_id") or msg.get("subtype") in (
|
||||
"message_changed",
|
||||
"message_deleted",
|
||||
"bot_message",
|
||||
"channel_join",
|
||||
"channel_leave",
|
||||
):
|
||||
continue
|
||||
user_info = user_map.get(user_id, {})
|
||||
author_name: str = user_info.get("name", user_id)
|
||||
author_email: str = user_info.get("email", "")
|
||||
|
||||
ts: str = msg.get("ts", "")
|
||||
user_id: str = msg.get("user", "")
|
||||
text: str = msg.get("text", "")
|
||||
thread_ts: Optional[str] = msg.get("thread_ts")
|
||||
timestamp = _ts_to_datetime(ts)
|
||||
url = _slack_archive_url(team_domain, chan_id, ts)
|
||||
|
||||
user_info = user_map.get(user_id, {})
|
||||
author = user_info.get("name", user_id)
|
||||
# v1 schema participants: lowercase email when we have
|
||||
# one, else the display name — matches the Gmail
|
||||
# connector's contract (one identity per participant)
|
||||
# so cross-source queries work.
|
||||
canonical = (author_email or author_name).lower()
|
||||
participants = [canonical] if canonical else []
|
||||
participants_raw = [user_id] if user_id else []
|
||||
|
||||
timestamp = _ts_to_datetime(ts)
|
||||
url = _slack_archive_url("", chan_id, ts)
|
||||
# Encode workspace into doc_id so research_loop can
|
||||
# rebuild a workspace-qualified permalink from
|
||||
# source + document_id alone.
|
||||
doc_id = f"slack:{team_domain}:{chan_id}:{ts}"
|
||||
|
||||
doc = Document(
|
||||
doc_id=f"slack:{chan_id}:{ts}",
|
||||
source="slack",
|
||||
doc_type="message",
|
||||
content=text,
|
||||
title=f"#{chan_name}",
|
||||
author=author,
|
||||
timestamp=timestamp,
|
||||
thread_id=thread_ts,
|
||||
url=url,
|
||||
metadata={
|
||||
"channel_id": chan_id,
|
||||
"channel_name": chan_name,
|
||||
"user_id": user_id,
|
||||
"ts": ts,
|
||||
},
|
||||
)
|
||||
synced += 1
|
||||
yield doc
|
||||
# Channel rows get ``#name``; DM rows get
|
||||
# ``DM with <peer>`` (more useful than ``#dm-alice``
|
||||
# in result chips).
|
||||
if is_im:
|
||||
title = f"DM with {chan_name.removeprefix('dm-')}"
|
||||
else:
|
||||
title = f"#{chan_name}"
|
||||
|
||||
next_history_cursor: str = (
|
||||
history_resp.get("response_metadata", {}).get("next_cursor", "")
|
||||
or ""
|
||||
doc = Document(
|
||||
doc_id=doc_id,
|
||||
source="slack",
|
||||
doc_type="message",
|
||||
content=text,
|
||||
title=title,
|
||||
author=author_email or author_name,
|
||||
participants=participants,
|
||||
participants_raw=participants_raw,
|
||||
timestamp=timestamp,
|
||||
thread_id=thread_ts,
|
||||
channel=chan_name,
|
||||
url=url,
|
||||
metadata={
|
||||
"channel_id": chan_id,
|
||||
"channel_name": chan_name,
|
||||
"channel_type": channel_type,
|
||||
"user_id": user_id,
|
||||
"ts": ts,
|
||||
"team_id": team_id,
|
||||
"team_domain": team_domain,
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_url": workspace_url,
|
||||
},
|
||||
)
|
||||
if not history_resp.get("has_more") or not next_history_cursor:
|
||||
break
|
||||
history_cursor = next_history_cursor
|
||||
synced += 1
|
||||
yield doc
|
||||
|
||||
next_channels_cursor: str = (
|
||||
channels_resp.get("response_metadata", {}).get("next_cursor", "") or ""
|
||||
)
|
||||
if not next_channels_cursor:
|
||||
self._last_cursor = None
|
||||
break
|
||||
channels_cursor = next_channels_cursor
|
||||
self._last_cursor = channels_cursor
|
||||
next_history_cursor: str = (
|
||||
history_resp.get("response_metadata", {}).get("next_cursor", "")
|
||||
or ""
|
||||
)
|
||||
if not history_resp.get("has_more") or not next_history_cursor:
|
||||
break
|
||||
history_cursor = next_history_cursor
|
||||
|
||||
self._items_synced = synced
|
||||
self._last_sync = datetime.now()
|
||||
self._last_cursor = None
|
||||
self._last_error = None
|
||||
|
||||
def sync_status(self) -> SyncStatus:
|
||||
"""Return sync progress from the most recent :meth:`sync` call."""
|
||||
@@ -394,6 +621,7 @@ class SlackConnector(BaseConnector):
|
||||
items_synced=self._items_synced,
|
||||
last_sync=self._last_sync,
|
||||
cursor=self._last_cursor,
|
||||
error=self._last_error,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user