From 6ae297f0cbc10597da94ba3d5ed5554e964d2753 Mon Sep 17 00:00:00 2001 From: Siva Kumar Yarramsetti Date: Fri, 19 Jun 2026 19:46:29 +0530 Subject: [PATCH 1/6] fix: wire llm_max_calls_per_min into ReactInvestigationLoop --- repi/core/container.py | 2 ++ repi/investigation/config.py | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 repi/investigation/config.py diff --git a/repi/core/container.py b/repi/core/container.py index 41252f8..df25f22 100644 --- a/repi/core/container.py +++ b/repi/core/container.py @@ -232,6 +232,8 @@ async def cached_find_logs_by_id(**kwargs): store=store, enable_reflection=settings.ENABLE_REFLECTION, reflection_interval=settings.REFLECTION_INTERVAL, + llm_max_calls_per_min=settings.LLM_MAX_CALLS_PER_MIN, + ) def get_investigation_store(self, session: AsyncSession) -> InvestigationStore: diff --git a/repi/investigation/config.py b/repi/investigation/config.py new file mode 100644 index 0000000..f85e769 --- /dev/null +++ b/repi/investigation/config.py @@ -0,0 +1,53 @@ +import json +import logging +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from repi.core.config import settings, CONFIG_PATH, CONFIG_DIR +from repi.core.container import get_container + +logger = logging.getLogger("repi.api.config") + +router = APIRouter() + +@router.get("/config") +async def get_config(): + """Return the current configuration.""" + return settings.model_dump() + +@router.put("/config") +async def update_config(new_config: dict): + """Merge `new_config` on top of the existing config.json and reload. + + Semantically a PATCH: a partial body (e.g. `{"MISTRAL_API_KEY": "..."}`) + must not clobber unsent fields with their class defaults, which would + break a running container instantly. + """ + try: + from repi.core.config import Settings + + existing: dict = {} + if CONFIG_PATH.exists(): + try: + existing = json.loads(CONFIG_PATH.read_text()) + except json.JSONDecodeError: + existing = {} + + merged = {**existing, **new_config} + validated = Settings(**merged) + + # Fail fast on an unknown EMBEDDING_BACKEND so we don't persist a + # value that would 500 on first /ingest or /investigate. + from repi.embeddings import create_embedder + create_embedder(validated.EMBEDDING_BACKEND) + + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH, "w") as f: + json.dump(validated.model_dump(), f, indent=2) + + settings.reload() + get_container().refresh_llm() + + return {"status": "success", "message": "Configuration updated and reloaded"} + except Exception as e: + logger.error(f"Failed to update config: {e}") + raise HTTPException(status_code=400, detail=str(e)) From 117597a07efa62f2c06f74d42b7ce91083af3bab Mon Sep 17 00:00:00 2001 From: Varun Singh Date: Sun, 14 Jun 2026 23:21:38 +0530 Subject: [PATCH 2/6] fix: fixed the view issue --- uv.lock | 2 +- web/app/layout.tsx | 6 +++--- web/app/page.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/uv.lock b/uv.lock index e17ac3f..42f63e0 100644 --- a/uv.lock +++ b/uv.lock @@ -1350,7 +1350,7 @@ wheels = [ [[package]] name = "repi" -version = "0.2.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "asyncpg" }, diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 240e27b..79f7ce1 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -30,7 +30,7 @@ export default function RootLayout({ return ( -
+
-
{children}
+
{children}
diff --git a/web/app/page.tsx b/web/app/page.tsx index 6997131..12652b7 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -335,7 +335,7 @@ export default function HomePage() { // New chat, no project yet → step 1 of the flow: pick (or create) a project. if (!conversationId && !project) { return ( -
+
Date: Mon, 15 Jun 2026 01:11:49 +0530 Subject: [PATCH 3/6] fixes feat(R4): make LLM rate limit configurable via LLM_MAX_CALLS_PER_MIN feat(R4): add LLM_MAX_CALLS_PER_MIN field to Settings --- .gitignore | 3 ++ repi/core/config.py | 1 + repi/core/container.py | 8 ++++ repi/investigation/react_loop.py | 4 +- repi/retrieval/event_feed.py | 12 ++--- web/app/page.tsx | 8 +++- web/app/repi/docs/page.tsx | 18 ++++++-- web/components/chat/CitedChunks.tsx | 4 +- web/components/chat/Timeline.tsx | 4 +- web/components/investigation-step.tsx | 18 ++++---- web/components/navbar.tsx | 51 ++++++++------------- web/components/projects/ProjectOverview.tsx | 13 +----- web/lib/log-levels.ts | 23 +++++++--- 13 files changed, 93 insertions(+), 74 deletions(-) diff --git a/.gitignore b/.gitignore index 1ec032a..ab13b00 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ scripts/ assets/ CLAUDE.md + +# Local docs / personal notes — not for the public repo +docs/ diff --git a/repi/core/config.py b/repi/core/config.py index ef4fada..ef7dddf 100644 --- a/repi/core/config.py +++ b/repi/core/config.py @@ -57,6 +57,7 @@ class Settings(BaseSettings): OLLAMA_BASE_URL: str = "http://localhost:11434" WATCHER_CONFIG_REFRESH_SECS: int = 30 + LLM_MAX_CALLS_PER_MIN: int = Field(default=60, ge=1) # "fastembed" (ONNX Runtime, ~50 MB) or "torch" via sentence-transformers # (~790 MB). Vectors are byte-identical; the choice is image size / RSS. diff --git a/repi/core/container.py b/repi/core/container.py index df25f22..40c50c7 100644 --- a/repi/core/container.py +++ b/repi/core/container.py @@ -5,6 +5,14 @@ from sqlmodel.ext.asyncio.session import AsyncSession from repi.core.config import settings + +try: + create_async_engine = importlib.import_module("sqlalchemy.ext.asyncio").create_async_engine +except ImportError as e: + raise RuntimeError( + "sqlalchemy.ext.asyncio is required for async DB support. " + "Install SQLAlchemy>=1.4." + ) from e from repi.core.cache import cache from repi.retrieval.pgvector_store import PgVectorStore from repi.retrieval.pg_fts_retriever import PgFTSRetriever diff --git a/repi/investigation/react_loop.py b/repi/investigation/react_loop.py index a74ab94..54578cb 100644 --- a/repi/investigation/react_loop.py +++ b/repi/investigation/react_loop.py @@ -107,6 +107,7 @@ def __init__( enable_reflection: bool = True, reflection_interval: int = 3, max_reflections: int = 2, + llm_max_calls_per_min: int = 60, ) -> None: self.llm = llm self.tools = tools @@ -120,6 +121,7 @@ def __init__( self.enable_reflection = enable_reflection self.reflection_interval = reflection_interval self.max_reflections = max_reflections + self.llm_max_calls_per_min = llm_max_calls_per_min self._llm_call_timestamps: list[float] = [] @staticmethod @@ -145,7 +147,7 @@ def _ledger_summary(ledger: dict[str, dict]) -> str: async def _wait_for_rate_limit(self): now = time.time() self._llm_call_timestamps = [t for t in self._llm_call_timestamps if now - t < 60] - while len(self._llm_call_timestamps) >= 3: + while len(self._llm_call_timestamps) >= self.llm_max_calls_per_min: wait_time = 60 - (now - self._llm_call_timestamps[0]) + 1 logger.warning(f"Rate limit: Waiting {wait_time:.1f}s...") await asyncio.sleep(wait_time) diff --git a/repi/retrieval/event_feed.py b/repi/retrieval/event_feed.py index 5f35801..936d484 100644 --- a/repi/retrieval/event_feed.py +++ b/repi/retrieval/event_feed.py @@ -203,14 +203,14 @@ def bucket_end(b: int) -> str: events.append(TimelineEvent( kind="new_pattern", ts=bucket_start(first_b), service=svc, signature=sig, level=level, - title=f"New error pattern: {sig}", count=counts[first_b], + title=sig, count=counts[first_b], )) if not is_new and first_b > 1 and counts[first_b] >= BEGINS_MIN: events.append(TimelineEvent( kind="begins", ts=bucket_start(first_b), service=svc, signature=sig, level=level, - title=f"{sig} begins", count=counts[first_b], + title=sig, count=counts[first_b], )) # Spike: biggest bucket vs trailing active average before it. @@ -225,14 +225,14 @@ def bucket_end(b: int) -> str: events.append(TimelineEvent( kind="spike", ts=bucket_start(best), service=svc, signature=sig, level=level, - title=f"{sig} spikes (×{counts[best]})", count=counts[best], + title=f"{sig} (×{counts[best]})", count=counts[best], )) if total >= SUBSIDE_MIN and last_b <= n_buckets - QUIET_BUCKETS: events.append(TimelineEvent( kind="subsides", ts=bucket_end(last_b), service=svc, signature=sig, level=level, - title=f"{sig} subsides", count=0, + title=sig, count=0, )) # ── health transitions per service ─────────────────────────────────────── @@ -249,14 +249,14 @@ def bucket_end(b: int) -> str: events.append(TimelineEvent( kind="health_degraded", ts=bucket_start(b), service=svc, signature=None, level="ERROR", - title=f"{svc} enters degraded state", count=errs.get(b, 0), + title=svc, count=errs.get(b, 0), )) elif degraded and frac <= RECOVERED_FRAC: degraded = False events.append(TimelineEvent( kind="health_recovered", ts=bucket_start(b), service=svc, signature=None, level="INFO", - title=f"{svc} recovers", count=0, + title=svc, count=0, )) if len(events) > max_events: diff --git a/web/app/page.tsx b/web/app/page.tsx index 12652b7..452b18f 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -474,8 +474,12 @@ function InvestigateTurnView({ investigationId, alreadyHoisted, onComplete, onSe
)}
- {steps.map((s: Step) => ( - + {steps.map((s: Step, idx: number) => ( + ))}
{!done && !error && !awaitingClarification && ( diff --git a/web/app/repi/docs/page.tsx b/web/app/repi/docs/page.tsx index e4f9566..f175a61 100644 --- a/web/app/repi/docs/page.tsx +++ b/web/app/repi/docs/page.tsx @@ -1,7 +1,9 @@ +import Link from "next/link" import { - Search, Bot, Eye, Layers, Clock, Database, Globe, + Search, Bot, Eye, Layers, Clock, Database, Globe, ArrowRight, } from "lucide-react" import { Brand } from "@/components/brand" +import { isPublicMode } from "@/lib/public-mode" function GithubIcon({ className }: { className?: string }) { return ( @@ -35,7 +37,7 @@ const FEATURES = [ icon: Search, title: "Hybrid Search", description: - "BM25 full-text + pgvector HNSW dense retrieval fused with Reciprocal Rank Fusion for best-of-both recall.", + "PostgreSQL full-text search + pgvector HNSW dense retrieval fused with Reciprocal Rank Fusion for best-of-both recall.", }, { icon: Bot, @@ -196,6 +198,7 @@ const ENV_VARS = [ // ─── Page ────────────────────────────────────────────────────────────────────── export default function DocsPage() { + const publicDeploy = isPublicMode() return (
{/* ── Background Decoration ──────────────────────────────────────────────── */} @@ -205,7 +208,7 @@ export default function DocsPage() {
{/* ── Docs Navbar ───────────────────────────────────────────────────────── */} -
- {KIND_LABEL[e.kind] ?? e.kind} + {normalizeLevel(e.level)} {e.service && ( diff --git a/web/lib/log-levels.ts b/web/lib/log-levels.ts index 97edb0f..fd6d40c 100644 --- a/web/lib/log-levels.ts +++ b/web/lib/log-levels.ts @@ -1,16 +1,27 @@ -// Single source of truth for log-level badge styling, shared by every panel -// that renders a level. Minimal palette: theme `destructive` for error-class -// levels, amber for warnings, neutral for INFO (the majority of rows). -export function levelTone(level: string | null | undefined): string { +export type NormalisedLevel = "INFO" | "WARN" | "ERROR" + +export function normalizeLevel(level: string | null | undefined): NormalisedLevel { switch ((level || "").toUpperCase()) { case "ERROR": case "CRITICAL": case "FATAL": - return "text-destructive border-destructive/30" + return "ERROR" + case "WARN": case "WARNING": + return "WARN" + default: + return "INFO" + } +} + +export function levelTone(level: string | null | undefined): string { + switch (normalizeLevel(level)) { + case "ERROR": + return "text-destructive border-destructive/30" case "WARN": return "text-amber-600 dark:text-amber-500 border-amber-500/30" + case "INFO": default: - return "text-muted-foreground border-border" + return "text-blue-600 dark:text-blue-400 border-blue-500/30" } } From cde8ccaf0a2a64c35a7ba07876d62bae71be3917 Mon Sep 17 00:00:00 2001 From: Siva Kumar Yarramsetti Date: Mon, 22 Jun 2026 20:29:23 +0530 Subject: [PATCH 4/6] fix: remove redundant importlib block in container.py --- repi/core/container.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/repi/core/container.py b/repi/core/container.py index 2c56a77..f4882a2 100644 --- a/repi/core/container.py +++ b/repi/core/container.py @@ -6,13 +6,6 @@ from repi.core.config import settings -try: - create_async_engine = importlib.import_module("sqlalchemy.ext.asyncio").create_async_engine -except ImportError as e: - raise RuntimeError( - "sqlalchemy.ext.asyncio is required for async DB support. " - "Install SQLAlchemy>=1.4." - ) from e from repi.core.cache import cache from repi.retrieval.pgvector_store import PgVectorStore from repi.retrieval.fts_factory import create_fts_retriever From 69199a30f2e8c705ba7f36404814c7ae9bfcc73d Mon Sep 17 00:00:00 2001 From: Varun Singh Date: Tue, 23 Jun 2026 13:13:46 +0530 Subject: [PATCH 5/6] fix: added importlib --- repi/core/container.py | 1 + 1 file changed, 1 insertion(+) diff --git a/repi/core/container.py b/repi/core/container.py index f4882a2..b256670 100644 --- a/repi/core/container.py +++ b/repi/core/container.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import sessionmaker from sqlmodel import select, func from sqlmodel.ext.asyncio.session import AsyncSession +import importlib from repi.core.config import settings From f2e460d8092002bc0b8a93ef1fc0a842be3c8b87 Mon Sep 17 00:00:00 2001 From: Siva Kumar Yarramsetti Date: Tue, 23 Jun 2026 17:36:31 +0530 Subject: [PATCH 6/6] fix: address review comments - remove dead config.py, fix default rate limit to 10, remove duplicate bullet, document LLM_MAX_CALLS_PER_MIN in README --- README.md | 1 + repi/core/container.py | 5 ++-- repi/investigation/config.py | 53 ------------------------------------ web/app/repi/docs/page.tsx | 1 - 4 files changed, 3 insertions(+), 57 deletions(-) delete mode 100644 repi/investigation/config.py diff --git a/README.md b/README.md index 43fe4a7..7b36f4d 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ All keys live in `.repi/config.json` (see `config.example.json` for the full sch | `UI_PORT` | `3000` | Port the web UI binds to (read by `repi ui`) | | `WATCHER_CONFIG_REFRESH_SECS` | `30` | How often the worker polls for config changes | | `OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama endpoint | +| `LLM_MAX_CALLS_PER_MIN` | `10` | Maximum LLM API calls per minute in the ReAct loop. Increase for high-tier API plans. | ## Development diff --git a/repi/core/container.py b/repi/core/container.py index b256670..faa735f 100644 --- a/repi/core/container.py +++ b/repi/core/container.py @@ -239,9 +239,8 @@ async def cached_find_logs_by_id(**kwargs): store=store, enable_reflection=settings.ENABLE_REFLECTION, reflection_interval=settings.REFLECTION_INTERVAL, - llm_max_calls_per_min=settings.LLM_MAX_CALLS_PER_MIN, - - ) + llm_max_calls_per_min=settings.LLM_MAX_CALLS_PER_MIN, + ) def get_investigation_store(self, session: AsyncSession) -> InvestigationStore: return InvestigationStore(session) diff --git a/repi/investigation/config.py b/repi/investigation/config.py deleted file mode 100644 index f85e769..0000000 --- a/repi/investigation/config.py +++ /dev/null @@ -1,53 +0,0 @@ -import json -import logging -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from repi.core.config import settings, CONFIG_PATH, CONFIG_DIR -from repi.core.container import get_container - -logger = logging.getLogger("repi.api.config") - -router = APIRouter() - -@router.get("/config") -async def get_config(): - """Return the current configuration.""" - return settings.model_dump() - -@router.put("/config") -async def update_config(new_config: dict): - """Merge `new_config` on top of the existing config.json and reload. - - Semantically a PATCH: a partial body (e.g. `{"MISTRAL_API_KEY": "..."}`) - must not clobber unsent fields with their class defaults, which would - break a running container instantly. - """ - try: - from repi.core.config import Settings - - existing: dict = {} - if CONFIG_PATH.exists(): - try: - existing = json.loads(CONFIG_PATH.read_text()) - except json.JSONDecodeError: - existing = {} - - merged = {**existing, **new_config} - validated = Settings(**merged) - - # Fail fast on an unknown EMBEDDING_BACKEND so we don't persist a - # value that would 500 on first /ingest or /investigate. - from repi.embeddings import create_embedder - create_embedder(validated.EMBEDDING_BACKEND) - - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - with open(CONFIG_PATH, "w") as f: - json.dump(validated.model_dump(), f, indent=2) - - settings.reload() - get_container().refresh_llm() - - return {"status": "success", "message": "Configuration updated and reloaded"} - except Exception as e: - logger.error(f"Failed to update config: {e}") - raise HTTPException(status_code=400, detail=str(e)) diff --git a/web/app/repi/docs/page.tsx b/web/app/repi/docs/page.tsx index 6522789..0a3d096 100644 --- a/web/app/repi/docs/page.tsx +++ b/web/app/repi/docs/page.tsx @@ -37,7 +37,6 @@ const FEATURES = [ icon: Search, title: "Hybrid Search", description: - "PostgreSQL full-text search + pgvector HNSW dense retrieval fused with Reciprocal Rank Fusion for best-of-both recall.", "ParadeDB BM25 full-text search + pgvector HNSW dense retrieval fused with Reciprocal Rank Fusion for best-of-both recall.", }, {