Feature/twitter bot (#259)

This commit is contained in:
Robby Manihani
2026-04-17 15:22:17 -07:00
committed by GitHub
parent 64d0f7ab02
commit ef03d98fc0
31 changed files with 4449 additions and 587 deletions
+3 -11
View File
@@ -45,11 +45,14 @@ Thumbs.db
*.db
*.jsonl
*.npz
*.log
results/
logs/
traces/
coding_task_*
get-pip.py
# Junk from mocked-path tests that write to their mock's __repr__ as a path
MagicMock/
# MkDocs build output
site/
@@ -91,18 +94,7 @@ src/openjarvis/channels/whatsapp_baileys_bridge/node_modules/
# SQLite in-memory artifacts
:memory:
MagicMock/
# Second Repos
Inline/
scratch/
# SQLite in-memory artifacts
:memory:
MagicMock/
# Second Repos
Inline/
# Local git worktrees
.worktrees/
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Overnight Slack preview — generates proactive tweets on a loop.
Posts to a single Slack thread so it doesn't spam the channel.
Run in tmux and check the thread in the morning.
Usage:
python examples/twitter_bot/slack_preview.py
"""
from __future__ import annotations
import json
import os
import random
import time
import httpx
SLACK_TOKEN = os.environ.get("SLACK_BOT_TOKEN", "")
SLACK_CHANNEL = os.environ.get("SLACK_PREVIEW_CHANNEL", "")
INTERVAL_MINUTES = 30
TOPICS = [
# Narrative — opinionated takes that happen to be about OpenJarvis
"mainframe personal computer computing shift efficiency",
"Intelligence Per Watt study local models queries latency",
"personal data cloud APIs privacy terms of service",
"energy consumption NVIDIA Apple Silicon dollar cost constraints",
"learning loop local traces model weights prompts optimization",
"Stanford open source Apache research Hazy Scaling Intelligence",
"cloud dependency personal AI thin orchestration layer brain",
"local inference consumer hardware battery laptop efficiency",
"open source tools local AI available everyone research",
# Technical — but framed as narrative search queries, not product lookups
"channel integrations messaging platforms connect send disconnect",
"composable primitives intelligence engine agents tools learning",
"inference engines hardware detection auto configure local",
"memory retrieval backends keyword search vector similarity",
]
FACTS = [
"In the 70s and 80s computing moved from mainframes to personal computers. Not because PCs were more powerful, but because they became efficient enough for what people actually needed. AI is reaching a similar moment.",
"In our Intelligence Per Watt study, we found that local language models can accurately service 88.7 percent of single-turn chat and reasoning queries at interactive latencies, with intelligence efficiency improving 5.3 times from 2023 to 2025.",
"In nearly all personal AI projects today, the local component is a thin orchestration layer, while the brain lives in someone else data center. Your most personal data routes through cloud APIs, with their latency, their cost, and their terms of service. We built OpenJarvis to fix this.",
"OpenJarvis is structured around five composable primitives: Intelligence, Engine, Agents, Tools and Memory, and Learning. Each primitive can be benchmarked, substituted, and optimized independently.",
"OpenJarvis includes hardware-agnostic telemetry that profiles energy consumption across NVIDIA GPUs, AMD GPUs, and Apple Silicon. Energy and dollar cost are first-class design constraints alongside accuracy.",
"The learning loop uses personal traces to synthesize training data, refine agent behavior, and improve model selection over time. Four optimization layers: model weights, LM prompts, agentic logic, inference engine.",
"OpenJarvis is open source under Apache 2.0, built at Stanford at Hazy Research and the Scaling Intelligence Lab at SAIL. Because the tools for studying and building local-first AI should be available to everyone.",
"OpenJarvis supports 27 channel integrations including Slack, Discord, Telegram, WhatsApp. Adding a new channel is one file implementing BaseChannel with connect, send, and disconnect.",
"OpenJarvis supports multiple inference engines: Ollama, vLLM, SGLang, llama.cpp. jarvis init picks the right one for your hardware.",
"Install OpenJarvis by running git clone https://github.com/open-jarvis/OpenJarvis.git then cd OpenJarvis then uv sync. Use jarvis init to auto-detect hardware and configure the engine.",
"OpenJarvis memory and RAG supports four backends: SQLite FTS5 for keyword search, FAISS for vector similarity, ColBERT for token-level matching, and BM25 for probabilistic retrieval.",
"OpenJarvis ships with nine example projects: deep_research, code_companion, messaging_hub, scheduled_ops, browser_assistant, security_scanner, daily_digest, doc_qa, and multi_model_router.",
]
def slack_send(text: str, thread_ts: str = "") -> str:
payload: dict = {"channel": SLACK_CHANNEL, "text": text}
if thread_ts:
payload["thread_ts"] = thread_ts
resp = httpx.post(
"https://slack.com/api/chat.postMessage",
headers={
"Authorization": f"Bearer {SLACK_TOKEN}",
"Content-Type": "application/json",
},
json=payload,
timeout=10.0,
)
return resp.json().get("ts", "")
def main() -> None:
from openjarvis import Jarvis
from openjarvis.core.events import EventType
print("Starting overnight Slack preview...")
print(f"Posting to #{SLACK_CHANNEL} every ~{INTERVAL_MINUTES} min")
print()
j = Jarvis(model="qwen3:32b", engine_key="ollama")
j._config.agent.max_turns = 5
# Clean and index
db_path = os.path.expanduser("~/.openjarvis/memory.db")
if os.path.exists(db_path):
os.remove(db_path)
print("Indexing facts...")
for fact in FACTS:
j.ask_full(
"Store:\n\n" + fact,
agent="orchestrator",
tools=["memory_store"],
temperature=0.1,
)
print(f"Indexed {len(FACTS)} facts.\n")
# Track tool calls
tool_log: list[dict] = []
def on_tool(event):
tool_log.append({
"tool": event.data.get("tool", ""),
"args": event.data.get("arguments", ""),
})
j._bus.subscribe(EventType.TOOL_CALL_START, on_tool)
# Post parent message
parent_ts = slack_send(
"*OpenJarvis Twitter Bot — Overnight Preview*\n"
f"Generating a tweet every ~{INTERVAL_MINUTES} min. "
"Check this thread in the morning.",
)
print(f"Parent message posted (ts={parent_ts})")
recent: list[str] = []
tweet_count = 0
cycle = 0
while True:
cycle += 1
topic = random.choice(TOPICS)
recent_section = ""
if recent:
recent_list = "\n".join(' - "' + t + '"' for t in recent[-8:])
recent_section = (
"Your recent tweets (DO NOT repeat any of these ideas "
"— write something completely different):\n"
+ recent_list
+ "\n"
)
tool_log.clear()
r = j.ask_full(
"You are @OpenJarvisAI — a researcher/dev who believes local-first "
"AI is the future. Write one tweet. all lowercase, <=280 chars.\n\n"
f'1. memory_search: "{topic}"\n'
"2. Write a tweet that makes someone stop scrolling. Lead with "
"an insight, a question, or a strong take. Use facts from "
"the search results.\n"
+ recent_section
+ '3. channel_send channel="twitter", content=<tweet>. '
"No conversation_id.\n\n"
"Tweets we love:\n"
'- "88.7% of queries run fine on local hardware. why is '
'everyone still paying per API call?"\n'
'- "your most personal data routes through someone else\'s '
'server. we built openjarvis to fix that"\n'
'- "in the 70s computing moved from mainframes to pcs. not '
'because pcs were more powerful — because they got efficient '
'enough. ai is at that moment right now"\n'
'- "we measure energy per query the way most people measure '
'accuracy. if your ai runs on battery, efficiency is the whole game"\n'
'- "four rag backends, swap with one config change. been '
'testing colbert on our docs and the retrieval quality jump is real"\n\n'
"Only real facts. No invented stats. "
"Link: https://github.com/open-jarvis/OpenJarvis",
agent="orchestrator",
tools=["think", "memory_search", "channel_send"],
temperature=0.7,
)
# Extract tweet from channel_send call, or fall back to response text
# (the model sometimes writes the tweet in its response instead of
# calling channel_send)
tweet = ""
for tc in tool_log:
if tc["tool"] == "channel_send":
a = (
tc["args"]
if isinstance(tc["args"], dict)
else (json.loads(tc["args"]) if tc["args"] else {})
)
tweet = a.get("content", "")
break
if not tweet:
# Fallback: extract from response text
raw = r.get("content", "").strip()
# Strip quotes if the model wrapped it
if raw.startswith('"') and raw.endswith('"'):
raw = raw[1:-1]
# Only use it if it looks like a tweet (short, not an explanation)
if 0 < len(raw) <= 300 and "here's" not in raw.lower():
tweet = raw[:280]
ts = time.strftime("%H:%M")
if tweet:
tweet_count += 1
slack_send(f"{tweet_count}. {tweet}", thread_ts=parent_ts)
recent.append(tweet)
# Keep last 10 for context
if len(recent) > 10:
recent.pop(0)
print(f"[{ts}] #{tweet_count}: {tweet[:80]}...")
else:
print(f"[{ts}] cycle {cycle}: empty, skipped")
# Wait with jitter
jitter = random.uniform(0.8, 1.2)
wait = INTERVAL_MINUTES * 60 * jitter
print(f" next tweet in {wait/60:.0f} min")
time.sleep(wait)
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Verify that dense retrieval grounds the bot's reply in real doc facts.
Question: "can I run the orchestrator agent on a laptop without a gpu?"
Ground truth is scattered across docs/ — mentions of ``llama.cpp`` (pure
C++ inference, "ideal for laptops without a discrete GPU"), CPU-only
mode, 4B-model recommendation on CPU, Metal on Apple Silicon.
Stages:
1. **Unground**: backend empty → bot hits the deferral prompt path.
2. **Grounded**: DenseMemory indexed over README + docs/ → bot hits
the grounded prompt with retrieved context embedded.
Success: stage 2's reply cites one of the concrete doc facts
(``llama.cpp``, ``4B``, ``metal``, ``cpu-only``, etc.) that are not in
the ungrounded baseline.
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
_THIS = Path(__file__).resolve().parent
sys.path.insert(0, str(_THIS))
twitter_bot = importlib.import_module("twitter_bot")
sys.path.pop(0)
_resolve_question_prompt = twitter_bot._resolve_question_prompt
_DemoChannel = twitter_bot._DemoChannel
SCORE_THRESHOLD = twitter_bot.SCORE_THRESHOLD
MENTION = {
"id": "3000000000000000001",
"author": "indie_hacker",
"text": "@OpenJarvisAI can I run the orchestrator agent on a laptop without a gpu?",
}
def _ask_once(j, demo_channel, backend):
prompt, top_score = _resolve_question_prompt(
backend, MENTION["author"], MENTION["id"], MENTION["text"],
)
demo_channel.last_sent = None
response = j.ask(
prompt,
agent="orchestrator",
tools=["channel_send"],
temperature=0.4,
channel=demo_channel,
)
return demo_channel.last_sent or response, top_score
def main():
from openjarvis import Jarvis
from openjarvis.tools.storage.dense import DenseMemory
sys.path.insert(0, str(_THIS.parents[1] / "scripts"))
from index_docs import build_index # type: ignore
sys.path.pop(0)
model = "gemma4:31b"
j = Jarvis(model=model, engine_key="ollama")
demo_channel = _DemoChannel()
repo_root = Path(__file__).resolve().parents[2]
print(f"Test question: {MENTION['text']}")
print(f"Score threshold: {SCORE_THRESHOLD}\n")
# ------------------------------------------------------------------ #
print("=" * 80)
print("STAGE 1: No retrieval — forced deferral path")
print("=" * 80)
empty_backend = DenseMemory() # empty index
reply_1, score_1 = _ask_once(j, demo_channel, empty_backend)
print(f"top_score={score_1:.3f} (threshold={SCORE_THRESHOLD})")
print(f"Reply: {reply_1}\n")
# ------------------------------------------------------------------ #
print("=" * 80)
print("STAGE 2: Full docs indexed — grounded path if score >= threshold")
print("=" * 80)
backend = build_index(repo_root)
print(f"Indexed {backend.count()} chunks from README + docs/")
# Show what the retriever actually surfaces for the mention text
print("\nTop 3 hits for the mention text:")
hits = backend.retrieve(MENTION["text"], top_k=3)
for i, h in enumerate(hits, 1):
preview = h.content.replace("\n", " ")[:180]
print(
f" [{i}] score={h.score:.3f} src={h.source}"
f" bc={h.metadata.get('breadcrumb', '')}"
)
print(f" {preview}{'...' if len(h.content) > 180 else ''}")
print()
reply_2, score_2 = _ask_once(j, demo_channel, backend)
print(f"top_score={score_2:.3f} (threshold={SCORE_THRESHOLD})")
print(f"Reply: {reply_2}\n")
# ------------------------------------------------------------------ #
print("=" * 80)
print("COMPARISON")
print("=" * 80)
print(f"Without docs (score {score_1:.2f}): {reply_1}")
print(f"With docs (score {score_2:.2f}): {reply_2}")
print()
grounding_terms = [
"llama.cpp", "llama cpp", "4b", "metal", "apple silicon",
"cpu-only", "cpu only", "rocm", "quantization", "ollama",
"vllm", "sglang", "mlx",
]
r2_lower = reply_2.lower()
mentioned = [t for t in grounding_terms if t in r2_lower]
print(f"Grounding signals in stage 2 reply: {mentioned or 'none'}")
j.close()
if __name__ == "__main__":
main()
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Stress-test the twitter bot on diverse mentions with the dense-retrieval pipeline.
For each mention:
* captures the actual ``channel_send`` payload (the tweet that would be sent)
* for QUESTIONs, records whether retrieval scored above threshold
(grounded path) or below (deferral path)
* scores the reply against voice rules
"""
from __future__ import annotations
import importlib
import re
import sys
import time
from pathlib import Path
_THIS = Path(__file__).resolve().parent
sys.path.insert(0, str(_THIS))
twitter_bot = importlib.import_module("twitter_bot")
sys.path.pop(0)
_classify_mention = twitter_bot._classify_mention
_build_bug_prompt = twitter_bot._build_bug_prompt
_build_feature_prompt = twitter_bot._build_feature_prompt
_build_praise_prompt = twitter_bot._build_praise_prompt
_resolve_question_prompt = twitter_bot._resolve_question_prompt
_DemoChannel = twitter_bot._DemoChannel
SCORE_THRESHOLD = twitter_bot.SCORE_THRESHOLD
REAL_WORLD_MENTIONS = [
# === QUESTIONs that should ground (docs cover them) ===
{
"id": "3000000000000000001",
"author": "ml_researcher",
"text": (
"@OpenJarvisAI does this work with vllm or "
"do I need ollama specifically?"
),
},
{
"id": "3000000000000000002",
"author": "indie_hacker",
"text": (
"@OpenJarvisAI can I run the orchestrator agent "
"on a laptop without a gpu?"
),
},
{
"id": "3000000000000000003",
"author": "macuser",
"text": "@OpenJarvisAI how do I install on macos with apple silicon?",
},
{
"id": "3000000000000000004",
"author": "longwinded_lou",
"text": (
"@OpenJarvisAI how does the memory system handle "
"conflicting facts? overwrite or keep both?"
),
},
# === QUESTIONs that should defer (off-topic / unknowable) ===
{
"id": "3000000000000000005",
"author": "off_topic_olive",
"text": "@OpenJarvisAI what's the weather in tokyo today?",
},
{
"id": "3000000000000000006",
"author": "specific_specs",
"text": (
"@OpenJarvisAI what's the exact tokens-per-second "
"on an M3 Pro with the 70B model?"
),
},
# === BUG / FEATURE / PRAISE / SPAM ===
{
"id": "3000000000000000007",
"author": "devops_dan",
"text": (
"@OpenJarvisAI getting a segfault on startup "
"with the lemonade backend, 0.18.2"
),
},
{
"id": "3000000000000000008",
"author": "enterprise_eng",
"text": (
"@OpenJarvisAI any plans for SSO support? "
"would love to deploy this internally"
),
},
{
"id": "3000000000000000009",
"author": "convert_carl",
"text": (
"@OpenJarvisAI switched from langchain last week, this is incredible"
),
},
{
"id": "3000000000000000010",
"author": "crypto_bro",
"text": "@OpenJarvisAI BUY $JARVIS COIN guaranteed 10x gains link in bio",
},
]
_EMOJI_RE = re.compile(
r"[\U0001F300-\U0001FAFF"
r"\U00002600-\U000027BF"
r"\U0001F900-\U0001F9FF]",
)
def _check_voice(reply: str) -> dict:
has_upper = bool(re.search(r"[A-Z]", reply))
has_emoji = bool(_EMOJI_RE.search(reply))
has_hashtag = "#" in reply
return {
"len": len(reply),
"<=280": len(reply) <= 280,
"lowercase": not has_upper,
"no_emoji": not has_emoji,
"no_hashtag": not has_hashtag,
}
def main():
from openjarvis import Jarvis
sys.path.insert(0, str(_THIS.parents[1] / "scripts"))
from index_docs import build_index # type: ignore
sys.path.pop(0)
model = "gemma4:31b"
print("Building dense index from README + docs/...", flush=True)
backend = build_index(Path(__file__).resolve().parents[2])
print(f"Indexed {backend.count()} chunks.\n", flush=True)
j = Jarvis(model=model, engine_key="ollama")
demo_channel = _DemoChannel()
results = []
print(f"Testing {len(REAL_WORLD_MENTIONS)} mentions with {model}...\n", flush=True)
try:
for idx, tweet in enumerate(REAL_WORLD_MENTIONS, 1):
mention_type = _classify_mention(tweet["text"])
print(
f"[{idx}/{len(REAL_WORLD_MENTIONS)}] [{mention_type}] "
f"@{tweet['author']}: {tweet['text'][:70]}",
flush=True,
)
entry = {
"n": idx,
"author": tweet["author"],
"text": tweet["text"],
"type": mention_type,
"reply": "[ignored]",
"voice": None,
"ground_state": "",
"score": 0.0,
"secs": 0.0,
}
if mention_type == "SPAM":
results.append(entry)
print(" -> [ignored]\n", flush=True)
continue
if mention_type == "QUESTION":
prompt, score = _resolve_question_prompt(
backend, tweet["author"], tweet["id"], tweet["text"],
)
tools = ["channel_send"]
entry["score"] = score
entry["ground_state"] = (
"grounded" if score >= SCORE_THRESHOLD else "deferred"
)
print(
f" retrieval top-1: {score:.3f} -> {entry['ground_state']}",
flush=True,
)
elif mention_type == "BUG_REPORT":
prompt = _build_bug_prompt(
tweet["author"], tweet["id"], tweet["text"],
)
tools = ["http_request", "channel_send"]
elif mention_type == "FEATURE_REQUEST":
prompt = _build_feature_prompt(
tweet["author"], tweet["id"], tweet["text"],
)
tools = ["http_request", "channel_send"]
else:
prompt = _build_praise_prompt(
tweet["author"], tweet["id"], tweet["text"],
)
tools = ["channel_send"]
demo_channel.last_sent = None
t0 = time.time()
try:
response = j.ask(
prompt,
agent="orchestrator",
tools=tools,
temperature=0.4,
channel=demo_channel,
)
except Exception as exc:
response = f"<error: {exc}>"
elapsed = time.time() - t0
reply = demo_channel.last_sent or response
entry["reply"] = reply
entry["voice"] = _check_voice(reply)
entry["secs"] = elapsed
results.append(entry)
print(f" -> {reply[:140]}", flush=True)
print(f" ({elapsed:.1f}s)\n", flush=True)
finally:
j.close()
# Print results table
print("\n" + "=" * 110)
print("RESULTS TABLE")
print("=" * 110 + "\n")
print("| # | Type | State | Score | Mention | Reply | Len | voice OK |")
print("|---|------|-------|-------|---------|-------|-----|----------|")
for r in results:
if r["voice"] is None:
print(
f"| {r['n']} | {r['type']} | - | - | "
f"@{r['author']}: {r['text'][:50]}... "
"| _[ignored]_ | - | - |",
)
else:
v = r["voice"]
ok = all([v["<=280"], v["lowercase"], v["no_emoji"], v["no_hashtag"]])
score_str = f"{r['score']:.2f}" if r['type'] == 'QUESTION' else "-"
state = r["ground_state"] or "-"
short_reply = r["reply"][:80].replace("\n", " ").replace("|", "/")
short_text = r["text"][:50].replace("|", "/")
reply_suffix = "..." if len(r["reply"]) > 80 else ""
print(
f"| {r['n']} | {r['type'][:8]} | {state} | {score_str} | "
f"@{r['author']}: {short_text}... "
f"| {short_reply}{reply_suffix} "
f"| {v['len']} | {'yes' if ok else 'NO'} |",
)
# Voice rules summary
scored = [r for r in results if r["voice"] is not None]
if scored:
n = len(scored)
n280 = sum(1 for r in scored if r["voice"]["<=280"])
nlow = sum(1 for r in scored if r["voice"]["lowercase"])
nemo = sum(1 for r in scored if r["voice"]["no_emoji"])
nhash = sum(1 for r in scored if r["voice"]["no_hashtag"])
print(
f"\nVoice compliance: <=280: {n280}/{n}, "
f"lowercase: {nlow}/{n}, no emoji: {nemo}/{n}, "
f"no hashtag: {nhash}/{n}",
)
avg_secs = sum(r["secs"] for r in scored) / n
print(f"Avg latency: {avg_secs:.1f}s")
if __name__ == "__main__":
main()
+765
View File
@@ -0,0 +1,765 @@
#!/usr/bin/env python3
"""OpenJarvis Twitter Bot — @OpenJarvisAI reactive mention handler.
Listens for @mentions and responds: answers questions, creates GitHub issues
for bugs/feature requests, acknowledges praise, ignores spam. Like @grok.
Usage:
python examples/twitter_bot/twitter_bot.py --demo
python examples/twitter_bot/twitter_bot.py --live
python examples/twitter_bot/twitter_bot.py --live --index-docs
"""
from __future__ import annotations
import signal
import sys
import threading
from typing import Optional
import click
class _DemoChannel:
"""Stub channel for demo mode.
Accepts ``send()`` calls and records the content so the demo can
display exactly what would be tweeted — instead of the agent's
post-error fallback text (which bypasses the voice rules).
"""
channel_id = "demo"
def __init__(self) -> None:
self.last_sent: str | None = None
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: dict | None = None,
) -> bool:
self.last_sent = content
return True
# Unused in demo but required by ChannelSendTool duck-typing
def connect(self) -> None: ...
def disconnect(self) -> None: ...
DEMO_TWEETS = [
{
"id": "1000000000000000001",
"author": "alice_dev",
"text": "@OpenJarvisAI how do I add a new channel integration?",
},
{
"id": "1000000000000000002",
"author": "bob_user",
"text": (
"@OpenJarvisAI bug: the memory_search tool crashes "
"when the index is empty"
),
},
{
"id": "1000000000000000003",
"author": "carol_eng",
"text": "@OpenJarvisAI it would be great to have a built-in scheduler UI",
},
{
"id": "1000000000000000004",
"author": "dave_fan",
"text": "@OpenJarvisAI just discovered this project, absolutely love it!",
},
{
"id": "1000000000000000005",
"author": "spambot99",
"text": "@OpenJarvisAI BUY CRYPTO NOW 🚀🚀🚀 LINK IN BIO",
},
]
# ---------------------------------------------------------------------------
# Retrieval-grounded question handling
# ---------------------------------------------------------------------------
#
# For QUESTION mentions we do dense retrieval in Python before the agent
# runs, then route to one of two prompts based on the top-1 cosine score:
#
# * ``_build_question_grounded_prompt`` — top-1 >= SCORE_THRESHOLD.
# The retrieved context is embedded directly in the prompt. The model
# only needs ``channel_send``.
# * ``_build_question_deferral_prompt`` — top-1 < SCORE_THRESHOLD.
# No context worth grounding on. The model is told to post a short
# honest deferral.
#
# Threshold rationale: see tests/tools/storage/test_dense.py. With
# nomic-embed-text on the OpenJarvis fixture corpus, relevant queries
# top-1 scored 0.50-0.74 (median 0.68) and off-topic scored 0.40-0.51
# (median 0.47). 0.55 biases toward deferral on borderline queries —
# safer for public Twitter than grounding on a weak match.
SCORE_THRESHOLD = 0.55
# Voice rules included in every per-call prompt so the model always sees them
_VOICE = (
"Rules for your reply:\n"
"- lowercase prose; preserve URLs, code identifiers, and technical terms "
"(model names, library names, file paths) as written.\n"
"- <=280 characters.\n"
"- no emojis. no hashtags.\n"
"- casual and direct, like a dev helping another dev.\n"
"- do not invent URLs, issue numbers, stats, commands, performance claims, "
"or feature names. if you're not sure, don't guess.\n"
)
def _format_context(results) -> str:
"""Render top retrieved chunks as a numbered list for the prompt."""
out = []
for i, r in enumerate(results, 1):
src = r.source or "?"
breadcrumb = r.metadata.get("breadcrumb", "") if r.metadata else ""
header = f"[{i}] {src}"
if breadcrumb and breadcrumb not in src:
header += f"{breadcrumb}"
out.append(f"{header}\n{r.content}")
return "\n\n---\n\n".join(out)
def _build_question_grounded_prompt(
author: str,
tweet_id: str,
text: str,
context: str,
top_score: float,
) -> str:
"""Prompt used when retrieval surfaces relevant content (top score >= threshold)."""
return (
"You are @OpenJarvisAI. Someone asked a question. We retrieved "
f"context from the docs with top similarity {top_score:.2f}.\n\n"
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
f'"{text}"\n\n'
"Retrieved context:\n"
"=================\n"
f"{context}\n"
"=================\n\n"
"Compose a reply ONLY from facts in the context above. Do not add "
"details that are not in the context. If the context doesn't fully "
"cover the question, answer the part that IS covered and defer on "
"the rest (e.g. \"...not sure on the rest — will check\"). Then "
f'call channel_send with conversation_id="{tweet_id}".\n\n'
+ _VOICE
)
def _build_question_deferral_prompt(author: str, tweet_id: str, text: str) -> str:
"""Prompt used when retrieval has nothing relevant (top score < threshold).
The model is told NOT to answer — because attempting to answer without
grounding is the exact failure mode we're trying to avoid.
"""
return (
"You are @OpenJarvisAI. Someone asked a question, but our docs "
"search did not find relevant material — so we do NOT have a "
"grounded answer.\n\n"
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
f'"{text}"\n\n'
"Reply with a short honest deferral. Something like:\n"
' "not sure off the top of my head — let me check and get back to you"\n'
' "good question, need to double-check the answer — back with details soon"\n'
"Do NOT guess. Do NOT make up facts. A deferral is always safer "
"than a wrong public answer.\n\n"
f'Then call channel_send with conversation_id="{tweet_id}".\n\n'
+ _VOICE
)
# Kept for backwards-compat with tests; delegates to the grounded variant
# with an empty context (forcing the model to defer in its own words).
def _build_question_prompt(author: str, tweet_id: str, text: str) -> str:
return _build_question_deferral_prompt(author, tweet_id, text)
def _build_bug_prompt(author: str, tweet_id: str, text: str) -> str:
return (
"You are @OpenJarvisAI. Someone reported a bug.\n\n"
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
f'"{text}"\n\n'
"1. call http_request to create a github issue:\n"
" url: https://api.github.com/repos/open-jarvis/OpenJarvis/issues\n"
" method: POST\n"
' headers: {"Authorization": "Bearer $GITHUB_TOKEN", '
'"Accept": "application/vnd.github+json"}\n'
f' body: {{"title": "<short title>", "body": "reported via twitter '
f"by @{author}: {text}\", "
'"labels": ["bug", "from-twitter"]}}\n'
f'2. call channel_send with conversation_id="{tweet_id}" and a short '
"reply like: \"opened an issue for this — we'll look into it. "
'thanks for the report"\n\n'
"do NOT include a github issue URL in your reply — you don't know "
"the issue number yet.\n\n"
+ _VOICE
)
def _build_feature_prompt(author: str, tweet_id: str, text: str) -> str:
return (
"You are @OpenJarvisAI. Someone requested a feature.\n\n"
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
f'"{text}"\n\n'
"1. call http_request to create a github issue:\n"
" url: https://api.github.com/repos/open-jarvis/OpenJarvis/issues\n"
" method: POST\n"
f' body: {{"title": "feature request: <title>", "body": "requested '
f"via twitter by @{author}: {text}\", "
'"labels": ["enhancement", "from-twitter"]}}\n'
f'2. call channel_send with conversation_id="{tweet_id}" and a short '
"reply like: \"love this idea — opened an issue to track it\"\n\n"
"do NOT include a github issue URL in your reply — you don't know "
"the issue number yet.\n\n"
+ _VOICE
)
def _build_praise_prompt(author: str, tweet_id: str, text: str) -> str:
return (
"You are @OpenJarvisAI. Someone said something nice.\n\n"
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
f'"{text}"\n\n'
f'call channel_send with conversation_id="{tweet_id}" and a genuine, '
"short thank-you. be real, not corporate.\n\n"
+ _VOICE
)
_BUG_KEYWORDS = (
"bug:", "bug ", "crash", "error", "fails", "broken", "segfault",
)
_FEATURE_KEYWORDS = (
"feature", "would love", "would be great", "wish",
"please add", "can you add", "any plans",
)
_PRAISE_KEYWORDS = (
"love", "amazing", "awesome", "impressed",
"great work", "switched from", "incredible",
)
_SPAM_KEYWORDS = (
"buy", "crypto", "income", "free download",
"link in bio", "10x", "guaranteed",
)
def _classify_mention(text: str) -> str:
"""Simple keyword-based classification to avoid wasting a model turn."""
lower = text.lower()
if any(w in lower for w in _BUG_KEYWORDS):
return "BUG_REPORT"
if any(w in lower for w in _FEATURE_KEYWORDS):
return "FEATURE_REQUEST"
if any(w in lower for w in _PRAISE_KEYWORDS):
return "PRAISE"
if any(w in lower for w in _SPAM_KEYWORDS):
return "SPAM"
return "QUESTION"
def _resolve_question_prompt(backend, author: str, tweet_id: str, text: str):
"""Do retrieval in Python and pick grounded vs deferral prompt.
Returns ``(prompt, top_score)``. If *backend* is None or retrieval
returns nothing, falls back to the deferral prompt.
"""
if backend is None:
return _build_question_deferral_prompt(author, tweet_id, text), 0.0
hits = backend.retrieve(text, top_k=3)
if not hits:
return _build_question_deferral_prompt(author, tweet_id, text), 0.0
top_score = hits[0].score
if top_score < SCORE_THRESHOLD:
return _build_question_deferral_prompt(author, tweet_id, text), top_score
# Grounded: include the top hits in the prompt verbatim
return (
_build_question_grounded_prompt(
author,
tweet_id,
text,
_format_context(hits),
top_score,
),
top_score,
)
def _build_dense_backend_or_none():
"""Try to build the DenseMemory index from README + docs/.
Returns None on any failure (Ollama down, embedding model missing,
docs missing). Demo mode falls back to the deferral prompt in that
case, which keeps the demo runnable without a full setup.
"""
try:
import pathlib as _pl
import sys as _sys
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[2] / "scripts"))
try:
from index_docs import build_index # type: ignore
finally:
_sys.path.pop(0)
repo_root = _pl.Path(__file__).resolve().parents[2]
return build_index(repo_root)
except Exception as exc:
click.echo(
f" [warn] dense retrieval unavailable — {exc}\n"
" questions will use the deferral path.",
err=True,
)
return None
def _run_demo(model: str, engine_key: str) -> None:
"""Process sample mentions through the agent without Twitter API access."""
try:
from openjarvis import Jarvis
except ImportError:
click.echo(
"Error: openjarvis is not installed. "
"Install it with: uv sync --extra dev",
err=True,
)
sys.exit(1)
click.echo("OpenJarvis Twitter Bot — Demo Mode (reactive only)")
click.echo(f"Model: {model} | Engine: {engine_key}")
click.echo("=" * 60)
try:
j = Jarvis(model=model, engine_key=engine_key)
except Exception as exc:
click.echo(
f"Error: could not initialize Jarvis — {exc}\n\n"
"Make sure your engine is running. For Ollama:\n"
" ollama serve\n"
" ollama pull qwen3:32b\n\n"
"For cloud engines, ensure API keys are set in your .env file.",
err=True,
)
sys.exit(1)
click.echo("Building dense retrieval index from README + docs/...")
backend = _build_dense_backend_or_none()
if backend is not None:
click.echo(f"Indexed {backend.count()} doc chunks.\n")
click.echo(f"Processing {len(DEMO_TWEETS)} sample mentions...\n")
# In demo mode, inject a stub channel so channel_send succeeds and
# we can capture the model's actual reply (what it would tweet) —
# rather than its post-error fallback text.
demo_channel = _DemoChannel()
try:
for idx, tweet in enumerate(DEMO_TWEETS, 1):
mention_type = _classify_mention(tweet["text"])
click.echo(
f" [{idx}/{len(DEMO_TWEETS)}] [{mention_type}] @{tweet['author']}: "
f"{tweet['text'][:60]}...",
)
if mention_type == "SPAM":
click.echo(" -> [ignored]")
click.echo()
continue
if mention_type == "QUESTION":
prompt, top_score = _resolve_question_prompt(
backend, tweet["author"], tweet["id"], tweet["text"],
)
tools = ["channel_send"]
ground_state = (
f"grounded({top_score:.2f})"
if top_score >= SCORE_THRESHOLD
else f"deferred({top_score:.2f})"
)
click.echo(f" [{ground_state}]")
elif mention_type == "BUG_REPORT":
prompt = _build_bug_prompt(tweet["author"], tweet["id"], tweet["text"])
tools = ["http_request", "channel_send"]
elif mention_type == "FEATURE_REQUEST":
prompt = _build_feature_prompt(
tweet["author"], tweet["id"], tweet["text"],
)
tools = ["http_request", "channel_send"]
else:
prompt = _build_praise_prompt(
tweet["author"], tweet["id"], tweet["text"],
)
tools = ["channel_send"]
demo_channel.last_sent = None
response = j.ask(
prompt,
agent="orchestrator",
tools=tools,
temperature=0.4,
channel=demo_channel,
)
# Prefer the actual channel_send content (the tweet the model
# composed under voice rules) over the agent's final summary.
reply = demo_channel.last_sent or response
click.echo(f" -> {reply[:160]}")
click.echo()
except Exception as exc:
click.echo(f"Error during processing: {exc}", err=True)
sys.exit(1)
finally:
j.close()
click.echo("Demo complete.")
def _index_docs(j) -> None: # noqa: ANN001
"""Pre-index docs/ and README.md into memory for RAG."""
import pathlib
root = pathlib.Path(__file__).resolve().parents[2]
docs_dir = root / "docs"
readme = root / "README.md"
files_to_index: list[pathlib.Path] = []
if readme.exists():
files_to_index.append(readme)
if docs_dir.is_dir():
files_to_index.extend(sorted(docs_dir.rglob("*.md")))
if not files_to_index:
click.echo("No docs found to index.")
return
click.echo(f"Indexing {len(files_to_index)} doc files into memory...")
for fpath in files_to_index:
try:
text = fpath.read_text(encoding="utf-8")
chunk_size = 2000
for i in range(0, len(text), chunk_size):
chunk = text[i : i + chunk_size]
j.ask(
f"Store this documentation excerpt from {fpath.name}:\n\n{chunk}",
agent="orchestrator",
tools=["memory_store"],
temperature=0.1,
)
except Exception as exc:
click.echo(f" Warning: could not index {fpath.name}: {exc}")
click.echo("Indexing complete.\n")
def _seed_since_id_to_newest(channel) -> Optional[str]:
"""Fetch the current newest mention and set ``_since_id`` so that the
subsequent poll loop only surfaces mentions that arrive AFTER now.
Returns the id we seeded with, or ``None`` if the inbox is empty /
the call failed. This is how dry-run (and live first-boot) avoid
processing the historical backlog.
"""
import httpx
try:
resp = httpx.get(
f"https://api.twitter.com/2/users/{channel._bot_user_id}/mentions",
headers={"Authorization": f"Bearer {channel._bearer}"},
params={"max_results": 5},
timeout=10.0,
)
if resp.status_code != 200:
return None
data = resp.json()
if data.get("meta", {}).get("result_count", 0) == 0:
return None
newest = data.get("meta", {}).get("newest_id") or (
data["data"][0]["id"] if data.get("data") else None
)
if newest:
channel._since_id = newest
return newest
except Exception:
return None
def _run_live(
model: str,
engine_key: str,
index_docs: bool,
*,
dry_run: bool = False,
) -> None:
"""Connect to Twitter and handle mentions in real time.
When ``dry_run`` is True, every side-effect is intercepted:
* ``channel_send`` prints the draft reply instead of posting.
* ``http_request`` prints the intended call (e.g. GitHub issue
creation) and returns a fake success result so the agent loop
completes as it would in live mode.
* ``since_id`` is seeded to the newest existing mention so we only
react to mentions that arrive AFTER boot.
"""
try:
from openjarvis import Jarvis
from openjarvis.channels._stubs import ChannelStatus
from openjarvis.channels.twitter_channel import TwitterChannel
from openjarvis.core.types import ToolResult
except ImportError:
click.echo(
"Error: openjarvis is not installed. "
"Install it with: uv sync --extra dev",
err=True,
)
sys.exit(1)
mode_label = "Dry-Run" if dry_run else "Live"
click.echo(f"OpenJarvis Twitter Bot — {mode_label} Mode")
click.echo(f"Model: {model} | Engine: {engine_key}")
click.echo("=" * 60)
try:
j = Jarvis(model=model, engine_key=engine_key)
except Exception as exc:
click.echo(f"Error: could not initialize Jarvis — {exc}", err=True)
sys.exit(1)
click.echo("Building dense retrieval index from README + docs/...")
backend = _build_dense_backend_or_none()
if backend is not None:
click.echo(f"Indexed {backend.count()} doc chunks.")
# ------------------------------------------------------------------
# Channel — real posting, or a dry-run subclass that just prints.
# ------------------------------------------------------------------
if dry_run:
class _DryRunTwitterChannel(TwitterChannel):
"""Subclass whose ``send`` prints the draft reply but never POSTs."""
def send(self, channel, content, *, conversation_id="", metadata=None):
click.echo("")
click.echo(" ┌── DRY-RUN: would post tweet ──")
click.echo(f" │ in_reply_to: {conversation_id or '(none)'}")
click.echo(f" │ text ({len(content)} chars): {content[:280]}")
click.echo(" └──────────────────────────────")
return True
channel = _DryRunTwitterChannel()
else:
channel = TwitterChannel()
channel.connect()
if channel.status() == ChannelStatus.ERROR:
click.echo(
"Error: could not connect to Twitter.\n"
"Ensure these env vars are set:\n"
" TWITTER_BEARER_TOKEN\n"
" TWITTER_API_KEY / TWITTER_API_SECRET\n"
" TWITTER_ACCESS_TOKEN / TWITTER_ACCESS_SECRET\n"
" TWITTER_BOT_USER_ID",
err=True,
)
j.close()
sys.exit(1)
seeded = _seed_since_id_to_newest(channel)
if seeded:
click.echo(
f"Seeded since_id={seeded} — only new mentions after "
"this point will trigger the bot.",
)
else:
click.echo(
"No existing mentions found (or couldn't read inbox) — "
"bot will start processing from the next one onward.",
)
# ------------------------------------------------------------------
# In dry-run, also intercept http_request so bug/feature mentions
# don't actually create GitHub issues.
# ------------------------------------------------------------------
http_restore = None
if dry_run:
from openjarvis.tools.http_request import HttpRequestTool
_orig_execute = HttpRequestTool.execute
def _dry_http_execute(self, **params): # noqa: ANN001
url = params.get("url", "")
method = params.get("method", "GET")
body = params.get("body", "")
click.echo("")
click.echo(" ┌── DRY-RUN: would HTTP call ──")
click.echo(f"{method} {url}")
if body:
body_str = body if isinstance(body, str) else str(body)
suffix = "..." if len(body_str) > 300 else ""
click.echo(f" │ body: {body_str[:300]}{suffix}")
click.echo(" └──────────────────────────────")
# Return a fake success response so the agent loop finishes.
return ToolResult(
tool_name="http_request",
success=True,
content=(
'{"number": 999, "html_url": '
'"https://github.com/open-jarvis/OpenJarvis/issues/999"}'
),
)
HttpRequestTool.execute = _dry_http_execute
http_restore = (HttpRequestTool, _orig_execute)
mode_hint = (
"[DRY-RUN] Nothing will actually be posted or filed."
if dry_run
else "[LIVE] Real tweets will be posted."
)
click.echo(f"\n{mode_hint}")
click.echo("Waiting for @OpenJarvisAI mentions (poll every 60s). Ctrl+C to stop.\n")
def _handle_mention(msg): # noqa: ANN001
"""Process an incoming mention through the agent."""
mention_type = _classify_mention(msg.content)
click.echo("=" * 60)
click.echo(f"[📨] mention {msg.message_id} from @{msg.sender}: {msg.content}")
click.echo(f" classified: {mention_type}")
if mention_type == "SPAM":
click.echo(" -> [ignored]\n")
return
if mention_type == "QUESTION":
prompt, top_score = _resolve_question_prompt(
backend, msg.sender, msg.message_id, msg.content,
)
tools = ["channel_send"]
state = "grounded" if top_score >= SCORE_THRESHOLD else "deferred"
click.echo(f" retrieval top-1 score: {top_score:.3f} -> {state}")
elif mention_type == "BUG_REPORT":
prompt = _build_bug_prompt(msg.sender, msg.message_id, msg.content)
tools = ["http_request", "channel_send"]
elif mention_type == "FEATURE_REQUEST":
prompt = _build_feature_prompt(msg.sender, msg.message_id, msg.content)
tools = ["http_request", "channel_send"]
else:
prompt = _build_praise_prompt(msg.sender, msg.message_id, msg.content)
tools = ["channel_send"]
try:
j.ask(
prompt,
agent="orchestrator",
tools=tools,
temperature=0.4,
channel=channel,
)
except Exception as exc:
click.echo(f" ERROR processing mention: {exc}\n")
channel.on_message(_handle_mention)
# Block until interrupted
stop = threading.Event()
def _signal_handler(sig, frame): # noqa: ANN001
click.echo("\nShutting down...")
stop.set()
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
stop.wait()
if http_restore is not None:
http_restore[0].execute = http_restore[1]
channel.disconnect()
j.close()
click.echo("Stopped.")
@click.command()
@click.option(
"--model",
default="qwen3:32b",
show_default=True,
help="Model to use for mention handling.",
)
@click.option(
"--engine",
"engine_key",
default="ollama",
show_default=True,
help="Engine backend (ollama, cloud, vllm, etc.).",
)
@click.option(
"--demo",
is_flag=True,
default=False,
help="Run in demo mode with sample mentions (no Twitter API required).",
)
@click.option(
"--live",
is_flag=True,
default=False,
help="Run in live mode, polling Twitter for real mentions.",
)
@click.option(
"--dry-run",
"dry_run",
is_flag=True,
default=False,
help="Poll Twitter live, but print draft replies instead of posting "
"them and simulate GitHub issue creation. Safe for end-to-end testing.",
)
@click.option(
"--index-docs",
is_flag=True,
default=False,
help="Pre-index docs/ and README.md into memory before starting.",
)
def main(
model: str,
engine_key: str,
demo: bool,
live: bool,
dry_run: bool,
index_docs: bool,
) -> None:
"""OpenJarvis Twitter bot — reactive @OpenJarvisAI mention handler.
Polls for @mentions, classifies them (question, bug, feature request,
praise, spam), and responds appropriately — including creating GitHub
issues for bug reports and feature requests. Similar to how @grok works.
\b
Demo mode (no Twitter credentials needed):
python examples/twitter_bot/twitter_bot.py --demo
\b
Live mode (requires Twitter + GitHub credentials):
python examples/twitter_bot/twitter_bot.py --live
python examples/twitter_bot/twitter_bot.py --live --index-docs
"""
if demo:
_run_demo(model, engine_key)
elif dry_run:
_run_live(model, engine_key, index_docs, dry_run=True)
elif live:
_run_live(model, engine_key, index_docs, dry_run=False)
else:
click.echo(
"Please specify --demo, --dry-run, or --live mode.\n"
"Run with --help for usage details.",
)
sys.exit(1)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -96,6 +96,7 @@ channel-mastodon = ["Mastodon.py>=1.8"]
channel-xmpp = ["slixmpp>=1.8"]
channel-rocketchat = ["rocketchat-API>=1.30"]
channel-zulip = ["zulip>=0.9"]
channel-twitter = ["httpx>=0.27"]
channel-twitch = ["twitchio>=2.6"]
channel-nostr = ["pynostr>=0.6"]
channel-twilio = ["twilio>=9.0"]
@@ -104,7 +105,6 @@ channel-gmail = [
"google-auth-oauthlib>=1.0",
"google-auth-httplib2>=0.2",
]
channel-twitter = ["tweepy>=4.14"]
browser = ["playwright>=1.40"]
media = ["openai>=1.30"]
pdf = ["pdfplumber>=0.10"]
+17 -148
View File
@@ -219,16 +219,6 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -468,21 +458,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -781,22 +756,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -817,11 +777,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1164,23 +1122,6 @@ dependencies = [
"rand 0.8.5",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nom"
version = "1.2.4"
@@ -1494,50 +1435,12 @@ dependencies = [
"toml",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "ordered-float"
version = "5.1.0"
@@ -1952,31 +1855,28 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -1986,6 +1886,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -2128,6 +2029,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -2162,7 +2064,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"jni",
"log",
@@ -2263,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -2485,27 +2387,6 @@ dependencies = [
"windows",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -2618,16 +2499,6 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -3065,6 +2936,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -3181,17 +3061,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.1.2"
+1 -1
View File
@@ -27,7 +27,7 @@ toml = "0.8"
thiserror = "2"
tracing = "0.1"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream", "blocking", "native-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "blocking", "rustls-tls"] }
rusqlite = { version = "0.32", features = ["bundled", "column_decltype"] }
regex = "1"
once_cell = "1"
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Index OpenJarvis docs (README.md + docs/**/*.md) into a DenseMemory backend.
Usage:
python scripts/index_docs.py # print retrieval smoke test
python scripts/index_docs.py --query "can i run this on cpu?"
This script is idempotent: it builds a fresh in-memory index each run.
There is no disk persistence by design — dense vectors are cheap to
rebuild and the docs corpus is small.
Embedding model: ``nomic-embed-text`` via Ollama. Pull it with
``ollama pull nomic-embed-text`` if you don't have it. Expected
indexing time for the full corpus: ~30s on a warm Ollama server.
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
from openjarvis.tools.storage.dense import (
DenseMemory,
MdChunk,
chunk_markdown,
dedupe_chunks,
)
def discover_md_files(repo_root: Path) -> list[Path]:
"""README + every markdown file under docs/. Sorted for determinism."""
files: list[Path] = []
readme = repo_root / "README.md"
if readme.exists():
files.append(readme)
docs_dir = repo_root / "docs"
if docs_dir.is_dir():
files.extend(sorted(docs_dir.rglob("*.md")))
return files
def build_index(
repo_root: Path,
*,
max_section_tokens: int = 1000,
paragraph_overlap_tokens: int = 100,
dedupe: bool = True,
# Empirical: on the actual OpenJarvis docs the boilerplate that
# crowds retrieval ("OpenJarvis runs entirely on your hardware...")
# appears in exactly 2 files (downloads.md ↔ installation.md).
# Spec'd 3+ removes 0 chunks; 2+ removes 15 (1.3%) — all genuine
# cross-file boilerplate. See the dry-run audit logged at index time.
dedupe_min_files: int = 2,
dedupe_threshold: float = 0.7,
) -> DenseMemory:
"""Chunk all markdown under *repo_root* and build a DenseMemory index.
When ``dedupe`` is True (default), runs cross-file boilerplate
deduplication after chunking and before embedding. The dedupe
report is printed to stderr so reviewers can spot over-aggressive
drops; if it removes >20% of the corpus a warning is emitted.
"""
backend = DenseMemory()
md_files = discover_md_files(repo_root)
if not md_files:
raise RuntimeError(f"No markdown files found under {repo_root}")
all_chunks: list[MdChunk] = []
for fpath in md_files:
try:
text = fpath.read_text(encoding="utf-8")
except Exception as exc:
print(f" WARN: could not read {fpath}: {exc}", file=sys.stderr)
continue
rel = str(fpath.relative_to(repo_root))
all_chunks.extend(
chunk_markdown(
text,
source=rel,
max_section_tokens=max_section_tokens,
paragraph_overlap_tokens=paragraph_overlap_tokens,
)
)
print(
f"Chunked {len(md_files)} files into {len(all_chunks)} chunks",
file=sys.stderr,
)
if dedupe:
before = len(all_chunks)
all_chunks, report = dedupe_chunks(
all_chunks,
similarity_threshold=dedupe_threshold,
min_files_for_dup=dedupe_min_files,
)
pct = report.removed_fraction * 100
print(
f"Dedupe: {before} -> {len(all_chunks)} chunks "
f"({report.removed_count} removed, {pct:.1f}%) "
f"across {len(report.groups)} clusters",
file=sys.stderr,
)
for g in report.groups:
dropped = sorted(set(g.dropped_sources))
print(
f" KEPT {g.kept_source}\n"
f" DROP {len(g.dropped_indices)} from {dropped}\n"
f" TEXT {g.sample_text!r}",
file=sys.stderr,
)
if report.removed_fraction > 0.20:
print(
f" WARNING: dedupe removed {pct:.1f}% of chunks (>20% threshold). "
f"Review the list above before trusting the index.",
file=sys.stderr,
)
print(
f"Embedding {len(all_chunks)} chunks via nomic-embed-text...",
file=sys.stderr,
)
t0 = time.time()
backend.store_many(
[c.content for c in all_chunks],
sources=[c.source for c in all_chunks],
metadatas=[{"breadcrumb": c.breadcrumb} for c in all_chunks],
)
print(
f"Indexed {backend.count()} chunks in {time.time() - t0:.1f}s",
file=sys.stderr,
)
return backend
def _print_hits(query: str, backend: DenseMemory, top_k: int = 3) -> None:
print(f"\nQ: {query}")
print("-" * 80)
hits = backend.retrieve(query, top_k=top_k)
if not hits:
print(" (no hits)")
return
for i, h in enumerate(hits, 1):
preview = h.content.replace("\n", " ")[:200]
print(f" [{i}] score={h.score:.3f} src={h.source}")
print(f" breadcrumb={h.metadata.get('breadcrumb', '')}")
print(f" {preview}{'...' if len(h.content) > 200 else ''}")
def main() -> int:
p = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
p.add_argument(
"--repo-root",
default=str(Path(__file__).resolve().parents[1]),
help="Repository root (default: script's parent)",
)
p.add_argument(
"--query",
"-q",
action="append",
default=None,
help="Query to test against the built index (can be given multiple times)",
)
p.add_argument("--top-k", type=int, default=3, help="Top-K results per query")
args = p.parse_args()
repo_root = Path(args.repo_root).resolve()
backend = build_index(repo_root)
queries = args.query or [
"can I run the orchestrator agent on a laptop without a gpu?",
"what inference engines does openjarvis support?",
"how do I add a new channel integration?",
"why would I choose the dense memory backend over sqlite?",
]
for q in queries:
_print_hits(q, backend, top_k=args.top_k)
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -37,10 +37,10 @@ _CHANNEL_MODULES = [
"rocketchat_channel",
"zulip_channel",
"twitch_channel",
"twitter_channel",
"nostr_channel",
"twilio_sms",
"sendblue",
"twitter",
"gmail",
]
-250
View File
@@ -1,250 +0,0 @@
"""TwitterChannel — native Twitter/X API adapter using tweepy."""
from __future__ import annotations
import logging
import os
import threading
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelMessage,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("twitter")
class TwitterChannel(BaseChannel):
"""Native Twitter/X channel adapter using tweepy.
Parameters
----------
bearer_token:
Twitter API v2 Bearer Token. Falls back to ``TWITTER_BEARER_TOKEN``.
api_key:
Twitter API Key (consumer key). Falls back to ``TWITTER_API_KEY``.
api_secret:
Twitter API Secret (consumer secret). Falls back to ``TWITTER_API_SECRET``.
access_token:
Twitter Access Token. Falls back to ``TWITTER_ACCESS_TOKEN``.
access_secret:
Twitter Access Token Secret. Falls back to ``TWITTER_ACCESS_SECRET``.
poll_interval:
Seconds between mention polls (default 60).
bus:
Optional event bus for publishing channel events.
"""
channel_id = "twitter"
def __init__(
self,
bearer_token: str = "",
*,
api_key: str = "",
api_secret: str = "",
access_token: str = "",
access_secret: str = "",
poll_interval: int = 60,
bus: Optional[EventBus] = None,
) -> None:
self._bearer_token = bearer_token or os.environ.get(
"TWITTER_BEARER_TOKEN",
"",
)
self._api_key = api_key or os.environ.get("TWITTER_API_KEY", "")
self._api_secret = api_secret or os.environ.get("TWITTER_API_SECRET", "")
self._access_token = access_token or os.environ.get(
"TWITTER_ACCESS_TOKEN",
"",
)
self._access_secret = access_secret or os.environ.get(
"TWITTER_ACCESS_SECRET",
"",
)
self._poll_interval = poll_interval
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
self._client: Any = None
self._poll_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Build a tweepy Client and optionally start polling for mentions."""
if not self._bearer_token:
logger.warning("No Twitter bearer token configured")
self._status = ChannelStatus.ERROR
return
self._stop_event.clear()
try:
import tweepy # noqa: F401
self._client = tweepy.Client(
bearer_token=self._bearer_token,
consumer_key=self._api_key or None,
consumer_secret=self._api_secret or None,
access_token=self._access_token or None,
access_token_secret=self._access_secret or None,
)
self._status = ChannelStatus.CONNECTED
logger.info("Twitter channel connected")
if self._access_token:
self._poll_thread = threading.Thread(
target=self._poll_loop,
daemon=True,
)
self._poll_thread.start()
except ImportError:
logger.info("tweepy not installed; Twitter channel unavailable")
self._status = ChannelStatus.ERROR
except Exception:
logger.debug("Twitter connect failed", exc_info=True)
self._status = ChannelStatus.ERROR
def disconnect(self) -> None:
"""Stop the polling thread and disconnect."""
self._stop_event.set()
if self._poll_thread is not None:
self._poll_thread.join(timeout=5.0)
self._poll_thread = None
self._client = None
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a tweet or direct message.
If *channel* is numeric, sends a DM via ``create_direct_message()``.
Otherwise sends a tweet via ``create_tweet()``. If *conversation_id*
is provided, it is used as ``in_reply_to_tweet_id``.
"""
if self._client is None:
logger.warning("Cannot send: Twitter client not connected")
return False
try:
if channel.isdigit():
# Direct message to a user ID
self._client.create_direct_message(
participant_id=int(channel),
text=content,
)
else:
kwargs: Dict[str, Any] = {"text": content}
if conversation_id:
kwargs["in_reply_to_tweet_id"] = int(conversation_id)
self._client.create_tweet(**kwargs)
self._publish_sent(channel, content, conversation_id)
return True
except Exception:
logger.debug("Twitter send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["timeline", "dm"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _poll_loop(self) -> None:
"""Poll for new mentions in a background thread."""
since_id: Optional[str] = None
while not self._stop_event.is_set():
try:
kwargs: Dict[str, Any] = {}
if since_id:
kwargs["since_id"] = since_id
me = self._client.get_me()
if me and me.data:
user_id = me.data.id
else:
self._stop_event.wait(self._poll_interval)
continue
response = self._client.get_users_mentions(
id=user_id,
**kwargs,
)
if response and response.data:
for tweet in response.data:
since_id = str(tweet.id)
cm = ChannelMessage(
channel="twitter",
sender=str(getattr(tweet, "author_id", "")),
content=tweet.text,
message_id=str(tweet.id),
conversation_id=str(
getattr(tweet, "conversation_id", ""),
),
)
for handler in self._handlers:
try:
handler(cm)
except Exception:
logger.exception("Twitter handler error")
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_RECEIVED,
{
"channel": cm.channel,
"sender": cm.sender,
"content": cm.content,
"message_id": cm.message_id,
},
)
except Exception:
logger.debug("Twitter poll error", exc_info=True)
self._stop_event.wait(self._poll_interval)
def _publish_sent(
self,
channel: str,
content: str,
conversation_id: str,
) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["TwitterChannel"]
+335
View File
@@ -0,0 +1,335 @@
"""TwitterChannel — Twitter/X API v2 adapter using OAuth 1.0a."""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import os
import threading
import time
import urllib.parse
import uuid
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelMessage,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
_API_BASE = "https://api.twitter.com/2"
# ---------------------------------------------------------------------------
# OAuth 1.0a signing (stdlib only — no authlib dependency)
# ---------------------------------------------------------------------------
class _OAuth1Auth:
"""Minimal OAuth 1.0a request signer for httpx.
Implements HMAC-SHA1 signature as required by the Twitter v2 API for
user-context endpoints (posting tweets).
"""
def __init__(
self,
consumer_key: str,
consumer_secret: str,
access_token: str,
access_secret: str,
) -> None:
self._consumer_key = consumer_key
self._consumer_secret = consumer_secret
self._access_token = access_token
self._access_secret = access_secret
def __call__(self, request): # noqa: ANN001
"""Sign *request* in-place and return it (httpx auth protocol)."""
oauth_params = {
"oauth_consumer_key": self._consumer_key,
"oauth_nonce": uuid.uuid4().hex,
"oauth_signature_method": "HMAC-SHA1",
"oauth_timestamp": str(int(time.time())),
"oauth_token": self._access_token,
"oauth_version": "1.0",
}
# Build signature base string
method = request.method.upper()
base_url = str(request.url).split("?")[0]
# Merge query params + oauth params for signing
all_params: dict[str, str] = dict(oauth_params)
for key, value in request.url.params.items():
all_params[key] = value
param_str = "&".join(
f"{_pct(k)}={_pct(v)}"
for k, v in sorted(all_params.items())
)
base_string = f"{method}&{_pct(base_url)}&{_pct(param_str)}"
signing_key = f"{_pct(self._consumer_secret)}&{_pct(self._access_secret)}"
signature = base64.b64encode(
hmac.new(
signing_key.encode(), base_string.encode(), hashlib.sha1,
).digest(),
).decode()
oauth_params["oauth_signature"] = signature
auth_header = "OAuth " + ", ".join(
f'{_pct(k)}="{_pct(v)}"' for k, v in sorted(oauth_params.items())
)
request.headers["Authorization"] = auth_header
return request
def _pct(value: str) -> str:
"""Percent-encode per RFC 5849."""
return urllib.parse.quote(str(value), safe="")
# ---------------------------------------------------------------------------
# Channel implementation
# ---------------------------------------------------------------------------
@ChannelRegistry.register("twitter")
class TwitterChannel(BaseChannel):
"""Native Twitter/X channel adapter using the v2 API.
Parameters
----------
bearer_token:
Bearer token for read endpoints. Falls back to
``TWITTER_BEARER_TOKEN`` env var.
api_key / api_secret:
OAuth 1.0a consumer credentials. Fall back to
``TWITTER_API_KEY`` / ``TWITTER_API_SECRET``.
access_token / access_secret:
OAuth 1.0a user credentials. Fall back to
``TWITTER_ACCESS_TOKEN`` / ``TWITTER_ACCESS_SECRET``.
bot_user_id:
Numeric Twitter user ID for the bot account. Falls back to
``TWITTER_BOT_USER_ID``.
poll_interval:
Seconds between mention polls (default 60).
bus:
Optional event bus for publishing channel events.
"""
channel_id = "twitter"
def __init__(
self,
bearer_token: str = "",
*,
api_key: str = "",
api_secret: str = "",
access_token: str = "",
access_secret: str = "",
bot_user_id: str = "",
poll_interval: int = 60,
bus: Optional[EventBus] = None,
) -> None:
self._bearer = bearer_token or os.environ.get("TWITTER_BEARER_TOKEN", "")
self._api_key = api_key or os.environ.get("TWITTER_API_KEY", "")
self._api_secret = api_secret or os.environ.get("TWITTER_API_SECRET", "")
self._access_token = access_token or os.environ.get("TWITTER_ACCESS_TOKEN", "")
self._access_secret = access_secret or os.environ.get(
"TWITTER_ACCESS_SECRET", "",
)
self._bot_user_id = bot_user_id or os.environ.get("TWITTER_BOT_USER_ID", "")
self._poll_interval = poll_interval
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
self._listener_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._since_id: Optional[str] = None
# -- OAuth helper -------------------------------------------------------
def _oauth(self) -> _OAuth1Auth:
return _OAuth1Auth(
self._api_key, self._api_secret,
self._access_token, self._access_secret,
)
# -- connection lifecycle -----------------------------------------------
def connect(self) -> None:
"""Validate credentials and start mention polling."""
if not self._bearer:
logger.warning("No Twitter bearer token configured")
self._status = ChannelStatus.ERROR
return
self._stop_event.clear()
self._status = ChannelStatus.CONNECTING
self._listener_thread = threading.Thread(
target=self._poll_mentions, daemon=True,
)
self._listener_thread.start()
self._status = ChannelStatus.CONNECTED
logger.info("Twitter channel connected (polling mentions)")
def disconnect(self) -> None:
"""Stop the polling thread."""
self._stop_event.set()
if self._listener_thread is not None:
self._listener_thread.join(timeout=5.0)
self._listener_thread = None
self._status = ChannelStatus.DISCONNECTED
# -- send / receive -----------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Post a tweet, optionally as a reply.
*channel* is ignored (tweets go to the bot's timeline).
*conversation_id* is used as ``reply.in_reply_to_tweet_id`` when set.
Content is truncated to 280 characters.
"""
if not self._api_key:
logger.warning("Cannot send: no Twitter OAuth credentials")
return False
text = content[:280]
try:
import httpx
payload: Dict[str, Any] = {"text": text}
if conversation_id:
payload["reply"] = {"in_reply_to_tweet_id": conversation_id}
resp = httpx.post(
f"{_API_BASE}/tweets",
json=payload,
auth=self._oauth(),
timeout=10.0,
)
if resp.status_code < 300:
self._publish_sent(channel, text, conversation_id)
return True
logger.warning(
"Twitter API returned status %d: %s",
resp.status_code,
resp.text,
)
return False
except Exception:
logger.debug("Twitter send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["twitter"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming mentions."""
self._handlers.append(handler)
# -- internal helpers ---------------------------------------------------
def _poll_mentions(self) -> None:
"""Poll ``GET /2/users/{id}/mentions`` on a fixed interval."""
try:
import httpx
except ImportError:
logger.debug("httpx not available for Twitter polling")
self._status = ChannelStatus.ERROR
return
headers = {"Authorization": f"Bearer {self._bearer}"}
url = f"{_API_BASE}/users/{self._bot_user_id}/mentions"
while not self._stop_event.is_set():
try:
params: Dict[str, str] = {
"tweet.fields": "author_id,conversation_id,created_at",
}
if self._since_id:
params["since_id"] = self._since_id
resp = httpx.get(
url, headers=headers, params=params, timeout=10.0,
)
if resp.status_code < 300:
data = resp.json()
tweets = data.get("data", [])
for tweet in tweets:
tweet_id = tweet["id"]
cm = ChannelMessage(
channel="twitter",
sender=tweet.get("author_id", ""),
content=tweet.get("text", ""),
message_id=tweet_id,
conversation_id=tweet.get("conversation_id", ""),
)
for handler in self._handlers:
try:
handler(cm)
except Exception:
logger.exception("Twitter handler error")
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_RECEIVED,
{
"channel": cm.channel,
"sender": cm.sender,
"content": cm.content,
"message_id": cm.message_id,
},
)
# Track highest ID to avoid reprocessing
if self._since_id is None or tweet_id > self._since_id:
self._since_id = tweet_id
else:
logger.warning(
"Twitter mentions API returned %d: %s",
resp.status_code,
resp.text,
)
except Exception:
logger.debug("Twitter poll error", exc_info=True)
self._stop_event.wait(self._poll_interval)
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["TwitterChannel"]
+52 -10
View File
@@ -33,7 +33,13 @@ logger = logging.getLogger(__name__)
def _get_memory_backend(config):
"""Try to instantiate the memory backend, return None on failure."""
"""Try to instantiate the memory backend.
Returns None on failure and logs at DEBUG. Callers that *require*
the backend (e.g. when a memory tool is being instantiated) should
warn loudly themselves — silent failure of an explicitly-requested
tool is the hallucination vector.
"""
try:
import openjarvis.tools.storage # noqa: F401
from openjarvis.core.registry import MemoryRegistry
@@ -50,20 +56,38 @@ def _get_memory_backend(config):
else:
backend = MemoryRegistry.create(key)
# Check if there's actually anything indexed
if hasattr(backend, "count") and backend.count() == 0:
if hasattr(backend, "close"):
backend.close()
return None
return backend
except Exception as exc:
logger.debug("Memory backend unavailable (optional): %s", exc)
return None
def _build_tools(tool_names: list[str], config, engine, model_name: str):
"""Instantiate tool objects from names."""
_MEMORY_TOOLS = frozenset(
{"retrieval", "memory_store", "memory_search", "memory_index", "memory_retrieve"}
)
_CHANNEL_TOOLS = frozenset({"channel_send", "channel_list", "channel_status"})
def _build_tools(
tool_names: list[str],
config,
engine,
model_name: str,
*,
channel=None,
):
"""Instantiate tool objects from names.
``channel`` is an optional :class:`BaseChannel` used by ``channel_*``
tools. Threading it through here mirrors how memory backends are
injected; without it, channel tools have no backend and fail at
runtime.
Emits a WARNING when a memory tool is requested but no backend is
available, and when a channel tool is requested but no channel is
provided — these are the failure modes that silently cascade into
hallucinated or dropped replies downstream.
"""
from openjarvis.core.registry import ToolRegistry
tools = []
@@ -75,9 +99,27 @@ def _build_tools(tool_names: list[str], config, engine, model_name: str):
continue
tool_cls = ToolRegistry.get(name)
# Instantiate with appropriate arguments
if name == "retrieval":
if name in _MEMORY_TOOLS:
backend = _get_memory_backend(config)
if backend is None:
logger.warning(
"Tool %r was requested but no memory backend is "
"available (default=%r). The tool will load but "
"return no results — downstream agents may answer "
"without grounding.",
name,
getattr(config.memory, "default_backend", "?"),
)
tools.append(tool_cls(backend=backend))
elif name in _CHANNEL_TOOLS:
if channel is None:
logger.warning(
"Tool %r was requested but no channel was injected. "
"The tool will load but every call will fail with "
"'No channel backend configured'.",
name,
)
tools.append(tool_cls(channel=channel))
elif name == "llm":
tools.append(tool_cls(engine=engine, model=model_name))
elif name == "file_read":
@@ -0,0 +1,22 @@
[recipe]
name = "openjarvis-twitter-bot"
kind = "operator"
description = "Reactive Twitter bot for @OpenJarvisAI — answers questions, creates GitHub issues from bug/feature mentions"
version = "0.1.0"
[intelligence]
model = "qwen3:32b"
[engine]
key = "ollama"
[agent]
type = "orchestrator"
max_turns = 8
temperature = 0.4
tools = ["think", "memory_search", "memory_store", "http_request", "channel_send"]
system_prompt_path = "openjarvis_twitter_bot_prompt.md"
[channels]
input = ["twitter"]
output = ["twitter"]
@@ -0,0 +1,75 @@
You are @OpenJarvisAI on Twitter — a reactive mention handler for the OpenJarvis project. You only reply when someone @mentions you. You never post unprompted.
You respond like a helpful maintainer — casual, direct, knowledgeable. You're part of the team that built this.
Your voice:
- all lowercase. casual. like texting a dev friend.
- short sentences. direct answers. no fluff.
- first person: "we built", "we found", "we ship".
- be helpful and genuine, not corporate.
HARD RULE: Every reply MUST be ≤280 characters. Count before sending.
## Facts (ONLY reference these — never invent others)
- GitHub: https://github.com/open-jarvis/OpenJarvis
- Docs: https://open-jarvis.github.io/OpenJarvis/
- Discord: https://discord.gg/wfXEkpPX
- Blog: https://scalingintelligence.stanford.edu/blogs/openjarvis/
- Install: `git clone https://github.com/open-jarvis/OpenJarvis.git && cd OpenJarvis && uv sync`
- CLI commands (ONLY these exist):
- `jarvis init` — auto-detects hardware, configures engine
- `jarvis ask "question"` — ask from terminal
- `jarvis doctor` — diagnose issues
- `jarvis add slack` — add Slack channel
- `jarvis channel list` — list channels
- `jarvis bench` — benchmark latency, throughput, energy
- `jarvis optimize` — run optimization on local traces
- 27+ channel integrations: Slack, Discord, Telegram, WhatsApp, Teams, Matrix, IRC, Reddit, Mastodon, Twitch, LINE, Viber, Messenger, Nostr, and more
- Engines: Ollama, vLLM, SGLang, llama.cpp, cloud APIs (OpenAI, Anthropic, Google)
- Agent types: orchestrator, react, router, operative
- Memory/RAG: SQLite, FAISS, ColBERT, BM25
- Evals: 30+ benchmarks, measures energy, FLOPs, latency, cost alongside accuracy
- Examples: deep_research, code_companion, messaging_hub, scheduled_ops, browser_assistant, security_scanner, daily_digest, doc_qa, multi_model_router
- Runs on Apple Silicon, NVIDIA GPUs, AMD GPUs, CPU-only
- Built at Stanford, Hazy Research and Scaling Intelligence Lab at SAIL
- Apache 2.0 open source
- Intelligence Per Watt research: local models handle 88.7% of queries at interactive latency, efficiency improved 5.3x from 2023-2025
- NO commands like `jarvis add memory`, `jarvis research`, or `jarvis add channel` exist
## Mention Handling
Classify using `think`, then act. ALWAYS set `conversation_id` to the tweet ID when replying.
### QUESTION
1. `memory_search` for the answer.
2. Reply (≤280 chars) with the ACTUAL answer — real commands, real steps. If you don't know, say so honestly.
3. `channel_send` with `conversation_id=<tweet_id>`.
Reply like a maintainer:
- Good: "clone the repo, `uv sync`, then `jarvis init` — it auto-detects your hardware. `jarvis ask` works right after that"
- Good: "`jarvis add slack` and set SLACK_BOT_TOKEN in your env. that's it"
- Bad: "pip install openjarvis" (wrong — install is git clone + uv sync)
- Bad: formal numbered steps
### BUG_REPORT
1. `think` to extract title and description.
2. `http_request` POST to `https://api.github.com/repos/open-jarvis/OpenJarvis/issues` with title, body mentioning reporter, labels `["bug", "from-twitter"]`.
3. `channel_send` with `conversation_id=<tweet_id>`: something like "opened an issue for this — we'll take a look. thanks for the report"
### FEATURE_REQUEST
Same as BUG_REPORT but labels `["enhancement", "from-twitter"]`. Reply like: "love this idea — opened an issue to track it"
### PRAISE
`channel_send` with `conversation_id=<tweet_id>`. Be genuine: "glad you're liking it! the examples/ folder has some fun stuff if you want to go deeper"
### SPAM
Do nothing. No tool calls. No reply.
## Rules
- ≤280 characters per reply. No exceptions.
- ALWAYS set `conversation_id` when replying.
- NEVER make up features, commands, stats, or steps not in the facts above.
- NEVER retry a failed tool call. Move on.
- ONE `http_request` and ONE `channel_send` per action. No repeats.
+6
View File
@@ -250,6 +250,7 @@ class Jarvis:
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
context: bool = True,
channel: Optional[Any] = None,
) -> str:
"""Send a query and return the response text."""
result = self.ask_full(
@@ -260,6 +261,7 @@ class Jarvis:
temperature=temperature,
max_tokens=max_tokens,
context=context,
channel=channel,
)
return result["content"]
@@ -273,6 +275,7 @@ class Jarvis:
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
context: bool = True,
channel: Optional[Any] = None,
) -> Dict[str, Any]:
"""Send a query and return the full result dict.
@@ -304,6 +307,7 @@ class Jarvis:
temperature=temperature,
max_tokens=max_tokens,
context=context,
channel=channel,
)
# Direct engine mode
@@ -443,6 +447,7 @@ class Jarvis:
temperature: float,
max_tokens: int,
context: bool,
channel: Optional[Any] = None,
) -> Dict[str, Any]:
"""Run an agent and return the result dict."""
import openjarvis.agents # noqa: F401
@@ -468,6 +473,7 @@ class Jarvis:
self._config,
self._engine,
model_name,
channel=channel,
)
agent_kwargs: Dict[str, Any] = {
+5 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import os
import time
from typing import Any
@@ -100,7 +101,10 @@ class HttpRequestTool(BaseTool):
success=False,
)
headers = params.get("headers") or {}
headers = {
k: os.path.expandvars(v) if isinstance(v, str) else v
for k, v in (params.get("headers") or {}).items()
}
body = params.get("body")
timeout = params.get("timeout", 30)
+5
View File
@@ -26,6 +26,11 @@ try:
except ImportError:
pass
try:
import openjarvis.tools.storage.dense # noqa: F401
except ImportError:
pass
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
from openjarvis.tools.storage.chunking import Chunk, ChunkConfig, chunk_text
from openjarvis.tools.storage.context import ContextConfig, inject_context
+727
View File
@@ -0,0 +1,727 @@
"""In-memory dense retrieval backend.
Uses any :class:`Embedder` (default: :class:`OllamaEmbedder` with
``nomic-embed-text``) to embed stored text, then ranks queries by
cosine similarity via a single matrix multiply against an
L2-normalized matrix of document embeddings.
Design notes
------------
* **No persistence.** The index lives in memory and is rebuilt at
startup via :mod:`scripts.index_docs`. The docs corpus is small
(~700 chunks) so this is fine and keeps the implementation simple.
* **Normalization happens at embed time**, not at query time. Storing
unit vectors means retrieval is one ``docs @ query`` dot product.
* **Store growth is amortized**: we keep a list of per-call embedding
matrices and concatenate lazily in :meth:`retrieve`. This avoids an
O(n²) ``np.concatenate`` pattern while still giving callers one
contiguous matrix when they actually need to search.
"""
from __future__ import annotations
import logging
import re
import threading
import uuid
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from openjarvis.core.registry import MemoryRegistry
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
from openjarvis.tools.storage.embeddings import Embedder, OllamaEmbedder
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Markdown-aware chunking
# ---------------------------------------------------------------------------
_FENCE_RE = re.compile(r"^```")
_HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
@dataclass(slots=True)
class MdChunk:
"""A markdown chunk annotated with its header breadcrumb."""
content: str # chunk body text (with breadcrumb prefix)
source: str # originating file
# e.g. "macOS Installation Guide > Step-by-Step > Step 6 — Install llama.cpp"
breadcrumb: str
start_line: int = 0
def _iter_nonfenced_lines(text: str):
"""Yield ``(line_number, line_text, in_code)`` for each line.
``in_code`` is True for lines inside a ```...``` fenced block, so
callers can ignore shell/python comments that start with ``#``.
"""
in_code = False
for lineno, line in enumerate(text.splitlines()):
stripped = line.strip()
if _FENCE_RE.match(stripped):
in_code = not in_code
yield lineno, line, True # the fence itself is "code"
continue
yield lineno, line, in_code
def chunk_markdown(
text: str,
*,
source: str = "",
max_section_tokens: int = 500,
paragraph_overlap_tokens: int = 50,
max_section_chars: int = 4000,
) -> List[MdChunk]:
"""Split markdown into chunks using ``##``/``###`` as primary boundaries.
Strategy:
1. Detect section boundaries at h2 (``##``) and h3 (``###``).
h1 (``#``) is treated as the document title (captured in the
breadcrumb but not used to split).
2. Skip headers that appear inside fenced code blocks — those are
usually shell comments (``# Install X``) not real headers.
3. If a section body exceeds ``max_section_tokens`` (whitespace
tokens) OR ``max_section_chars`` (raw chars), slide a window
over its paragraphs with ``paragraph_overlap_tokens`` overlap.
4. Prefix each chunk with a ``breadcrumb`` of its parent headers
so the embedding captures hierarchical context.
The char cap is the critical safety net: embedding models count BPE
tokens, and technical content (file paths, code, URLs) has ~8 BPE
tokens per whitespace token — so a whitespace-token-only limit
silently lets 23× overflows through and some embedders (e.g.
``nomic-embed-text``) reject them at runtime. 4000 chars is a
conservative ceiling for ``nomic-embed-text``'s 8192-token window.
Empty input → empty list.
"""
if not text or not text.strip():
return []
# Pass 1: parse into (h1, h2, h3, body_lines) sections
h1: Optional[str] = None
h2: Optional[str] = None
h3: Optional[str] = None
buffered: List[str] = []
# Each entry: (breadcrumb, body_text, start_line)
sections: List[tuple[str, str, int]] = []
section_start_line = 0
def _flush(start_line: int):
nonlocal buffered
if not buffered:
return
body = "\n".join(buffered).strip()
if not body:
buffered = []
return
parts = [p for p in (h1, h2, h3) if p]
breadcrumb = " > ".join(parts) if parts else (source or "(unnamed)")
sections.append((breadcrumb, body, start_line))
buffered = []
for lineno, line, in_code in _iter_nonfenced_lines(text):
if in_code:
buffered.append(line)
continue
m = _HEADER_RE.match(line.strip())
if m is None:
buffered.append(line)
continue
hashes, title = m.group(1), m.group(2).strip()
level = len(hashes)
if level == 1:
# Document title — flush whatever we had, then set h1
_flush(section_start_line)
h1 = title
h2 = None
h3 = None
section_start_line = lineno
elif level == 2:
_flush(section_start_line)
h2 = title
h3 = None
section_start_line = lineno
elif level == 3:
_flush(section_start_line)
h3 = title
section_start_line = lineno
else:
# h4+ stays inline in the body
buffered.append(line)
_flush(section_start_line)
if not sections:
# Doc had no ## / ### splits at all; fall back to treating the
# whole thing as one section.
parts = [p for p in (h1,) if p]
breadcrumb = " > ".join(parts) if parts else (source or "(unnamed)")
sections = [(breadcrumb, text.strip(), 0)]
# Pass 2: for each section, split if it's too large (token or char).
chunks: List[MdChunk] = []
def _over_limit(tok_count: int, char_count: int) -> bool:
return tok_count > max_section_tokens or char_count > max_section_chars
for breadcrumb, body, start_line in sections:
body_tokens = body.split()
if not _over_limit(len(body_tokens), len(body)):
chunks.append(
MdChunk(
content=f"{breadcrumb}\n\n{body}",
source=source,
breadcrumb=breadcrumb,
start_line=start_line,
)
)
continue
# Oversized — slide over paragraphs, with a token-level fallback
# for single paragraphs that are themselves larger than the window.
paragraphs = [p for p in body.split("\n\n") if p.strip()]
window_paragraphs: List[str] = []
window_tokens = 0
window_chars = 0
def _emit_window():
nonlocal window_paragraphs, window_tokens, window_chars
if not window_paragraphs:
return
chunk_body = "\n\n".join(window_paragraphs).strip()
chunks.append(
MdChunk(
content=f"{breadcrumb}\n\n{chunk_body}",
source=source,
breadcrumb=breadcrumb,
start_line=start_line,
)
)
# Carry overlap tail into the next window
tail = " ".join(chunk_body.split()[-paragraph_overlap_tokens:]) \
if paragraph_overlap_tokens > 0 else ""
window_paragraphs = [tail] if tail else []
window_tokens = len(tail.split())
window_chars = len(tail)
for para in paragraphs:
p_tokens = para.split()
# Single paragraph too big for the window: flush what we
# have, then slide a fixed token window over it.
if _over_limit(len(p_tokens), len(para)):
_emit_window()
# Use whichever cap is tighter for this paragraph —
# if it's char-bound, slide by chars; else by tokens.
char_bound = (
len(para) > max_section_chars
and len(p_tokens) <= max_section_tokens
)
if char_bound:
step_chars = max(
1, max_section_chars - (paragraph_overlap_tokens * 8),
)
for i in range(0, len(para), step_chars):
piece = para[i : i + max_section_chars]
chunks.append(
MdChunk(
content=f"{breadcrumb}\n\n{piece}",
source=source,
breadcrumb=breadcrumb,
start_line=start_line,
)
)
if i + max_section_chars >= len(para):
break
else:
step = max(1, max_section_tokens - paragraph_overlap_tokens)
for i in range(0, len(p_tokens), step):
window_content = " ".join(
p_tokens[i : i + max_section_tokens]
)
# Safety: truncate if still over char cap
if len(window_content) > max_section_chars:
window_content = window_content[:max_section_chars]
chunks.append(
MdChunk(
content=f"{breadcrumb}\n\n{window_content}",
source=source,
breadcrumb=breadcrumb,
start_line=start_line,
)
)
if i + max_section_tokens >= len(p_tokens):
break
window_paragraphs = []
window_tokens = 0
window_chars = 0
continue
# Would adding this paragraph push us over either limit?
sep = 2 if window_paragraphs else 0 # for the "\n\n" between paras
if (
_over_limit(
window_tokens + len(p_tokens),
window_chars + len(para) + sep,
)
and window_paragraphs
):
_emit_window()
window_paragraphs.append(para)
window_tokens += len(p_tokens)
window_chars += len(para) + (2 if len(window_paragraphs) > 1 else 0)
_emit_window()
return chunks
# ---------------------------------------------------------------------------
# Cross-file deduplication
# ---------------------------------------------------------------------------
_NORM_WS_RE = re.compile(r"\s+")
_NORM_NONALPHA_RE = re.compile(r"[^a-z0-9\s]+")
@dataclass(slots=True)
class DuplicateGroup:
"""A cluster of chunks judged to be near-duplicates of each other."""
kept_index: int # surviving chunk's index in the input list
kept_source: str
dropped_indices: List[int] = field(default_factory=list)
dropped_sources: List[str] = field(default_factory=list)
distinct_files: int = 0 # # of unique source files in the group
sample_text: str = "" # ~120-char preview of the duplicated content
@dataclass(slots=True)
class DedupeReport:
"""Audit trail for a deduplication pass."""
input_count: int = 0
output_count: int = 0
groups: List[DuplicateGroup] = field(default_factory=list)
@property
def removed_count(self) -> int:
return self.input_count - self.output_count
@property
def removed_fraction(self) -> float:
return self.removed_count / self.input_count if self.input_count else 0.0
def _strip_breadcrumb(content: str) -> str:
"""Drop the breadcrumb prefix produced by chunk_markdown.
The breadcrumb varies between files even for boilerplate body text
(Downloads vs Installation, etc.), which would suppress similarity
if included in the n-gram set. Compare body-only.
"""
parts = content.split("\n\n", 1)
return parts[1] if len(parts) == 2 else content
def _normalize(text: str) -> str:
"""Lowercase, drop non-alphanumeric, collapse whitespace."""
text = text.lower()
text = _NORM_NONALPHA_RE.sub(" ", text)
text = _NORM_WS_RE.sub(" ", text)
return text.strip()
def _ngrams(text: str, n: int = 5) -> set:
"""Word-level n-gram set."""
tokens = text.split()
if len(tokens) < n:
# Short chunks: use the whole token tuple as a single n-gram so
# very-short identical chunks still cluster.
return {tuple(tokens)} if tokens else set()
return {tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)}
def _jaccard(a: set, b: set) -> float:
if not a or not b:
return 0.0
inter = len(a & b)
if inter == 0:
return 0.0
return inter / len(a | b)
class _UnionFind:
"""Tiny union-find for clustering near-duplicate chunks."""
def __init__(self, n: int) -> None:
self.parent = list(range(n))
def find(self, x: int) -> int:
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
def union(self, a: int, b: int) -> None:
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.parent[ra] = rb
def _path_specificity(source: str) -> Tuple[int, int, str]:
"""Sort key — bigger is more specific.
Tiebreakers:
1. Path depth (slashes) — deeper = more specific.
2. Length of basename — proxy for descriptiveness.
3. Lexicographic source path — deterministic last-resort.
"""
if not source:
return (-1, 0, "")
depth = source.count("/")
basename = source.rsplit("/", 1)[-1]
return (depth, len(basename), source)
def dedupe_chunks(
chunks: List[MdChunk],
*,
ngram_n: int = 5,
similarity_threshold: float = 0.7,
min_files_for_dup: int = 3,
) -> Tuple[List[MdChunk], DedupeReport]:
"""Drop near-duplicate chunks that recur across many source files.
A chunk cluster is considered a duplicate (and collapsed to one
canonical entry) when:
1. Pairwise word-level n-gram **Jaccard >= ``similarity_threshold``**
on the body text (breadcrumb stripped). N-grams are computed
after lowercasing and stripping non-alphanumeric punctuation,
so superficial differences (capitalization, typography) don't
hide duplication.
2. The cluster spans **>= ``min_files_for_dup`` distinct source
files**. Two-file repeats are kept on the assumption they may
be legitimately doc-specific; >=3 is the bar for boilerplate.
For each qualifying cluster, the chunk from the most-specific
source path wins (deepest dir, longest basename, lexicographic
tiebreak); the rest are dropped.
Returns ``(surviving_chunks, report)``. The chunker, embedder and
retrieval logic are NOT modified — this function is a pure pre-
processing pass over the chunk list before embedding.
"""
n = len(chunks)
if n == 0:
return [], DedupeReport()
# 1) Compute n-gram set for each chunk (body only)
chunk_ngrams: List[set] = []
for c in chunks:
body = _strip_breadcrumb(c.content)
chunk_ngrams.append(_ngrams(_normalize(body), n=ngram_n))
# 2) Inverted index: ngram -> [chunk indices that contain it]
# Lets us skip pairs that share zero n-grams without computing Jaccard.
inverted: Dict[tuple, List[int]] = defaultdict(list)
for i, ngs in enumerate(chunk_ngrams):
for ng in ngs:
inverted[ng].append(i)
# 3) Build candidate-pair set: any pair sharing at least one n-gram.
# For each n-gram, at most ``cap`` chunks contribute to candidate
# pairs to avoid quadratic blowup on hyper-common n-grams (e.g.
# ``("the", "the", ...)``-style noise — unlikely but defensive).
cap = 200
candidate_pairs: set = set()
for chunk_ids in inverted.values():
if len(chunk_ids) < 2:
continue
if len(chunk_ids) > cap:
chunk_ids = chunk_ids[:cap]
for i_idx in range(len(chunk_ids)):
for j_idx in range(i_idx + 1, len(chunk_ids)):
a, b = chunk_ids[i_idx], chunk_ids[j_idx]
if a > b:
a, b = b, a
candidate_pairs.add((a, b))
# 4) Cluster via union-find on pairs that exceed the threshold.
uf = _UnionFind(n)
for a, b in candidate_pairs:
if _jaccard(chunk_ngrams[a], chunk_ngrams[b]) >= similarity_threshold:
uf.union(a, b)
# 5) Group by cluster root
cluster_to_members: Dict[int, List[int]] = defaultdict(list)
for i in range(n):
cluster_to_members[uf.find(i)].append(i)
# 6) For each cluster, decide: dedupe or keep all?
keep_mask = [True] * n
report = DedupeReport(input_count=n)
for members in cluster_to_members.values():
if len(members) < 2:
continue
distinct_files = {chunks[i].source for i in members}
if len(distinct_files) < min_files_for_dup:
continue # not boilerplate enough — keep all
# Pick canonical: most-specific source path
sorted_members = sorted(
members,
key=lambda i: _path_specificity(chunks[i].source),
reverse=True,
)
kept_idx = sorted_members[0]
dropped_idxs = sorted_members[1:]
for d in dropped_idxs:
keep_mask[d] = False
sample = _strip_breadcrumb(chunks[kept_idx].content).strip().replace("\n", " ")
report.groups.append(
DuplicateGroup(
kept_index=kept_idx,
kept_source=chunks[kept_idx].source,
dropped_indices=dropped_idxs,
dropped_sources=[chunks[i].source for i in dropped_idxs],
distinct_files=len(distinct_files),
sample_text=sample[:120] + ("..." if len(sample) > 120 else ""),
)
)
survivors = [c for i, c in enumerate(chunks) if keep_mask[i]]
report.output_count = len(survivors)
return survivors, report
def log_dedupe_report(report: DedupeReport, *, level: int = logging.INFO) -> None:
"""Emit a human-readable summary of a dedupe pass at ``level``."""
pct = report.removed_fraction * 100
logger.log(
level,
"dedupe: %d -> %d chunks (%d removed, %.1f%%) across %d clusters",
report.input_count,
report.output_count,
report.removed_count,
pct,
len(report.groups),
)
for grp in report.groups:
logger.log(
level,
" kept %s dropped %d (across %d files): %s | %r",
grp.kept_source,
len(grp.dropped_indices),
grp.distinct_files,
", ".join(sorted(set(grp.dropped_sources))),
grp.sample_text,
)
# ---------------------------------------------------------------------------
# DenseMemory backend
# ---------------------------------------------------------------------------
@MemoryRegistry.register("dense")
class DenseMemory(MemoryBackend):
"""In-memory dense retrieval via cosine similarity.
The embedder is lazy: it is created on first :meth:`store` or
:meth:`retrieve` call, so instantiating :class:`DenseMemory` does
not require Ollama to be running.
Parameters
----------
embedder:
An :class:`Embedder` instance. If ``None``, defaults to
:class:`OllamaEmbedder` with ``nomic-embed-text``.
"""
backend_id = "dense"
def __init__(self, embedder: Optional[Embedder] = None) -> None:
self._embedder: Optional[Embedder] = embedder
# Shape (n_docs, dim), L2-normalized row-wise. None until first store.
self._matrix = None
self._contents: List[str] = []
self._sources: List[str] = []
self._metadatas: List[Dict[str, Any]] = []
self._doc_ids: List[str] = []
# id -> index; lets us delete in O(1) for lookups
self._id_to_index: Dict[str, int] = {}
self._lock = threading.Lock()
# -- embedder lifecycle ------------------------------------------------
def _get_embedder(self) -> Embedder:
if self._embedder is None:
self._embedder = OllamaEmbedder()
return self._embedder
# -- MemoryBackend ABC -------------------------------------------------
def store(
self,
content: str,
*,
source: str = "",
metadata: Optional[Dict[str, Any]] = None,
) -> str:
"""Embed and store one document. Returns its id."""
return self.store_many(
[content], sources=[source], metadatas=[metadata or {}],
)[0]
def store_many(
self,
contents: List[str],
*,
sources: Optional[List[str]] = None,
metadatas: Optional[List[Dict[str, Any]]] = None,
) -> List[str]:
"""Embed a batch of documents in one go. Much faster than per-doc.
Accepts parallel lists; missing sources/metadatas default to empty.
"""
import numpy as np
if not contents:
return []
sources = sources if sources is not None else [""] * len(contents)
metadatas = metadatas if metadatas is not None else [{} for _ in contents]
assert len(sources) == len(contents) and len(metadatas) == len(contents)
emb = self._get_embedder()
vectors = emb.embed(contents) # already normalized
new_ids = [uuid.uuid4().hex for _ in contents]
with self._lock:
if self._matrix is None:
self._matrix = vectors
else:
self._matrix = np.concatenate([self._matrix, vectors], axis=0)
for i, (c, s, m, doc_id) in enumerate(
zip(contents, sources, metadatas, new_ids),
):
self._contents.append(c)
self._sources.append(s)
self._metadatas.append(dict(m))
self._doc_ids.append(doc_id)
self._id_to_index[doc_id] = len(self._contents) - 1
return new_ids
def retrieve(
self,
query: str,
*,
top_k: int = 5,
**kwargs: Any,
) -> List[RetrievalResult]:
"""Return top-k documents by cosine similarity.
Scores are in ``[-1, 1]`` for normalized vectors; for
nomic-embed-text in practice scores on reasonable queries
fall in ``[0.3, 0.8]``. Empty index or query → empty list.
"""
import numpy as np
if not query or not query.strip():
return []
with self._lock:
matrix_snapshot = self._matrix
contents = list(self._contents)
sources = list(self._sources)
metadatas = [dict(m) for m in self._metadatas]
doc_ids = list(self._doc_ids)
if matrix_snapshot is None or matrix_snapshot.shape[0] == 0:
return []
emb = self._get_embedder()
q_vec = emb.embed([query]) # shape (1, dim), normalized
# Single matrix-vector mult
scores = matrix_snapshot @ q_vec[0] # shape (n,)
# Top-k via argpartition then sort
n = scores.shape[0]
k = min(top_k, n)
if k <= 0:
return []
if k < n:
top_idx = np.argpartition(-scores, k - 1)[:k]
else:
top_idx = np.arange(n)
# Sort the top-k chunk descending by score
top_idx = top_idx[np.argsort(-scores[top_idx])]
results: List[RetrievalResult] = []
for i in top_idx:
i = int(i)
results.append(
RetrievalResult(
content=contents[i],
score=float(scores[i]),
source=sources[i],
metadata={**metadatas[i], "doc_id": doc_ids[i]},
)
)
return results
def delete(self, doc_id: str) -> bool:
"""Remove a document by id. Returns True if it existed."""
import numpy as np
with self._lock:
idx = self._id_to_index.pop(doc_id, None)
if idx is None:
return False
# Remove row from matrix
self._matrix = np.delete(self._matrix, idx, axis=0)
self._contents.pop(idx)
self._sources.pop(idx)
self._metadatas.pop(idx)
self._doc_ids.pop(idx)
# Rebuild id -> index for entries after the removed one
for did, i in list(self._id_to_index.items()):
if i > idx:
self._id_to_index[did] = i - 1
return True
def clear(self) -> None:
"""Drop all stored documents."""
with self._lock:
self._matrix = None
self._contents.clear()
self._sources.clear()
self._metadatas.clear()
self._doc_ids.clear()
self._id_to_index.clear()
def count(self) -> int:
"""Number of stored documents."""
with self._lock:
return len(self._contents)
__all__ = [
"DenseMemory",
"DedupeReport",
"DuplicateGroup",
"MdChunk",
"chunk_markdown",
"dedupe_chunks",
"log_dedupe_report",
]
+118 -7
View File
@@ -1,13 +1,10 @@
"""Embeddings abstraction for dense retrieval backends.
Provides an ABC and a default ``SentenceTransformerEmbedder`` that wraps
the ``sentence-transformers`` library.
"""
"""Embeddings abstraction for dense retrieval backends."""
from __future__ import annotations
import concurrent.futures
from abc import ABC, abstractmethod
from typing import Any
from typing import Any, List, Optional
class Embedder(ABC):
@@ -59,4 +56,118 @@ class SentenceTransformerEmbedder(Embedder):
return self._dim
__all__ = ["Embedder", "SentenceTransformerEmbedder"]
class OllamaEmbedder(Embedder):
"""Embedder backed by an Ollama server's ``/api/embed`` endpoint.
Sends batches in parallel (up to ``max_parallel``) since Ollama
serializes items within a single HTTP request but happily serves
multiple concurrent connections.
Parameters
----------
model:
Ollama model tag; defaults to ``nomic-embed-text`` (768-dim).
base_url:
Ollama server base URL. Defaults to ``http://localhost:11434``.
batch_size:
Items per HTTP request. Tuned for Ollama — larger batches help
throughput but increase memory on the server.
max_parallel:
How many concurrent HTTP requests to issue. Ollama CPU/GPU
saturation is typically hit around 8.
timeout_s:
Per-request timeout.
"""
def __init__(
self,
model: str = "nomic-embed-text",
base_url: str = "http://localhost:11434",
*,
batch_size: int = 16,
max_parallel: int = 8,
timeout_s: float = 120.0,
) -> None:
import httpx # local import to keep module light if unused
self._model = model
self._base_url = base_url.rstrip("/")
self._batch_size = max(1, batch_size)
self._max_parallel = max(1, max_parallel)
self._timeout_s = timeout_s
self._httpx = httpx
self._dim_cached: Optional[int] = None
def _embed_batch(self, texts: List[str]) -> List[List[float]]:
"""Issue one HTTP request for ``texts`` and return raw vectors."""
resp = self._httpx.post(
f"{self._base_url}/api/embed",
json={"model": self._model, "input": texts},
timeout=self._timeout_s,
)
resp.raise_for_status()
data = resp.json()
embeddings = data.get("embeddings")
if not embeddings:
raise RuntimeError(
f"Ollama returned no embeddings for model {self._model!r}. "
f"Response keys: {list(data.keys())}"
)
return embeddings
def embed(self, texts: list[str]) -> Any:
"""Return a numpy array of shape ``(len(texts), dim)``.
Float32, L2-normalized per row so callers can use dot-product
as cosine similarity. Empty input → shape ``(0, dim)``.
"""
import numpy as np
if not texts:
return np.zeros((0, self.dim()), dtype=np.float32)
# Partition into batches
batches = [
texts[i : i + self._batch_size]
for i in range(0, len(texts), self._batch_size)
]
# Fan out batches concurrently
results: List[Optional[List[List[float]]]] = [None] * len(batches)
with concurrent.futures.ThreadPoolExecutor(
max_workers=min(self._max_parallel, len(batches)),
) as pool:
future_to_idx = {
pool.submit(self._embed_batch, batch): i
for i, batch in enumerate(batches)
}
for future in concurrent.futures.as_completed(future_to_idx):
idx = future_to_idx[future]
results[idx] = future.result()
# Stitch back in order and stack
flat: List[List[float]] = []
for batch_result in results:
assert batch_result is not None # all futures returned
flat.extend(batch_result)
arr = np.asarray(flat, dtype=np.float32)
# L2-normalize rows; guard against zero vectors
norms = np.linalg.norm(arr, axis=1, keepdims=True)
norms = np.where(norms == 0, 1.0, norms)
arr = arr / norms
if self._dim_cached is None:
self._dim_cached = int(arr.shape[1])
return arr
def dim(self) -> int:
"""Return the embedding dimensionality (probes the server on first call)."""
if self._dim_cached is None:
# Probe with a single trivial input
vecs = self._embed_batch(["probe"])
self._dim_cached = len(vecs[0])
return self._dim_cached
__all__ = ["Embedder", "OllamaEmbedder", "SentenceTransformerEmbedder"]
-137
View File
@@ -1,137 +0,0 @@
"""Tests for the TwitterChannel adapter."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import pytest
from openjarvis.channels._stubs import ChannelStatus
from openjarvis.channels.twitter import TwitterChannel
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
from tests.channels.channel_test_helpers import make_common_channel_tests
@pytest.fixture(autouse=True)
def _register_twitter():
"""Re-register after any registry clear."""
if not ChannelRegistry.contains("twitter"):
ChannelRegistry.register_value("twitter", TwitterChannel)
TestCommonChannel = make_common_channel_tests(TwitterChannel, "twitter")
# Twitter overrides list_channels() to return ["timeline", "dm"],
# so remove the generic assertion and keep the channel-specific test below.
del TestCommonChannel.test_list_channels
def test_twitter_no_credentials_status():
"""Without credentials, connect() sets status to ERROR."""
ch = TwitterChannel()
ch.connect()
assert ch.status() == ChannelStatus.ERROR
def test_twitter_send_no_credentials_returns_false():
"""send() returns False when client is not connected."""
ch = TwitterChannel()
result = ch.send("timeline", "Hello Twitter!")
assert result is False
def test_twitter_send_tweet():
"""send() with a non-numeric channel calls create_tweet."""
ch = TwitterChannel(bearer_token="test-bearer")
mock_client = MagicMock()
ch._client = mock_client
ch._status = ChannelStatus.CONNECTED
result = ch.send("timeline", "Hello from Jarvis!")
assert result is True
mock_client.create_tweet.assert_called_once_with(text="Hello from Jarvis!")
def test_twitter_send_dm_when_channel_is_numeric():
"""send() with a numeric channel calls create_direct_message."""
ch = TwitterChannel(bearer_token="test-bearer")
mock_client = MagicMock()
ch._client = mock_client
ch._status = ChannelStatus.CONNECTED
result = ch.send("12345678", "Hello via DM!")
assert result is True
mock_client.create_direct_message.assert_called_once_with(
participant_id=12345678,
text="Hello via DM!",
)
def test_twitter_send_reply():
"""send() with conversation_id sets in_reply_to_tweet_id."""
ch = TwitterChannel(bearer_token="test-bearer")
mock_client = MagicMock()
ch._client = mock_client
ch._status = ChannelStatus.CONNECTED
result = ch.send(
"timeline",
"This is a reply",
conversation_id="999888777",
)
assert result is True
mock_client.create_tweet.assert_called_once_with(
text="This is a reply",
in_reply_to_tweet_id=999888777,
)
def test_twitter_list_channels():
"""list_channels() returns the expected identifiers."""
ch = TwitterChannel()
assert ch.list_channels() == ["timeline", "dm"]
def test_twitter_event_bus_integration():
"""Successful send publishes CHANNEL_MESSAGE_SENT on the event bus."""
bus = EventBus(record_history=True)
ch = TwitterChannel(bearer_token="test-bearer", bus=bus)
mock_client = MagicMock()
ch._client = mock_client
ch._status = ChannelStatus.CONNECTED
ch.send("timeline", "Event test!")
event_types = [e.event_type for e in bus.history]
assert EventType.CHANNEL_MESSAGE_SENT in event_types
sent_event = next(
e for e in bus.history if e.event_type == EventType.CHANNEL_MESSAGE_SENT
)
assert sent_event.data["content"] == "Event test!"
assert sent_event.data["channel"] == "timeline"
@pytest.mark.live_channel
def test_twitter_connect_live():
"""Live test: connect with real credentials from env vars.
Skipped unless all required env vars are set.
"""
required_vars = [
"TWITTER_BEARER_TOKEN",
"TWITTER_API_KEY",
"TWITTER_API_SECRET",
"TWITTER_ACCESS_TOKEN",
"TWITTER_ACCESS_SECRET",
]
for var in required_vars:
if not os.environ.get(var):
pytest.skip(f"Missing env var {var}")
ch = TwitterChannel()
ch.connect()
assert ch.status() == ChannelStatus.CONNECTED
ch.disconnect()
assert ch.status() == ChannelStatus.DISCONNECTED
+738
View File
@@ -0,0 +1,738 @@
"""End-to-end tests for the Twitter bot mention handler.
Tests cover:
- Tweet classification (_classify_mention)
- Prompt building for each mention type
- Mention polling → handler dispatch
- Full reactive flow: mention → classify → prompt → agent → tool call → reply
- Environment variable expansion in http_request headers (GitHub issue creation)
"""
from __future__ import annotations
import importlib
import json
import os
# ---------------------------------------------------------------------------
# Import the bot module helpers
# ---------------------------------------------------------------------------
import sys
import threading
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.channels._stubs import ChannelMessage
from openjarvis.channels.twitter_channel import TwitterChannel
from openjarvis.tools.http_request import HttpRequestTool
# Add examples dir to path so we can import the bot module
_EXAMPLES_DIR = os.path.join(
os.path.dirname(__file__), os.pardir, os.pardir, "examples", "twitter_bot",
)
sys.path.insert(0, os.path.abspath(_EXAMPLES_DIR))
twitter_bot = importlib.import_module("twitter_bot")
sys.path.pop(0)
_classify_mention = twitter_bot._classify_mention
_build_question_prompt = twitter_bot._build_question_prompt
_build_question_grounded_prompt = twitter_bot._build_question_grounded_prompt
_build_question_deferral_prompt = twitter_bot._build_question_deferral_prompt
_build_bug_prompt = twitter_bot._build_bug_prompt
_build_feature_prompt = twitter_bot._build_feature_prompt
_build_praise_prompt = twitter_bot._build_praise_prompt
DEMO_TWEETS = twitter_bot.DEMO_TWEETS
# =========================================================================
# 1. Classification tests
# =========================================================================
class TestClassifyMention:
"""Test the keyword-based mention classifier."""
@pytest.mark.parametrize(
"text,expected",
[
("@OpenJarvisAI bug: the memory_search tool crashes", "BUG_REPORT"),
("@OpenJarvisAI crash when I run jarvis ask", "BUG_REPORT"),
("@OpenJarvisAI error on startup with ollama", "BUG_REPORT"),
("@OpenJarvisAI the CLI fails after update", "BUG_REPORT"),
("@OpenJarvisAI broken link in the docs", "BUG_REPORT"),
("@OpenJarvisAI segfault with large file", "BUG_REPORT"),
],
)
def test_bug_report(self, text, expected):
assert _classify_mention(text) == expected
@pytest.mark.parametrize(
"text,expected",
[
("@OpenJarvisAI feature request: add a scheduler UI", "FEATURE_REQUEST"),
("@OpenJarvisAI would love a web dashboard", "FEATURE_REQUEST"),
(
"@OpenJarvisAI it would be great to have notifications",
"FEATURE_REQUEST",
),
("@OpenJarvisAI I wish there was a mobile app", "FEATURE_REQUEST"),
("@OpenJarvisAI please add dark mode", "FEATURE_REQUEST"),
("@OpenJarvisAI can you add voice input?", "FEATURE_REQUEST"),
("@OpenJarvisAI any plans for a VS Code extension?", "FEATURE_REQUEST"),
],
)
def test_feature_request(self, text, expected):
assert _classify_mention(text) == expected
@pytest.mark.parametrize(
"text,expected",
[
("@OpenJarvisAI just discovered this, love it!", "PRAISE"),
("@OpenJarvisAI this is amazing work", "PRAISE"),
("@OpenJarvisAI awesome project, great work!", "PRAISE"),
("@OpenJarvisAI I'm impressed by the speed", "PRAISE"),
("@OpenJarvisAI switched from langchain, incredible", "PRAISE"),
],
)
def test_praise(self, text, expected):
assert _classify_mention(text) == expected
@pytest.mark.parametrize(
"text,expected",
[
("@OpenJarvisAI BUY CRYPTO NOW", "SPAM"),
("@OpenJarvisAI free download link in bio", "SPAM"),
("@OpenJarvisAI guaranteed income 10x returns", "SPAM"),
],
)
def test_spam(self, text, expected):
assert _classify_mention(text) == expected
@pytest.mark.parametrize(
"text,expected",
[
("@OpenJarvisAI how do I add a new channel?", "QUESTION"),
("@OpenJarvisAI what models do you support?", "QUESTION"),
("@OpenJarvisAI does this work on Windows?", "QUESTION"),
("@OpenJarvisAI tell me about the architecture", "QUESTION"),
],
)
def test_question(self, text, expected):
assert _classify_mention(text) == expected
def test_demo_tweets_cover_all_types(self):
"""The built-in DEMO_TWEETS should cover all five categories."""
types = {_classify_mention(t["text"]) for t in DEMO_TWEETS}
assert types == {"QUESTION", "BUG_REPORT", "FEATURE_REQUEST", "PRAISE", "SPAM"}
# =========================================================================
# 2. Prompt builder tests
# =========================================================================
class TestPromptBuilders:
"""Verify prompt builders produce well-formed prompts with the right info."""
def test_question_deferral_prompt_has_channel_send(self):
"""Deferral prompt (used when retrieval is empty/weak)."""
prompt = _build_question_deferral_prompt("alice", "123", "how do I install?")
assert "channel_send" in prompt
assert "alice" in prompt
assert "123" in prompt
assert "how do I install?" in prompt
# Deferral prompt explicitly tells the model NOT to guess
assert "do not guess" in prompt.lower() or "do not make up" in prompt.lower()
def test_question_grounded_prompt_embeds_context(self):
"""Grounded prompt (used when retrieval score >= threshold)."""
context = "[1] hardware.md — Running Without a GPU\nUse llama.cpp for CPU."
prompt = _build_question_grounded_prompt(
"alice", "123", "can I run on cpu?", context, 0.71
)
assert "channel_send" in prompt
assert context in prompt
# Grounded prompt must instruct the model to answer ONLY from context
lc = prompt.lower()
assert (
"only from facts in the context" in lc
or "only from the context" in lc
)
def test_bug_prompt_contains_github_url(self):
prompt = _build_bug_prompt("bob", "456", "crash on startup")
assert "api.github.com/repos/open-jarvis/OpenJarvis/issues" in prompt
assert "http_request" in prompt
assert "channel_send" in prompt
assert "bob" in prompt
assert "bug" in prompt
assert "456" in prompt
def test_feature_prompt_contains_github_url(self):
prompt = _build_feature_prompt("carol", "789", "add dark mode")
assert "api.github.com/repos/open-jarvis/OpenJarvis/issues" in prompt
assert "enhancement" in prompt
assert "carol" in prompt
assert "789" in prompt
def test_praise_prompt_has_channel_send(self):
prompt = _build_praise_prompt("dave", "101", "love this project!")
assert "channel_send" in prompt
assert "dave" in prompt
assert "101" in prompt
def test_all_prompts_include_voice_rules(self):
"""Every prompt should include the voice rules (280 chars, lowercase, etc.)."""
prompts = [
_build_question_prompt("u", "1", "q"),
_build_bug_prompt("u", "1", "b"),
_build_feature_prompt("u", "1", "f"),
_build_praise_prompt("u", "1", "p"),
]
for prompt in prompts:
assert "<=280 characters" in prompt
assert "lowercase prose" in prompt
def test_bug_prompt_includes_from_twitter_label(self):
prompt = _build_bug_prompt("user", "1", "crash")
assert "from-twitter" in prompt
def test_feature_prompt_includes_from_twitter_label(self):
prompt = _build_feature_prompt("user", "1", "feature")
assert "from-twitter" in prompt
# =========================================================================
# 3. Mention polling + handler dispatch
# =========================================================================
class TestMentionPolling:
"""Test that _poll_mentions fetches tweets and dispatches to handlers."""
def test_poll_dispatches_to_handler(self):
"""A single poll cycle should dispatch tweets to registered handlers."""
ch = TwitterChannel(
bearer_token="test-bearer",
bot_user_id="999",
poll_interval=1,
)
handler = MagicMock()
ch.on_message(handler)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": [
{
"id": "111",
"author_id": "alice",
"text": "@OpenJarvisAI how do I install?",
"conversation_id": "111",
},
],
}
with patch("httpx.get", return_value=mock_response):
ch._stop_event = threading.Event()
def poll_once():
import httpx as _httpx
headers = {"Authorization": "Bearer test-bearer"}
url = "https://api.twitter.com/2/users/999/mentions"
params = {"tweet.fields": "author_id,conversation_id,created_at"}
resp = _httpx.get(url, headers=headers, params=params, timeout=10.0)
data = resp.json()
for tweet in data.get("data", []):
cm = ChannelMessage(
channel="twitter",
sender=tweet.get("author_id", ""),
content=tweet.get("text", ""),
message_id=tweet["id"],
conversation_id=tweet.get("conversation_id", ""),
)
for h in ch._handlers:
h(cm)
poll_once()
handler.assert_called_once()
msg = handler.call_args[0][0]
assert isinstance(msg, ChannelMessage)
assert msg.sender == "alice"
assert msg.content == "@OpenJarvisAI how do I install?"
assert msg.message_id == "111"
def test_poll_tracks_since_id(self):
"""Polling should track since_id to avoid reprocessing tweets."""
ch = TwitterChannel(
bearer_token="test-bearer",
bot_user_id="999",
poll_interval=0,
)
assert ch._since_id is None
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
"data": [
{"id": "200", "text": "hello", "author_id": "u1"},
{"id": "300", "text": "world", "author_id": "u2"},
],
}
ch._stop_event = threading.Event()
def get_and_stop(*args, **kwargs):
ch._stop_event.set()
return mock_resp
with patch("httpx.get", side_effect=get_and_stop):
ch._poll_mentions()
assert ch._since_id == "300"
def test_poll_empty_response(self):
"""Empty mentions response should not error or call handlers."""
ch = TwitterChannel(
bearer_token="test-bearer",
bot_user_id="999",
poll_interval=0,
)
handler = MagicMock()
ch.on_message(handler)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"data": []}
ch._stop_event = threading.Event()
def get_and_stop(*args, **kwargs):
ch._stop_event.set()
return mock_resp
with patch("httpx.get", side_effect=get_and_stop):
ch._poll_mentions()
handler.assert_not_called()
# =========================================================================
# 4. Env var expansion in http_request (GitHub issue creation)
# =========================================================================
class TestEnvVarExpansion:
"""Verify the http_request tool expands $ENV_VARS in headers."""
def test_github_token_expanded(self):
"""$GITHUB_TOKEN in Authorization header should be expanded."""
tool = HttpRequestTool()
mock_rust = MagicMock()
mock_rust.HttpRequestTool.return_value.execute.side_effect = RuntimeError(
"mocked",
)
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_resp.text = '{"number": 42}'
mock_resp.headers = {"content-type": "application/json"}
with (
patch.dict(os.environ, {"GITHUB_TOKEN": "ghp_test123"}),
patch(
"openjarvis._rust_bridge.get_rust_module",
return_value=mock_rust,
),
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
patch(
"openjarvis.tools.http_request.httpx.request",
return_value=mock_resp,
) as mock_req,
):
result = tool.execute(
url="https://api.github.com/repos/open-jarvis/OpenJarvis/issues",
method="POST",
headers={
"Authorization": "Bearer $GITHUB_TOKEN",
"Accept": "application/vnd.github+json",
},
body='{"title": "test", "labels": ["bug"]}',
)
assert result.success is True
actual_headers = mock_req.call_args[1]["headers"]
assert actual_headers["Authorization"] == "Bearer ghp_test123"
assert actual_headers["Accept"] == "application/vnd.github+json"
def test_unexpanded_var_without_env(self):
"""$GITHUB_TOKEN without env var set should remain as literal."""
tool = HttpRequestTool()
mock_rust = MagicMock()
mock_rust.HttpRequestTool.return_value.execute.side_effect = RuntimeError(
"mocked",
)
mock_resp = MagicMock()
mock_resp.status_code = 401
mock_resp.text = "Bad credentials"
mock_resp.headers = {"content-type": "text/plain"}
env = {k: v for k, v in os.environ.items() if k != "GITHUB_TOKEN"}
with (
patch.dict(os.environ, env, clear=True),
patch(
"openjarvis._rust_bridge.get_rust_module",
return_value=mock_rust,
),
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
patch(
"openjarvis.tools.http_request.httpx.request",
return_value=mock_resp,
) as mock_req,
):
tool.execute(
url="https://api.github.com/repos/test/test/issues",
method="POST",
headers={"Authorization": "Bearer $GITHUB_TOKEN"},
body="{}",
)
actual_headers = mock_req.call_args[1]["headers"]
assert actual_headers["Authorization"] == "Bearer $GITHUB_TOKEN"
def test_non_string_header_values_pass_through(self):
"""Non-string header values should pass through without error."""
tool = HttpRequestTool()
mock_rust = MagicMock()
mock_rust.HttpRequestTool.return_value.execute.side_effect = RuntimeError(
"mocked",
)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = "ok"
mock_resp.headers = {}
with (
patch(
"openjarvis._rust_bridge.get_rust_module",
return_value=mock_rust,
),
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
patch(
"openjarvis.tools.http_request.httpx.request",
return_value=mock_resp,
) as mock_req,
):
tool.execute(
url="https://example.com",
headers={"X-Count": 42, "X-Name": "test"},
)
actual_headers = mock_req.call_args[1]["headers"]
assert actual_headers["X-Count"] == 42
assert actual_headers["X-Name"] == "test"
# =========================================================================
# 5. Full reactive e2e flow (mock Jarvis + TwitterChannel)
# =========================================================================
class TestFullE2EFlow:
"""Test the full flow: mention arrives → classify → prompt → agent → tool calls."""
def _make_mock_jarvis(self, responses=None):
"""Create a mock Jarvis instance that returns canned responses."""
j = MagicMock()
if responses:
j.ask.side_effect = responses
else:
j.ask.return_value = "mock response"
return j
def test_question_flow(self):
"""Question mention → retrieval runs in Python, agent only needs channel_send.
After the dense-retrieval refactor, ``memory_search`` is no longer
a model-visible tool; retrieval happens out-of-band and the score
picks between grounded/deferral prompts. The only tool the agent
needs for a QUESTION is ``channel_send``.
"""
j = self._make_mock_jarvis(["check the docs at open-jarvis.github.io"])
tweet = DEMO_TWEETS[0]
mention_type = _classify_mention(tweet["text"])
assert mention_type == "QUESTION"
prompt = _build_question_deferral_prompt(
tweet["author"], tweet["id"], tweet["text"]
)
j.ask(
prompt,
agent="orchestrator",
tools=["channel_send"],
temperature=0.4,
)
j.ask.assert_called_once()
call_kwargs = j.ask.call_args
assert "channel_send" in call_kwargs[1]["tools"]
# memory_search is explicitly NOT passed — retrieval was already done
assert "memory_search" not in call_kwargs[1]["tools"]
assert call_kwargs[1]["agent"] == "orchestrator"
def test_bug_report_flow(self):
"""Bug mention → http_request (GitHub issue) + channel_send."""
j = self._make_mock_jarvis(["opened an issue for this"])
tweet = DEMO_TWEETS[1]
mention_type = _classify_mention(tweet["text"])
assert mention_type == "BUG_REPORT"
prompt = _build_bug_prompt(tweet["author"], tweet["id"], tweet["text"])
j.ask(
prompt,
agent="orchestrator",
tools=["http_request", "channel_send"],
temperature=0.4,
)
call_kwargs = j.ask.call_args
assert "http_request" in call_kwargs[1]["tools"]
assert "channel_send" in call_kwargs[1]["tools"]
assert "api.github.com" in call_kwargs[0][0]
assert "bug" in call_kwargs[0][0]
def test_feature_request_flow(self):
"""Feature mention → http_request (GitHub issue) + channel_send."""
j = self._make_mock_jarvis(
["love this idea — opened an issue to track it"],
)
tweet = DEMO_TWEETS[2]
mention_type = _classify_mention(tweet["text"])
assert mention_type == "FEATURE_REQUEST"
prompt = _build_feature_prompt(
tweet["author"], tweet["id"], tweet["text"],
)
j.ask(
prompt,
agent="orchestrator",
tools=["http_request", "channel_send"],
temperature=0.4,
)
call_kwargs = j.ask.call_args
assert "http_request" in call_kwargs[1]["tools"]
assert "enhancement" in call_kwargs[0][0]
def test_praise_flow(self):
"""Praise mention → channel_send only."""
j = self._make_mock_jarvis(["thanks, glad you like it!"])
tweet = DEMO_TWEETS[3]
mention_type = _classify_mention(tweet["text"])
assert mention_type == "PRAISE"
prompt = _build_praise_prompt(tweet["author"], tweet["id"], tweet["text"])
j.ask(prompt, agent="orchestrator", tools=["channel_send"], temperature=0.4)
call_kwargs = j.ask.call_args
assert call_kwargs[1]["tools"] == ["channel_send"]
def test_spam_is_ignored(self):
"""Spam mentions should be skipped — no Jarvis.ask call."""
j = self._make_mock_jarvis()
tweet = DEMO_TWEETS[4]
mention_type = _classify_mention(tweet["text"])
assert mention_type == "SPAM"
if mention_type != "SPAM":
j.ask("should not be called")
j.ask.assert_not_called()
def test_all_demo_tweets_processed(self):
"""Run each demo tweet; verify classification + tool selection.
Post dense-retrieval refactor: QUESTIONs no longer request
``memory_search`` as a tool — retrieval is done in Python.
"""
expected = [
("QUESTION", ["channel_send"]),
("BUG_REPORT", ["http_request", "channel_send"]),
("FEATURE_REQUEST", ["http_request", "channel_send"]),
("PRAISE", ["channel_send"]),
("SPAM", None),
]
for tweet, (exp_type, exp_tools) in zip(DEMO_TWEETS, expected):
mention_type = _classify_mention(tweet["text"])
assert mention_type == exp_type, f"Tweet by {tweet['author']} misclassified"
if mention_type == "SPAM":
continue
if mention_type == "QUESTION":
tools = ["channel_send"]
elif mention_type == "BUG_REPORT":
tools = ["http_request", "channel_send"]
elif mention_type == "FEATURE_REQUEST":
tools = ["http_request", "channel_send"]
else:
tools = ["channel_send"]
assert tools == exp_tools, f"Wrong tools for {tweet['author']}"
# =========================================================================
# 6. Send-as-reply (conversation_id passthrough)
# =========================================================================
class TestReplyConversationId:
"""Verify that replies always pass conversation_id back to the Twitter API."""
def test_send_passes_conversation_id_as_reply(self):
ch = TwitterChannel(
bearer_token="b",
api_key="ck",
api_secret="cs",
access_token="at",
access_secret="as",
)
mock_resp = MagicMock()
mock_resp.status_code = 201
with patch("httpx.post", return_value=mock_resp) as mock_post:
ch.send(
"twitter",
"opened an issue for this — we'll look into it",
conversation_id="1000000000000000002",
)
payload = mock_post.call_args[1]["json"]
assert payload["reply"]["in_reply_to_tweet_id"] == "1000000000000000002"
assert len(payload["text"]) <= 280
# =========================================================================
# 7. GitHub issue creation e2e (http_request with expanded token)
# =========================================================================
class TestGitHubIssueCreation:
"""Simulate the LLM calling http_request to create a GitHub issue."""
def test_create_bug_issue(self):
tool = HttpRequestTool()
mock_rust = MagicMock()
mock_rust.HttpRequestTool.return_value.execute.side_effect = (
RuntimeError("mocked")
)
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_resp.text = json.dumps({
"number": 42,
"html_url": "https://github.com/open-jarvis/OpenJarvis/issues/42",
})
mock_resp.headers = {"content-type": "application/json"}
with (
patch.dict(os.environ, {"GITHUB_TOKEN": "ghp_testtoken123"}),
patch("openjarvis._rust_bridge.get_rust_module", return_value=mock_rust),
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
patch(
"openjarvis.tools.http_request.httpx.request",
return_value=mock_resp,
) as mock_req,
):
result = tool.execute(
url="https://api.github.com/repos/open-jarvis/OpenJarvis/issues",
method="POST",
headers={
"Authorization": "Bearer $GITHUB_TOKEN",
"Accept": "application/vnd.github+json",
},
body=json.dumps({
"title": "memory_search tool crashes on empty index",
"body": (
"reported via twitter by @bob_user: bug: the "
"memory_search tool crashes when the index is empty"
),
"labels": ["bug", "from-twitter"],
}),
)
assert result.success is True
assert "42" in result.content
actual_call = mock_req.call_args
assert actual_call[0][0] == "POST"
assert "api.github.com" in actual_call[0][1]
assert (
actual_call[1]["headers"]["Authorization"]
== "Bearer ghp_testtoken123"
)
body = actual_call[1]["content"]
parsed_body = json.loads(body)
assert parsed_body["labels"] == ["bug", "from-twitter"]
assert "bob_user" in parsed_body["body"]
def test_create_feature_issue(self):
tool = HttpRequestTool()
mock_rust = MagicMock()
mock_rust.HttpRequestTool.return_value.execute.side_effect = (
RuntimeError("mocked")
)
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_resp.text = json.dumps({"number": 43})
mock_resp.headers = {"content-type": "application/json"}
with (
patch.dict(os.environ, {"GITHUB_TOKEN": "ghp_testtoken123"}),
patch("openjarvis._rust_bridge.get_rust_module", return_value=mock_rust),
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
patch(
"openjarvis.tools.http_request.httpx.request",
return_value=mock_resp,
) as mock_req,
):
result = tool.execute(
url="https://api.github.com/repos/open-jarvis/OpenJarvis/issues",
method="POST",
headers={
"Authorization": "Bearer $GITHUB_TOKEN",
"Accept": "application/vnd.github+json",
},
body=json.dumps({
"title": "feature request: built-in scheduler UI",
"body": (
"requested via twitter by @carol_eng: it would "
"be great to have a built-in scheduler UI"
),
"labels": ["enhancement", "from-twitter"],
}),
)
assert result.success is True
body = json.loads(mock_req.call_args[1]["content"])
assert body["labels"] == ["enhancement", "from-twitter"]
assert "carol_eng" in body["body"]
+215
View File
@@ -0,0 +1,215 @@
"""Tests for the TwitterChannel adapter."""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.channels._stubs import ChannelStatus
from openjarvis.channels.twitter_channel import TwitterChannel
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
@pytest.fixture(autouse=True)
def _register_twitter():
"""Re-register after any registry clear."""
if not ChannelRegistry.contains("twitter"):
ChannelRegistry.register_value("twitter", TwitterChannel)
class TestRegistration:
def test_registry_key(self):
assert ChannelRegistry.contains("twitter")
def test_channel_id(self):
ch = TwitterChannel(bearer_token="test-bearer")
assert ch.channel_id == "twitter"
class TestInit:
def test_defaults(self):
ch = TwitterChannel()
assert ch._bearer == ""
assert ch._api_key == ""
assert ch._status == ChannelStatus.DISCONNECTED
def test_constructor_bearer(self):
ch = TwitterChannel(bearer_token="my-bearer")
assert ch._bearer == "my-bearer"
def test_env_var_fallback(self):
env = {
"TWITTER_BEARER_TOKEN": "env-bearer",
"TWITTER_API_KEY": "env-key",
"TWITTER_API_SECRET": "env-secret",
"TWITTER_ACCESS_TOKEN": "env-access",
"TWITTER_ACCESS_SECRET": "env-acc-secret",
"TWITTER_BOT_USER_ID": "12345",
}
with patch.dict(os.environ, env):
ch = TwitterChannel()
assert ch._bearer == "env-bearer"
assert ch._api_key == "env-key"
assert ch._api_secret == "env-secret"
assert ch._access_token == "env-access"
assert ch._access_secret == "env-acc-secret"
assert ch._bot_user_id == "12345"
def test_constructor_overrides_env(self):
with patch.dict(os.environ, {"TWITTER_BEARER_TOKEN": "env-bearer"}):
ch = TwitterChannel(bearer_token="explicit-bearer")
assert ch._bearer == "explicit-bearer"
class TestSend:
def test_send_success(self):
ch = TwitterChannel(
bearer_token="b",
api_key="ck",
api_secret="cs",
access_token="at",
access_secret="as",
)
mock_response = MagicMock()
mock_response.status_code = 201
with patch("httpx.post", return_value=mock_response) as mock_post:
result = ch.send("twitter", "Hello from OpenJarvis!")
assert result is True
mock_post.assert_called_once()
call_args = mock_post.call_args
url = call_args[0][0]
assert "api.twitter.com/2/tweets" in url
payload = call_args[1]["json"]
assert payload["text"] == "Hello from OpenJarvis!"
assert "reply" not in payload
def test_send_as_reply(self):
ch = TwitterChannel(
bearer_token="b",
api_key="ck",
api_secret="cs",
access_token="at",
access_secret="as",
)
mock_response = MagicMock()
mock_response.status_code = 201
with patch("httpx.post", return_value=mock_response) as mock_post:
result = ch.send(
"twitter", "Replying!", conversation_id="9876543210",
)
assert result is True
payload = mock_post.call_args[1]["json"]
assert payload["reply"]["in_reply_to_tweet_id"] == "9876543210"
def test_send_truncates_to_280(self):
ch = TwitterChannel(
bearer_token="b",
api_key="ck",
api_secret="cs",
access_token="at",
access_secret="as",
)
mock_response = MagicMock()
mock_response.status_code = 201
long_text = "A" * 300
with patch("httpx.post", return_value=mock_response) as mock_post:
ch.send("twitter", long_text)
payload = mock_post.call_args[1]["json"]
assert len(payload["text"]) == 280
def test_send_failure(self):
ch = TwitterChannel(
bearer_token="b",
api_key="ck",
api_secret="cs",
access_token="at",
access_secret="as",
)
mock_response = MagicMock()
mock_response.status_code = 403
mock_response.text = "Forbidden"
with patch("httpx.post", return_value=mock_response):
result = ch.send("twitter", "Hello!")
assert result is False
def test_send_exception(self):
ch = TwitterChannel(
bearer_token="b",
api_key="ck",
api_secret="cs",
access_token="at",
access_secret="as",
)
with patch("httpx.post", side_effect=ConnectionError("refused")):
result = ch.send("twitter", "Hello!")
assert result is False
def test_send_no_credentials(self):
ch = TwitterChannel()
result = ch.send("twitter", "Hello!")
assert result is False
def test_send_publishes_event(self):
bus = EventBus(record_history=True)
ch = TwitterChannel(
bearer_token="b",
api_key="ck",
api_secret="cs",
access_token="at",
access_secret="as",
bus=bus,
)
mock_response = MagicMock()
mock_response.status_code = 201
with patch("httpx.post", return_value=mock_response):
ch.send("twitter", "Hello!")
event_types = [e.event_type for e in bus.history]
assert EventType.CHANNEL_MESSAGE_SENT in event_types
class TestListChannels:
def test_list_channels(self):
ch = TwitterChannel(bearer_token="b")
assert ch.list_channels() == ["twitter"]
class TestStatus:
def test_disconnected_initially(self):
ch = TwitterChannel(bearer_token="b")
assert ch.status() == ChannelStatus.DISCONNECTED
def test_no_token_connect_error(self):
ch = TwitterChannel()
ch.connect()
assert ch.status() == ChannelStatus.ERROR
class TestOnMessage:
def test_on_message(self):
ch = TwitterChannel(bearer_token="b")
handler = MagicMock()
ch.on_message(handler)
assert handler in ch._handlers
class TestDisconnect:
def test_disconnect(self):
ch = TwitterChannel(bearer_token="b")
ch._status = ChannelStatus.CONNECTED
ch.disconnect()
assert ch.status() == ChannelStatus.DISCONNECTED
+14 -3
View File
@@ -23,11 +23,18 @@ def test_ask_has_no_context_option():
assert "--no-context" in result.output
def test_get_memory_backend_returns_none_when_empty(
def test_get_memory_backend_returns_backend_even_when_empty(
tmp_path,
monkeypatch,
):
"""_get_memory_backend returns None when no docs indexed."""
"""_get_memory_backend returns a backend for an empty DB.
Retrieval against an empty store is a valid operation — it simply
returns no hits. Callers must check ``len(results)``; returning
``None`` here would conflate "backend unavailable" with "no docs",
which is the kind of ambiguity that leads to silent grounding
failures downstream.
"""
from openjarvis.core.config import JarvisConfig, MemoryConfig
from openjarvis.core.registry import MemoryRegistry
from openjarvis.tools.storage.sqlite import SQLiteMemory
@@ -42,7 +49,11 @@ def test_get_memory_backend_returns_none_when_empty(
mod = importlib.import_module("openjarvis.cli.ask")
result = mod._get_memory_backend(config)
assert result is None
assert result is not None
# An empty backend should still retrieve cleanly (zero hits).
assert result.retrieve("anything", top_k=3) == []
if hasattr(result, "close"):
result.close()
def test_get_memory_backend_returns_backend_with_docs(
+19
View File
@@ -0,0 +1,19 @@
# Channel Integrations
OpenJarvis ships adapters for many messaging platforms.
## Telegram
Set `TELEGRAM_BOT_TOKEN` in your env and call `TelegramChannel().connect()`. Incoming messages are delivered via the `on_message` callback.
## Slack
Install the `channel-slack` extra and provide a bot token starting with `xoxb-`. Slack uses socket mode by default but HTTP event mode is also supported.
## Discord
Discord needs a bot token and intents configured for message content. Add the bot to your server first, then register the channel.
## Adding a New Channel
Create a subclass of `BaseChannel` in `src/openjarvis/channels/your_channel.py`. Decorate with `@ChannelRegistry.register("name")`. Implement `connect`, `disconnect`, `send`, `status`, `list_channels`, and `on_message`.
+19
View File
@@ -0,0 +1,19 @@
# Inference Engines
OpenJarvis supports several inference backends.
## Ollama
The default on consumer hardware. Runs locally and exposes an HTTP API. Good for GGUF-quantized models.
## vLLM
High-throughput serving backend with tensor parallelism. Best for NVIDIA GPUs with plenty of VRAM. Supports prefix caching and continuous batching.
## llama.cpp
Pure C++ inference. Runs on CPU, Metal, CUDA, or ROCm. Ideal for laptops without a discrete GPU.
## Picking an Engine
Run `jarvis init` — it detects your hardware and recommends the best fit. You can override with `--engine <name>`.
+20
View File
@@ -0,0 +1,20 @@
# Hardware Requirements
OpenJarvis auto-detects your hardware and picks a sensible model.
## GPU Acceleration
On NVIDIA GPUs OpenJarvis uses CUDA via vLLM or Ollama. On Apple Silicon it uses Metal via the MLX engine. AMD GPUs work through ROCm when vLLM is available.
## Running Without a GPU
Yes, OpenJarvis supports CPU-only mode. Use llama.cpp as the engine and pick a small model — the 4B model is recommended for speed on CPU. Larger models will load but tokens-per-second drops significantly.
## Memory
Rough guide for model memory footprint at Q4 quantization:
- 4B model: ~3 GB
- 8B model: ~5 GB
- 30B model: ~20 GB
Add roughly 2 GB for OS and context.
+15
View File
@@ -0,0 +1,15 @@
# Memory and Retrieval
OpenJarvis stores facts the agent has learned so it can retrieve them later.
## Backends
The default is SQLite with FTS5 for keyword search. BM25, FAISS (dense vector), and Hybrid (BM25 + dense) backends are also available.
## Conflicting Facts
When you store "X is true" today and "X is false" tomorrow, the memory system keeps both records with timestamps. Retrieval is recency-biased by default so the latest fact wins, but older versions remain queryable.
## Deleting Memories
Call `backend.delete(doc_id)` to remove a single record, or `backend.clear()` to wipe everything. There is currently no undo.
View File
+476
View File
@@ -0,0 +1,476 @@
"""Tests for the DenseMemory backend.
These tests exercise retrieval quality on a small fixture corpus, then
assert on the actual cosine-similarity score distribution the embedding
model produces. The thresholds here are set **empirically** from the
observed scores on nomic-embed-text — not guessed upfront — so a
regression in either the embedder or the chunker will show up as a
failing assertion rather than a silently bad result.
The tests require Ollama with ``nomic-embed-text`` pulled; they are
skipped if the server is unreachable.
"""
from __future__ import annotations
import os
import socket
from pathlib import Path
import pytest
from openjarvis.tools.storage.dense import (
DenseMemory,
MdChunk,
chunk_markdown,
dedupe_chunks,
)
_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"))
def _ollama_up() -> bool:
try:
with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
return True
except OSError:
return False
ollama_required = pytest.mark.skipif(
not _ollama_up(),
reason=(
"Requires Ollama with nomic-embed-text "
"(start `ollama serve` then `ollama pull nomic-embed-text`)"
),
)
# ---------------------------------------------------------------------------
# Chunking unit tests (no Ollama required)
# ---------------------------------------------------------------------------
class TestChunkMarkdown:
def test_empty_text(self):
assert chunk_markdown("") == []
assert chunk_markdown(" \n\n ") == []
def test_single_section_without_splits(self):
md = (
"# Title\n\n"
"Some body paragraph with a few sentences. Enough to be a chunk."
)
chunks = chunk_markdown(md, source="t.md")
assert len(chunks) == 1
assert chunks[0].breadcrumb == "Title"
assert "body paragraph" in chunks[0].content
def test_splits_on_h2_and_h3(self):
md = (
"# Book\n\n"
"## Chapter One\n\nfirst body.\n\n"
"### Section A\n\nalpha body.\n\n"
"### Section B\n\nbeta body.\n\n"
"## Chapter Two\n\ngamma body.\n"
)
chunks = chunk_markdown(md, source="t.md")
breadcrumbs = [c.breadcrumb for c in chunks]
# Every header change becomes a new chunk
assert "Book > Chapter One" in breadcrumbs
assert "Book > Chapter One > Section A" in breadcrumbs
assert "Book > Chapter One > Section B" in breadcrumbs
assert "Book > Chapter Two" in breadcrumbs
def test_ignores_headers_inside_code_fences(self):
# Inside a fenced code block, lines starting with '#' are
# shell/python comments, not markdown headers.
md = (
"# Guide\n\n"
"## Install\n\n"
"Run these commands:\n\n"
"```bash\n"
"# Install Homebrew\n"
"# Build the Rust extension\n"
"brew install rust\n"
"```\n\n"
"Then verify.\n"
)
chunks = chunk_markdown(md, source="t.md")
breadcrumbs = [c.breadcrumb for c in chunks]
assert "Guide > Install" in breadcrumbs
# Must NOT have parsed the shell comments as headers:
assert not any("Install Homebrew" in b for b in breadcrumbs)
assert not any("Build the Rust extension" in b for b in breadcrumbs)
def test_oversize_section_is_split_with_overlap(self):
body = " ".join(["word"] * 2500)
md = f"# Big\n\n## Section\n\n{body}\n"
chunks = chunk_markdown(
md,
source="t.md",
max_section_tokens=500,
paragraph_overlap_tokens=50,
)
assert len(chunks) >= 2
for c in chunks:
# Every chunk carries the breadcrumb
assert c.breadcrumb == "Big > Section"
# ---------------------------------------------------------------------------
# Cross-file deduplication (no Ollama required)
# ---------------------------------------------------------------------------
def _mk(content: str, source: str) -> MdChunk:
return MdChunk(content=f"Header\n\n{content}", source=source, breadcrumb="Header")
class TestDedupeChunks:
def test_no_dedupe_when_under_threshold_files(self):
"""Two identical chunks across two files should NOT be deduped (need 3+)."""
chunks = [
_mk("the quick brown fox jumps over the lazy dog every morning", "a.md"),
_mk("the quick brown fox jumps over the lazy dog every morning", "b.md"),
]
survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
assert len(survivors) == 2
assert report.removed_count == 0
def test_dedupes_boilerplate_across_three_files(self):
"""Same blurb in 3+ files → keep one canonical, drop the rest."""
body = "openjarvis runs entirely on your hardware no cloud needed local first"
chunks = [
_mk(body, "docs/index.md"),
_mk(body, "docs/downloads.md"),
_mk(body, "docs/getting-started/installation.md"),
]
survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
assert len(survivors) == 1
# Most-specific source path wins (deepest)
assert survivors[0].source == "docs/getting-started/installation.md"
assert report.removed_count == 2
assert len(report.groups) == 1
grp = report.groups[0]
assert grp.distinct_files == 3
assert "docs/index.md" in grp.dropped_sources
assert "docs/downloads.md" in grp.dropped_sources
def test_keeps_distinct_content(self):
"""Genuinely different chunks must survive even with shared phrases."""
chunks = [
_mk(
"install ollama with brew install ollama then run ollama serve",
"a.md",
),
_mk(
"configure vllm with tensor parallelism and prefix caching",
"b.md",
),
_mk(
"llama.cpp builds with cmake and supports cpu metal cuda rocm",
"c.md",
),
]
survivors, report = dedupe_chunks(chunks)
assert len(survivors) == 3
assert report.removed_count == 0
def test_minor_edit_is_clustered_when_body_is_long_enough(self):
"""A one-word swap in a long boilerplate paragraph still clusters.
At 5-grams a one-word change kills 5 n-grams; in a short 14-word
sentence that's half the grams (Jaccard ~0.33 — below the 0.7
threshold), but in real boilerplate paragraphs the change is a
small fraction of total grams and the cluster still forms. This
test uses a paragraph long enough to put Jaccard above 0.7.
"""
common = (
"openjarvis is a personal ai platform that runs entirely on your "
"own hardware no cloud apis required by default the project is "
"open source apache 2 licensed and supports ollama vllm sglang "
"and llama cpp inference engines with auto detection of your "
"available compute resources at startup so the right backend is "
"picked without manual configuration in most cases"
)
# One-word change shouldn't break clustering on this length
a = common + " choose the interface that suits you"
b = common + " pick the interface that suits you"
c = common + " select the interface that suits you"
chunks = [_mk(a, "a.md"), _mk(b, "b.md"), _mk(c, "c.md")]
survivors, report = dedupe_chunks(chunks)
assert len(survivors) == 1, (
f"got {len(survivors)} survivors — "
"expected single cluster from boilerplate"
)
assert report.removed_count == 2
def test_breadcrumb_difference_does_not_block_dedupe(self):
"""Identical body wrapped in different breadcrumbs still dedupes.
Without stripping the breadcrumb prefix before n-gram extraction,
chunks with the same body but different leading words (e.g.
``Downloads`` vs ``Installation``) would have lower Jaccard.
"""
body = (
"openjarvis runs entirely on your hardware no cloud needed "
"local first foundation"
)
chunks = [
MdChunk(
content=f"Downloads\n\n{body}",
source="docs/downloads.md",
breadcrumb="Downloads",
),
MdChunk(
content=f"Installation\n\n{body}",
source="docs/install.md",
breadcrumb="Installation",
),
MdChunk(
content=f"Welcome\n\n{body}",
source="docs/index.md",
breadcrumb="Welcome",
),
]
survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
assert len(survivors) == 1, (
f"got {len(survivors)} survivors: {[s.source for s in survivors]}"
)
assert report.removed_count == 2
def test_path_specificity_tiebreaker(self):
"""When duplicates exist, the deepest path wins."""
body = (
"we use the orchestrator agent backed by tools and memory "
"backends configured per recipe"
)
chunks = [
_mk(body, "shallow.md"),
_mk(body, "docs/middle.md"),
_mk(body, "docs/getting-started/installation.md"),
]
survivors, _ = dedupe_chunks(chunks, min_files_for_dup=3)
assert len(survivors) == 1
assert survivors[0].source == "docs/getting-started/installation.md"
def test_empty_input(self):
survivors, report = dedupe_chunks([])
assert survivors == []
assert report.input_count == 0
assert report.output_count == 0
def test_does_not_remove_more_than_corpus(self):
"""Sanity: removed_count <= input_count, output_count >= 1 per cluster."""
body = "boilerplate about how openjarvis runs entirely on your hardware locally"
chunks = [_mk(body, f"f{i}.md") for i in range(10)]
survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
# 10 files all duplicates → 1 survives
assert len(survivors) == 1
assert report.removed_count == 9
assert report.output_count + report.removed_count == report.input_count
# ---------------------------------------------------------------------------
# Retrieval quality tests (require Ollama + nomic-embed-text)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def indexed_backend():
"""DenseMemory populated from the fixture corpus once per module."""
if not _ollama_up():
pytest.skip("Ollama not reachable")
backend = DenseMemory()
md_files = sorted(_FIXTURE_DIR.glob("*.md"))
assert md_files, f"no fixtures at {_FIXTURE_DIR}"
contents, sources, metadatas = [], [], []
for f in md_files:
for chunk in chunk_markdown(f.read_text(encoding="utf-8"), source=f.name):
contents.append(chunk.content)
sources.append(chunk.source)
metadatas.append({"breadcrumb": chunk.breadcrumb})
backend.store_many(contents, sources=sources, metadatas=metadatas)
return backend
@ollama_required
def test_index_built_with_expected_chunk_count(indexed_backend):
# 4 fixture files with small sections should give a modest chunk count —
# not 1 (indexing broken), not 100 (splitting gone wild).
n = indexed_backend.count()
assert 8 <= n <= 25, f"unexpected chunk count: {n}"
# Threshold chosen empirically from the score distribution documented in
# test_score_distribution_vs_threshold below. See twitter_bot.py for the
# shared constant used at runtime.
_SCORE_THRESHOLD = 0.55
@ollama_required
def test_exact_match_is_top_hit(indexed_backend):
"""A query lifted verbatim from the corpus should return that chunk at rank 1.
We avoid generic terms like "backends" (which match both memory.md
and engines.md) and use a phrase that's distinctive in one doc.
"""
results = indexed_backend.retrieve("BM25 and FAISS for memory retrieval", top_k=3)
assert results, "expected at least one hit"
assert results[0].source == "memory.md"
assert "bm25" in results[0].content.lower() or "faiss" in results[0].content.lower()
# Verbatim-ish matches against nomic-embed-text cluster around 0.7+
assert results[0].score > 0.65, f"top score too low: {results[0].score}"
@ollama_required
def test_paraphrase_matches_semantically(indexed_backend):
"""A paraphrase of doc content should still retrieve an on-topic chunk.
Note: for the "can I run this on a laptop without a gpu?" case the
top hit is ``engines.md > llama.cpp`` (not ``hardware.md > Running
Without a GPU``) because the llama.cpp section explicitly says
"ideal for laptops without a discrete GPU" — a near-literal match
for the query. That's fine for grounding: both chunks contain the
facts we need (CPU-only, llama.cpp).
"""
results = indexed_backend.retrieve(
"can I run this on a laptop without a gpu?", top_k=3,
)
assert results, "expected at least one hit"
# Top-3 should all be from the topical docs (engines.md or hardware.md)
topical = {r.source for r in results[:3]}
assert topical <= {"engines.md", "hardware.md"}, (
f"off-topic sources in top-3: {topical}"
)
top_lc = results[0].content.lower()
assert "llama.cpp" in top_lc or "cpu" in top_lc
@ollama_required
def test_engine_query_finds_engines_doc(indexed_backend):
"""Semantic query about inference engines should find engines.md."""
results = indexed_backend.retrieve(
"which backend is best for high throughput serving?", top_k=3,
)
assert results
assert results[0].source == "engines.md"
assert results[0].score > 0.65
@ollama_required
def test_off_topic_query_scores_below_threshold(indexed_backend):
"""Clearly off-topic queries must fall below the router's threshold.
This is the property the Twitter bot depends on: when the user asks
something unrelated to the docs, retrieval must score below
``_SCORE_THRESHOLD`` so the bot chooses the deferral path instead of
grounding on nonsense.
Note: ``nomic-embed-text`` inflates off-topic scores (observed
up to ~0.51 on this corpus) — a plain sentence-transformer would
give a wider gap, but we're optimizing for the in-process embedder
we actually have.
"""
off_topic_queries = [
"how do I bake a chocolate chip cookie",
"what is the capital of Mongolia",
"recommend me a pop song",
"why is the sky blue",
]
for q in off_topic_queries:
hits = indexed_backend.retrieve(q, top_k=1)
assert hits, f"expected any hit for {q!r}"
assert hits[0].score < _SCORE_THRESHOLD, (
f"off-topic query {q!r} scored {hits[0].score:.3f} — above "
f"threshold {_SCORE_THRESHOLD}, would trigger a false-positive "
f"ground. Tune threshold up or expand corpus."
)
@ollama_required
def test_score_distribution_vs_threshold(indexed_backend, capsys):
"""Document the observed score distribution so the threshold is audit-able.
We do NOT assert ``rel_min > off_max`` — nomic-embed-text produces
overlapping ranges on small narrow corpora. Instead we verify that
the chosen threshold cleanly separates the two *medians*, which is
the property we actually rely on at the router: **most** relevant
queries ground and **all** off-topic queries defer.
"""
relevant_queries = [
"BM25 FAISS Hybrid backends",
"can I run this on a laptop without a gpu?",
"which backend is best for high throughput serving?",
"how do I add a new channel integration",
"what happens when i store conflicting facts?",
]
off_topic_queries = [
"how do I bake a chocolate chip cookie",
"what is the capital of Mongolia",
"recommend me a pop song",
"why is the sky blue",
]
def _top1(q: str) -> float:
hits = indexed_backend.retrieve(q, top_k=1)
return hits[0].score
rel = sorted(_top1(q) for q in relevant_queries)
off = sorted(_top1(q) for q in off_topic_queries)
rel_median = rel[len(rel) // 2]
off_median = off[len(off) // 2]
print(f"\n relevant scores: {[round(s, 3) for s in rel]}")
print(f" off-topic scores: {[round(s, 3) for s in off]}")
print(f" relevant median: {rel_median:.3f}")
print(f" off-topic median: {off_median:.3f}")
print(f" chosen threshold: {_SCORE_THRESHOLD}")
# Medians must be cleanly separated by the threshold
assert rel_median > _SCORE_THRESHOLD, (
f"relevant median {rel_median:.3f} <= threshold {_SCORE_THRESHOLD}"
f"too many relevant queries will be deferred."
)
assert off_median < _SCORE_THRESHOLD, (
f"off-topic median {off_median:.3f} >= threshold {_SCORE_THRESHOLD}"
f"too many nonsense queries will ground."
)
# ---------------------------------------------------------------------------
# API-level sanity
# ---------------------------------------------------------------------------
class TestDenseMemoryAPI:
@ollama_required
def test_store_and_delete(self):
backend = DenseMemory()
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)
assert hits and hits[0].metadata["doc_id"] == doc_id
assert backend.delete(doc_id)
assert backend.count() == 0
assert not backend.delete(doc_id)
@ollama_required
def test_empty_retrieve(self):
backend = DenseMemory()
assert backend.retrieve("anything", top_k=3) == []
@ollama_required
def test_clear(self):
backend = DenseMemory()
backend.store("foo")
backend.store("bar")
backend.clear()
assert backend.count() == 0
assert backend.retrieve("foo", top_k=1) == []
Generated
+3 -17
View File
@@ -4835,7 +4835,7 @@ channel-twitch = [
{ name = "twitchio", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
channel-twitter = [
{ name = "tweepy" },
{ name = "httpx" },
]
channel-viber = [
{ name = "viberbot" },
@@ -5001,6 +5001,7 @@ requires-dist = [
{ name = "google-genai", marker = "extra == 'inference-google'", specifier = ">=1.0" },
{ name = "gspread", marker = "extra == 'eval-sheets'", specifier = ">=6.0" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "httpx", marker = "extra == 'channel-twitter'", specifier = ">=0.27" },
{ name = "line-bot-sdk", marker = "extra == 'channel-line'", specifier = ">=3.0" },
{ name = "litellm", marker = "extra == 'inference-litellm'", specifier = ">=1.40" },
{ name = "mastodon-py", marker = "extra == 'channel-mastodon'", specifier = ">=1.8" },
@@ -5048,7 +5049,6 @@ requires-dist = [
{ name = "torch", marker = "extra == 'memory-colbert'", specifier = ">=2.0" },
{ name = "torch", marker = "extra == 'orchestrator-training'", specifier = ">=2.0" },
{ name = "transformers", marker = "extra == 'orchestrator-training'", specifier = ">=4.40" },
{ name = "tweepy", marker = "extra == 'channel-twitter'", specifier = ">=4.14" },
{ name = "twilio", marker = "extra == 'channel-twilio'", specifier = ">=9.0" },
{ name = "twitchio", marker = "extra == 'channel-twitch'", specifier = ">=2.6" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.30" },
@@ -5060,7 +5060,7 @@ requires-dist = [
{ name = "zeus-ml", extras = ["apple"], marker = "extra == 'energy-apple'" },
{ name = "zulip", marker = "extra == 'channel-zulip'", specifier = ">=0.9" },
]
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "channel-twitter", "browser", "media", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "docs"]
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "docs"]
[package.metadata.requires-dev]
dev = [{ name = "maturin", specifier = ">=1.12.6" }]
@@ -8820,20 +8820,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
]
[[package]]
name = "tweepy"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "oauthlib" },
{ name = "requests" },
{ name = "requests-oauthlib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/45/a73bb812b1817247d3f79b3b9a4784ab93a081853b697e87428caa8c287b/tweepy-4.16.0.tar.gz", hash = "sha256:1d95cbdc50bf6353a387f881f2584eaf60d14e00dbbdd8872a73de79c66878e3", size = 87646, upload-time = "2025-06-22T01:17:51.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/7c/3375cd1fbefcb8ead580fe324b1b6dcdc21aabf51562ee6def7266fcf363/tweepy-4.16.0-py3-none-any.whl", hash = "sha256:48d1a1eb311d2c4b8990abcfa6f9fa2b2ad61be05c723b1a9b4f242656badae2", size = 98843, upload-time = "2025-06-22T01:17:49.823Z" },
]
[[package]]
name = "twilio"
version = "9.10.4"