Compare commits

...
72 Commits
Author SHA1 Message Date
Jon Saad-Falcon 7e8db280e4 Merge pull request #413 from open-jarvis/ci/desktop-stable-updater-channel
ci(desktop): stable (desktop-latest) + edge (desktop-edge) updater channels
2026-05-25 16:41:08 -07:00
krypticmouseandClaude Opus 4.7 e3f2b008d2 ci(desktop): split updater into stable (desktop-latest) + edge (desktop-edge) channels
The installed desktop app polls `desktop-latest/latest.json`. Previously
every push to `main` (autotag -> v*.devN -> desktop.yml dispatch) rebuilt
and republished `desktop-latest` as a DEV prerelease, so stable users were
auto-updated onto unvetted dev builds, and any manual stable mirror was
clobbered on the next merge.

Split the streams so the app's channel only ever serves vetted stable:

- Dev/rolling builds (v* autotag + manual workflow_dispatch) now publish to
  a new `desktop-edge` pre-release. The shipped app does not poll edge, so
  dev builds never auto-install onto stable users.
- Stable `desktop-v*` builds publish the user-facing release as before, then
  a new `refresh-stable-channel` job copies that release's signed
  `latest.json` into `desktop-latest` (mirror; URLs already point at the
  desktop-v* assets). Cut a `desktop-v*` tag to ship an update.
- `clean-release` now targets `desktop-edge`; `desktop-latest` is never
  wiped by CI.

