mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2d127de4 | ||
|
|
87f6238338 | ||
|
|
452bcc38cf | ||
|
|
b35a4c8113 | ||
|
|
f001e3b0ca | ||
|
|
b6dba93ae5 | ||
|
|
3000116d18 | ||
|
|
9db21d37ef | ||
|
|
95480363b7 | ||
|
|
4419b76412 | ||
|
|
99bbc2054a |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "159,322",
|
||||
"message": "166,579",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 159322,
|
||||
"last_updated": "2026-07-16T08:08:14Z",
|
||||
"total_clones": 166579,
|
||||
"last_updated": "2026-07-20T08:53:12Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -112,6 +112,10 @@
|
||||
"2026-07-12": 1917,
|
||||
"2026-07-13": 2102,
|
||||
"2026-07-14": 2337,
|
||||
"2026-07-15": 2362
|
||||
"2026-07-15": 2362,
|
||||
"2026-07-16": 2497,
|
||||
"2026-07-17": 1773,
|
||||
"2026-07-18": 1542,
|
||||
"2026-07-19": 1445
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<p><i>Personal AI, On Personal Devices.</i></p>
|
||||
|
||||
<p>
|
||||
<a href="https://arxiv.org/abs/2605.17172"><img src="https://img.shields.io/badge/arXiv-2605.17172-b31b1b.svg" alt="arXiv"></a>
|
||||
<a href="https://openjarvis.stanford.edu/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
|
||||
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
|
||||
|
||||
@@ -22,6 +22,8 @@ export function ChatArea() {
|
||||
const navigate = useNavigate();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -34,15 +36,34 @@ export function ChatArea() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (streamState.isStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = streamState.isStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content]);
|
||||
}, [messages, streamState.content, streamState.isStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
const distance = scrollHeight - scrollTop - clientHeight;
|
||||
const scrolledUp = scrollTop < lastScrollTop.current;
|
||||
lastScrollTop.current = scrollTop;
|
||||
if (scrolledUp && distance >= 1) {
|
||||
// Any upward scroll away from the bottom stops autoscroll immediately,
|
||||
// so streaming content never fights the user (no jitter). Sub-1px
|
||||
// upward movement (elastic bounce settling at the bottom) is ignored.
|
||||
shouldAutoScroll.current = false;
|
||||
} else if (!scrolledUp) {
|
||||
// Re-engage when scrolled back to the bottom. < 2 rather than < 1:
|
||||
// at fractional zoom levels the at-bottom residual can reach 1px,
|
||||
// which would otherwise leave autoscroll permanently disengaged.
|
||||
shouldAutoScroll.current = distance < 2;
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
|
||||
@@ -149,8 +149,7 @@ export function CommandPalette() {
|
||||
setCommandPaletteOpen(false);
|
||||
|
||||
if (modelId !== previousModel) {
|
||||
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
createConversation(modelId);
|
||||
const { setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
setModelLoading(true);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
|
||||
try {
|
||||
|
||||
@@ -104,7 +104,9 @@ class SkillImporter:
|
||||
|
||||
try:
|
||||
frontmatter, body = self._read_skill_md(source_md)
|
||||
manifest = self._parser.parse_frontmatter(frontmatter, markdown_content=body)
|
||||
manifest = self._parser.parse_frontmatter(
|
||||
frontmatter, markdown_content=body
|
||||
)
|
||||
except Exception as exc:
|
||||
result.success = False
|
||||
result.warnings.append(f"Parse error: {exc}")
|
||||
|
||||
@@ -139,8 +139,8 @@ class GitStatusTool(BaseTool):
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
repo_path = params.get("repo_path", ".")
|
||||
_rust = get_rust_module()
|
||||
try:
|
||||
_rust = get_rust_module()
|
||||
output = _rust.GitStatusTool().execute(repo_path)
|
||||
return ToolResult(
|
||||
tool_name="git_status",
|
||||
@@ -148,6 +148,8 @@ class GitStatusTool(BaseTool):
|
||||
success=True,
|
||||
metadata={"returncode": 0},
|
||||
)
|
||||
except ImportError as exc:
|
||||
logger.debug("Rust git_status fallback to CLI: %s", exc)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="git_status",
|
||||
@@ -155,6 +157,8 @@ class GitStatusTool(BaseTool):
|
||||
success=False,
|
||||
)
|
||||
|
||||
return _run_git(["git", "status", "--porcelain"], cwd=repo_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitDiffTool
|
||||
@@ -208,9 +212,9 @@ class GitDiffTool(BaseTool):
|
||||
staged = params.get("staged", False)
|
||||
file_path = params.get("path")
|
||||
|
||||
_rust = get_rust_module()
|
||||
if not staged and not file_path:
|
||||
try:
|
||||
_rust = get_rust_module()
|
||||
output = _rust.GitDiffTool().execute(repo_path)
|
||||
return ToolResult(
|
||||
tool_name="git_diff",
|
||||
@@ -218,6 +222,8 @@ class GitDiffTool(BaseTool):
|
||||
success=True,
|
||||
metadata={"returncode": 0},
|
||||
)
|
||||
except ImportError as exc:
|
||||
logger.debug("Rust git_diff fallback to CLI: %s", exc)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="git_diff",
|
||||
@@ -371,8 +377,8 @@ class GitLogTool(BaseTool):
|
||||
count = params.get("count", 10)
|
||||
oneline = params.get("oneline", True)
|
||||
|
||||
_rust = get_rust_module()
|
||||
try:
|
||||
_rust = get_rust_module()
|
||||
output = _rust.GitLogTool().execute(repo_path, count)
|
||||
return ToolResult(
|
||||
tool_name="git_log",
|
||||
|
||||
@@ -30,6 +30,19 @@ from openjarvis.core.registry import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_update_check(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never let the CLI's PyPI update-check nag run during tests.
|
||||
|
||||
``check_for_updates`` writes its banner to stderr, which ``CliRunner``
|
||||
merges into ``result.output`` — polluting JSON/CSV output of any test
|
||||
that invokes a CLI command. It already self-disables when ``CI`` is
|
||||
set, but that only helps in CI; locally (e.g. a dev with a stale
|
||||
version-check cache and network access) it fires for real.
|
||||
"""
|
||||
monkeypatch.setenv("OPENJARVIS_NO_UPDATE_CHECK", "1")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registries() -> None:
|
||||
"""Ensure each test starts with empty registries and a fresh event bus."""
|
||||
|
||||
@@ -255,9 +255,7 @@ class TestDangerousCapabilityGate:
|
||||
assert result.success
|
||||
assert result.requires_confirmation
|
||||
assert any("confirmed by caller" in w for w in result.warnings)
|
||||
content = (
|
||||
tmp_path / "skills" / "hermes" / "my-skill" / ".source"
|
||||
).read_text()
|
||||
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
|
||||
assert 'trust_tier = "unreviewed"' in content
|
||||
assert 'dangerous_capabilities = ["shell:execute"]' in content
|
||||
|
||||
@@ -269,8 +267,6 @@ class TestDangerousCapabilityGate:
|
||||
assert result.success
|
||||
assert not result.requires_confirmation
|
||||
assert result.dangerous_capabilities == []
|
||||
content = (
|
||||
tmp_path / "skills" / "hermes" / "my-skill" / ".source"
|
||||
).read_text()
|
||||
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
|
||||
assert 'trust_tier = "unreviewed"' in content
|
||||
assert "dangerous_capabilities = []" in content
|
||||
|
||||
@@ -171,9 +171,7 @@ class TestSkillExecutorCapabilities:
|
||||
assert result.context.get("result") == "hello"
|
||||
|
||||
def test_policy_blocks_missing_capability(self):
|
||||
executor = SkillExecutor(
|
||||
ToolExecutor([EchoTool()]), allowed_capabilities=set()
|
||||
)
|
||||
executor = SkillExecutor(ToolExecutor([EchoTool()]), allowed_capabilities=set())
|
||||
result = executor.run(self._manifest())
|
||||
assert not result.success
|
||||
assert len(result.step_results) == 1
|
||||
|
||||
@@ -14,9 +14,9 @@ skipped if the server is unreachable.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from openjarvis.tools.storage.dense import (
|
||||
@@ -25,20 +25,58 @@ from openjarvis.tools.storage.dense import (
|
||||
chunk_markdown,
|
||||
dedupe_chunks,
|
||||
)
|
||||
from openjarvis.tools.storage.embeddings import OllamaEmbedder
|
||||
|
||||
_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "fixtures" / "docs"
|
||||
_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "localhost")
|
||||
_OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", "11434"))
|
||||
_EMBED_MODEL = "nomic-embed-text"
|
||||
|
||||
|
||||
def _ollama_base_url() -> str:
|
||||
"""Resolve the Ollama base URL from ``OLLAMA_HOST``.
|
||||
|
||||
The project documents ``OLLAMA_HOST`` as a full URL
|
||||
(``http://<remote-ip>:11434``), but Ollama's own convention also
|
||||
allows bare ``host`` / ``host:port`` forms — accept all three so
|
||||
the probe and the embedder under test agree on one endpoint.
|
||||
"""
|
||||
host = os.environ.get("OLLAMA_HOST", "")
|
||||
if not host:
|
||||
return "http://localhost:11434"
|
||||
if host.startswith(("http://", "https://")):
|
||||
return host.rstrip("/")
|
||||
if ":" in host:
|
||||
return f"http://{host}"
|
||||
return f"http://{host}:11434"
|
||||
|
||||
|
||||
def _ollama_up() -> bool:
|
||||
"""True only if Ollama is reachable *and* the embed model is pulled.
|
||||
|
||||
A bare TCP connect isn't enough — a machine can run Ollama for chat
|
||||
models without ever having pulled ``nomic-embed-text``, which makes
|
||||
``/api/embed`` 404 instead of the tests skipping as intended.
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
|
||||
return True
|
||||
except OSError:
|
||||
resp = httpx.get(f"{_ollama_base_url()}/api/tags", timeout=1.0)
|
||||
resp.raise_for_status()
|
||||
models = {m.get("model", "") for m in resp.json().get("models", [])}
|
||||
return any(m.startswith(_EMBED_MODEL) for m in models)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _make_backend() -> DenseMemory:
|
||||
"""DenseMemory wired to the same Ollama endpoint the probe checked.
|
||||
|
||||
``DenseMemory()`` alone would build an ``OllamaEmbedder`` with its
|
||||
hard-coded localhost default, so a remote ``OLLAMA_HOST`` could pass
|
||||
the probe and then have every test call the wrong server.
|
||||
"""
|
||||
return DenseMemory(
|
||||
embedder=OllamaEmbedder(model=_EMBED_MODEL, base_url=_ollama_base_url())
|
||||
)
|
||||
|
||||
|
||||
ollama_required = pytest.mark.skipif(
|
||||
not _ollama_up(),
|
||||
reason=(
|
||||
@@ -48,6 +86,65 @@ ollama_required = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip-guard unit tests (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeTagsResponse:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
class TestOllamaProbe:
|
||||
def test_base_url_accepts_all_documented_forms(self, monkeypatch):
|
||||
cases = {
|
||||
"http://remote:11434": "http://remote:11434",
|
||||
"http://remote:11434/": "http://remote:11434",
|
||||
"https://ollama.internal": "https://ollama.internal",
|
||||
"remote:8080": "http://remote:8080",
|
||||
"remote": "http://remote:11434",
|
||||
}
|
||||
for raw, expected in cases.items():
|
||||
monkeypatch.setenv("OLLAMA_HOST", raw)
|
||||
assert _ollama_base_url() == expected, raw
|
||||
monkeypatch.delenv("OLLAMA_HOST", raising=False)
|
||||
assert _ollama_base_url() == "http://localhost:11434"
|
||||
|
||||
def test_probe_false_when_server_down(self, monkeypatch):
|
||||
def _refuse(url, timeout):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _refuse)
|
||||
assert _ollama_up() is False
|
||||
|
||||
def test_probe_false_when_model_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"get",
|
||||
lambda url, timeout: _FakeTagsResponse({"models": [{"model": "llama3"}]}),
|
||||
)
|
||||
assert _ollama_up() is False
|
||||
|
||||
def test_probe_true_with_tagged_model_on_configured_url(self, monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_HOST", "http://remote:9999")
|
||||
seen = {}
|
||||
|
||||
def _get(url, timeout):
|
||||
seen["url"] = url
|
||||
return _FakeTagsResponse({"models": [{"model": "nomic-embed-text:latest"}]})
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
assert _ollama_up() is True
|
||||
assert seen["url"] == "http://remote:9999/api/tags"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunking unit tests (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -283,7 +380,7 @@ def indexed_backend():
|
||||
if not _ollama_up():
|
||||
pytest.skip("Ollama not reachable")
|
||||
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
md_files = sorted(_FIXTURE_DIR.glob("*.md"))
|
||||
assert md_files, f"no fixtures at {_FIXTURE_DIR}"
|
||||
|
||||
@@ -452,7 +549,7 @@ def test_score_distribution_vs_threshold(indexed_backend, capsys):
|
||||
class TestDenseMemoryAPI:
|
||||
@ollama_required
|
||||
def test_store_and_delete(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
doc_id = backend.store("the cat sat on the mat", source="a.txt")
|
||||
assert backend.count() == 1
|
||||
hits = backend.retrieve("where is the cat", top_k=1)
|
||||
@@ -463,12 +560,12 @@ class TestDenseMemoryAPI:
|
||||
|
||||
@ollama_required
|
||||
def test_empty_retrieve(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
assert backend.retrieve("anything", top_k=3) == []
|
||||
|
||||
@ollama_required
|
||||
def test_clear(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
backend.store("foo")
|
||||
backend.store("bar")
|
||||
backend.clear()
|
||||
|
||||
@@ -537,3 +537,51 @@ class TestGitLogTool:
|
||||
fn = tool.to_openai_function()
|
||||
assert fn["type"] == "function"
|
||||
assert fn["function"]["name"] == "git_log"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI fallback when the Rust extension is missing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCliFallbackWhenRustMissing:
|
||||
"""When ``get_rust_module`` raises ImportError (extension not built,
|
||||
e.g. a plain pip install), the read-only git tools must fall back to
|
||||
the git CLI instead of letting the ImportError escape ``execute()``."""
|
||||
|
||||
def _patch_no_rust(self):
|
||||
return patch(
|
||||
"openjarvis.tools.git_tool.get_rust_module",
|
||||
side_effect=ImportError("No module named 'openjarvis_rust'"),
|
||||
)
|
||||
|
||||
def test_git_status_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
(tmp_path / "new_file.txt").write_text("hello")
|
||||
with self._patch_no_rust():
|
||||
result = GitStatusTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "new_file.txt" in result.content
|
||||
|
||||
def test_git_diff_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
(tmp_path / "README.md").write_text("# Modified\n")
|
||||
with self._patch_no_rust():
|
||||
result = GitDiffTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "README.md" in result.content
|
||||
|
||||
def test_git_log_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
with self._patch_no_rust():
|
||||
result = GitLogTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "Initial commit" in result.content
|
||||
|
||||
def test_fallback_failure_is_a_tool_result_not_an_exception(self, tmp_path):
|
||||
# Even when the fallback itself fails (not a git repo), the tool
|
||||
# must return a failed ToolResult rather than raising.
|
||||
with self._patch_no_rust():
|
||||
result = GitStatusTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is False
|
||||
assert "not a git repository" in result.content
|
||||
|
||||
@@ -70,9 +70,7 @@ def test_allows_select_with_keyword_substring(store: KnowledgeStore) -> None:
|
||||
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
|
||||
|
||||
tool = KnowledgeSQLTool(store=store)
|
||||
result = tool.execute(
|
||||
query="SELECT author AS created_author FROM knowledge_chunks"
|
||||
)
|
||||
result = tool.execute(query="SELECT author AS created_author FROM knowledge_chunks")
|
||||
assert result.success, result.content
|
||||
assert "Alice" in result.content
|
||||
|
||||
|
||||
Reference in New Issue
Block a user