No app/tauri.conf.json change — the updater endpoint stays `desktop-latest`.
Doc updated to describe the now-implemented stable/edge split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:36:17 +00:00
Jon Saad-Falcon cdae426d48 docs: fix broken desktop download links → stable desktop-v1.0.2 release (#412)
Repoints all desktop download links (README, docs/index.md, downloads.md, getting-started/installation.md) from the removed desktop-latest prerelease (with wrong 0.1.0 filenames) to the stable desktop-v1.0.2 release. All 5 URLs verified live (200). macOS .dmg now included (addresses #356).
2026-05-25 16:20:33 -07:00
krypticmouseandClaude Opus 4.7 ae9727599b docs: point desktop download links at the stable desktop-v1.0.2 release
Every desktop download link in the docs was broken on two counts:
1. They pointed at the rolling `desktop-latest` prerelease, which had
   been removed (404 for all of README, docs/index.md,
   docs/downloads.md, docs/getting-started/installation.md).
2. They used the wrong version in the filenames (`OpenJarvis_0.1.0_*`)
   — the actual published assets were never `0.1.0`.

Repointed all four files at the new stable `Desktop desktop-v1.0.2`
release with the exact asset filenames it ships
(`OpenJarvis_1.0.1_*` — the Tauri bundle version is 1.0.1, distinct
from the 1.0.2 Python/CLI release). This release also includes a
macOS universal `.dmg`, which the prior desktop releases lacked
(addresses #356 "No Mac Download") — so the macOS rows now say
"Universal" (Apple Silicon + Intel) instead of "Apple Silicon".

All five download URLs verified live (HTTP 200) against the
desktop-v1.0.2 release before committing.

Note: `docs/desktop-auto-update.md` still references the
`desktop-latest` rolling channel — that's the auto-updater's endpoint,
a separate concern from the manual download links, and is left as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:18:33 +00:00
Robby Manihani a86d022947 fix(agents): repair continuous monitor_operative agent (parrot loop, tool calling, traces) (#407) 2026-05-25 11:08:14 -07:00
Robby Manihani 8e6bc343d8 fix(connectors): validate credentials before persisting + populate Gmail URLs (#410) 2026-05-25 11:07:35 -07:00
Jon Saad-Falcon 56c9a59f8d release: v1.0.2 — fix broken PyPI wheel (#372) + pynvml/Windows/install fixes (#411)
Bumps 1.0.1 → 1.0.2 so the merged fixes (#372 wheel packaging, #389 pynvml, #373 Windows RAM, #331 desktop diagnostics, #337/#352 install URL) can ship to PyPI. See PR #411.
2026-05-25 10:51:56 -07:00
krypticmouseandClaude Opus 4.7 bed3524b62 release: v1.0.2
Patch release bundling the fixes merged since v1.0.1. The headline is
#372 — the v1.0.1 wheel on PyPI is missing `openjarvis/traces/`, so
every `pip install openjarvis==1.0.1` breaks at import. PyPI filenames
are immutable, so the fix has to ship under a new version number.

Bumps version 1.0.1 → 1.0.2 and adds the CHANGELOG entry covering
#372 (wheel packaging), #389 (pynvml warning), #373 (Windows RAM),
#331 (desktop uv-sync diagnostics), and #337/#352 (install URL → GitHub
Pages).

After this merges, cut the release:
  uv build
  unzip -l dist/openjarvis-1.0.2-*.whl | grep traces   # verify
  twine upload dist/openjarvis-1.0.2-*

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:49:40 +00:00
github-actions[bot] eb5d1e467f chore: update clone traffic data [skip ci] 2026-05-25 07:37:54 +00:00
Jon Saad-Falcon b43f4d2291 refactor(desktop): extract testable uv-sync error helpers + unit tests (#331) (#406)
Extracts the #331 uv-sync error-formatting logic into pure functions with 7 unit tests, now run via cargo test in desktop.yml's validate job. Verified: all 7 pass on the real Tauri crate in CI.
2026-05-24 08:31:54 -07:00
krypticmouseandClaude Opus 4.7 658effb964 refactor(desktop): extract testable uv-sync error helpers + unit tests (#331)
The uv-sync failure handling added in #398 was inline in the async
`boot_backend` GUI path, so its logic — exit-code rendering and the
stderr "tail" extraction — could only be verified by running the
desktop app. This extracts the pure logic into three free functions
and adds unit tests, so the message formatting is now covered by
`cargo test` with no GUI / webview runtime needed.

Extracted:
- `uv_sync_stderr_tail(stderr, max_chars)` — last N chars of stderr,
  trimmed, on char boundaries. The original inline version used
  `.rev().take(N).collect().chars().rev().collect()`; the new version
  is `skip(count - N)` which is clearer and equally UTF-8-safe (matters
  because Windows consoles emit non-ASCII cp9xx bytes — a byte slice
  could split a codepoint).
- `format_uv_sync_failure(root, exit_code, stderr)` — the non-zero-exit
  message. Now renders a missing exit code (signal-terminated process)
  as "unknown" instead of a misleading "-1".
- `format_uv_sync_spawn_error(root, uv_bin, err)` — the can't-spawn
  message.

`boot_backend` now calls these instead of formatting inline.

Tests (7, in `#[cfg(test)] mod tests`):
- tail returns whole string when shorter than the limit
- tail keeps the END (the actionable line), not the spinner-noise start
- tail trims surrounding whitespace
- tail never splits a multi-byte codepoint (500×"é", limit 100 → exactly
  100 chars, all "é")
- failure message includes exit code + stderr tail + the actionable
  "run uv sync manually" hint
- missing exit code renders as "exit unknown", never "exit -1"
- spawn-error names the uv binary path and repo root

Verified the logic standalone via `rustc --test` (7/7 pass). In CI they
run via `cargo test` in desktop.yml's `validate` job, which already
builds the Tauri crate with the webkit deps and runs on every PR that
touches `frontend/**` — so this changes the existing `cargo check` step
to `cargo test` (a superset: same build coverage, plus the tests).

This doesn't verify the GUI *behavior* (that still needs a human running
the app, or the windows-latest empirical path) — but the error-message
logic that was previously untestable now has automated coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:26:07 +00:00
Jon Saad-Falcon 0c34817ecd ci: add windows-latest job to empirically verify Windows code paths (#405)
Adds a windows-latest CI job that executes the real GlobalMemoryStatusEx RAM path (#373), the #293 stdout reconfigure test, and builds+imports the openjarvis_rust PyO3 extension on Windows. Verified green on a real Windows runner (4m1s, all steps success).
2026-05-24 08:08:49 -07:00
krypticmouseandClaude Opus 4.7 c06633e26f ci: add windows-latest job to empirically verify Windows code paths
The `test` job runs only on ubuntu-latest, so the Windows-specific
branches added for #373 (RAM detection via GlobalMemoryStatusEx) and
#293 (cp9xx → UTF-8 stdout reconfigure) were never *executed* in CI —
only unit-tested with mocks on Linux. `tests/hardware/test_hardware_profiles.py::test_total_ram_gb_windows`
has existed all along but is `skipif(sys.platform != "win32")`, so it
silently skipped on every run.

This adds a `test-windows` job that runs on a real Windows runner
(free for public repos) and:

1. Verifies `_total_ram_gb()` returns > 0 on actual Windows — executes
   the real `ctypes.windll.kernel32.GlobalMemoryStatusEx` path (#373).
   Runs as a pure-Python step BEFORE any Rust build, so a flaky
   toolchain install can't mask the result.
2. Runs `tests/hardware/test_hardware_profiles.py` (the now-unskipped
   Windows RAM test) and `tests/cli/test_cli.py` (the
   `test_windows_reconfigures_stdout_to_utf8` test from #293).
3. Builds + imports the `openjarvis_rust` PyO3 extension on Windows —
   the only CI job that does so. The extension is mandatory at runtime
   (`_rust_bridge.py` hard-errors without it), yet nothing else
   verified it compiles/imports on Windows. desktop.yml builds the
   Tauri app's Rust, not this extension.
4. Smoke-tests `jarvis --version`.

Scoped to the platform-relevant test files (not the full 6700-test
suite) so the job stays fast; the slow part is the Rust build, which
doubles as Windows-extension-build coverage.

All `run:` steps are static commands with no `github.event.*`
interpolation — no workflow-injection surface.

Note: #331 (desktop "did not become healthy" — uv sync error
surfacing) is GUI-triggered Tauri boot logic and isn't covered here;
its only automatable surface is string formatting, and testing it
needs the heavy webview build. Left as a possible follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:03:11 +00:00
github-actions[bot] deb0dd509c chore: update clone traffic data [skip ci] 2026-05-24 07:09:11 +00:00
Jon Saad-Falcon a76319841a fix(install): serve install.sh from GitHub Pages as canonical URL (#402)
Resolves the openjarvis.ai SSL failure (#337, #352) by serving the installer from the project-controlled GitHub Pages site. Adds uv prerequisite docs for the Windows desktop path. See PR #402 for the full breakdown and the openjarvis.ai CNAME migration note.
2026-05-23 12:10:00 -07:00
krypticmouseandClaude Opus 4.7 cf8d3e2cbe fix(install): serve install.sh from GitHub Pages, make it the canonical URL
The documented install command pointed at `https://openjarvis.ai/install.sh`,
but that domain is community-operated (not controlled by this project) and
its TLS config broke — every new user hit `sslv3 alert handshake failure`
(#337, #352). Since we can't fix a domain we don't control, this moves the
installer onto infrastructure we DO control: the project's GitHub Pages
docs site.

Changes:

- **`docs/gen_install_script.py`** (new) + **`mkdocs.yml`**: a `gen-files`
  hook copies `scripts/install/install.sh` verbatim into the built site at
  `install.sh` on every `mkdocs build`. Single source of truth — the script
  stays at `scripts/install/install.sh` (still bundled into the wheel as
  `_install_scripts/`); the published copy can't drift. Verified locally:
  `mkdocs build` emits `site/install.sh` byte-identical to the source.

  New canonical URL: `https://open-jarvis.github.io/OpenJarvis/install.sh`
  — HTTPS always valid (GitHub's cert), fully under project control.

- **`README.md`**: canonical command switched to the github.io URL for
  both the Installation and Quick Start blocks. Also addresses the uv
  discoverability gap — explicitly states the curl installer downloads uv
  for you (no prerequisite), and that the Windows **desktop .exe** expects
  uv to be installed first, with the exact PowerShell command.

- **`docs/getting-started/{install,wsl2,macos,linux}.md`**: canonical URL
  switched to github.io. `install.md` gains an "Install URL" info note
  explaining the github.io URL is canonical and that the older
  `openjarvis.ai` URL is community-operated with intermittent TLS issues.

- **`scripts/install/install.sh`** + **`jarvis-wrapper.sh`**: usage comment,
  the WSL re-run hint (added in #399), and the wrapper's re-install message
  all updated to the github.io URL.

Migration note for maintainers: ask whoever operates `openjarvis.ai` to
CNAME it to `open-jarvis.github.io`. Once they do, `openjarvis.ai/install.sh`
will serve this same GitHub Pages content with a valid GitHub-managed cert,
and the nicer brand URL can become canonical again with zero further code
changes. Until then, the github.io URL works and is under our control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:05:26 +00:00
Jon Saad-Falcon bced6cc274 fix(install): Windows discoverability + Git Bash early bail + concrete uv install command (#399)
Follow-up to PR #398. Three surface fixes for the Windows install confusion documented in two Discord support threads. See PR #399 for the full breakdown.
2026-05-23 08:54:10 -07:00
krypticmouseandClaude Opus 4.7 612d3e1f88 fix(install): make Windows install path discoverable + bail early on Git Bash
Addresses the second half of the Discord support thread on the
"Jarvis server did not become healthy in time" issue (PR #398 fixed
the diagnostic gap; this fixes discoverability of the right install
path so users don't end up there in the first place).

Three changes, all surface improvements:

1. **`README.md`** — explicit Windows section in the Installation
   block. Previously, the only Windows mention was a footnote
   ("Platforms: ... WSL2 on Windows") that came AFTER the `curl … |
   bash` install command. Users on PowerShell would copy/paste the
   command, get a syntax error, then try to debug bash on Windows.
   Now the README clearly says: bash installer is macOS/Linux only;
   Windows users have two paths (WSL2 with one-time `wsl --install`
   setup, or the desktop .exe from Releases). Both link to the
   relevant docs.

2. **`scripts/install/install.sh`** — early bail when running under
   Git Bash / MSYS2 / Cygwin (MINGW*, MSYS*, CYGWIN* per `uname -s`).
   These environments aren't WSL — `uv` and `git` will install to
   Windows-side paths that OpenJarvis can't reach, Ollama integration
   silently breaks, and the user gets to debug it 3 minutes into a
   doomed install. The bail message points at both the WSL2 setup
   command (`wsl --install -d Ubuntu-24.04`) and the desktop .exe
   download as alternatives.

   Verified the case-match doesn't fire on Linux (`uname -s` →
   `Linux`, matches the wildcard fall-through, not the MINGW patterns).

3. **`frontend/src-tauri/src/lib.rs`** — when `resolve_bin("uv")`
   can't find uv, the per-OS error message now contains the exact
   install command for the user's OS, ready to copy/paste. On Windows
   that's the `irm https://astral.sh/uv/install.ps1 | iex` command
   Marc kept reposting on the Discord support thread (5/12-5/14).
   On macOS/Linux it's the standard `curl | sh` installer.

   The previous generic "Install it from https://astral.sh/uv" left
   users guessing whether to use winget, scoop, pip, or the official
   installer — which is exactly the confusion the Discord thread
   captured.

None of these are root-cause code fixes (Discord users' uv installs
fail for environment-specific reasons we can't diagnose remotely),
but together they remove the three biggest friction sources we saw:
copy-paste install command that can't possibly work, doomed git-bash
installs that fail mysteriously, and missing exact install commands
when uv isn't found.

The Tauri change ships in the desktop binary; the README change is
visible immediately on the repo page; the install.sh change reaches
users via openjarvis.ai (when it's restored) and via the GitHub-raw
fallback from PR #398.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:48:13 +00:00
Jon Saad-Falcon 58f7b753bf fix: PyPI v1.0.2 cluster — wheel packaging + pynvml warning + install fallback + uv-sync diagnostics (#398)
Closes #372 #389 #337 #352 #331. Resolves #373 (already on main, awaiting release). See PR #398 for the full per-issue breakdown.

Credits: @gilbert-barajas (#372), @boeani05 (#389), @kumanday + @filactre (#337/#352), @Hris4oG + @NatanelBranski + @Robjes87 + @EmiDaDuck (#331).
2026-05-23 08:36:44 -07:00
krypticmouseandClaude Opus 4.7 158fc83feb fix(desktop): surface uv sync errors so users aren't stuck waiting for health check
Addresses #331 (multiple Windows users hit "Jarvis server did not
become healthy in time" with no actionable detail).

The boot sequence ran `uv sync` with **both** stdout AND stderr piped
to `/dev/null` and discarded the exit code (`let _ = …`). When
`uv sync` failed for any reason — Windows PATH/permission issues,
network problems, lockfile conflicts, stale `.venv/` — the user saw
nothing useful. The boot proceeded to `uv run jarvis serve` in an
under-provisioned venv, then waited the full 600-second health-check
window before showing a generic "did not start" message with no
hint of what actually went wrong.

Fix: capture stderr, check the exit status, and surface a useful
error to the user **before** the long server-start wait, including:
- The exit code
- The last ~800 chars of `uv sync` stderr (where the diagnostic
  message usually lives)
- A concrete next step (open a terminal and run `uv sync --extra server`
  manually for full output)

Also updated the status detail message to "Installing dependencies
(uv sync — may take 1-2 min on first boot)..." so users on slow
connections don't restart the app thinking it's stuck.

Discord support thread (5/12-5/14) shows the same pattern across
multiple Windows users (@ItsVoyage, @Mystic_irl, @doevud, @Sainthood):
all stuck on "Starting api server" / "did not become healthy in time"
for 4+ minutes, with the actual root cause turning out to be a uv
installation issue that the discarded stderr would have surfaced
immediately. The community workaround (uninstall, install Feb
pre-release, run a magic PowerShell `irm` command for uv) is the
right diagnosis applied without diagnostic output — this commit
makes that diagnostic output visible.

Note: this fix lands in the desktop binary's source (`frontend/src-tauri/src/lib.rs`).
Users running the v1.0.1 desktop binary won't see the improved
error message until the desktop release is re-cut from this branch.
Until then, the workaround documented in this PR's `openjarvis.ai`
fallback section (curl the install.sh from GitHub raw) gets new
users past the install step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:31:17 +00:00
krypticmouseandClaude Opus 4.7 52e830e1a4 docs(install): add GitHub-raw fallback for the openjarvis.ai install URL
Addresses #337 (also reported as #352).

`curl -fsSL https://openjarvis.ai/install.sh | bash` — the documented
one-liner — currently fails with:

    curl: (35) ... sslv3 alert handshake failure

Reproduced from this machine just now; @kumanday and @filactre both
report the same against different OpenSSL and LibreSSL versions. The
underlying SSL issue is on the openjarvis.ai server (cert / TLS
config) and needs an operational fix at the infra layer — not
something a code change in this repo can resolve.

What this commit *can* do is unblock users immediately:

- `README.md` (under "Installation"): callout pointing at issue #337
  and the GitHub-mirror fallback.
- `docs/getting-started/install.md`: same callout as a
  Material-for-MkDocs `!!! warning` admonition.

Both link to the canonical script at
`https://raw.githubusercontent.com/open-jarvis/OpenJarvis/main/scripts/install/install.sh`.
The script is identical content — it's the same `scripts/install/install.sh`
served from GitHub's CDN instead of openjarvis.ai. Once the
installer runs, it pulls everything else (`uv` from astral.sh, the
project source from `github.com/open-jarvis/OpenJarvis.git`, Ollama
from `ollama.com`) — none of which depend on `openjarvis.ai`. So
the rest of install proceeds normally.

When the openjarvis.ai SSL issue is fixed at the server / DNS layer,
both callouts can be removed and the canonical URL becomes the only
documented path again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:25:08 +00:00
krypticmouseandClaude Opus 4.7 0483f5377d fix(telemetry): silence pynvml deprecation FutureWarning on startup
Fixes #389.

The legacy `pynvml` PyPI package (since version 13.x) registers a
meta-path-finder shim — `_pynvml_redirector.py` — that prints a
`FutureWarning("The pynvml package is deprecated. Please install
nvidia-ml-py instead.")` on every `import pynvml`, even when the
caller's project doesn't depend on pynvml directly. The warning was
firing on every `jarvis --version` / `jarvis ask` / any command that
touches the telemetry path.

Two-layer fix:

1. **pyproject.toml**: switch `pynvml>=13.0.1` → `nvidia-ml-py>=12.560.30`
   in the core deps, `gpu-metrics` extra, and `energy-all` extra.
   `nvidia-ml-py` is NVIDIA's official package and ships the same
   `pynvml` module name without the redirector shim, so the warning
   doesn't fire.

2. **Defensive filters** at all four `import pynvml` sites
   (`telemetry/gpu_monitor.py`, `telemetry/energy_nvidia.py`,
   `server/research_router.py`, `evals/backends/external/_subprocess_runner.py`):
   wrap the import in a narrowly-scoped `warnings.filterwarnings("ignore",
   message=r"The pynvml package is deprecated.*", category=FutureWarning)`.
   Belt-and-suspenders for the case where `pynvml` gets pulled in
   transitively by torch / vllm / etc. — the user's environment may
   still have it installed even if our deps don't pull it in.

Verified locally:
- `uv sync` swaps pynvml → nvidia-ml-py.
- `python -c "import warnings; warnings.simplefilter('error', FutureWarning); from openjarvis.telemetry import gpu_monitor"` → no warning fires (would raise if it did).
- `jarvis --version` → clean output, no FutureWarning preceding the version string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:20:12 +00:00
krypticmouseandClaude Opus 4.7 2d6d6ed7bd fix(packaging): anchor traces/ gitignore pattern so hatch ships it in the wheel
Fixes #372.

The `.gitignore` had `traces/` (unanchored), which matches any directory
named `traces/` anywhere in the tree — including the runtime module at
`src/openjarvis/traces/`. hatchling honors `.gitignore` when building
the wheel, so it silently dropped the entire `openjarvis/traces/`
package.

Effect: every fresh `pip install openjarvis==1.0.1` failed at import
time with `ModuleNotFoundError: No module named 'openjarvis.traces'`
the moment the user touched `jarvis ask`, learning, or the server.
Confirmed by @gilbert-barajas with a clean repro on macOS Apple Silicon.

Reproduced locally by running `uv build --wheel` on a clean checkout
of `main`:
- Before this change: `openjarvis/traces/` absent from the wheel.
- After this change: all 4 expected files present
  (`__init__.py`, `analyzer.py`, `collector.py`, `store.py`).

Anchored the pattern to `/traces/` so it only matches a top-level
`traces/` directory (where ad-hoc trace dumps may live during
development), not any nested `traces/` subdir. Added a comment in the
gitignore explaining the gotcha so it doesn't recur.

The other unanchored directory patterns in the file (`results/`,
`logs/`, `htmlcov/`, `site/`, `build/`, `dist/`, `venv/`, `env/`)
were audited — none collide with any directory currently under
`src/openjarvis/`. Left them unanchored to keep the diff minimal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:17:12 +00:00
Robby Manihani 71c5b2bce1 feat(cli): arc-reactor startup banner for init/serve/ask (#397) 2026-05-23 07:57:32 -07:00
github-actions[bot] 440915c822 chore: update clone traffic data [skip ci] 2026-05-23 06:58:07 +00:00
Tanvir Bhathal c84f1fddee feat: proactive agent approval bell with approve/deny UI (#370) 2026-05-22 17:38:48 -07:00
github-actions[bot] fecbc51767 chore: update clone traffic data [skip ci] 2026-05-22 07:16:50 +00:00
Tanvir Bhathal 07b6cba6df remove (#371) 2026-05-21 10:46:13 -07:00
github-actions[bot] 7b6fe86c91 chore: update clone traffic data [skip ci] 2026-05-21 07:20:37 +00:00
Jon Saad-Falcon a6f18dff43 Consolidate contributor PRs #203 + #260 (#368)
Two clean contributor PRs landed with original authorship preserved. Two related PRs (#116, #119) excluded — both need rebase against significant main refactors (see PR #368 for details). Credits: @bootcrowns (#203), @samy19980109 (#260).
2026-05-20 18:56:20 -07:00
Samarth Agarwal 5a74fd82b8 adding TTS for morning digest 2026-05-21 01:47:13 +00:00
Abhinav Cherukuru 9d0bb25516 fix(lint): wrap long lines in collect_metrics available dict (E501) 2026-05-21 01:46:40 +00:00
Abhinav Cherukuru e963321114 feat(operators): wire metrics field and add collect_metrics() to OperatorManager
OperatorManifest has had a `metrics: List[str]` field since the initial
commit but OperatorManager never read it. This change wires that field
through the manager in three ways:

1. activate(): passes `metrics` into the scheduler task metadata so
   workers can introspect which metrics an operator cares about.

2. status(): includes `metrics` in the per-operator status dict returned
   to callers, making it visible alongside tools and schedule info.

3. collect_metrics(operator_id, *, since, until) [new method]: queries
   the system's TelemetryAggregator (system.telemetry) and returns only
   the summary fields explicitly declared in manifest.metrics. Unknown
   metric names are skipped with a DEBUG log so old manifests remain
   forward-compatible. Returns an empty dict gracefully when telemetry
   is not configured.
2026-05-21 01:46:39 +00:00
Robby Manihani 363bdbfa41 feat: Deep Research Granola integration + citation fixes (#366) 2026-05-20 15:56:42 -07:00
Jon Saad-Falcon 75cb70526e Consolidate contributor PRs #340 + #341 + #301 + #347 + #202 (#367)
Consolidates five contributor PRs into one bundle to avoid overlapping issues (notably #340/#341 both touching mcp/transport.py + agent_cmd.py + executor.py). Original authorship preserved on every commit. See PR #367 for full per-author breakdown.

Credits: @Dilligaf371 (#340, #341), @eddierichter-amd (#301), @kriptoburak (#347), @bootcrowns (#202).
2026-05-20 13:37:46 -07:00
krypticmouseandClaude Opus 4.7 e087773b36 style: ruff line-length + import fixes for #340 and #202
Follow-up on the cherry-picks from @Dilligaf371's #340 and
@bootcrowns's #202. Both fail ruff's E501 (88-char line limit) on
main:

- ``src/openjarvis/agents/executor.py:325`` (from #340) — the
  ``self._system is not None and getattr(..., "tool_executor", None)
  is not None`` guard was 101 chars. Wrapped the condition.
- ``src/openjarvis/telemetry/gpu_monitor.py:55-60`` (from #202) —
  five new GPU_SPECS entries (Jetson Orin NX 16GB/8GB, AGX Orin,
  Snapdragon X Elite/Plus) were 90-93 chars. Wrapped each
  GpuHardwareSpec constructor call onto its own block.
- ``tests/telemetry/test_gpu_monitor.py`` (from #202) — removed a
  spurious blank line between imports and the first ``#`` comment
  block, picked up by ``ruff check --fix`` (rule I001).

No behavior change — pure formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:22:44 +00:00
Abhinav Cherukuru 9c9c883f24 test: update test_all_specs_present with 7 new GPUs 2026-05-20 20:20:58 +00:00
Abhinav Cherukuru ef267abcc1 fix: remove extra whitespace in Arc B580 entry 2026-05-20 20:20:58 +00:00
Abhinav Cherukuru 5f685a2e3b feat(telemetry): expand GPU_SPECS with Intel Arc, Jetson Orin, and Snapdragon entries
Adds hardware specs for Intel Arc B580/B570, NVIDIA Jetson Orin NX/AGX Orin, and Qualcomm Snapdragon X Elite/Plus to the GPU_SPECS database in telemetry/gpu_monitor.py.

Specs sourced from official vendor datasheets:
- Intel Arc B580: 196 TFLOPS FP16, 456 GB/s, 190W TDP
- Intel Arc B570: 136 TFLOPS FP16, 380 GB/s, 150W TDP
- Jetson Orin NX 16GB: 50 TFLOPS FP16, 102 GB/s, 25W TDP
- Jetson Orin NX 8GB: 25 TFLOPS FP16, 68 GB/s, 15W TDP
- Jetson AGX Orin: 108 TFLOPS FP16, 204 GB/s, 60W TDP
- Snapdragon X Elite (Adreno X1-85): 4.6 TFLOPS FP16, 136 GB/s, 80W SoC TDP
- Snapdragon X Plus: 3.8 TFLOPS FP16, 136 GB/s, 80W SoC TDP

Closes Workstream 5 roadmap item: GPU specs database expansion.
2026-05-20 20:20:58 +00:00
kriptoburak 1d37528637 docs: add Hermes Tweet skill install example 2026-05-20 20:20:58 +00:00
Eddie Richter 86c42e4e4a tests: wrap lemonade host override signature 2026-05-20 20:20:53 +00:00
Eddie Richter a65cd12ea2 lemonade: update default host and recommended model 2026-05-20 20:20:53 +00:00
Gilles Ceyssat 36482b1a27 fix(mcp): bump default tool timeout 30s → 600s for MCP-discovered tools
MCPClient.list_tools constructs ToolSpec without a timeout_seconds
override, so MCP tools inherit the dataclass default of 30s. Most MCP
servers in practice wrap long-running tools (pentest scanners, build
runners, search agents, …) that comfortably take longer than 30s.

The symptom is misleading — the ToolExecutor reports the timeout, and
the LLM relays it as "command execution timed out", with no hint that
the cause is the OpenJarvis client side and not the MCP server's
actual policy. (CyberStrikeAI, for example, allows 30 *minutes* on its
side via tool_timeout_minutes.)

Bump the default to 600s (10 min) — still bounded, but enough for the
typical long-running tool. Individual MCP servers can shorten via
their own timeout policy if they care.
2026-05-20 20:20:49 +00:00
Gilles Ceyssat 333cb9a370 feat(cli): wire confirm_callback for jarvis agents ask (with --yes/--no-yes)
`jarvis agents ask` is a non-interactive CLI path, so the AgentExecutor's
ToolExecutor never had a confirm_callback wired. Tools whose ToolSpec
sets requires_confirmation=True (shell_exec, git_*, ...) returned
"requires confirmation but no confirmation callback is available" and
the agent relayed that back as natural language, never executing.

This adds a --yes/--no-yes flag (default --yes):
- --yes: auto-approve (lambda _prompt: True). Suited for CLI runs where
  the operator already authorized the engagement scope.
- --no-yes: prompt on TTY via click.confirm.

The callback is set on the AgentExecutor itself; executor.py:_invoke_agent
reads it and forwards it to the constructed agent through agent_kwargs
(both `interactive=True` and `confirm_callback=...`).
2026-05-20 20:20:42 +00:00
Gilles Ceyssat 4c37aa83df feat(executor): pull SystemBuilder MCP tools into agent's tool list
The AgentExecutor builds agents from a template's `tools` whitelist by
resolving each name against the static `ToolRegistry`. External MCP
tools (discovered at SystemBuilder.build() time via _discover_external_mcp)
were never picked up — they exist on system.tool_executor._tools but
the per-agent build path never read from there.

Result: any agent whose template declared MCP tool names by name got
0 of them at runtime, falling back to natives only. The agent's system
prompt could still mention the tools, but the model could not call them
(only shell_exec or other native fallbacks were available).

This adds a fallback after the ToolRegistry loop: for any tool name not
resolved from the registry, look it up in system.tool_executor._tools
and append the already-instantiated MCP adapter. Native tools still
take precedence (same name, the registry hit wins).

Verified by configuring a CyberStrikeAI stdio MCP server (78 tools).
Before this patch: agent built with 4 tools. After: 4 native + 78 MCP.
2026-05-20 20:20:42 +00:00
Jon Saad-Falcon c8f1b3c54a Consolidate top-priority contributor PRs (#235 + #339 + #293 + #294 + #295) (#362)
Lands 5 high-priority contributor PRs as one bundle, with original authorship preserved on every commit, plus follow-up commits from me to make each PR CI-green and non-regressive. See PR #362 for the full per-author commit breakdown and credits.

Credits: @tomaioo (#235), @Dilligaf371 (#339), @TX-Huang (#293, #294, #295).
2026-05-20 13:11:34 -07:00
Robby Manihani 855ed51780 feat: Deep Research Slack integration (#365) 2026-05-20 12:02:05 -07:00
Tanvir Bhathal 4652b6e8a8 [FEAT] Proactive Agents (#364) 2026-05-20 12:00:19 -07:00
github-actions[bot] 30f8d57398 chore: update clone traffic data [skip ci] 2026-05-20 07:16:55 +00:00
krypticmouseandClaude Opus 4.7 9a42e62172 style: wrap long line in test_ask_agent.py
Follow-up on @TX-Huang's PR #295. The new
test_soul_md_content_reaches_engine_in_simple_agent test has one
line at 92 chars that trips ruff's E501 (88 char limit). Wrap the
ternary onto multiple lines so the file passes ruff check on CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:37:18 +00:00
IsaacHandClaude Opus 4.7 90f009e906 feat(ask): wire SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md actually load
`SystemPromptBuilder` is fully implemented and tested in
`openjarvis.prompt.builder`, and `BaseAgent.__init__` accepts a
`prompt_builder` kwarg. But no production code path ever instantiates
the builder, so the persona-files feature documented in
`MemoryFilesConfig` (SOUL.md / MEMORY.md / USER.md) had no effect.
Users could write a fully-customized `~/.openjarvis/SOUL.md` and the
file was never read.

This PR wires it up in `_run_agent` (called by `jarvis ask --agent
<name>` and the new fallback from #294):

```python
if "prompt_builder" in inspect.signature(agent_cls.__init__).parameters:
    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,
    )
```

The `inspect` guard means agents that override `__init__` without
forwarding `prompt_builder` (e.g. OrchestratorAgent, which has its
own tool-aware system prompt) opt out automatically and keep
working unchanged. SimpleAgent and any future agent that inherits
`BaseAgent.__init__` directly picks up the persona files.

Also fixes a latent bug in `SystemPromptBuilder._load_file`: it
called `path.read_text()` with no encoding, which on Windows falls
back to the system code page (cp950 / cp932 / cp949) and raises
`UnicodeDecodeError` on any non-ASCII persona content. Pin to UTF-8.

## Tests

- Add `test_soul_md_content_reaches_engine_in_simple_agent` — writes
  sentinel SOUL.md / MEMORY.md / USER.md to tmp_path, runs the
  command, and asserts each sentinel appears in the SYSTEM message
  passed to engine.generate.
- Add `test_orchestrator_keeps_its_own_system_prompt` — exercises
  the `inspect`-based opt-out so OrchestratorAgent doesn't crash
  on the unexpected kwarg.

Run: `pytest tests/cli/test_ask_agent.py tests/cli/test_ask_router.py
tests/cli/test_ask_e2e.py tests/agents/ tests/prompt/`

The 6 remaining failures (test_base_agent, test_loop_guard,
test_manager, test_native_openhands) are pre-existing on
origin/main and unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:37:18 +00:00
krypticmouseandClaude Opus 4.7 0217a901dd test(energy_wiring): opt out of #294 default-agent fallback in _energy_config
Follow-up on @TX-Huang's PR #294. The default-agent fallback now
routes `jarvis ask "..."` (no --agent) through `config.agent.default_agent`,
which dataclass-defaults to `"simple"`. The autouse `_clean_registries`
fixture in tests/conftest.py clears AgentRegistry between tests, so
the fallback then fails with `Unknown agent: simple` and every
CLI-driven test_energy_wiring test exits with code 1.

These tests exercise engine-level instrumentation, not agent dispatch
— ``test_engine_wrapped_with_instrumented``, the energy-monitor
lifecycle tests, and the end-to-end pipeline tests all care that the
engine gets wrapped and telemetry lands in SQLite, regardless of
whether the call goes through an agent. Set ``cfg.agent.default_agent
= ""`` in ``_energy_config`` to keep these tests on the direct-engine
path they were originally designed for. The dedicated
``test_agent_mode_uses_instrumented_engine`` (which explicitly passes
``--agent``) is unaffected.

Same pattern PR #294 already uses in tests/cli/test_ask_router.py
for the two MagicMock-config tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:37:09 +00:00
IsaacHandClaude Opus 4.7 c7f451fbc8 fix(ask): honor agent.default_agent from config when --agent omitted
`jarvis ask "..."` (no `--agent` flag) routed straight to
`engine.generate()` regardless of `agent.default_agent` in the user's
config. As a result the persona stack — `agent.default_system_prompt`,
SOUL.md, MEMORY.md, USER.md — was silently bypassed for the most
common command.

Behavior now:
- `--agent X`              → use agent X (unchanged)
- `--agent ""` (empty)     → explicit opt-out, direct-to-engine mode
- (omitted)                → fall back to `config.agent.default_agent`,
                             which dataclass-defaults to `"simple"`,
                             so persona settings finally take effect

Tests:
- Rename `test_no_agent_uses_direct_mode` to
  `test_no_agent_flag_falls_back_to_config_default_agent` and update
  its docstring to document the new behavior.
- Add `test_explicit_empty_agent_opts_out_of_agent_mode`.
- Add `test_no_agent_with_blank_config_default_uses_direct_mode` to
  cover the case where the user clears `default_agent`.
- Update `_patch_engine` (test_ask_router) and `_patch_ask`
  (test_ask_e2e) to re-register `SimpleAgent` after the autouse
  `_clean_registries` conftest fixture, since the agent path now
  runs in tests that previously short-circuited to direct mode.
- Add explicit `cfg.agent.default_agent = ""` to two
  `test_ask_router` tests that mock load_config with a MagicMock
  (so `cfg.agent.default_agent` doesn't auto-create as a truthy mock).

Note: `tests/cli/test_ask_context.py` has 2 unrelated pre-existing
failures on origin/main (memory backend returns None on Windows);
those are out of scope for this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:37:09 +00:00
IsaacHandClaude Opus 4.7 30ac635e83 fix: force UTF-8 stdout on Windows for CJK CLI output
On Windows the default Python stdout encoding follows the system
ANSI code page (cp950 for zh-TW, cp932 for ja, cp949 for ko).
`click.echo()` then raises `UnicodeEncodeError` whenever a CJK
character lands in CLI output — `jarvis ask` returning Chinese
crashes with `'cp950' codec can't encode character '义'`.

Reconfigure `sys.stdout` and `sys.stderr` to UTF-8 with
`errors='replace'` at the `main()` entry point. Scoped to
`win32` so other platforms are untouched. Two unit tests verify
the reconfigure happens on Windows and doesn't on Linux.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:36:55 +00:00
IsaacHandClaude Opus 4.7 31efd9e672 fix: detect total RAM on Windows via GlobalMemoryStatusEx
`_total_ram_gb()` had branches for Darwin (sysctl) and Linux
(/proc/meminfo) but no Windows path, so `jarvis init` reported
"0.0 GB RAM" on every Windows host. The downstream
`recommend_model()` then fell back to VRAM-only sizing, often
selecting a smaller tier than the system can actually run.

Add a Windows branch that calls `GlobalMemoryStatusEx` via
ctypes — no new dependency. Add a platform-skip-aware test for
each OS branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:36:55 +00:00
krypticmouseandClaude Opus 4.7 8b5a304222 test(mcp): regression test for StdioTransport.send_notification
Follow-up on @Dilligaf371's PR #339 fix. Adds a regression test that
spawns a subprocess which consumes stdin without ever writing to
stdout, then issues `send_notification` from a worker thread. If the
override is missing, the thread blocks forever on `proc.stdout.readline()`
and the 2-second join timeout fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:36:37 +00:00
Gilles Ceyssat df566d4186 fix(mcp): StdioTransport.send_notification must not read response
The base MCPTransport.send_notification falls back to self.send() which
writes a request AND reads a response. For stdio MCP servers this hangs
forever because notifications (per JSON-RPC 2.0 spec) have no response.

This affected every stdio MCP server attached to OpenJarvis: after
MCPClient.initialize() sent its `initialize` request and received the
server capabilities, it sent the spec-required `notifications/initialized`
notification, which then blocked indefinitely on proc.stdout.readline().

StreamableHTTPTransport already overrides send_notification correctly
(transport.py:213). This adds the symmetric fix for StdioTransport so
stdio MCP servers can complete the handshake and be discovered.

Reproduced with the CyberStrikeAI cmd/mcp-stdio server (78 tools): without
the fix, `_discover_external_mcp` hangs forever in `MCPClient.initialize`.
With the fix, all 78 tools are discovered cleanly.
2026-05-20 03:36:37 +00:00
krypticmouseandClaude Opus 4.7 4177b5c50e fix(skills): satisfy Clippy + reject non-ASCII hex + add regression tests
Follow-up on @tomaioo's signature-verification panic fix in PR #235.

1. Clippy on Rust 1.95+ flags `len() % 2 != 0` with the
   `manual_is_multiple_of` lint, which was failing the `rust` CI job
   on PR #235 and blocking merge. Switch to `is_multiple_of(2)`.

2. Add an explicit `is_ascii()` guard before slicing. With only the
   length check, a non-ASCII input (e.g. `"é"` — 2 bytes but 1 char)
   would survive the length check before `from_str_radix` caught it.
   The explicit guard makes the rejection intent clear and avoids
   relying on the post-slice error path.

3. Extract the hex-parsing into a private `parse_public_key_hex`
   helper. PyO3-bound `#[pymethods]` are awkward to unit-test from
   Rust (need a Python interpreter via `prepare_freethreaded_python`);
   a plain function is testable with no GIL boilerplate.

4. Add 5 unit tests covering the security boundary:
   - empty input -> Some(empty vec)
   - valid hex decodes to the right bytes
   - odd-length rejected without panic (the original bug)
   - non-hex chars rejected (previously silently filtered)
   - multi-byte UTF-8 rejected without panic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:36:28 +00:00
tomaioo f21eec6c86 fix(security): panic on malformed hex input in signature verifica
`verify_signature` slices `public_key_hex[i..i + 2]` without validating that the input length is even. An odd-length or otherwise malformed string can trigger an out-of-bounds panic, which may crash the process (or at minimum terminate the request path), creating a denial-of-service vector if this method is reachable from untrusted input.

Signed-off-by: tomaioo <203048277+tomaioo@users.noreply.github.com>
2026-05-20 03:36:16 +00:00
Jon Saad-Falcon 4a7817509b docs(index): align homepage with canonical surfaces (#361) 2026-05-19 18:52:30 -07:00
Jon Saad-Falcon 2b0e6e7130 Update README.md 2026-05-19 18:18:26 -07:00
Jon Saad-Falconandkrypticmouse 289fcb00e2 fix(ci): desktop — translate PEP 440 .devN to SemVer for Tauri (#360)
Tauri's build script enforces strict SemVer
(`MAJOR.MINOR.PATCH[-pre][+build]`) on `tauri.conf.json > version`, but
the autotag scheme introduced in #358 emits PEP 440 dev releases like
`1.0.2.dev661` — PEP 440 separates dev with `.`, SemVer requires `-`.
First post-#358 desktop run failed with:

    tauri.conf.json > version must be a semver string

PyPI requires PEP 440; Tauri requires SemVer. They genuinely don't
agree on `.devN`. Translate just for the Tauri bundle:

  1.0.2.dev661  -> 1.0.2-dev.661   (valid SemVer prerelease)
  1.0.2          -> 1.0.2          (passthrough)
  1.0.0-rc.1     -> 1.0.0-rc.1     (passthrough)

Tag, git history, PyPI wheel, and the updater's `latest.json` all keep
the PEP 440 form. Only the embedded bundle version uses SemVer, and
the comparison the updater does is bundle-vs-latest.json (both SemVer
now) so the upgrade path stays consistent.

Failing run for reference: 26118619500

Co-authored-by: krypticmouse <herumbshandilya123@gmail.com>
2026-05-19 12:22:24 -07:00
Jon Saad-Falconandkrypticmouse 4668ea0b7f fix(ci): publish-to-pypi — match Vite's actual outDir (#359)
The frontend bundling step in pypi-publish.yml assumed Vite produced
`frontend/dist/`, but vite.config.ts is configured with
`outDir: '../src/openjarvis/server/static'` and `emptyOutDir: true` —
Vite writes straight to the package's static dir and clears stale
assets itself. The `rm -rf dist; cp -r dist/.` ceremony was dead code
that would have deleted the build output had the assertion not
short-circuited it first.

First post-#358 autotag run failed at this step with
`frontend/dist/index.html missing or empty after build` (build was
fine; the assertion checked the wrong path).

Fix: drop the rm/cp dance and assert the actual output location.

Co-authored-by: krypticmouse <herumbshandilya123@gmail.com>
2026-05-19 12:12:04 -07:00
Tanvir Bhathal a58d6b6c48 Auto Update (#358) 2026-05-19 11:57:10 -07:00
github-actions[bot] af21bc18ea chore: update clone traffic data [skip ci] 2026-05-19 07:16:45 +00:00
Jon Saad-Falcon fea8d3e872 release: v1.0.1 (#357) 2026-05-18 20:19:37 -07:00
Jon Saad-Falcon 5b847cd081 Update README.md 2026-05-18 18:41:48 -07:00
Jon Saad-Falcon a5029e297f Update README.md 2026-05-18 18:40:54 -07:00
Robby Manihani 9af0dfe336 feat: Deep Research — personal deep research over Gmail with hybrid retrieval and agentic synthesis (#354) 2026-05-18 17:46:12 -07:00
github-actions[bot] b863cbb07b chore: update clone traffic data [skip ci] 2026-05-18 07:27:15 +00:00
Tanvir Bhathal 7081be7bd3 [FEAT] Telemetry (#351) 2026-05-17 13:07:05 -07:00
github-actions[bot] df4332b0e8 chore: update clone traffic data [skip ci] 2026-05-17 07:00:19 +00:00
155 changed files with 16234 additions and 855 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "59,867",
"message": "77,239",
"color": "green",
"namedLogo": "git"
}
+13 -3
View File
@@ -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
}
}
+86
View File
@@ -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}"
+52
View File
@@ -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:
+92 -14
View File
@@ -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}"
+64
View File
@@ -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
View File
@@ -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
View File
@@ -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,
Submodule Inline deleted from 03673aaa42
+40 -11
View File
@@ -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},
}
```
+142
View File
@@ -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
+5 -5
View File
@@ -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.
+112
View File
@@ -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
View File
@@ -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.
+21
View File
@@ -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())
+12 -1
View File
@@ -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
+5 -5
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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> &bull;
<a href="https://hai.stanford.edu/">Stanford HAI</a>
</p>
Follow [@OpenJarvisAI](https://x.com/OpenJarvisAI) on X for updates.
+135
View File
@@ -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.
+151
View File
@@ -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.
+8
View File
@@ -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:
+441 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+93
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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"));
}
}
+5 -3
View File
@@ -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
View File
@@ -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 />} />
+264
View File
@@ -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>
);
}
+23 -8
View File
@@ -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>
+235 -8
View File
@@ -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={{
+35 -3
View File
@@ -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>
);
}
+5 -2
View File
@@ -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>
+14 -5
View File
@@ -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',
+2
View File
@@ -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 && (
+8 -2
View File
@@ -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)' }}>
+46
View File
@@ -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;
}
+208
View File
@@ -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
View File
@@ -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}`);
}
+203
View File
@@ -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
View File
@@ -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();
}
}
+50
View File
@@ -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 }),
+5
View File
@@ -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>
+225 -113
View File
@@ -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'} &rarr;
</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)' }}>
+57 -1
View File
@@ -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)' }}>
+18 -17
View File
@@ -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' },
],
},
{
+57
View File
@@ -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
View File
@@ -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"}
+2 -1
View File
@@ -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',
},
},
});
+1
View File
@@ -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
View File
@@ -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 = [
+69 -5
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
+5
View File
@@ -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.
+148 -17
View File
@@ -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:
+30
View File
@@ -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()
+14 -11
View File
@@ -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
+2
View File
@@ -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,
}
),
)
+605
View File
@@ -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,
},
)
+867
View File
@@ -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",
]
+33
View File
@@ -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",
]
+186
View File
@@ -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"]
+217
View File
@@ -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"]
+127
View File
@@ -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"]
+407
View File
@@ -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",
]
+92
View File
@@ -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 installfirst-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
+106
View File
@@ -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"]
+29 -9
View File
@@ -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(
*,
+19 -2
View File
@@ -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()
+41
View File
@@ -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()
+87
View File
@@ -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",
)
+145 -20
View File
@@ -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
+20 -2
View File
@@ -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
View File
@@ -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),
+28 -2
View File
@@ -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
+4
View File
@@ -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.
+77
View File
@@ -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",
+89
View File
@@ -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.")
+4
View File
@@ -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
+9
View File
@@ -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)
+229 -115
View File
@@ -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)))
+156
View File
@@ -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",
]
+104 -7
View File
@@ -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(
+5 -4
View File
@@ -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:
+11 -5
View File
@@ -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:
+279 -28
View File
@@ -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(
+19 -11
View File
@@ -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]
+130
View File
@@ -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",
]
+20 -6
View File
@@ -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", "")
+68 -10
View File
@@ -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."""
+477
View File
@@ -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"]
+61 -2
View File
@@ -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
# ---------------------------------------------------------------------------
+92 -3
View File
@@ -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
+334 -106
View File
@@ -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