Merge remote-tracking branch 'origin/dev'

This commit is contained in:
pewdiepie-archdaemon
2026-06-05 12:14:34 +09:00
154 changed files with 7750 additions and 2496 deletions

View File

@@ -25,7 +25,7 @@ Fixes #
## Checklist
- [ ] I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues) and [open PRs](https://github.com/pewdiepie-archdaemon/odysseus/pulls) — this is not a duplicate.
- [ ] This PR targets `main`
- [ ] This PR targets `dev`
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.

60
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: CI
on:
push:
branches: [main]
pull_request:
# Least privilege: none of the jobs write to the repo.
permissions:
contents: read
# Cancel superseded runs on the same ref to save Actions minutes.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
python-syntax:
name: Python syntax (compileall)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
# Byte-compile sources — catches syntax errors without installing deps.
- run: python -m compileall -q app.py core routes src services scripts tests
node-syntax:
name: JS syntax (node --check)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
# Syntax-check our own JS (skip vendored libs in static/lib).
- name: node --check
run: |
shopt -s globstar nullglob
for f in static/app.js static/js/**/*.js; do
node --check "$f"
done
python-tests:
name: Python tests (pytest)
runs-on: ubuntu-latest
# Informational for now: the suite has known flaky / environment-dependent
# failures (test isolation + embedding-model assertions). Tracked under the
# ROADMAP "fresh install smoke tests" item; make this required once green.
continue-on-error: true
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
cache: pip
- run: pip install -r requirements.txt
- run: mkdir -p data # sqlite DB lives at ./data/app.db
- run: python -m pytest -q

View File

@@ -22,9 +22,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
# Install Python deps first (layer cache)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install Python deps first (layer cache). Optional extras (PyMuPDF AGPL, etc.)
# are opt-in so the default image stays MIT-core; see requirements-optional.txt.
ARG INSTALL_OPTIONAL=false
COPY requirements.txt requirements-optional.txt ./
RUN pip install --no-cache-dir -r requirements.txt \
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
# Copy app code
COPY . .

View File

@@ -11,7 +11,7 @@
A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
## Features
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI</sub>
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot</sub>
- **Agent** -- hand it tools and let it run the whole task itself.<br> <sub>built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory</sub>
- **Cookbook** -- Scans your hardware, recommends models, click to download and serve.. easy!<br> <sub>built on [llmfit](https://github.com/AlexsJones/llmfit) · VRAM-aware · GGUF / FP8 / AWQ · fit scoring · vLLM / llama.cpp serving</sub>
- **Deep Research** -- multi-step runs that gather, read, and synthesize sources into a nice visual report.<br> <sub>adapted from [Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch)</sub>
@@ -64,6 +64,8 @@ cd odysseus
cp .env.example .env # optional, but recommended for explicit defaults
docker compose up -d --build
```
To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`.
Open `http://localhost:7000` when the containers are healthy. Docker Compose
binds the web UI to `127.0.0.1` by default. If the port is taken, set
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`

8
app.py
View File

@@ -34,7 +34,6 @@ from dotenv import load_dotenv
# is silently ignored and the user is unexpectedly forced to log in (issue #142).
# utf-8-sig reads plain UTF-8 (no BOM) identically, so this is safe everywhere.
load_dotenv(encoding="utf-8-sig")
import uuid
import asyncio
import logging
@@ -526,6 +525,9 @@ upload_cleanup_task = None
from routes.emoji_routes import setup_emoji_routes
app.include_router(setup_emoji_routes())
from routes.workspace_routes import setup_workspace_routes
app.include_router(setup_workspace_routes())
# Sessions
from routes.session_routes import setup_session_routes
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
@@ -588,6 +590,10 @@ app.include_router(setup_embedding_routes())
from routes.model_routes import setup_model_routes
app.include_router(setup_model_routes(model_discovery))
# GitHub Copilot device-flow login
from routes.copilot_routes import setup_copilot_routes
app.include_router(setup_copilot_routes())
# TTS
from routes.tts_routes import setup_tts_routes
app.include_router(setup_tts_routes(tts_service))

View File

@@ -375,6 +375,7 @@ class McpServer(TimestampMixin, Base):
is_enabled = Column(Boolean, default=True)
oauth_config = Column(Text, nullable=True) # JSON: provider, keys_file, token_file, scopes
disabled_tools = Column(Text, nullable=True) # JSON array of tool names to hide from LLM
oauth_tokens = Column(EncryptedText, nullable=True) # JSON {tokens, client_info} for generic MCP OAuth, encrypted at rest
class Comparison(TimestampMixin, Base):
@@ -1311,6 +1312,23 @@ def _migrate_add_disabled_tools():
except Exception as e:
logging.getLogger(__name__).warning(f"disabled_tools migration: {e}")
def _migrate_add_mcp_oauth_tokens_column():
"""Add oauth_tokens column to mcp_servers table if missing.
The model declares this column as EncryptedText, but the SQL type is plain
TEXT on purpose: EncryptedText is a SQLAlchemy TypeDecorator that encrypts at
the Python layer and stores the ciphertext as TEXT, so the DB column type is
TEXT. This matches the existing encrypted columns (see _migrate_encrypt_*)."""
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(mcp_servers)"))]
if "oauth_tokens" not in cols:
conn.execute(text("ALTER TABLE mcp_servers ADD COLUMN oauth_tokens TEXT"))
conn.commit()
logging.getLogger(__name__).info("Added oauth_tokens column to mcp_servers")
except Exception as e:
logging.getLogger(__name__).warning(f"oauth_tokens migration: {e}")
def _migrate_add_task_v2_columns():
"""Add cron_expression, then_task_id, webhook_token to scheduled_tasks."""
new_cols = {
@@ -1467,6 +1485,10 @@ class CalendarEvent(TimestampMixin, Base):
importance = Column(String, default="normal") # low | normal | high | critical
event_type = Column(String, nullable=True) # work | personal | health | travel | meal | social | admin | other
last_pinged = Column(DateTime, nullable=True) # last time the assistant pinged about this event
# "caldav" = pulled from a CalDAV server (so the sync may prune it when it
# vanishes upstream). NULL/local = created locally (agent, email triage, or
# a UI event whose write-back failed) and must NOT be pruned by the sync.
origin = Column(String, nullable=True, index=True)
calendar = relationship("CalendarCal", back_populates="events")
@@ -1589,6 +1611,7 @@ def init_db():
_migrate_add_oauth_config()
_migrate_add_task_automation_columns()
_migrate_add_disabled_tools()
_migrate_add_mcp_oauth_tokens_column()
_migrate_add_task_v2_columns()
_migrate_add_notifications_enabled()
_migrate_drop_ping_notes_tasks()
@@ -1598,6 +1621,7 @@ def init_db():
_migrate_seed_email_account()
_migrate_add_calendar_metadata()
_migrate_add_calendar_is_utc()
_migrate_add_calendar_origin()
_migrate_encrypt_email_passwords()
_migrate_encrypt_signatures()
_migrate_encrypt_endpoint_keys()
@@ -1740,6 +1764,28 @@ def _migrate_add_calendar_is_utc():
logging.getLogger(__name__).warning(f"is_utc migration failed: {e}")
def _migrate_add_calendar_origin():
"""Add `origin` to calendar_events so the CalDAV sync can tell server-pulled
rows (prunable when they vanish upstream) from locally-created ones (agent /
email triage / failed write-back), which must never be pruned. Idempotent."""
import sqlite3
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
try:
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA table_info(calendar_events)")
columns = [row[1] for row in cursor.fetchall()]
if columns and "origin" not in columns:
conn.execute("ALTER TABLE calendar_events ADD COLUMN origin TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS ix_calendar_events_origin ON calendar_events(origin)")
conn.commit()
logging.getLogger(__name__).info("Migrated: added 'origin' column to calendar_events")
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"calendar_events.origin migration failed: {e}")
def _migrate_add_calendar_metadata():
"""Add importance/event_type/last_pinged columns to calendar_events table."""
import sqlite3

View File

@@ -76,8 +76,20 @@ class Session:
_session_manager._persist_message(self.id, message)
def get_context_messages(self) -> List[Dict[str, Any]]:
"""Get messages in format for LLM API."""
return [msg.to_dict() for msg in self.history]
"""Get messages in format for LLM API.
Slash-command / setup replies are persisted to history so they render
in the transcript, but they are UI chatter (e.g. ``/setup ...`` and its
status lines) the user never meant as conversation. They carry
``metadata.source == "slash"``; exclude them here so they never reach
the model. Display/history-load paths use the raw ``history`` and are
unaffected.
"""
return [
msg.to_dict()
for msg in self.history
if (msg.metadata or {}).get("source") != "slash"
]
def get(self, key: str, default=None):
"""Dict-like access for compatibility."""

View File

@@ -273,7 +273,10 @@ class SessionManager:
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session:
db_session.message_count = keep_count
# keep_count can exceed the real message total (e.g. the AI tool
# defaults to keep_count=10 on a short session); message_count must
# track the rows that actually remain, not the requested cap.
db_session.message_count = min(keep_count, len(db_messages))
db_session.updated_at = datetime.now(timezone.utc)
db.commit()

View File

@@ -340,6 +340,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
ok = auth_manager.rename_user(old_username, new_username, user)
if not ok:
raise HTTPException(400, "Cannot rename user")
# The owner-rename loop above updated ApiToken.owner in the DB, but the
# bearer-token cache still maps each token to the OLD owner. Without
# refreshing it, the renamed user's API tokens resolve to the old (now
# non-existent) owner and stop reaching their data until the cache next
# goes dirty. Invalidate it now, like the token CRUD routes do.
invalidator = getattr(request.app.state, "invalidate_token_cache", None)
if callable(invalidator):
invalidator()
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
@router.post("/signup-toggle", deprecated=True)
@@ -430,9 +438,24 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(403, "Admin only")
body = await request.json()
current = _load_settings()
# Per-key validation for numeric settings: coerce to int and clamp to a
# sane range so a bad value can't disable the agent or let it run away.
_INT_RANGES = {
"agent_max_rounds": (1, 200),
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
}
for key in DEFAULT_SETTINGS:
if key in body:
current[key] = body[key]
if key not in body:
continue
val = body[key]
if key in _INT_RANGES:
lo, hi = _INT_RANGES[key]
try:
val = int(val)
except (TypeError, ValueError):
raise HTTPException(400, f"{key} must be an integer")
val = max(lo, min(val, hi))
current[key] = val
_save_settings(current)
return current

View File

@@ -589,6 +589,8 @@ def _normalize_thinking(text: str) -> str:
import re
if not text:
return text
from src.text_helpers import normalize_thinking_markup
text = normalize_thinking_markup(text)
reasoning_prefix_re = re.compile(
r'^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )',
re.IGNORECASE,
@@ -699,6 +701,10 @@ def _extract_thinking_meta(text: str) -> dict | None:
import re
if not text:
return None
from src.text_helpers import normalize_thinking_markup
original_text = text
text = normalize_thinking_markup(text)
normalized_changed = text != original_text
# Check for <think> tags (native or injected)
time_match = re.search(r'<think(?:ing)?\s+time="([\d.]+)"', text)
@@ -729,6 +735,9 @@ def _extract_thinking_meta(text: str) -> dict | None:
if thinking and reply:
return {"thinking": thinking, "reply": reply, "time": think_time}
if normalized_changed and text.strip() and text.strip() != original_text.strip():
return {"thinking": "", "reply": text.strip(), "time": think_time}
return None
@@ -737,7 +746,8 @@ def clean_thinking_for_save(content: str, metadata: dict | None = None) -> tuple
md = dict(metadata) if metadata else {}
info = _extract_thinking_meta(content)
if info:
md["thinking"] = info["thinking"]
if info.get("thinking"):
md["thinking"] = info["thinking"]
if info.get("time"):
md["thinking_time"] = info["time"]
return info["reply"], md
@@ -781,8 +791,10 @@ def save_assistant_response(
# Extract thinking into metadata (don't pollute message content with <think> tags)
_think_info = _extract_thinking_meta(full_response)
if _think_info:
md["thinking"] = _think_info["thinking"]
md["thinking_time"] = _think_info.get("time")
if _think_info.get("thinking"):
md["thinking"] = _think_info["thinking"]
if _think_info.get("time"):
md["thinking_time"] = _think_info.get("time")
_content = _think_info["reply"]
else:
_content = full_response

View File

@@ -2,6 +2,7 @@
import asyncio
import json
import os
import time
import logging
from datetime import datetime
@@ -394,6 +395,12 @@ def setup_chat_routes(
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
incognito = str(form_data.get("incognito", "")).lower() == "true"
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
# Workspace: confine the agent's file/shell tools to this folder. Validate
# it's a real directory; ignore (no confinement) otherwise.
workspace = (form_data.get("workspace") or "").strip()
if workspace:
_ws_real = os.path.realpath(os.path.expanduser(workspace))
workspace = _ws_real if os.path.isdir(_ws_real) else ""
# Did the USER explicitly pick agent mode? (vs. us auto-escalating
# below). Skill extraction should only learn from real agent sessions,
# not chats we quietly promoted for a notes/calendar intent.
@@ -981,7 +988,15 @@ def setup_chat_routes(
_answered_by = None # set if the selected model failed and a fallback answered
try:
from src.settings import get_setting
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
_tool_budget = int(get_setting("agent_max_tool_calls", 0))
# Per-message round cap from settings; clamp defensively in
# case settings.json was hand-edited to a bad value.
try:
_max_rounds = int(get_setting("agent_max_rounds", _DEFAULT_ROUNDS) or _DEFAULT_ROUNDS)
except (TypeError, ValueError):
_max_rounds = _DEFAULT_ROUNDS
_max_rounds = max(1, min(_max_rounds, 200))
async for chunk in stream_agent_loop(
sess.endpoint_url,
@@ -992,12 +1007,14 @@ def setup_chat_routes(
max_tokens=ctx.preset.max_tokens,
prompt_type=preset_id,
max_tool_calls=_tool_budget,
max_rounds=_max_rounds,
context_length=ctx.context_length,
active_document=active_doc,
session_id=session,
disabled_tools=disabled_tools if disabled_tools else None,
owner=_user,
fallbacks=_fallback_candidates,
workspace=workspace or None,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
@@ -1017,6 +1034,7 @@ def setup_chat_routes(
"tool_start", "tool_output", "agent_step",
"doc_stream_open", "doc_stream_delta",
"doc_update", "doc_suggestions", "ui_control",
"rounds_exhausted",
):
if data.get("type") == "agent_step":
_agent_rounds = max(_agent_rounds, data.get("round", 1))

223
routes/copilot_routes.py Normal file
View File

@@ -0,0 +1,223 @@
# routes/copilot_routes.py
"""GitHub Copilot device-flow login.
Drives the GitHub OAuth *device flow* and, on success, creates (or refreshes)
an owner-scoped ``ModelEndpoint`` pointing at the Copilot API with the
device-flow access token stored as its (encrypted) ``api_key``. After that the
endpoint behaves like any other OpenAI-compatible provider — the Copilot-
specific request headers are injected centrally by ``build_headers`` /
``_provider_headers`` (see :mod:`src.copilot`).
Flow:
1. ``POST /api/copilot/device/start`` → returns a ``poll_id`` plus the
``user_code`` + ``verification_uri`` to show the user. The secret
``device_code`` is kept server-side, never sent to the browser.
2. The browser polls ``POST /api/copilot/device/poll`` with ``poll_id``.
While pending it returns ``{status: "pending"}``; once the user authorises
it provisions the endpoint and returns ``{status: "authorized", ...}``.
All routes are admin-gated (endpoint/provider management is an admin action).
"""
import json
import time
import uuid
import logging
import threading
from typing import Dict, Optional
import httpx
from fastapi import APIRouter, Request, Form, HTTPException
from core.database import SessionLocal, ModelEndpoint
from core.middleware import require_admin
from src.auth_helpers import get_current_user
from src import copilot
logger = logging.getLogger(__name__)
# Pending device-flow logins, keyed by an opaque poll_id. The device_code is a
# bearer-like secret, so it lives here (server memory) rather than in the
# browser. Entries expire with the GitHub device code.
#
# NOTE: this is per-process state. The device flow assumes a single worker
# (Odysseus' default): with multiple uvicorn workers, the poll request can land
# on a worker that never saw the start, returning "Unknown or expired login
# session". Move this to a shared store (DB/Redis) if running multi-worker.
_PENDING: Dict[str, Dict] = {}
_PENDING_LOCK = threading.Lock()
def _prune_expired() -> None:
now = time.time()
with _PENDING_LOCK:
for k in [k for k, v in _PENDING.items() if v.get("expires_at", 0) < now]:
_PENDING.pop(k, None)
def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict:
"""Create or update the owner's Copilot endpoint with a fresh token."""
try:
models = copilot.fetch_models(base, token)
except Exception as e:
logger.warning(f"Copilot model fetch failed during provisioning: {e}")
models = []
model_ids = [m["id"] for m in models]
# Copilot picker models support OpenAI-style tool calling; mark the endpoint
# tool-capable so the agent loop sends native tool schemas.
# Tool-capable if any picker model advertises tool_calls. When the model
# fetch failed (empty list) default to True, since Copilot picker models
# support OpenAI-style tool calling.
supports_tools = bool(not models or any(m.get("tool_calls") for m in models))
db = SessionLocal()
try:
ep = (
db.query(ModelEndpoint)
.filter(ModelEndpoint.base_url == base)
.filter((ModelEndpoint.owner.is_(None)) | (ModelEndpoint.owner == owner))
.order_by(ModelEndpoint.owner.desc())
.first()
)
if ep is None:
ep = ModelEndpoint(
id=str(uuid.uuid4())[:8],
name="GitHub Copilot",
base_url=base,
model_type="llm",
owner=owner,
)
db.add(ep)
ep.api_key = token
ep.is_enabled = True
ep.supports_tools = supports_tools
if model_ids:
ep.cached_models = json.dumps(model_ids)
db.commit()
result = {
"id": ep.id,
"name": ep.name,
"base_url": ep.base_url,
"models": model_ids,
}
finally:
db.close()
# Best-effort: refresh the model cache so the new endpoint shows up.
try:
from routes.model_routes import _invalidate_models_cache
_invalidate_models_cache()
except Exception:
pass
return result
def setup_copilot_routes() -> APIRouter:
router = APIRouter(prefix="/api/copilot", tags=["copilot"])
@router.post("/device/start")
def device_start(request: Request, enterprise_url: str = Form("")):
require_admin(request)
_prune_expired()
host = copilot.GITHUB_HOST
ent = (enterprise_url or "").strip()
if ent:
host = copilot.normalize_domain(ent)
try:
data = copilot.request_device_code(host)
except httpx.HTTPStatusError as e:
status = e.response.status_code if e.response is not None else "unknown"
raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})")
except Exception as e:
raise HTTPException(502, f"GitHub device-code request failed: {e}")
device_code = data.get("device_code")
if not device_code:
raise HTTPException(502, "GitHub did not return a device code")
interval = int(data.get("interval") or 5)
expires_in = int(data.get("expires_in") or 900)
poll_id = uuid.uuid4().hex
with _PENDING_LOCK:
_PENDING[poll_id] = {
"device_code": device_code,
"host": host,
"enterprise_url": ent,
"interval": interval,
"owner": get_current_user(request) or None,
"expires_at": time.time() + expires_in,
"next_poll_at": 0.0,
}
# verification_uri_complete embeds the user code, so the browser tab we
# open lands the user straight on GitHub's "Authorize" screen with the
# code pre-filled — one click, no manual code entry.
return {
"poll_id": poll_id,
"user_code": data.get("user_code"),
"verification_uri": data.get("verification_uri"),
"verification_uri_complete": data.get("verification_uri_complete"),
"interval": interval,
"expires_in": expires_in,
}
@router.post("/device/poll")
def device_poll(request: Request, poll_id: str = Form(...)):
require_admin(request)
_prune_expired()
with _PENDING_LOCK:
pending = _PENDING.get(poll_id)
if not pending:
raise HTTPException(404, "Unknown or expired login session")
# Enforce GitHub's polling interval server-side so a chatty client
# can't trip slow_down.
now = time.time()
if now < pending.get("next_poll_at", 0):
return {"status": "pending"}
try:
data = copilot.poll_access_token(pending["host"], pending["device_code"])
except Exception as e:
return {"status": "pending", "detail": f"poll error: {e}"}
token = data.get("access_token")
if token:
base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE
try:
result = _provision_endpoint(token, base, pending["owner"])
except Exception as e:
logger.exception("Copilot endpoint provisioning failed")
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
raise HTTPException(500, f"Login succeeded but provisioning failed: {e}")
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
return {"status": "authorized", "endpoint": result}
err = data.get("error")
if err == "authorization_pending":
with _PENDING_LOCK:
if poll_id in _PENDING:
_PENDING[poll_id]["next_poll_at"] = now + pending["interval"]
return {"status": "pending"}
if err == "slow_down":
new_interval = int(data.get("interval") or (pending["interval"] + 5))
with _PENDING_LOCK:
if poll_id in _PENDING:
_PENDING[poll_id]["interval"] = new_interval
_PENDING[poll_id]["next_poll_at"] = now + new_interval
return {"status": "pending"}
if err in ("expired_token", "access_denied"):
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
return {"status": "failed", "error": err}
# Unknown error — surface but keep the session for another try.
return {"status": "pending", "detail": err or "unknown"}
@router.post("/device/cancel")
def device_cancel(request: Request, poll_id: str = Form(...)):
require_admin(request)
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
return {"status": "cancelled"}
return router

View File

@@ -153,7 +153,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
with a `pdf_source` marker so the viewer renders the pages without
overlays.
"""
from src.constants import UPLOAD_DIR
from src.pdf_forms import has_form_fields, extract_fields
from src.pdf_form_doc import (
save_field_sidecar,
@@ -950,7 +949,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
any wrong values before triggering the actual download.
"""
from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar
from src.constants import UPLOAD_DIR
user = get_current_user(request)
db = SessionLocal()
@@ -1015,7 +1013,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
Frontend overlays HTML form controls at those positions.
"""
from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar
from src.constants import UPLOAD_DIR
user = get_current_user(request)
db = SessionLocal()
@@ -1083,7 +1080,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
frontend overlays HTML form inputs on top)."""
from fastapi.responses import Response
from src.pdf_form_doc import find_source_upload_id
from src.constants import UPLOAD_DIR
user = get_current_user(request)
db = SessionLocal()
@@ -1132,7 +1128,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
import json
import fitz
from src.pdf_form_doc import find_source_upload_id
from src.constants import UPLOAD_DIR
from src.document_processor import _resolve_vl_model, _load_vl_settings
from src.llm_core import llm_call_async
@@ -1275,7 +1270,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
from starlette.background import BackgroundTask
from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, parse_markdown_annotations
from src.pdf_forms import fill_fields, stamp_annotations
from src.constants import UPLOAD_DIR
from core.database import Signature
# Track temp files for this request so they get unlinked AFTER
@@ -1370,7 +1364,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
from starlette.background import BackgroundTask
from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar, parse_markdown_annotations
from src.pdf_forms import fill_fields, stamp_signatures, stamp_annotations
from src.constants import UPLOAD_DIR
from core.database import Signature
_to_unlink: list[str] = []
@@ -1512,7 +1505,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
load_field_sidecar, parse_markdown_annotations,
)
from src.pdf_forms import fill_fields, stamp_signatures, stamp_annotations
from src.constants import UPLOAD_DIR
from core.database import Signature
# COMPOSE_UPLOADS_DIR lives in email_routes — re-derive here so we
# don't import from a routes file (cycle-prone). Same env override

View File

@@ -266,6 +266,48 @@ COMPOSE_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
SCHEDULED_DB = DATA_DIR / "scheduled_emails.db"
OWNER_SCOPED_EMAIL_CACHE_TABLES = {
"email_summaries",
"email_ai_replies",
"email_calendar_extractions",
"email_urgency_alerts",
}
def _email_cache_owner_clause(owner: str = "") -> tuple[str, tuple[str, ...]]:
owner = (owner or "").strip()
if owner:
return "owner = ?", (owner,)
return "(owner = '' OR owner IS NULL)", ()
def _ensure_owner_scoped_email_cache_table(conn, table: str, create_sql: str, columns: list[str]):
"""Rebuild legacy Message-ID-only cache tables with owner in the PK."""
conn.execute(create_sql)
try:
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
cols = [r[1] for r in info]
pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])]
if "owner" in cols and pk_cols == ["message_id", "owner"]:
return
conn.execute(f"ALTER TABLE {table} RENAME TO {table}__old")
conn.execute(create_sql)
old_cols = [r[1] for r in conn.execute(f"PRAGMA table_info({table}__old)").fetchall()]
copy_cols = [c for c in columns if c != "owner" and c in old_cols]
source_owner = "COALESCE(owner, '')" if "owner" in old_cols else "''"
target_cols = ["owner", *copy_cols]
select_exprs = [source_owner, *copy_cols]
conn.execute(
f"INSERT OR IGNORE INTO {table} ({', '.join(target_cols)}) "
f"SELECT {', '.join(select_exprs)} FROM {table}__old"
)
conn.execute(f"DROP TABLE {table}__old")
except Exception as _mig_e:
import logging as _lg
_lg.getLogger(__name__).warning(f"{table} owner-migration skipped: {_mig_e}")
def attachment_extract_dir(folder: str, uid: str) -> Path:
"""Containment-safe extraction directory for an attachment.
@@ -301,30 +343,35 @@ def _init_scheduled_db():
owner TEXT DEFAULT ''
)
""")
# Email summary cache (keyed by Message-ID)
conn.execute("""
# Email summary cache. SECURITY: Message-IDs are global, so AI-derived
# cache rows must be owner-scoped just like email_tags.
_ensure_owner_scoped_email_cache_table(conn, "email_summaries", """
CREATE TABLE IF NOT EXISTS email_summaries (
message_id TEXT PRIMARY KEY,
message_id TEXT,
owner TEXT DEFAULT '',
uid TEXT,
folder TEXT,
subject TEXT,
sender TEXT,
summary TEXT NOT NULL,
model_used TEXT,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, owner)
)
""")
""", ["message_id", "owner", "uid", "folder", "subject", "sender", "summary", "model_used", "created_at"])
# Email AI reply cache (pre-generated draft replies)
conn.execute("""
_ensure_owner_scoped_email_cache_table(conn, "email_ai_replies", """
CREATE TABLE IF NOT EXISTS email_ai_replies (
message_id TEXT PRIMARY KEY,
message_id TEXT,
owner TEXT DEFAULT '',
uid TEXT,
folder TEXT,
reply TEXT NOT NULL,
model_used TEXT,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, owner)
)
""")
""", ["message_id", "owner", "uid", "folder", "reply", "model_used", "created_at"])
# Email tags / spam classification cache. SECURITY: keyed by
# (message_id, owner) because Message-IDs are GLOBAL (a newsletter goes
# to many users with the same Message-ID). Without owner-scoping, a
@@ -384,17 +431,20 @@ def _init_scheduled_db():
# Best-effort — log via the module logger if available
import logging as _lg
_lg.getLogger(__name__).warning(f"email_tags owner-migration skipped: {_mig_e}")
conn.execute("""
_ensure_owner_scoped_email_cache_table(conn, "email_calendar_extractions", """
CREATE TABLE IF NOT EXISTS email_calendar_extractions (
message_id TEXT PRIMARY KEY,
message_id TEXT,
owner TEXT DEFAULT '',
uid TEXT,
events_created INTEGER DEFAULT 0,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, owner)
)
""")
conn.execute("""
""", ["message_id", "owner", "uid", "events_created", "created_at"])
_ensure_owner_scoped_email_cache_table(conn, "email_urgency_alerts", """
CREATE TABLE IF NOT EXISTS email_urgency_alerts (
message_id TEXT PRIMARY KEY,
message_id TEXT,
owner TEXT DEFAULT '',
uid TEXT,
folder TEXT,
subject TEXT,
@@ -402,9 +452,10 @@ def _init_scheduled_db():
urgency TEXT,
reason TEXT,
alerted INTEGER DEFAULT 0,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, owner)
)
""")
""", ["message_id", "owner", "uid", "folder", "subject", "sender", "urgency", "reason", "alerted", "created_at"])
conn.execute("""
CREATE TABLE IF NOT EXISTS email_event_seen (
owner TEXT NOT NULL,

View File

@@ -39,7 +39,7 @@ from routes.email_helpers import (
_extract_attachment_text, _extract_text,
_pre_retrieve_context,
_attach_compose_uploads, _cleanup_compose_uploads, _q,
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE,
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
)
logger = logging.getLogger(__name__)
@@ -243,8 +243,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
await _emit_progress(progress_cb, f"Found {len(uid_list)} recent email(s); checking cache…")
_c = _sql3.connect(SCHEDULED_DB)
_sum_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_summaries").fetchall()}
_reply_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_ai_replies").fetchall()}
_cache_owner_clause, _cache_owner_params = _email_cache_owner_clause(account_owner)
_sum_existing = {r[0] for r in _c.execute(
f"SELECT message_id FROM email_summaries WHERE {_cache_owner_clause}",
_cache_owner_params,
).fetchall()}
_reply_existing = {r[0] for r in _c.execute(
f"SELECT message_id FROM email_ai_replies WHERE {_cache_owner_clause}",
_cache_owner_params,
).fetchall()}
if auto_tag or auto_spam:
if account_owner:
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner=?", (account_owner,)).fetchall()}
@@ -252,12 +259,18 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner='' OR owner IS NULL").fetchall()}
else:
_tag_existing = set()
_cal_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_calendar_extractions").fetchall()} if auto_cal else set()
_cal_existing = {r[0] for r in _c.execute(
f"SELECT message_id FROM email_calendar_extractions WHERE {_cache_owner_clause}",
_cache_owner_params,
).fetchall()} if auto_cal else set()
# Urgency is handled by the built-in `check_email_urgency` task. Keep
# this legacy poller path disabled so users don't get two independent
# urgent-email systems.
auto_urgent = False
_urgent_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_urgency_alerts").fetchall()} if auto_urgent else set()
_urgent_existing = {r[0] for r in _c.execute(
f"SELECT message_id FROM email_urgency_alerts WHERE {_cache_owner_clause}",
_cache_owner_params,
).fetchall()} if auto_urgent else set()
_c.close()
# Hoist the self-address lookup OUT of the per-email loop — fetching
@@ -415,9 +428,9 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c = _sql3.connect(SCHEDULED_DB)
_c.execute("""
INSERT OR REPLACE INTO email_summaries
(message_id, uid, folder, subject, sender, summary, model_used, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat()))
(message_id, owner, uid, folder, subject, sender, summary, model_used, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat()))
_c.commit()
_c.close()
_sum_existing.add(message_id)
@@ -458,9 +471,9 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c = _sql3.connect(SCHEDULED_DB)
_c.execute("""
INSERT OR REPLACE INTO email_ai_replies
(message_id, uid, folder, reply, model_used, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, reply, model, datetime.utcnow().isoformat()))
(message_id, owner, uid, folder, reply, model_used, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid), _folder, reply, model, datetime.utcnow().isoformat()))
_c.commit()
_c.close()
_reply_existing.add(message_id)
@@ -675,8 +688,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_cc = _sql3.connect(SCHEDULED_DB)
_cc.execute(
"INSERT OR REPLACE INTO email_calendar_extractions "
"(message_id, uid, events_created, created_at) VALUES (?, ?, ?, ?)",
(message_id, uid.decode() if isinstance(uid, bytes) else str(uid),
"(message_id, owner, uid, events_created, created_at) VALUES (?, ?, ?, ?, ?)",
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
_cal_run_count, datetime.utcnow().isoformat())
)
_cc.commit()
@@ -733,9 +746,9 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_uc = _sql3.connect(SCHEDULED_DB)
_uc.execute(
"INSERT OR REPLACE INTO email_urgency_alerts "
"(message_id, uid, folder, subject, sender, urgency, reason, alerted, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(message_id, uid.decode() if isinstance(uid, bytes) else str(uid),
"(message_id, owner, uid, folder, subject, sender, urgency, reason, alerted, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
_folder, subject, sender, urgency, reason,
1 if urgency in ("critical", "high") else 0,
datetime.utcnow().isoformat())

View File

@@ -49,7 +49,7 @@ from routes.email_helpers import (
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
SendEmailRequest, ExtractStyleRequest,
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
attachment_extract_dir,
attachment_extract_dir, _email_cache_owner_clause,
)
from routes.email_pollers import _start_poller
@@ -934,9 +934,11 @@ def setup_email_routes():
import sqlite3 as _sql3
_c = _sql3.connect(SCHEDULED_DB)
placeholders = ",".join("?" * len(ids))
owner_clause, owner_params = _email_cache_owner_clause(owner)
rows = _c.execute(
f"SELECT message_id, summary FROM email_summaries WHERE message_id IN ({placeholders})",
ids,
f"SELECT message_id, summary FROM email_summaries "
f"WHERE message_id IN ({placeholders}) AND {owner_clause}",
(*ids, *owner_params),
).fetchall()
_c.close()
by_id = {r[0]: r[1] for r in rows}
@@ -1219,15 +1221,16 @@ def setup_email_routes():
try:
import sqlite3 as _sql3
_c = _sql3.connect(SCHEDULED_DB)
owner_clause, owner_params = _email_cache_owner_clause(owner)
_row = _c.execute(
"SELECT summary FROM email_summaries WHERE message_id = ?",
(message_id.strip(),),
f"SELECT summary FROM email_summaries WHERE message_id = ? AND {owner_clause}",
(message_id.strip(), *owner_params),
).fetchone()
if _row:
cached_summary = _row[0]
_row2 = _c.execute(
"SELECT reply FROM email_ai_replies WHERE message_id = ?",
(message_id.strip(),),
f"SELECT reply FROM email_ai_replies WHERE message_id = ? AND {owner_clause}",
(message_id.strip(), *owner_params),
).fetchone()
if _row2:
cached_ai_reply = _apply_email_style_mechanics(_extract_reply(_row2[0] or ""))
@@ -2549,10 +2552,10 @@ def setup_email_routes():
_c = _sql3.connect(SCHEDULED_DB)
_c.execute("""
INSERT OR REPLACE INTO email_summaries
(message_id, uid, folder, subject, sender, summary, model_used, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(message_id, owner, uid, folder, subject, sender, summary, model_used, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
mid, data.get("uid", ""), data.get("folder", ""),
mid, owner, data.get("uid", ""), data.get("folder", ""),
subject, sender, content, model, datetime.utcnow().isoformat(),
))
_c.commit()
@@ -2587,9 +2590,10 @@ def setup_email_routes():
if message_id:
try:
_c = _sql3.connect(SCHEDULED_DB)
owner_clause, owner_params = _email_cache_owner_clause(owner)
_row = _c.execute(
"SELECT reply, model_used FROM email_ai_replies WHERE message_id = ?",
(message_id,),
f"SELECT reply, model_used FROM email_ai_replies WHERE message_id = ? AND {owner_clause}",
(message_id, *owner_params),
).fetchone()
_c.close()
if _row and _row[0]:
@@ -2791,9 +2795,9 @@ def setup_email_routes():
_c = _sql3.connect(SCHEDULED_DB)
_c.execute("""
INSERT OR REPLACE INTO email_ai_replies
(message_id, uid, folder, reply, model_used, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (message_id, source_uid, source_folder, reply, model, datetime.utcnow().isoformat()))
(message_id, owner, uid, folder, reply, model_used, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (message_id, owner, source_uid, source_folder, reply, model, datetime.utcnow().isoformat()))
_c.commit()
_c.close()
except Exception as e:

View File

@@ -10,11 +10,36 @@ from fastapi import APIRouter, Request, HTTPException
from core.models import ChatMessage
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
from src.topic_analyzer import analyze_topics
from routes.session_routes import _verify_session_owner
from routes.session_routes import (
_message_role,
_message_text,
_reject_compact_during_active_run,
_verify_session_owner,
)
logger = logging.getLogger(__name__)
def _merge_continue_rows_to_delete(db_messages, db1, db2):
"""DB rows to delete when merging the last two assistant messages.
Always the second assistant message (db2), plus ONLY the single
intervening "continue" user message (the one carrying "previous response
was interrupted") — matching the in-memory merge. The previous code
deleted the whole index range between the two assistant rows, destroying
any tool/system/user messages in between and desyncing the DB from the
in-memory history.
"""
to_delete = [db2]
i1 = next((i for i, m in enumerate(db_messages) if m is db1), None)
i2 = next((i for i, m in enumerate(db_messages) if m is db2), None)
if i1 is not None and i2 is not None and i2 - 1 > i1:
between = db_messages[i2 - 1]
if getattr(between, "role", "") == "user" and "previous response was interrupted" in (getattr(between, "content", "") or ""):
to_delete.append(between)
return to_delete
def setup_history_routes(session_manager) -> APIRouter:
router = APIRouter(tags=["history"])
@@ -418,11 +443,13 @@ def setup_history_routes(session_manager) -> APIRouter:
db1.content = merged_content
db1.meta_data = _json.dumps(merged_meta)
# Remove the continue user message if between them
db_idx2 = db_messages.index(db2)
db_idx1 = db_messages.index(db1)
for di in range(db_idx2, db_idx1, -1):
db.delete(db_messages[di])
# Mirror the in-memory deletion: remove the second assistant
# message and ONLY the "continue" user message between them
# (not arbitrary tool/system/user rows). The old
# range-delete destroyed every row between the two assistant
# messages, desyncing the DB from the in-memory history.
for _row in _merge_continue_rows_to_delete(db_messages, db1, db2):
db.delete(_row)
db.commit()
finally:
@@ -499,6 +526,7 @@ def setup_history_routes(session_manager) -> APIRouter:
session = session_manager.get_session(session_id)
except KeyError:
raise HTTPException(404, "Session not found")
_reject_compact_during_active_run(session_id)
try:
from src.model_context import estimate_tokens, get_context_length
@@ -521,8 +549,8 @@ def setup_history_routes(session_manager) -> APIRouter:
# Build text to summarize
convo_text = "\n".join(
f"{(m.role if isinstance(m, ChatMessage) else m.get('role', '')).upper()}: "
f"{(m.content if isinstance(m, ChatMessage) else m.get('content', ''))[:2000]}"
f"{_message_role(m).upper()}: "
f"{_message_text(m)[:2000]}"
for m in older
)

View File

@@ -5,6 +5,7 @@ import os
import uuid
import urllib.parse
import html
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
import logging
@@ -12,6 +13,7 @@ import httpx
from core.database import McpServer, SessionLocal
from core.middleware import require_admin
from src.constants import DATA_DIR
from src.mcp_manager import McpManager
logger = logging.getLogger(__name__)
@@ -19,6 +21,75 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
def _mcp_oauth_base_dir() -> Path:
"""Directory that may contain OAuth files managed by Odysseus."""
return (Path(DATA_DIR) / "mcp_oauth").resolve(strict=False)
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
raw = str(raw_path or "").strip()
if not raw:
return ""
base = _mcp_oauth_base_dir()
path = Path(os.path.expanduser(raw))
if not path.is_absolute():
path = base / path
resolved = path.resolve(strict=False)
try:
resolved.relative_to(base)
except ValueError as exc:
raise HTTPException(
400,
f"Invalid OAuth {field_name}: path must stay under {base}",
) from exc
return str(resolved)
def _sanitize_mcp_oauth_config(oauth_cfg):
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
if not oauth_cfg:
return oauth_cfg
if not isinstance(oauth_cfg, dict):
return {}
sanitized = dict(oauth_cfg)
for field_name in ("keys_file", "token_file"):
if sanitized.get(field_name):
sanitized[field_name] = _resolve_mcp_oauth_path(
sanitized[field_name],
field_name,
)
return sanitized
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
"""Check token existence without letting legacy bad paths break listing."""
if not isinstance(oauth_cfg, dict):
return False
try:
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
except HTTPException:
if strict:
raise
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
return True
return bool(token_file and not os.path.exists(token_file))
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
if not oauth_cfg or not isinstance(env, dict):
return
keys_file = oauth_cfg.get("keys_file")
token_file = oauth_cfg.get("token_file")
if keys_file:
env["GMAIL_OAUTH_PATH"] = keys_file
if token_file:
env["GMAIL_CREDENTIALS_PATH"] = token_file
def _load_disabled_map():
"""Load per-server disabled tool sets from DB."""
db = SessionLocal()
@@ -53,8 +124,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
needs_oauth = False
if oauth_cfg:
token_file = os.path.expanduser(oauth_cfg.get("token_file", ""))
needs_oauth = token_file and not os.path.exists(token_file)
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
total_tools = status.get("tool_count", 0)
result.append({
@@ -71,6 +141,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
"disabled_tool_count": len(disabled_list),
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"has_oauth": oauth_cfg is not None,
"needs_oauth": needs_oauth,
})
@@ -101,6 +172,8 @@ def setup_mcp_routes(mcp_manager: McpManager):
raise HTTPException(400, "command is required for stdio transport")
if transport == "sse" and not url:
raise HTTPException(400, "url is required for SSE transport")
if transport == "http" and not url:
raise HTTPException(400, "url is required for HTTP transport")
# Parse JSON fields
try:
@@ -111,26 +184,33 @@ def setup_mcp_routes(mcp_manager: McpManager):
parsed_env = json.loads(env) if env else {}
except json.JSONDecodeError:
parsed_env = {}
if not isinstance(parsed_env, dict):
parsed_env = {}
# Parse OAuth config
parsed_oauth_config = None
if oauth_config:
try:
parsed_oauth_config = json.loads(oauth_config)
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
except json.JSONDecodeError:
pass
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
# Write OAuth credentials file if provided (for Google MCP servers)
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
if oauth_file:
try:
oauth_data = json.loads(oauth_file)
oauth_dir = os.path.expanduser(oauth_data.get("dir", ""))
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
oauth_filename = oauth_data.get("filename", "")
client_id = oauth_data.get("client_id", "")
client_secret = oauth_data.get("client_secret", "")
if oauth_dir and oauth_filename and client_id and client_secret:
os.makedirs(oauth_dir, exist_ok=True)
filepath = _resolve_mcp_oauth_path(
Path(oauth_dir) / str(oauth_filename),
"filename",
)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
creds = {
"installed": {
"client_id": client_id,
@@ -140,7 +220,6 @@ def setup_mcp_routes(mcp_manager: McpManager):
"token_uri": "https://accounts.google.com/o/oauth2/token",
}
}
filepath = os.path.join(oauth_dir, oauth_filename)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(creds, f, indent=2)
logger.info(f"Wrote OAuth credentials to {filepath}")
@@ -171,9 +250,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
# Check if OAuth token already exists — skip connection attempt if not
needs_oauth = False
if parsed_oauth_config:
token_file = os.path.expanduser(parsed_oauth_config.get("token_file", ""))
if token_file and not os.path.exists(token_file):
needs_oauth = True
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
connected = False
if not needs_oauth:
@@ -188,6 +265,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
)
status = mcp_manager.get_server_status(server_id)
needs_auth = status.get("status") == "needs_auth"
return {
"id": server_id,
"name": name,
@@ -196,6 +274,8 @@ def setup_mcp_routes(mcp_manager: McpManager):
"tool_count": status.get("tool_count", 0),
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
"needs_oauth": needs_oauth,
"needs_auth": needs_auth,
"auth_url": status.get("auth_url"),
}
@router.post("/servers/{server_id}/reconnect")
@@ -228,6 +308,8 @@ def setup_mcp_routes(mcp_manager: McpManager):
"status": status.get("status", "disconnected"),
"tool_count": status.get("tool_count", 0),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"needs_auth": status.get("status") == "needs_auth",
}
finally:
db.close()
@@ -349,8 +431,8 @@ def setup_mcp_routes(mcp_manager: McpManager):
if not srv.oauth_config:
raise HTTPException(400, "Server has no OAuth config")
oauth_cfg = json.loads(srv.oauth_config)
keys_file = os.path.expanduser(oauth_cfg.get("keys_file", ""))
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
if not keys_file or not os.path.exists(keys_file):
raise HTTPException(400, "OAuth keys file not found")
@@ -393,10 +475,18 @@ def setup_mcp_routes(mcp_manager: McpManager):
@router.get("/oauth/callback")
async def oauth_callback(code: str, state: str, request: Request):
"""Handle OAuth callback from Google — exchange code for tokens."""
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
pending-state registry; Google flows fall through to the legacy path."""
require_admin(request)
server_id = state
return await _exchange_and_connect(server_id, code, request)
from src.mcp_oauth import resolve_pending
if resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
# Legacy Google path: state is the server_id
return await _exchange_and_connect(state, code, request)
@router.post("/oauth/exchange/{server_id}")
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
@@ -411,6 +501,17 @@ def setup_mcp_routes(mcp_manager: McpManager):
except Exception:
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
# resolve it directly (the background connect finishes the handshake).
state = params.get("state", [None])[0]
from src.mcp_oauth import resolve_pending
if state and resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
return await _exchange_and_connect(server_id, code, request)
async def _exchange_and_connect(server_id: str, code: str, request: Request):
@@ -423,9 +524,11 @@ def setup_mcp_routes(mcp_manager: McpManager):
if not srv.oauth_config:
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
oauth_cfg = json.loads(srv.oauth_config)
keys_file = os.path.expanduser(oauth_cfg.get("keys_file", ""))
token_file = os.path.expanduser(oauth_cfg.get("token_file", ""))
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
token_file = oauth_cfg.get("token_file", "")
if not keys_file or not token_file:
raise HTTPException(400, "OAuth keys/token file not configured")
with open(keys_file, encoding="utf-8") as f:
keys_data = json.load(f)
@@ -488,6 +591,9 @@ def setup_mcp_routes(mcp_manager: McpManager):
"Authorized but Connection Failed",
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
))
except HTTPException as e:
logger.warning(f"OAuth callback rejected: {e.detail}")
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
except Exception as e:
logger.exception(f"OAuth callback error: {e}")
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)

View File

@@ -1029,12 +1029,13 @@ def setup_model_routes(model_discovery):
for ep in endpoints:
base = _normalize_base(ep.base_url)
provider = _detect_provider(base)
# Use cached models — background refresh keeps them updated
model_ids = _cached_model_ids(ep)
# Merge cached + pinned models, then filter out hidden ones
ep_model_type = getattr(ep, "model_type", None) or "llm"
# Filter out hidden (probe-failed) models
hidden = _hidden_model_ids(ep)
model_ids = [m for m in model_ids if m not in hidden]
model_ids = _visible_models(
_cached_model_ids(ep),
ep.hidden_models,
getattr(ep, "pinned_models", None),
)
# Build correct URL based on provider
chat_url = build_chat_url(base)
kind = _effective_endpoint_kind(ep, base)
@@ -1043,6 +1044,13 @@ def setup_model_routes(model_discovery):
if model_ids:
curated_key = _match_provider_curated(base, None)
curated, extra = _curate_models(model_ids, curated_key)
# Pinned models are admin-selected — they always belong in the
# primary curated list, not buried in extras.
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
for m in pinned:
if m not in curated:
curated.append(m)
extra = [m for m in extra if m not in pinned]
items.append({
"host": "custom",
"port": 0,
@@ -1891,9 +1899,10 @@ def setup_model_routes(model_discovery):
if body:
if "supports_tools" in body:
v = body["supports_tools"]
ep.supports_tools = bool(v) if v in (True, False, "true", "false", 1, 0) else None
ep.supports_tools = {True: True, False: False, 'true': True, 'false': False, 1: True, 0: False}.get(v)
if "is_enabled" in body:
ep.is_enabled = bool(body["is_enabled"])
v_ie = body['is_enabled']
ep.is_enabled = v_ie.lower() in ('true', '1', 'yes') if isinstance(v_ie, str) else bool(v_ie)
if "name" in body and isinstance(body["name"], str):
ep.name = body["name"].strip() or ep.name
if "model_type" in body and isinstance(body["model_type"], str):

View File

@@ -57,6 +57,40 @@ def _content_to_text(content) -> str:
return ""
def _message_role(message) -> str:
if isinstance(message, ChatMessage):
return message.role or ""
if isinstance(message, dict):
return message.get("role", "") or ""
return getattr(message, "role", "") or ""
def _message_text(message) -> str:
if isinstance(message, ChatMessage):
content = message.content
elif isinstance(message, dict):
content = message.get("content")
else:
content = getattr(message, "content", None)
return _content_to_text(content)
def _message_metadata(message) -> dict:
if isinstance(message, ChatMessage):
metadata = message.metadata
elif isinstance(message, dict):
metadata = message.get("metadata")
else:
metadata = getattr(message, "metadata", None)
return metadata if isinstance(metadata, dict) else {}
def _reject_compact_during_active_run(session_id: str) -> None:
from src import agent_runs
if agent_runs.is_active(session_id):
raise HTTPException(409, "Session has an active run; try compacting after it finishes")
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
"""Verify the current user owns the session. Raises 404 if not.
@@ -872,6 +906,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
session = session_manager.get_session(session_id)
except KeyError:
raise HTTPException(404, f"Session {session_id} not found")
_reject_compact_during_active_run(session_id)
history = list(session.history or [])
if len(history) < 6:
@@ -897,7 +932,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
prior_compactions = sum(
1 for m in history
if (m.metadata or {}).get("compacted") or "[Conversation summary" in (m.content or "")
if _message_metadata(m).get("compacted") or "[Conversation summary" in _message_text(m)
)
prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace(
"{count}", str(len(older))
@@ -905,7 +940,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
"{n}", str(prior_compactions + 1)
)
convo_text = "\n".join(
f"{m.role.upper()}: {(m.content or '')[:2000]}"
f"{_message_role(m).upper()}: {_message_text(m)[:2000]}"
for m in older
)
try:

View File

@@ -455,7 +455,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
import sqlite3
from pathlib import Path
from routes.email_helpers import SCHEDULED_DB
from routes.email_helpers import SCHEDULED_DB, OWNER_SCOPED_EMAIL_CACHE_TABLES, _email_cache_owner_clause
cleared = {}
conn = sqlite3.connect(SCHEDULED_DB)
@@ -468,6 +468,13 @@ def setup_task_routes(task_scheduler) -> APIRouter:
(user,),
).fetchone()[0]
conn.execute("DELETE FROM email_tags WHERE owner = ? OR owner = ''", (user,))
elif table in OWNER_SCOPED_EMAIL_CACHE_TABLES and user:
owner_clause, owner_params = _email_cache_owner_clause(user)
before = conn.execute(
f"SELECT COUNT(*) FROM {table} WHERE {owner_clause}",
owner_params,
).fetchone()[0]
conn.execute(f"DELETE FROM {table} WHERE {owner_clause}", owner_params)
else:
before = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
conn.execute(f"DELETE FROM {table}")

View File

@@ -0,0 +1,56 @@
"""Workspace API — browse server directories to pick a tool workspace folder."""
import os
from fastapi import APIRouter, Request, HTTPException, Query
from src.auth_helpers import get_current_user
from src.tool_security import owner_is_admin_or_single_user
def setup_workspace_routes():
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
@router.get("/browse")
def browse(request: Request, path: str = Query(default="")):
"""List subdirectories of `path` (default: home) so the UI can navigate
the server filesystem and pick a workspace folder. Directories only.
ADMIN-ONLY: this enumerates the server filesystem, so it is gated the
same way the file/shell tools are (read_file/write_file/bash are in
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
be able to map the host's directory tree either.
"""
owner = get_current_user(request)
if not owner_is_admin_or_single_user(owner):
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
# Resolve symlinks so the reported path is canonical and the UI navigates
# real directories (defends against symlink games in displayed paths).
target = os.path.realpath(os.path.expanduser(path.strip() or "~"))
if not os.path.isdir(target):
target = os.path.realpath(os.path.expanduser("~"))
dirs = []
try:
with os.scandir(target) as it:
for entry in it:
try:
# Don't follow symlinks when classifying — a symlinked
# dir is skipped rather than letting the browser wander
# off via a link. Hidden entries are omitted.
if entry.is_dir(follow_symlinks=False) and not entry.name.startswith("."):
# Build the child path server-side with os.path.join
# so it's correct on Windows (backslashes) and Linux.
dirs.append({"name": entry.name, "path": os.path.join(target, entry.name)})
except OSError:
continue
except (PermissionError, OSError):
dirs = []
parent = os.path.dirname(target)
return {
"path": target,
"parent": parent if parent and parent != target else None,
"dirs": sorted(dirs, key=lambda d: d["name"].lower()),
}
return router

View File

@@ -576,6 +576,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
system_backend = (system.get("backend") or "").lower()
apple_silicon = system_backend in ("mps", "metal", "apple")
rocm = system_backend == "rocm"
is_windows = system.get("platform") == "windows"
# Consumer AMD Radeon (RDNA, gfx10/11/12): the practical local serving path
# is GGUF via llama.cpp. vLLM/SGLang on ROCm are validated for datacenter
@@ -615,7 +616,11 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
# servable path, so a model needs a real GGUF to be recommended.
# Otherwise the Cookbook rates vLLM-only AWQ/GPTQ builds "GOOD" on a
# Radeon that can't actually serve them.
if (apple_silicon or consumer_amd) and not (m.get("is_gguf") or m.get("gguf_sources")):
#
# Windows is the same: Odysseus only supports llama.cpp on Windows,
# which requires GGUF. vLLM/SGLang are explicitly blocked, so AWQ/GPTQ
# models without a GGUF source are unservable there.
if (apple_silicon or consumer_amd or is_windows) and not (m.get("is_gguf") or m.get("gguf_sources")):
continue
# Format filter: AWQ tab -> only AWQ models, FP4 tab -> FP4-family models, etc.

View File

@@ -539,6 +539,7 @@ def _detect_windows():
"backend": d.get("gpu_backend", "cpu_x86"),
"homogeneous": True,
"gpu_error": None,
"platform": "windows",
}
# PowerShell only reports aggregate GPU info, not per-card detail, so we
# can't tell a mixed box from a uniform one here — assume one homogeneous

View File

@@ -345,8 +345,17 @@ async def extract_and_store(
logger.warning(f"Memory dedup (vector) unavailable, using text fallback: {e}")
existing_id = None
if existing_id:
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
continue
# The vector store is a single shared collection with no
# owner metadata, so find_similar can return ANOTHER
# tenant's memory. Only treat it as a duplicate when the
# match is this user's own (or a legacy unowned) memory —
# otherwise the user's freshly-extracted fact would be
# silently dropped. Mirror the owner predicate used by the
# text dedup below; cross-tenant/stale matches fall through.
_match = next((e for e in existing if e.get("id") == existing_id), None)
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
continue
# Text dedup fallback: exact match + fuzzy similarity
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing

View File

@@ -7,6 +7,7 @@ import os
from .memory import MemoryManager
from .memory_vector import MemoryVectorStore
from src.memory_provider import MemoryRecord, NativeMemoryProvider
@dataclass
@@ -42,6 +43,10 @@ class MemoryService:
self.vector_store = MemoryVectorStore(data_dir) if os.path.exists(
os.path.join(data_dir, "memory_vectors")
) else None
self.provider = NativeMemoryProvider(self.manager, self.vector_store)
def _sync_provider(self) -> None:
self.provider.memory_vector = self.vector_store
@staticmethod
def _to_memory(entry: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None) -> Memory:
@@ -53,6 +58,19 @@ class MemoryService:
metadata=metadata or {},
)
@staticmethod
def _record_to_memory(record: MemoryRecord, metadata: Optional[Dict[str, Any]] = None) -> Memory:
merged_metadata = dict(record.metadata)
if metadata:
merged_metadata.update(metadata)
return Memory(
id=record.id,
text=record.text,
timestamp=record.timestamp,
session_id=record.session_id,
metadata=merged_metadata,
)
async def remember(self, text: str, session_id: Optional[str] = None) -> Memory:
"""
Store a new memory.
@@ -64,19 +82,9 @@ class MemoryService:
Returns:
Created Memory object
"""
entry = self.manager.add_entry(text)
if session_id:
entry["session_id"] = session_id
memories = self.manager.load_all()
memories.append(entry)
self.manager.save(memories)
# Also add to vector store if available
if self.vector_store and self.vector_store.healthy:
self.vector_store.add(entry["id"], entry["text"])
return self._to_memory(entry)
self._sync_provider()
record = await self.provider.remember(text, session_id=session_id)
return self._record_to_memory(record)
async def recall(self, query: str, top_k: int = 5) -> MemorySearchResult:
"""
@@ -89,28 +97,20 @@ class MemoryService:
Returns:
MemorySearchResult with matching memories
"""
# Try vector search first
all_memories = self.manager.load_all()
by_id = {m.get("id"): m for m in all_memories}
if self.vector_store and self.vector_store.healthy:
results = self.vector_store.search(query, k=top_k)
found = []
for result in results:
entry = by_id.get(result.get("memory_id"))
if entry:
found.append(self._to_memory(entry, metadata={"score": result.get("score")}))
if found:
return MemorySearchResult(memories=found, query=query, total=len(found))
# Fallback to keyword search
results = self.manager.get_relevant_memories(query, all_memories, max_items=top_k)
memories = [self._to_memory(m) for m in results]
self._sync_provider()
results = await self.provider.recall(query, top_k=top_k)
memories = [
self._record_to_memory(hit.memory, metadata={"score": hit.score})
if hit.score is not None
else self._record_to_memory(hit.memory)
for hit in results
]
return MemorySearchResult(memories=memories, query=query, total=len(memories))
def get_all(self, limit: int = 100) -> List[Memory]:
"""Get all memories."""
memories = self.manager.load_all()[:limit]
return [self._to_memory(m) for m in memories]
records = self.manager.load_all()[:limit]
return [self._to_memory(m) for m in records]
def delete(self, memory_id: str) -> bool:
"""Delete a memory by ID."""

View File

@@ -177,6 +177,7 @@ TOOL_SECTIONS = {
<shell command>
```
Run any shell command. Output is returned to you. Use for: installing packages, checking files, git, curl, system info, etc.
NEVER use bash to create or change files — no `>`/`>>` redirects, no heredocs (`cat > f << 'EOF'`), no `tee`, `sed -i`, `awk -i`, no `python -c` that writes. To CREATE or fully rewrite a file use `write_file`; to change part of an existing file use `edit_file`. Those show a diff and are the ONLY allowed way to write files. (bash is for read-only inspection: `ls`, `cat` to READ, `grep`, `git status`/`git diff`, builds, installs.)
For LONG-running commands (package installs, pip/npm, ffmpeg, model downloads, training, builds — anything that may take more than ~20s), make the FIRST line `#!bg` to run it in the BACKGROUND. You get a job id back immediately and are automatically re-invoked with the full output when it finishes — so you never block the chat waiting. Example:
```bash
#!bg
@@ -220,6 +221,12 @@ Read a file and return its contents.""",
```
Write content to a file. First line is the path, rest is the content.""",
"edit_file": """\
```edit_file
{"path": "<file path>", "old_string": "<exact text to replace>", "new_string": "<replacement>", "replace_all": false}
```
Edit an EXISTING file by exact string replacement. PREFER this over bash (sed/echo/redirects) for changing files — it shows a before/after diff. `old_string` must match the file exactly and be unique unless `replace_all` is true. Use write_file to create a new file.""",
"create_document": """\
```create_document
<title>
@@ -236,7 +243,7 @@ old text to find
new replacement text
<<<END>>>
```
PREFERRED way to change an existing document. Find exact text and replace it. Multiple FIND/REPLACE blocks per call OK. Use this for any edit smaller than a full rewrite — adding a function, fixing a bug, tweaking a section, renaming things. **If a document is open in the editor, treat it as the user's current context: don't ask which file they mean, and don't create a new one — just edit_document the active one.** Do NOT re-send the whole file with update_document for small changes.""",
Edit a document OPEN IN THE EDITOR PANEL — NOT a file on disk. For files on disk (home folder, project files, any real path like ~/sweden.txt) use `edit_file` instead. Find exact text and replace it. Multiple FIND/REPLACE blocks per call OK. Use for any edit smaller than a full rewrite. **If a document is open in the editor, treat it as the user's current context: don't ask which file they mean, and don't create a new one — just edit_document the active one.** Do NOT re-send the whole file with update_document for small changes.""",
"update_document": """\
```update_document
@@ -462,13 +469,14 @@ _API_HOSTS = frozenset([
"api.together.xyz", "api.fireworks.ai",
"api.perplexity.ai", "api.x.ai",
"ollama.com", "api.venice.ai",
"api.githubcopilot.com",
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
# Without these, `_is_api_model` falls back to keyword sniffing on the
# model name, so well-behaved local servers don't get native tool
# schemas and the agent silently degrades to fenced-block parsing.
"localhost", "127.0.0.1", "host.docker.internal",
])
_MCP_KEYWORDS = frozenset(["browse", "browser", "website", "calendar", "event", "email",
_MCP_KEYWORDS = frozenset(["mcp", "browse", "browser", "website", "calendar", "event", "email",
"gmail", "screenshot", "navigate", "click", "miniflux", "rss", "feed"])
_ADMIN_SCHEMA_NAMES = frozenset([
"manage_session", "manage_skills", "manage_tasks",
@@ -1380,6 +1388,7 @@ async def stream_agent_loop(
owner: Optional[str] = None,
relevant_tools: Optional[Set[str]] = None,
fallbacks: Optional[List[tuple]] = None,
workspace: Optional[str] = None,
_is_teacher_run: bool = False,
) -> AsyncGenerator[str, None]:
"""Streaming agent loop generator.
@@ -1546,6 +1555,27 @@ async def stream_agent_loop(
compact=_is_api_model,
owner=owner,
)
if workspace:
# PREPEND (not append) so it dominates the large base prompt — appended
# at the end, small models ignored it and asked the user for code. The
# folder IS the project; the agent must explore it, not ask.
_ws_note = (
f"## ACTIVE WORKSPACE — READ FIRST\n"
f"The user is working in this folder: {workspace}\n"
f"It IS the project. bash/python run with cwd set here and "
f"read_file/write_file are confined to it (paths outside are rejected).\n"
f"When the user says \"the code\" / \"this project\" / \"the workspace\" "
f"or asks to review/find/edit something WITHOUT a path, they mean THIS "
f"folder. Do NOT ask the user for code or a path, and do NOT read a file "
f"literally named \"workspace\". ALWAYS start by exploring it yourself: "
f"run `bash` → `git ls-files` (or `ls -R`) to see the files, then "
f"read_file the relevant ones by path RELATIVE to the workspace."
)
if messages and messages[0].get("role") == "system":
messages[0]["content"] = _ws_note + "\n\n" + (messages[0].get("content") or "")
else:
messages.insert(0, {"role": "system", "content": _ws_note})
logger.info("[workspace] active for this turn: %s", workspace)
prep_timings["prompt_build"] = time.time() - _t2
_t3 = time.time()
@@ -1658,6 +1688,11 @@ async def stream_agent_loop(
_doc_opened = False # whether doc_stream_open was sent
_doc_last_len = 0 # last content length sent
# Set when the loop runs out of rounds while the agent was still actively
# using tools — i.e. it was cut off, not finished. Drives a "Continue" event
# so the user can resume instead of the turn silently stalling.
_exhausted_rounds = False
for round_num in range(1, max_rounds + 1):
round_response = ""
round_reasoning = "" # reasoning_content deltas (DeepSeek-thinking, vLLM --reasoning-parser)
@@ -2167,6 +2202,7 @@ async def stream_agent_loop(
disabled_tools=disabled_tools,
owner=owner,
progress_cb=_push_progress,
workspace=workspace,
)
finally:
# Sentinel so the drainer knows to stop.
@@ -2282,6 +2318,9 @@ async def stream_agent_loop(
if result.get("images"):
img = result["images"][0]
tool_output_data["screenshot"] = f"data:{img['mimeType']};base64,{img['data']}"
# Forward a file-write diff for inline before/after rendering
if "diff" in result:
tool_output_data["diff"] = result["diff"]
yield f'data: {json.dumps(tool_output_data)}\n\n'
# Native document tools open in the editor + carry the REAL doc id.
@@ -2324,6 +2363,10 @@ async def stream_agent_loop(
if result.get("doc_id"):
tool_event["doc_id"] = result["doc_id"]
tool_event["doc_title"] = result.get("title", "")
# Persist the file-write/edit diff so it re-renders on reload — without
# this the diff shows live but vanishes from saved history.
if result.get("diff"):
tool_event["diff"] = result["diff"]
tool_events.append(tool_event)
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
_effectful_used = True
@@ -2348,6 +2391,20 @@ async def stream_agent_loop(
# Separator in accumulated response
full_response += "\n\n"
else:
# The for-loop completed every allowed round WITHOUT an early `break`
# (a `break` fires on "done", budget, or error). Reaching this `else`
# means the agent kept working until it ran out of rounds — so offer
# Continue instead of stopping silently. This catches ALL exhaustion
# paths, including a verifier `continue` on the final round (the old
# bottom-of-loop flag missed those).
_exhausted_rounds = True
# If the loop hit the round cap while still working, tell the client so it
# can show a "Continue" affordance instead of the turn just stopping.
if _exhausted_rounds:
logger.info("[agent] round cap (%d) reached mid-task — emitting rounds_exhausted", max_rounds)
yield f'data: {json.dumps({"type": "rounds_exhausted", "rounds": max_rounds})}\n\n'
# If the response is completely empty and no tools were executed,
# yield a fallback message so the user is not left hanging.

View File

@@ -26,7 +26,8 @@ MAX_OUTPUT_CHARS = 10_000
MAX_READ_CHARS = 20_000
# Tool types that trigger execution
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file",
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
"grep", "glob", "ls",
"create_document", "update_document", "edit_document",
"search_chats",
"chat_with_model", "create_session", "list_sessions",

View File

@@ -9,6 +9,7 @@ from src.constants import (
SESSIONS_FILE, DEFAULT_HOST, OPENAI_API_KEY
)
from src.memory import MemoryManager
from src.memory_provider import MemoryProviderRegistry, NativeMemoryProvider
from services.memory.skills import SkillsManager
from core.session_manager import SessionManager
from core.models import set_session_manager
@@ -73,6 +74,10 @@ def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
logger.warning(f"MemoryVectorStore DEGRADED: {e}")
memory_vector = None
memory_provider_registry = MemoryProviderRegistry([
NativeMemoryProvider(memory_manager, memory_vector),
])
# Initialize processors
chat_processor = ChatProcessor(memory_manager, personal_docs_manager, memory_vector=memory_vector, skills_manager=skills_manager)
research_handler = ResearchHandler()
@@ -99,6 +104,7 @@ def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
return {
"memory_manager": memory_manager,
"memory_vector": memory_vector,
"memory_provider_registry": memory_provider_registry,
"skills_manager": skills_manager,
"session_manager": session_manager,
"upload_handler": upload_handler,

View File

@@ -265,6 +265,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
existing.all_day = all_day
existing.is_utc = row_is_utc
existing.rrule = rrule
existing.origin = "caldav"
else:
new_ev = CalendarEvent(
uid=uid_val,
@@ -277,6 +278,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
all_day=all_day,
is_utc=row_is_utc,
rrule=rrule,
origin="caldav",
)
db.add(new_ev)
pending[uid_val] = new_ev
@@ -286,8 +288,13 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
# Prune locally-cached CalDAV events that vanished
# upstream (only within our sync window — events outside
# the window aren't in `objs`, so we'd false-delete them).
# Only rows we previously pulled from the server (origin=="caldav")
# are prunable; locally-created events (agent / email triage / a
# UI event whose write-back failed) carry origin NULL and must
# never be deleted just because the server didn't return them.
stale = db.query(CalendarEvent).filter(
CalendarEvent.calendar_id == local_cal.id,
CalendarEvent.origin == "caldav",
CalendarEvent.dtstart >= start,
CalendarEvent.dtstart <= end,
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),

253
src/copilot.py Normal file
View File

@@ -0,0 +1,253 @@
# src/copilot.py
"""GitHub Copilot provider support.
Copilot exposes an OpenAI-compatible API at ``https://api.githubcopilot.com``
(``/chat/completions`` + ``/models``). Authentication is a GitHub OAuth
**device flow**: the user authorises a device code in their browser and we
receive a long-lived ``access_token`` that is sent directly as
``Authorization: Bearer <token>`` — there is no separate Copilot-token
exchange and no refresh (mirrors how editors / opencode talk to Copilot).
The only provider-specific wrinkle beyond the bearer token is a handful of
required request headers (API version, intent, an editor-style User-Agent,
and ``x-initiator`` for agent-vs-user request accounting). Those live in
:func:`copilot_headers`.
This module holds the constants + pure helpers; the HTTP device-flow calls
live in :mod:`routes.copilot_routes` so they can be auth-gated.
"""
import os
from typing import Dict, List, Optional
from urllib.parse import urlparse
import httpx
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# GitHub OAuth client id used for the device flow. Copilot's token endpoint
# only accepts client ids that GitHub has allow-listed for Copilot access, so
# we reuse the public VS Code client id (the de-facto standard third-party
# clients use). Override via env if you register your own allow-listed app.
COPILOT_CLIENT_ID = os.environ.get(
"ODYSSEUS_COPILOT_CLIENT_ID", "01ab8ac9400c4e429b23"
)
# Dated API version header required by the Copilot API (models + chat).
COPILOT_API_VERSION = os.environ.get(
"ODYSSEUS_COPILOT_API_VERSION", "2026-06-01"
)
# Public Copilot API base. GitHub Enterprise uses ``copilot-api.<domain>``.
COPILOT_BASE = "https://api.githubcopilot.com"
# Copilot wants an editor-like User-Agent + integration id. These identify the
# client to GitHub; keep them stable.
COPILOT_USER_AGENT = os.environ.get(
"ODYSSEUS_COPILOT_USER_AGENT", "Odysseus/1.0"
)
COPILOT_INTEGRATION_ID = os.environ.get(
"ODYSSEUS_COPILOT_INTEGRATION_ID", "vscode-chat"
)
COPILOT_EDITOR_VERSION = os.environ.get(
"ODYSSEUS_COPILOT_EDITOR_VERSION", "Odysseus/1.0"
)
# OAuth scope requested during the device flow.
COPILOT_SCOPE = "read:user"
# Default GitHub host for the device flow (public github.com).
GITHUB_HOST = "github.com"
def device_code_url(host: str = GITHUB_HOST) -> str:
return f"https://{host}/login/device/code"
def access_token_url(host: str = GITHUB_HOST) -> str:
return f"https://{host}/login/oauth/access_token"
def normalize_domain(url: str) -> str:
"""Strip scheme/trailing slash from a GitHub Enterprise URL or domain."""
return (url or "").replace("https://", "").replace("http://", "").rstrip("/")
def enterprise_base(enterprise_url: Optional[str]) -> str:
"""Return the Copilot API base for a deployment.
Public github.com → ``https://api.githubcopilot.com``.
Enterprise <domain> → ``https://copilot-api.<domain>``.
"""
if not enterprise_url:
return COPILOT_BASE
return f"https://copilot-api.{normalize_domain(enterprise_url)}"
def is_copilot_base(url: Optional[str]) -> bool:
"""True if a base URL points at the Copilot API (public or enterprise)."""
if not url:
return False
try:
host = (urlparse(url).hostname or "").lower().rstrip(".")
except Exception:
return False
if not host:
return False
# Public: api.githubcopilot.com (or any *.githubcopilot.com).
if host == "githubcopilot.com" or host.endswith(".githubcopilot.com"):
return True
# Enterprise: copilot-api.<domain>.
if host.startswith("copilot-api."):
return True
return False
def copilot_headers(
api_key: Optional[str],
*,
agent: bool = False,
vision: bool = False,
) -> Dict[str, str]:
"""Build the Copilot-specific request headers.
Args:
api_key: the GitHub device-flow access token (sent as Bearer).
agent: request originates from the agent loop (a tool-driven turn)
rather than a direct user message. Sets ``x-initiator`` for
Copilot's agent-vs-user request accounting.
vision: the request carries an image part.
"""
headers: Dict[str, str] = {
"X-GitHub-Api-Version": COPILOT_API_VERSION,
"Openai-Intent": "conversation-edits",
"User-Agent": COPILOT_USER_AGENT,
"Editor-Version": COPILOT_EDITOR_VERSION,
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"x-initiator": "agent" if agent else "user",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
if vision:
headers["Copilot-Vision-Request"] = "true"
return headers
# ---------------------------------------------------------------------------
# Device-flow OAuth (pure HTTP; orchestration lives in routes.copilot_routes)
# ---------------------------------------------------------------------------
def _oauth_post_headers() -> Dict[str, str]:
return {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": COPILOT_USER_AGENT,
}
def request_device_code(host: str = GITHUB_HOST, *, timeout: float = 10.0) -> Dict:
"""Start the device flow. Returns GitHub's
``{device_code, user_code, verification_uri, expires_in, interval}``.
"""
r = httpx.post(
device_code_url(host),
headers=_oauth_post_headers(),
json={"client_id": COPILOT_CLIENT_ID, "scope": COPILOT_SCOPE},
timeout=timeout,
)
r.raise_for_status()
return r.json()
def poll_access_token(host: str, device_code: str, *, timeout: float = 10.0) -> Dict:
"""Poll once for the access token. GitHub returns HTTP 200 with an
``error`` field (``authorization_pending``/``slow_down``) while the user
hasn't authorised yet, or ``{access_token, ...}`` once they have.
"""
r = httpx.post(
access_token_url(host),
headers=_oauth_post_headers(),
json={
"client_id": COPILOT_CLIENT_ID,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
timeout=timeout,
)
r.raise_for_status()
return r.json()
def fetch_models(base: str, token: str, *, timeout: float = 15.0) -> List[Dict]:
"""Fetch Copilot's model catalogue, filtered to picker-enabled models.
Returns a list of ``{id, tool_calls, vision}`` dicts. Falls back to the
full list if no model advertises ``model_picker_enabled`` (defensive
against API-shape drift).
"""
url = base.rstrip("/") + "/models"
r = httpx.get(url, headers=copilot_headers(token), timeout=timeout)
r.raise_for_status()
data = (r.json() or {}).get("data") or []
def _parse(item: Dict) -> Optional[Dict]:
mid = item.get("id")
if not mid:
return None
supports = ((item.get("capabilities") or {}).get("supports")) or {}
return {
"id": mid,
"tool_calls": bool(supports.get("tool_calls")),
"vision": bool(supports.get("vision")),
"picker": bool(item.get("model_picker_enabled")),
}
parsed = [p for p in (_parse(it) for it in data) if p]
picker = [p for p in parsed if p["picker"]]
chosen = picker or parsed
for p in chosen:
p.pop("picker", None)
return chosen
# ---------------------------------------------------------------------------
# Per-request header flags
# ---------------------------------------------------------------------------
_IMAGE_PART_TYPES = ("image_url", "input_image", "image")
def request_flags(messages) -> tuple:
"""Derive ``(agent, vision)`` from an OpenAI-style message list.
Mirrors opencode's logic:
* ``agent`` — the last message is *not* a plain user message (i.e. it's a
tool result / assistant follow-up), so Copilot should treat the request
as agent-initiated for request accounting.
* ``vision`` — any message carries an image content part.
"""
msgs = messages or []
last = msgs[-1] if msgs else None
agent = bool(last) and last.get("role") != "user"
vision = False
for m in msgs:
content = m.get("content") if isinstance(m, dict) else None
if isinstance(content, list) and any(
isinstance(p, dict) and p.get("type") in _IMAGE_PART_TYPES for p in content
):
vision = True
break
return agent, vision
def apply_request_headers(headers: Dict[str, str], messages) -> Dict[str, str]:
"""Set ``x-initiator`` / ``Copilot-Vision-Request`` on a header dict based
on the outgoing messages. Mutates and returns ``headers``."""
agent, vision = request_flags(messages)
headers["x-initiator"] = "agent" if agent else "user"
if vision:
headers["Copilot-Vision-Request"] = "true"
return headers

View File

@@ -196,6 +196,8 @@ class DeepResearcher:
max_content_chars: int = 15000,
max_report_tokens: int = 8192,
extraction_timeout: int = 90,
planning_timeout: int = 90,
query_timeout: int = 120,
extraction_concurrency: int = 3,
min_rounds: int = 2,
max_empty_rounds: int = 2,
@@ -215,6 +217,8 @@ class DeepResearcher:
self.max_content_chars = max_content_chars
self.max_report_tokens = max_report_tokens
self.extraction_timeout = min(3600, max(15, int(extraction_timeout or 90)))
self.planning_timeout = min(3600, max(15, int(planning_timeout or 90)))
self.query_timeout = min(3600, max(15, int(query_timeout or 120)))
self.extraction_concurrency = min(12, max(1, int(extraction_concurrency or 3)))
self.min_rounds = min_rounds
self.max_empty_rounds = max_empty_rounds
@@ -395,7 +399,7 @@ class DeepResearcher:
[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=1024,
timeout=30,
timeout=getattr(self, "planning_timeout", 90),
)
# Try to parse as JSON for structured plan
parsed = self._parse_json_object(response)
@@ -478,6 +482,7 @@ class DeepResearcher:
[{"role": "user", "content": prompt}],
temperature=0.5,
max_tokens=4096,
timeout=getattr(self, "query_timeout", 120),
)
queries = self._parse_json_array(response)
# Deduplicate

View File

@@ -194,6 +194,9 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
headers["x-api-key"] = api_key
headers["anthropic-version"] = "2023-06-01"
return headers
if provider == "copilot":
from src.copilot import copilot_headers
return copilot_headers(api_key)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
if provider == "openrouter":

View File

@@ -67,7 +67,7 @@ _host_health_lock = threading.Lock()
_model_activity: Dict[str, float] = {}
def _model_activity_key(url: str, model: str) -> str:
return f"{(url or '').strip().rstrip()}|{(model or '').strip()}"
return f"{(url or '').strip()}|{(model or '').strip()}"
def note_model_activity(url: str, model: str):
"""Record that a real upstream request used this endpoint/model."""
@@ -317,6 +317,9 @@ def _detect_provider(url: str) -> str:
return "openrouter"
if _host_match(url, "groq.com"):
return "groq"
from src.copilot import is_copilot_base
if is_copilot_base(url):
return "copilot"
return "openai"
@@ -327,6 +330,14 @@ def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str
if provider == "openrouter":
h.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
h.setdefault("X-OpenRouter-Title", "Odysseus")
if provider == "copilot":
# Ensure the Copilot-required headers are present even when the caller
# didn't pass pre-built headers (e.g. model listing). build_headers()
# already injects these for the live chat path; setdefault keeps any
# request-specific values (x-initiator/vision) the caller set.
from src.copilot import copilot_headers
for k, v in copilot_headers(None).items():
h.setdefault(k, v)
return h
@@ -340,6 +351,8 @@ def _provider_label(url: str) -> str:
if _host_match(url, "openai.com"): return "OpenAI"
if _host_match(url, "openrouter.ai"): return "OpenRouter"
if _host_match(url, "groq.com"): return "Groq"
from src.copilot import is_copilot_base
if is_copilot_base(url): return "GitHub Copilot"
if _host_match(url, "mistral.ai"): return "Mistral"
if _host_match(url, "deepseek.com"): return "DeepSeek"
if _host_match(url, "googleapis.com"): return "Google"
@@ -481,7 +494,7 @@ def _build_anthropic_payload(model, messages, temperature, max_tokens, stream=Fa
chat_messages = []
for m in messages:
if m.get("role") == "system":
system_parts.append(m["content"])
system_parts.append(m.get("content") or "")
elif m.get("role") == "tool":
# Convert OpenAI tool result to Anthropic format
chat_messages.append({
@@ -884,7 +897,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
non_sys = []
for m in messages_copy:
if m.get("role") == "system":
sys_parts.append(m["content"])
sys_parts.append(m.get('content') or '')
else:
non_sys.append(m)
if sys_parts:
@@ -911,6 +924,9 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
)
else:
target_url = url
if provider == "copilot":
from src.copilot import apply_request_headers
apply_request_headers(h, messages_copy)
payload = {
"model": model,
"messages": messages_copy,
@@ -1028,7 +1044,7 @@ async def llm_call_async(
non_sys = []
for m in messages_copy:
if m.get("role") == "system":
sys_parts.append(m["content"])
sys_parts.append(m.get('content') or '')
else:
non_sys.append(m)
if sys_parts:
@@ -1058,6 +1074,9 @@ async def llm_call_async(
else:
target_url = url
h = _provider_headers(provider, headers)
if provider == "copilot":
from src.copilot import apply_request_headers
apply_request_headers(h, messages_copy)
payload = {
"model": model,
"messages": messages_copy,
@@ -1088,6 +1107,9 @@ async def llm_call_async(
f"LLM async call to {target_url} failed in {duration:.2f}s "
f"(attempt {attempt}): HTTP {r.status_code} {friendly}"
)
if r.status_code in (429, 502, 503, 504) and attempt < max_retries:
await asyncio.sleep(LLMConfig.RETRY_DELAY)
continue
raise HTTPException(r.status_code, friendly)
logger.info(f"LLM async call to {target_url} succeeded in {duration:.2f}s (attempt {attempt})")
_clear_host_dead(target_url)
@@ -1109,7 +1131,9 @@ async def llm_call_async(
duration = time.time() - start
_tail = f" — host cooled for {DEAD_HOST_COOLDOWN:.0f}s" if _cooled else " — transient, will retry"
logger.warning(f"LLM async connect to {target_url} failed after {duration:.2f}s: {e}{_tail}")
raise HTTPException(503, f"Cannot reach {_host_key(target_url)}: {e}")
if _cooled or attempt >= max_retries:
raise HTTPException(503, f"Cannot reach {_host_key(target_url)}: {e}")
await asyncio.sleep(LLMConfig.RETRY_DELAY)
except (httpx.RequestError, httpx.HTTPStatusError) as e:
duration = time.time() - start
logger.warning(f"LLM async call attempt {attempt} failed after {duration:.2f}s: {e}")
@@ -1138,7 +1162,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
non_sys = []
for m in messages_copy:
if m.get("role") == "system":
sys_parts.append(m["content"])
sys_parts.append(m.get('content') or '')
else:
non_sys.append(m)
if sys_parts:
@@ -1177,6 +1201,9 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
if tools:
payload["tools"] = tools
h = _provider_headers(provider, headers)
if provider == "copilot":
from src.copilot import apply_request_headers
apply_request_headers(h, messages_copy)
# Short connect timeout: a reachable peer answers SYN in <100ms even on
# Tailscale. 3s is plenty; 30s let one dead upstream wedge the UI.
@@ -1358,6 +1385,8 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
# can detect thinking-in-progress (some models output </think> but no <think>)
_thinking_model = _supports_thinking(model)
_first_content_sent = False
_in_think_tag = False # True while consuming <think>…</think> content
_think_open_stripped = False # opening <think> tag already removed
def _emit_tool_calls():
"""Build the tool_calls event string if any were accumulated."""
@@ -1439,14 +1468,53 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
yield f'data: {json.dumps({"delta": reasoning, "thinking": True})}\n\n'
content = delta.get("content") or ""
if content:
# Some thinking backends start normal content with a
# stray closing tag. Repair only that shape; do not
# wrap every first token for model families like
# MiniMax, which often stream ordinary answers.
if _thinking_model and not _first_content_sent and content.lstrip().lower().startswith("</think"):
content = "<think>" + content
_first_content_sent = True
yield f'data: {json.dumps({"delta": content})}\n\n'
stripped = content.lstrip()
# Auto-detect <think>…</think> in content stream.
# Covers Qwen3-derived models (Qwopus, QwQ forks) whose
# names don't match _THINKING_MODEL_PATTERNS but still
# emit literal <think> markup via llama.cpp --jinja.
if not _first_content_sent and not _thinking_model and not _in_think_tag and stripped.lower().startswith("<think"):
_thinking_model = True
_in_think_tag = True
if _in_think_tag:
close_idx = content.lower().find("</think>")
if close_idx != -1:
# Split: up-to-</think> → thinking, remainder → content
think_part = content[:close_idx]
if not _think_open_stripped:
# Strip the opening <think[...] > from the first chunk.
# Use a dedicated flag — _first_content_sent stays False
# throughout the think block, so it must not be reused.
tag_end = think_part.lower().find(">")
if tag_end != -1:
think_part = think_part[tag_end + 1:]
_think_open_stripped = True
regular_part = content[close_idx + len("</think>"):]
_in_think_tag = False
if think_part:
yield f'data: {json.dumps({"delta": think_part, "thinking": True})}\n\n'
if regular_part:
_first_content_sent = True
yield f'data: {json.dumps({"delta": regular_part})}\n\n'
else:
# Still inside <think>: route to thinking channel
if not _think_open_stripped:
# Strip the opening <think[...] > tag (first chunk only)
tag_end = stripped.lower().find(">")
if tag_end != -1:
content = stripped[tag_end + 1:]
_think_open_stripped = True
if content:
yield f'data: {json.dumps({"delta": content, "thinking": True})}\n\n'
else:
# Some thinking backends start normal content with a
# stray closing tag. Repair only that shape; do not
# wrap every first token for model families like
# MiniMax, which often stream ordinary answers.
if _thinking_model and not _first_content_sent and stripped.lower().startswith("</think"):
content = "<think>" + content
_first_content_sent = True
yield f'data: {json.dumps({"delta": content})}\n\n'
# Native tool calls — accumulate across chunks
for tc in delta.get("tool_calls") or []:
if tc is None:

View File

@@ -8,6 +8,7 @@ Each server exposes tools that are made available to the agent loop.
import json
import logging
import os
import re
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -30,6 +31,64 @@ def _format_mcp_connection_error(name: str, command: str = "", args: Optional[Li
return raw_error
# Caps for rendering untrusted MCP tool schemas into the agent prompt (issue #2660).
# MCP servers are third-party/user-added, so field names and parameter counts are
# untrusted input — bound them so an odd or hostile schema cannot distort the prompt.
_MCP_PARAM_MAX = 12 # max params rendered per tool
_MCP_TOKEN_MAX = 40 # max chars per rendered name / type token
_MCP_HINT_MAX = 300 # total-length backstop for the whole hint
def _sanitize_schema_token(value: Any, limit: int = _MCP_TOKEN_MAX) -> str:
"""Make an untrusted JSON-Schema token safe to splice into the prompt.
Replaces control chars / newlines with a space, collapses whitespace, and
length-caps the result, so a weird field name or type cannot inject newlines
or run on. Normal short identifiers pass through unchanged.
"""
text = re.sub(r"[\x00-\x1f\x7f]+", " ", str(value))
text = re.sub(r"\s+", " ", text).strip()
if len(text) > limit:
text = text[:limit].rstrip() + ""
return text
def _format_mcp_params(input_schema: Any) -> str:
"""Render an MCP tool's JSON-Schema inputs as a compact prompt hint.
Without this the agent only sees a tool's name + description and has to
guess its arguments (issue #2509). Produces e.g.
` Args (JSON): {"path": string (required), "limit": integer}` — names,
coarse types, and required-ness, kept short so it stays prompt-friendly.
Returns "" when there are no parameters.
MCP servers are third-party, so names/types are sanitized and the parameter
count + total length are capped (issue #2660); normal schemas are unaffected.
"""
if not isinstance(input_schema, dict):
return ""
props = input_schema.get("properties")
if not isinstance(props, dict) or not props:
return ""
required = set(input_schema.get("required") or [])
parts = []
for pname, pinfo in list(props.items())[:_MCP_PARAM_MAX]:
pinfo = pinfo if isinstance(pinfo, dict) else {}
ptype = pinfo.get("type") or "any"
if isinstance(ptype, list):
ptype = "|".join(str(x) for x in ptype)
tag = f'"{_sanitize_schema_token(pname)}": {_sanitize_schema_token(ptype)}'
if pname in required:
tag += " (required)"
parts.append(tag)
extra = len(props) - len(parts)
if extra > 0:
parts.append(f"…+{extra} more")
hint = " Args (JSON): {" + ", ".join(parts) + "}"
if len(hint) > _MCP_HINT_MAX:
hint = hint[:_MCP_HINT_MAX - 1].rstrip() + ""
return hint
class McpManager:
"""Manages MCP server connections and tool routing."""
@@ -43,7 +102,9 @@ class McpManager:
self._sessions: Dict[str, Any] = {}
# server_id -> exit stack (for cleanup)
self._stacks: Dict[str, Any] = {}
# Tracking updates to tools/connections for RAG indexing
# server_id -> background connect task (HTTP transport / OAuth)
self._connect_tasks: Dict[str, Any] = {}
# Tracking updates to tools/connections for RAG indexing / prompt cache
self._generation = 0
async def connect_server(
@@ -56,12 +117,14 @@ class McpManager:
env: Optional[Dict[str, str]] = None,
url: Optional[str] = None,
) -> bool:
"""Connect to an MCP server via stdio or SSE transport."""
"""Connect to an MCP server via stdio, SSE, or Streamable HTTP transport."""
try:
if transport == "stdio":
res = await self._connect_stdio(server_id, name, command, args or [], env or {})
elif transport == "sse":
res = await self._connect_sse(server_id, name, url)
elif transport == "http":
res = await self._start_http_connect(server_id, name, url)
else:
logger.error(f"Unknown MCP transport: {transport}")
res = False
@@ -184,8 +247,101 @@ class McpManager:
self._connections[server_id] = {"status": "error", "error": "mcp package not installed", "name": name}
return False
async def _start_http_connect(self, server_id: str, name: str, url: str, wait: float = 8.0) -> bool:
"""Begin a Streamable HTTP connect in the background. Returns within
`wait` seconds: True if it connected (cached-token path), otherwise the
flow is awaiting browser authorization and status becomes 'needs_auth'."""
import asyncio
self._connections[server_id] = {"status": "connecting", "name": name, "transport": "http"}
task = asyncio.create_task(self._connect_http(server_id, name, url))
self._connect_tasks[server_id] = task
done, _ = await asyncio.wait({task}, timeout=wait)
if task in done:
try:
return task.result()
except Exception as e:
self._connections[server_id] = {"status": "error", "error": str(e), "name": name}
return False
# Still running → either awaiting authorization, or discovery/DCR is
# still in flight. If _on_redirect already published needs_auth+auth_url,
# leave it; otherwise mark needs_auth (auth_url filled in once it fires).
from src.mcp_oauth import pop_auth_url
cur = self._connections.get(server_id, {})
if cur.get("status") != "needs_auth":
self._connections[server_id] = {
"status": "needs_auth", "name": name, "transport": "http",
"auth_url": pop_auth_url(server_id),
}
return False
async def _connect_http(self, server_id: str, name: str, url: str) -> bool:
"""Connect to a Streamable HTTP MCP server (with automatic OAuth)."""
try:
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from contextlib import AsyncExitStack
from src.mcp_oauth import build_provider, clear_auth_url
def _on_redirect(auth_url):
# Publish needs_auth the moment the URL is known, independent of
# how long discovery/DCR took (may exceed the bounded start wait).
self._connections[server_id] = {
"status": "needs_auth", "name": name, "transport": "http",
"auth_url": auth_url,
}
provider = build_provider(server_id, url, on_redirect=_on_redirect)
stack = AsyncExitStack()
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
read_stream, write_stream, _get_session_id = transport
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
tools_result = await session.list_tools()
tools = []
for tool in tools_result.tools:
tools.append({
"name": tool.name,
"description": tool.description or "",
"input_schema": tool.inputSchema if hasattr(tool, "inputSchema") else {},
})
self._sessions[server_id] = session
self._stacks[server_id] = stack
self._tools[server_id] = tools
self._connections[server_id] = {
"status": "connected", "name": name, "transport": "http",
"tool_count": len(tools),
}
clear_auth_url(server_id)
# Tools changed (this can complete after connect_server already
# returned, via the background OAuth flow), so bump the generation
# to invalidate the tool-prompt cache.
self._generation += 1
logger.info(f"MCP server connected: {name} ({server_id}) - {len(tools)} tools via http")
return True
except ImportError:
logger.warning("MCP package not installed. Install with: pip install mcp")
self._connections[server_id] = {"status": "error", "error": "mcp package not installed", "name": name}
return False
except Exception as e:
logger.error(f"Failed to connect HTTP MCP server {name} ({server_id}): {e}")
self._connections[server_id] = {"status": "error", "error": str(e), "name": name}
return False
async def disconnect_server(self, server_id: str):
"""Disconnect from an MCP server."""
# Cancel any in-flight HTTP/OAuth background connect so it stops
# publishing status for a server that may be getting deleted.
task = self._connect_tasks.pop(server_id, None)
if task is not None and not task.done():
task.cancel()
try:
from src.mcp_oauth import clear_auth_url
clear_auth_url(server_id)
except Exception:
pass
stack = self._stacks.pop(server_id, None)
if stack:
try:
@@ -376,6 +532,7 @@ class McpManager:
"name": tool["name"],
"qualified_name": f"mcp__{server_id}__{tool['name']}",
"description": tool.get("description", ""),
"input_schema": tool.get("input_schema") or {},
"is_disabled": tool["name"] in disabled,
})
return result
@@ -439,7 +596,11 @@ class McpManager:
for t in server_tools:
# Truncate long descriptions
desc = t['description'][:120] + '...' if len(t['description']) > 120 else t['description']
lines.append(f" - {t['qualified_name']}: {desc}")
# Include the tool's declared inputs so the model calls it with
# real argument names instead of guessing from the description
# alone (issue #2509).
args_hint = _format_mcp_params(t.get("input_schema"))
lines.append(f" - {t['qualified_name']}: {desc}{args_hint}")
result = "\n".join(lines)
self._cached_prompt_desc = result

193
src/mcp_oauth.py Normal file
View File

@@ -0,0 +1,193 @@
"""mcp_oauth.py — generic OAuth for remote (Streamable HTTP) MCP servers.
Bridges the mcp SDK's OAuthClientProvider (RFC 9728 discovery, Dynamic Client
Registration, authorization-code + PKCE, token refresh) to Odysseus's web
callback route. Tokens and the dynamic registration persist per-server,
encrypted, so the interactive flow runs only once.
"""
import asyncio
import json
import logging
import os
import time
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse, parse_qs
logger = logging.getLogger(__name__)
# OAuth redirect URI registered with every authorization server via DCR. Loopback
# is allowed for native/desktop clients (RFC 8252); remote users finish via the
# paste-back flow. Deployments not reachable at http://localhost:7000 (custom
# port, reverse proxy, or public domain) must set OAUTH_REDIRECT_BASE_URL (or
# APP_PUBLIC_URL) to their externally reachable origin so the redirect lands back
# on Odysseus. APP_PORT is intentionally not used: it is only the Docker host
# port-map; the app always listens on 7000 inside the container.
_REDIRECT_BASE = (
os.environ.get("OAUTH_REDIRECT_BASE_URL")
or os.environ.get("APP_PUBLIC_URL")
or "http://localhost:7000"
).rstrip("/")
REDIRECT_URI = f"{_REDIRECT_BASE}/api/mcp/oauth/callback"
# How long the background connect waits for the user to authorize before giving up.
AUTH_WAIT_SECONDS = 300
_pending: Dict[str, asyncio.Future] = {} # state -> Future[(code, state)]
_pending_ts: Dict[str, float] = {} # state -> monotonic timestamp, for pruning
_auth_urls: Dict[str, str] = {} # server_id -> authorization URL
def _prune_stale() -> None:
"""Drop abandoned flows whose authorization window has elapsed so the
module-level registries don't grow unbounded (e.g. a user who never
finishes the browser step)."""
now = time.monotonic()
for state in [s for s, ts in _pending_ts.items() if now - ts > AUTH_WAIT_SECONDS]:
fut = _pending.pop(state, None)
_pending_ts.pop(state, None)
if fut is not None and not fut.done():
fut.cancel()
def _discard_pending(state: Optional[str]) -> None:
if state is None:
return
_pending.pop(state, None)
_pending_ts.pop(state, None)
def register_pending(state: str) -> asyncio.Future:
_prune_stale()
fut = asyncio.get_running_loop().create_future()
_pending[state] = fut
_pending_ts[state] = time.monotonic()
return fut
def resolve_pending(state: str, code: str) -> bool:
fut = _pending.get(state)
if fut is not None and not fut.done():
fut.set_result((code, state))
return True
return False
def pop_auth_url(server_id: str) -> Optional[str]:
return _auth_urls.get(server_id)
def clear_auth_url(server_id: str) -> None:
_auth_urls.pop(server_id, None)
class DbTokenStorage:
"""SDK TokenStorage backed by the encrypted McpServer.oauth_tokens column."""
def __init__(self, server_id: str, session_factory=None):
self.server_id = server_id
if session_factory is None:
from core.database import SessionLocal
session_factory = SessionLocal
self._sf = session_factory
def _load(self) -> dict:
from core.database import McpServer
db = self._sf()
try:
srv = db.query(McpServer).filter(McpServer.id == self.server_id).first()
if srv and srv.oauth_tokens:
return json.loads(srv.oauth_tokens)
finally:
db.close()
return {}
def _update(self, key: str, value: dict) -> None:
"""Load, set one key, and persist the oauth_tokens JSON in a single
session/commit (avoids the load+save double round-trip per write)."""
from core.database import McpServer
db = self._sf()
try:
srv = db.query(McpServer).filter(McpServer.id == self.server_id).first()
if srv is None:
return
data = json.loads(srv.oauth_tokens) if srv.oauth_tokens else {}
data[key] = value
srv.oauth_tokens = json.dumps(data)
db.commit()
finally:
db.close()
async def get_tokens(self):
from mcp.shared.auth import OAuthToken
data = self._load().get("tokens")
return OAuthToken.model_validate(data) if data else None
async def set_tokens(self, tokens) -> None:
self._update("tokens", json.loads(tokens.model_dump_json()))
async def get_client_info(self):
from mcp.shared.auth import OAuthClientInformationFull
data = self._load().get("client_info")
return OAuthClientInformationFull.model_validate(data) if data else None
async def set_client_info(self, client_info) -> None:
self._update("client_info", json.loads(client_info.model_dump_json()))
def build_provider(server_id: str, url: str, on_redirect=None):
"""Construct an OAuthClientProvider that drives the browser flow via the
Odysseus callback route.
on_redirect(authorization_url): optional sync callback invoked the moment
the authorization URL is known (after discovery + DCR). The manager uses it
to publish 'needs_auth' + auth_url to connection state regardless of how
long discovery/DCR took.
"""
from mcp.client.auth import OAuthClientProvider
from mcp.shared.auth import OAuthClientMetadata
client_metadata = OAuthClientMetadata(
client_name="Odysseus",
redirect_uris=[REDIRECT_URI],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
# Leave scope unset: the SDK applies the MCP scope-selection strategy and
# overwrites this from the server's WWW-Authenticate / protected-resource
# metadata before building the auth URL. Hardcoding an OIDC scope here
# would break the many MCP servers that are not OpenID providers.
scope=None,
token_endpoint_auth_method="none",
)
async def redirect_handler(authorization_url: str) -> None:
state = (parse_qs(urlparse(authorization_url).query).get("state") or [None])[0]
if state:
register_pending(state)
_auth_urls[server_id] = authorization_url
if on_redirect is not None:
try:
on_redirect(authorization_url)
except Exception as e:
logger.warning(f"MCP OAuth on_redirect callback failed: {e}")
logger.info(f"MCP OAuth: server {server_id} awaiting authorization (state={state})")
async def callback_handler() -> Tuple[str, Optional[str]]:
auth_url = _auth_urls.get(server_id)
state = (parse_qs(urlparse(auth_url).query).get("state") or [None])[0] if auth_url else None
fut = _pending.get(state)
if fut is None:
raise RuntimeError("No pending OAuth flow for this server")
try:
code, ret_state = await asyncio.wait_for(fut, timeout=AUTH_WAIT_SECONDS)
return code, ret_state
finally:
_discard_pending(state)
_auth_urls.pop(server_id, None)
return OAuthClientProvider(
server_url=url,
client_metadata=client_metadata,
storage=DbTokenStorage(server_id),
redirect_handler=redirect_handler,
callback_handler=callback_handler,
)

320
src/memory_provider.py Normal file
View File

@@ -0,0 +1,320 @@
"""Memory provider interfaces for native and external memory systems."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional
@dataclass
class MemoryRecord:
"""Provider-neutral memory entry."""
id: str
text: str
timestamp: int = 0
category: str = "fact"
source: str = "unknown"
owner: Optional[str] = None
session_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class MemorySearchHit:
"""A memory returned by provider recall."""
memory: MemoryRecord
provider_id: str
score: Optional[float] = None
class MemoryProvider(ABC):
"""Base contract for Odysseus memory providers.
The native memory provider should always be available. External providers
can add recall/write behavior and their own tools without replacing the
built-in local memory baseline.
"""
provider_id = "unknown"
display_name = "Unknown"
enabled = True
async def initialize(self) -> None:
"""Prepare provider resources before use."""
async def shutdown(self) -> None:
"""Release provider resources."""
@abstractmethod
async def remember(
self,
text: str,
*,
owner: Optional[str] = None,
session_id: Optional[str] = None,
category: str = "fact",
source: str = "user",
metadata: Optional[Dict[str, Any]] = None,
) -> MemoryRecord:
"""Store a memory and return the stored record."""
@abstractmethod
async def recall(
self,
query: str,
*,
owner: Optional[str] = None,
top_k: int = 5,
) -> List[MemorySearchHit]:
"""Return provider memories relevant to the query."""
@abstractmethod
async def list_memories(
self,
*,
owner: Optional[str] = None,
limit: int = 100,
) -> List[MemoryRecord]:
"""List memories visible to the owner."""
@abstractmethod
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
"""Delete a memory by ID when allowed by the provider."""
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""Return provider-defined tool schemas when this provider is enabled."""
return []
async def handle_tool_call(self, name: str, arguments: Dict[str, Any]) -> Any:
"""Handle a provider-defined tool call."""
raise KeyError(f"Provider {self.provider_id} does not expose tool {name}")
class NativeMemoryProvider(MemoryProvider):
"""Provider adapter for Odysseus' built-in memory manager and vector store."""
provider_id = "native"
display_name = "Odysseus native memory"
_CORE_FIELDS = {
"id",
"text",
"timestamp",
"source",
"category",
"uses",
"owner",
"session_id",
"metadata",
}
def __init__(self, memory_manager, memory_vector=None):
self.memory_manager = memory_manager
self.memory_vector = memory_vector
def _to_record(self, entry: Dict[str, Any]) -> MemoryRecord:
metadata = {
key: value
for key, value in entry.items()
if key not in self._CORE_FIELDS
}
stored_metadata = entry.get("metadata")
if isinstance(stored_metadata, dict):
metadata.update(stored_metadata)
return MemoryRecord(
id=entry.get("id", ""),
text=entry.get("text", ""),
timestamp=entry.get("timestamp", 0),
category=entry.get("category", "fact"),
source=entry.get("source", "unknown"),
owner=entry.get("owner"),
session_id=entry.get("session_id"),
metadata=metadata,
)
async def remember(
self,
text: str,
*,
owner: Optional[str] = None,
session_id: Optional[str] = None,
category: str = "fact",
source: str = "user",
metadata: Optional[Dict[str, Any]] = None,
) -> MemoryRecord:
entry = self.memory_manager.add_entry(
text,
source=source,
category=category,
owner=owner,
)
if session_id:
entry["session_id"] = session_id
if metadata:
entry["metadata"] = dict(metadata)
memories = self.memory_manager.load_all()
memories.append(entry)
self.memory_manager.save(memories)
if self._vector_available():
self.memory_vector.add(entry["id"], entry["text"])
return self._to_record(entry)
async def recall(
self,
query: str,
*,
owner: Optional[str] = None,
top_k: int = 5,
) -> List[MemorySearchHit]:
memories = self.memory_manager.load(owner=owner)
by_id = {m.get("id"): m for m in memories}
if self._vector_available():
hits: List[MemorySearchHit] = []
for result in self.memory_vector.search(query, k=top_k):
if not isinstance(result, dict):
continue
memory_id = result.get("memory_id")
entry = by_id.get(memory_id) if memory_id else result
if not entry:
continue
if owner is not None and entry.get("owner") != owner:
continue
hits.append(
MemorySearchHit(
memory=self._to_record(entry),
provider_id=self.provider_id,
score=result.get("score"),
)
)
if hits:
return hits
fallback = self.memory_manager.get_relevant_memories(
query,
memories,
max_items=top_k,
)
return [
MemorySearchHit(
memory=self._to_record(entry),
provider_id=self.provider_id,
score=None,
)
for entry in fallback
]
async def list_memories(
self,
*,
owner: Optional[str] = None,
limit: int = 100,
) -> List[MemoryRecord]:
return [
self._to_record(entry)
for entry in self.memory_manager.load(owner=owner)[:limit]
]
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
memories = self.memory_manager.load_all()
remaining = []
deleted_id = None
for entry in memories:
if entry.get("id") != memory_id:
remaining.append(entry)
continue
if owner is not None and entry.get("owner") != owner:
remaining.append(entry)
continue
deleted_id = entry.get("id")
if deleted_id is None:
return False
self.memory_manager.save(remaining)
if self._vector_available():
self.memory_vector.remove(deleted_id)
return True
def _vector_available(self) -> bool:
return bool(self.memory_vector and getattr(self.memory_vector, "healthy", True))
class MemoryProviderRegistry:
"""Container for native and optional external memory providers."""
def __init__(self, providers: Optional[Iterable[MemoryProvider]] = None):
self._providers: Dict[str, MemoryProvider] = {}
for provider in providers or []:
self.register(provider)
def register(self, provider: MemoryProvider) -> None:
if provider.provider_id in self._providers:
raise ValueError(f"Memory provider already registered: {provider.provider_id}")
self._providers[provider.provider_id] = provider
def get(self, provider_id: str) -> MemoryProvider:
return self._providers[provider_id]
def all(self) -> List[MemoryProvider]:
return list(self._providers.values())
def active(self) -> List[MemoryProvider]:
return [provider for provider in self._providers.values() if provider.enabled]
def get_tool_schemas(self) -> List[Dict[str, Any]]:
schemas: List[Dict[str, Any]] = []
seen: Dict[str, str] = {}
for provider in self.active():
for schema in provider.get_tool_schemas():
name = self._tool_name(schema)
if name in seen:
raise ValueError(
f"Memory tool name conflict: {name} from "
f"{provider.provider_id} already exposed by {seen[name]}"
)
seen[name] = provider.provider_id
schemas.append(schema)
return schemas
async def handle_tool_call(self, name: str, arguments: Dict[str, Any]) -> Any:
provider_by_tool: Dict[str, MemoryProvider] = {}
for provider in self.active():
for schema in provider.get_tool_schemas():
tool_name = self._tool_name(schema)
if tool_name in provider_by_tool:
raise ValueError(
f"Memory tool name conflict: {tool_name} from "
f"{provider.provider_id} already exposed by "
f"{provider_by_tool[tool_name].provider_id}"
)
provider_by_tool[tool_name] = provider
provider = provider_by_tool.get(name)
if provider:
return await provider.handle_tool_call(name, arguments)
raise KeyError(f"No active memory provider exposes tool {name}")
@staticmethod
def _tool_name(schema: Dict[str, Any]) -> str:
if not isinstance(schema, dict):
raise ValueError("Memory provider tool schema must be a dict")
name = schema.get("name")
if isinstance(name, str) and name:
return name
function = schema.get("function")
if isinstance(function, dict):
function_name = function.get("name")
if isinstance(function_name, str) and function_name:
return function_name
raise ValueError("Memory provider tool schema is missing a tool name")

View File

@@ -7,7 +7,7 @@ Provides token estimation for context usage tracking.
import logging
import sys
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
@@ -208,27 +208,32 @@ KNOWN_CONTEXT_WINDOWS = {
# ---------------------------------------------------------------------------
# Cache
# ---------------------------------------------------------------------------
_context_cache: Dict[str, int] = {}
_context_cache: Dict[Tuple[str, str], int] = {}
def get_context_length(endpoint_url: str, model: str) -> int:
"""Get the context window size for a model.
Queries /v1/models on the endpoint and looks for context_length
or context_window fields. Caches result per model ID.
or context_window fields. Caches result per (endpoint, model).
Falls back to DEFAULT_CONTEXT if unavailable.
"""
configured_kind = _configured_endpoint_kind(endpoint_url)
is_local = _is_local_endpoint(endpoint_url)
if not is_local and model in _context_cache:
return _context_cache[model]
# Key on (endpoint_url, model): the same model id can be served by two
# different remote endpoints with different real context windows (e.g. a
# capped proxy vs. the full provider), so caching by model id alone would
# serve one endpoint's window for the other (issue #2603).
cache_key = (endpoint_url, model)
if not is_local and cache_key in _context_cache:
return _context_cache[cache_key]
ctx = _query_context_length(endpoint_url, model)
# Only cache non-default values to allow retry on next request.
# Local endpoints can restart with a different --max-model-len while keeping
# the same model id, so always re-query them instead of serving stale cache.
if not is_local and (ctx != DEFAULT_CONTEXT or configured_kind in ("api", "proxy")):
_context_cache[model] = ctx
_context_cache[cache_key] = ctx
logger.info(f"Context length for {model}: {ctx}")
return ctx
@@ -282,6 +287,16 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
except Exception:
pass
# GitHub Copilot's /models requires auth + X-GitHub-Api-Version headers that
# aren't available here; an unauthenticated probe just 400s. All Copilot
# picker models are major API models covered by the known-context table, so
# rely on that instead of a doomed network call.
from src.copilot import is_copilot_base
if is_copilot_base(endpoint_url):
if known:
logger.info(f"Using known context window for {model}: {known}")
return known or DEFAULT_CONTEXT
models_url = endpoint_url.replace("/chat/completions", "/models")
try:
r = httpx.get(models_url, timeout=REQUEST_TIMEOUT)

View File

@@ -722,6 +722,18 @@ class ResearchHandler:
minimum=1,
maximum=12,
)
_planning_timeout = _bounded_int(
get_setting("research_planning_timeout_seconds", _extraction_timeout),
default=_extraction_timeout,
minimum=15,
maximum=3600,
)
_query_timeout = _bounded_int(
get_setting("research_query_timeout_seconds", _extraction_timeout),
default=_extraction_timeout,
minimum=15,
maximum=3600,
)
researcher = DeepResearcher(
llm_endpoint=llm_endpoint,
@@ -732,6 +744,8 @@ class ResearchHandler:
max_time=max_time,
max_report_tokens=_max_report_tokens,
extraction_timeout=_extraction_timeout,
planning_timeout=_planning_timeout,
query_timeout=_query_timeout,
extraction_concurrency=_extraction_concurrency,
progress_callback=progress_callback,
search_provider=search_provider,

View File

@@ -1,141 +1,12 @@
"""Search analytics, metrics tracking, and exception hierarchy."""
"""Compatibility re-export shim for the live analytics module.
import json
import logging
from collections import Counter
from pathlib import Path
from typing import Dict, Any
The real implementation lives in :mod:`services.search.analytics`, which is
what the search runtime imports. Alias this module to that implementation so
mutable module state such as ``ANALYTICS_FILE`` cannot drift out of sync.
"""
from .cache import cache_metrics
import sys
logger = logging.getLogger(__name__)
from services.search import analytics as _analytics
# Dedicated error logger with file handler
_error_log_path = Path(__file__).resolve().parent.parent / "search_engine_error.log"
_error_handler = logging.FileHandler(_error_log_path, encoding="utf-8")
_error_handler.setLevel(logging.WARNING)
_error_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
error_logger = logging.getLogger("search_engine_error")
error_logger.addHandler(_error_handler)
error_logger.propagate = False
# Analytics file
ANALYTICS_FILE = Path(__file__).resolve().parent.parent / "search_analytics.json"
# ----------------------------------------------------------------------
# Custom exception hierarchy
# ----------------------------------------------------------------------
class SearchEngineError(Exception):
"""Base class for all search-engine related errors."""
class NetworkError(SearchEngineError):
"""Raised when a network request fails (e.g., timeout, DNS error)."""
class ParseError(SearchEngineError):
"""Raised when HTML or other content cannot be parsed."""
class RateLimitError(SearchEngineError):
"""Raised when the remote service returns a rate-limit (HTTP 429)."""
# ----------------------------------------------------------------------
# Analytics helpers
# ----------------------------------------------------------------------
def _default_analytics() -> Dict[str, Any]:
"""A fresh analytics document with every counter present."""
return {
"total_queries": 0,
"successful_queries": 0,
"failed_queries": 0,
"cache_hits": 0,
"cache_misses": 0,
"query_patterns": {},
}
def _load_analytics() -> Dict[str, Any]:
"""Load analytics data from the JSON file, creating defaults if missing."""
if not ANALYTICS_FILE.exists():
default = _default_analytics()
_save_analytics(default)
return default
try:
with open(ANALYTICS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# Merge over defaults so a file written by an older schema (or a
# partial write) still has every counter — _record_query indexes
# these keys directly and would otherwise raise KeyError.
merged = _default_analytics()
if isinstance(data, dict):
merged.update(data)
return merged
except Exception as e:
logger.warning(f"Failed to load analytics file: {e}")
return _default_analytics()
def _save_analytics(data: Dict[str, Any]) -> None:
"""Persist analytics data to the JSON file."""
try:
with open(ANALYTICS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except Exception as e:
logger.warning(f"Failed to write analytics file: {e}")
def _record_query(query: str, success: bool, cache_hit: bool) -> None:
"""Update analytics for a single query execution."""
analytics = _load_analytics()
analytics["total_queries"] += 1
if success:
analytics["successful_queries"] += 1
else:
analytics["failed_queries"] += 1
if cache_hit:
analytics["cache_hits"] += 1
cache_metrics["hits"] += 1
else:
analytics["cache_misses"] += 1
cache_metrics["misses"] += 1
patterns = analytics["query_patterns"]
entry = patterns.get(query, {"count": 0, "successes": 0})
entry["count"] += 1
if success:
entry["successes"] += 1
patterns[query] = entry
_save_analytics(analytics)
def get_search_stats() -> Dict[str, Any]:
"""Return aggregated search analytics."""
analytics = _load_analytics()
total = analytics.get("total_queries", 0) or 1
success_rate = analytics.get("successful_queries", 0) / total
cache_total = analytics.get("cache_hits", 0) + analytics.get("cache_misses", 0) or 1
cache_hit_rate = analytics.get("cache_hits", 0) / cache_total
pattern_counter = Counter({
q: data["count"] for q, data in analytics.get("query_patterns", {}).items()
})
most_common = [q for q, _ in pattern_counter.most_common(5)]
return {
"most_common_queries": most_common,
"success_rate": success_rate,
"cache_hit_rate": cache_hit_rate,
"total_queries": analytics.get("total_queries", 0),
"successful_queries": analytics.get("successful_queries", 0),
"failed_queries": analytics.get("failed_queries", 0),
"cache_hits": analytics.get("cache_hits", 0),
"cache_misses": analytics.get("cache_misses", 0),
"cache_evictions": cache_metrics["evictions"],
"runtime_cache_hits": cache_metrics["hits"],
"runtime_cache_misses": cache_metrics["misses"],
}
sys.modules[__name__] = _analytics

View File

@@ -1,57 +1,11 @@
"""Search and content caching with LRU eviction."""
"""Compatibility wrapper for the canonical services.search.cache module.
import hashlib
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict
``src.search.cache`` stays importable for older agent/deep-research code, but the
implementation now lives in ``services.search.cache`` so the two cannot drift.
"""
logger = logging.getLogger(__name__)
import sys
# Cache directories
CACHE_DIR = Path(__file__).resolve().parent.parent / "cache"
SEARCH_CACHE_DIR = CACHE_DIR / "search"
CONTENT_CACHE_DIR = CACHE_DIR / "content"
CACHE_MAX_ENTRIES = 1000
from services.search import cache as _cache
# Create cache directories
SEARCH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
CONTENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
# Track cache size for LRU eviction
search_cache_index: Dict[str, datetime] = {}
content_cache_index: Dict[str, datetime] = {}
# Cache metrics (shared across modules)
cache_metrics = {"hits": 0, "misses": 0, "evictions": 0}
def generate_cache_key(data: str) -> str:
"""Generate a unique cache key using SHA-256 hash."""
return hashlib.sha256(data.encode("utf-8")).hexdigest()
def cleanup_cache(cache_dir: Path, cache_index: Dict[str, datetime], max_age: timedelta):
"""Remove expired cache entries and enforce LRU policy."""
current_time = datetime.now()
files_in_dir = {f.name.split(".")[0]: f for f in cache_dir.glob("*.cache")}
to_remove = []
for key, timestamp in list(cache_index.items()):
if current_time - timestamp > max_age or key not in files_in_dir:
to_remove.append(key)
if key in files_in_dir:
files_in_dir[key].unlink(missing_ok=True)
for key in to_remove:
cache_index.pop(key, None)
cache_metrics["evictions"] += 1
if len(cache_index) > CACHE_MAX_ENTRIES:
sorted_items = sorted(cache_index.items(), key=lambda x: x[1])
excess_count = len(cache_index) - CACHE_MAX_ENTRIES
for key, _ in sorted_items[:excess_count]:
cache_index.pop(key, None)
cache_file = cache_dir / f"{key}.cache"
cache_file.unlink(missing_ok=True)
cache_metrics["evictions"] += 1
sys.modules[__name__] = _cache

View File

@@ -1,419 +1,11 @@
"""Webpage content fetching with caching, PDF extraction, and summarization helpers."""
"""Compatibility wrapper for the canonical services.search.content module.
import copy
import io
import ipaddress
import json
import os
import re
import logging
import socket
from datetime import datetime, timedelta
from typing import List
from urllib.parse import urljoin, urlparse
``src.search.content`` stays importable for older agent/deep-research code, but the
implementation now lives in ``services.search.content`` so the two cannot drift.
"""
import httpx
from bs4 import BeautifulSoup
import sys
from .analytics import RateLimitError, error_logger
from .cache import (
CONTENT_CACHE_DIR,
content_cache_index,
generate_cache_key,
cleanup_cache,
)
from services.search import content as _content
logger = logging.getLogger(__name__)
_PRIVATE_NETWORKS = (
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
)
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
return (
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_reserved
or addr.is_multicast
or addr.is_unspecified
or any(addr in net for net in _PRIVATE_NETWORKS)
)
def _resolve_hostname_ips(hostname: str) -> List[ipaddress._BaseAddress]:
ips = []
for family, _, _, _, sockaddr in socket.getaddrinfo(hostname, None):
if family in (socket.AF_INET, socket.AF_INET6):
ips.append(ipaddress.ip_address(sockaddr[0]))
return ips
def _public_http_url(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return False
host = parsed.hostname.strip().lower()
if host in ("localhost", "metadata.google.internal", "metadata"):
return False
if host.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
return False
try:
return not _is_private_address(ipaddress.ip_address(host))
except ValueError:
pass
try:
ips = _resolve_hostname_ips(host)
except OSError:
return False
# Fail closed: a hostname that resolves to nothing is treated as
# non-public (an empty all(...) would otherwise return True).
return bool(ips) and all(not _is_private_address(ip) for ip in ips)
def _get_public_url(url: str, *, headers: dict, timeout: int) -> httpx.Response:
if not _public_http_url(url):
raise httpx.RequestError(f"Blocked non-public URL: {url}")
current = url
with httpx.Client(headers=headers, timeout=timeout, follow_redirects=False) as client:
for _ in range(8):
response = client.get(current)
if response.status_code not in (301, 302, 303, 307, 308):
return response
location = response.headers.get("location")
if not location:
return response
current = urljoin(current, location)
if not _public_http_url(current):
raise httpx.RequestError(f"Blocked redirect to non-public URL: {current}")
raise httpx.RequestError("Too many redirects")
# PDF extraction (optional dependency)
try:
from pdfminer.high_level import extract_text as pdf_extract_text
except ImportError:
pdf_extract_text = None # type: ignore
# ----------------------------------------------------------------------
# HTML extraction helpers
# ----------------------------------------------------------------------
def _extract_meta(soup: BeautifulSoup) -> dict:
"""Pull meta description and keywords if present."""
description = ""
keywords = ""
desc_tag = soup.find("meta", attrs={"name": re.compile("description", re.I)})
if desc_tag and desc_tag.get("content"):
description = desc_tag["content"].strip()
kw_tag = soup.find("meta", attrs={"name": re.compile("keywords", re.I)})
if kw_tag and kw_tag.get("content"):
keywords = kw_tag["content"].strip()
return {"description": description, "keywords": keywords}
def _extract_og_image(soup: BeautifulSoup) -> str:
"""Extract the best representative image URL from meta tags.
Only returns absolute http(s) URLs — skips relative paths and data URIs.
"""
candidates = []
# Open Graph image (most reliable)
for prop in ("og:image", "og:image:url", "og:image:secure_url"):
tag = soup.find("meta", attrs={"property": prop})
if tag and tag.get("content", "").strip():
candidates.append(tag["content"].strip())
# Twitter card image
tag = soup.find("meta", attrs={"name": "twitter:image"})
if tag and tag.get("content", "").strip():
candidates.append(tag["content"].strip())
# Thumbnail meta
tag = soup.find("meta", attrs={"name": "thumbnail"})
if tag and tag.get("content", "").strip():
candidates.append(tag["content"].strip())
# Return first absolute http(s) URL
for url in candidates:
if url.startswith(("https://", "http://")) and not url.endswith((".svg", ".ico")):
return url
return ""
def _extract_lists(soup: BeautifulSoup) -> List[List[str]]:
"""Return a list of lists, each inner list representing a <ul>/<ol>."""
all_lists = []
for lst in soup.find_all(["ul", "ol"]):
items = [li.get_text(separator=" ", strip=True) for li in lst.find_all("li")]
if items:
all_lists.append(items)
return all_lists
def _extract_tables(soup: BeautifulSoup) -> List[List[List[str]]]:
"""Return a list of tables, each table is a list of rows, each row a list of cell texts."""
tables_data = []
for table in soup.find_all("table"):
rows = []
for tr in table.find_all("tr"):
cells = [td.get_text(separator=" ", strip=True) for td in tr.find_all(["td", "th"])]
if cells:
rows.append(cells)
if rows:
tables_data.append(rows)
return tables_data
def _extract_code_blocks(soup: BeautifulSoup) -> List[str]:
"""Collect text from <pre> and <code> blocks."""
blocks = []
for tag in soup.find_all(["pre", "code"]):
txt = tag.get_text(separator=" ", strip=True)
if txt:
blocks.append(txt)
return blocks
def _detect_js_frameworks(soup: BeautifulSoup) -> bool:
"""Very naive detection of common JS frameworks."""
js_indicators = [
"react", "angular", "vue", "svelte", "next", "nuxt",
"ember", "backbone", "jquery", "polymer", "mithril",
]
for script in soup.find_all("script"):
src = script.get("src", "").lower()
if any(fr in src for fr in js_indicators):
return True
if script.string:
content = script.string.lower()
if any(fr in content for fr in js_indicators):
return True
if soup.find(attrs={"data-reactroot": True}) or soup.find(attrs={"ng-app": True}):
return True
return False
def _empty_result(url: str, error: str = "") -> dict:
"""Build a standard failure result dict."""
return {
"url": url,
"title": "",
"content": "",
"lists": [],
"tables": [],
"code_blocks": [],
"meta_description": "",
"meta_keywords": "",
"js_rendered": False,
"js_message": "",
"success": False,
"error": error,
}
# ----------------------------------------------------------------------
# Main content fetcher
# ----------------------------------------------------------------------
def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) -> dict:
"""Fetch and extract meaningful content from a webpage with caching."""
cache_key = generate_cache_key(url)
cache_file = CONTENT_CACHE_DIR / f"{cache_key}.cache"
# Check cache
if cache_file.exists():
try:
with open(cache_file, "r", encoding="utf-8") as f:
cached_data = json.load(f)
timestamp = datetime.fromisoformat(cached_data["timestamp"])
if datetime.now() - timestamp < timedelta(hours=2):
logger.debug(f"Content cache hit for URL: {url}")
return cached_data["data"]
else:
cache_file.unlink(missing_ok=True)
content_cache_index.pop(cache_key, None)
except Exception as e:
logger.warning(f"Failed to read content cache for {url}: {e}")
cache_file.unlink(missing_ok=True)
content_cache_index.pop(cache_key, None)
# Fetch
try:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
}
response = _get_public_url(url, headers=headers, timeout=timeout)
if response.status_code == 429:
raise RateLimitError(f"Rate limit hit for {url} (attempt {retry_attempt})")
response.raise_for_status()
except httpx.RequestError as e:
error_logger.error(f"NetworkError fetching {url} (attempt {retry_attempt}): {e}")
return _empty_result(url, f"NetworkError: {e}")
except RateLimitError as e:
error_logger.error(str(e))
return _empty_result(url, str(e))
# PDF handling
content_type = response.headers.get("Content-Type", "").lower()
if "application/pdf" in content_type or url.lower().endswith(".pdf"):
if pdf_extract_text is None:
logger.error("pdfminer.six is not installed; cannot extract PDF text.")
pdf_text = ""
else:
try:
pdf_bytes = io.BytesIO(response.content)
pdf_text = pdf_extract_text(pdf_bytes)
except Exception as e:
logger.warning(f"PDF extraction failed for {url}: {e}")
pdf_text = ""
result = {
"url": url,
"title": os.path.basename(url),
"content": pdf_text,
"lists": [],
"tables": [],
"code_blocks": [],
"meta_description": "",
"meta_keywords": "",
"js_rendered": False,
"js_message": "",
"success": bool(pdf_text),
"error": "" if pdf_text else "Failed to extract PDF text",
}
_cache_result(cache_file, cache_key, result, url)
return result
# HTML handling
try:
soup = BeautifulSoup(response.text, "html.parser")
except Exception as e:
error_logger.error(f"ParseError parsing HTML from {url} (attempt {retry_attempt}): {e}")
result = _empty_result(url, f"ParseError: {e}")
_cache_result(cache_file, cache_key, result, url)
return result
title_tag = soup.find("title")
title_text = title_tag.get_text(strip=True) if title_tag else ""
meta_info = _extract_meta(soup)
og_image = _extract_og_image(soup)
js_rendered = _detect_js_frameworks(soup)
js_message = "Page appears to be rendered by a JavaScript framework; content may be incomplete." if js_rendered else ""
# Main textual content (heuristic): prefer semantic / "content"-classed
# containers to skip nav/footer/boilerplate; tuned for article pages.
main_content = ""
content_areas = soup.find_all(
["main", "article", "section", "div"],
class_=re.compile("content|main|body|article|post|entry|text", re.I),
)
if content_areas:
for area in content_areas[:3]:
main_content += area.get_text(separator=" ", strip=True) + " "
main_content = re.sub(r"\s+", " ", main_content).strip()
# The class heuristic can latch onto a small wrapper and miss the real
# content (app/landing pages, or SSR sites whose body isn't in a
# "content"-classed div, so these came back nearly empty before). When the
# heuristic returns nothing OR suspiciously little, fall back to the full
# <body>, stripping scripts/styles (so JSON/JS doesn't leak into the text)
# plus nav/header/footer/aside (boilerplate), and keep whichever yields
# more readable text.
THIN_CONTENT_CHARS = 600 # below this the heuristic likely missed the page
if len(main_content) < THIN_CONTENT_CHARS:
body = soup.find("body")
if body:
# Strip from a copy so the later list/table/code extractors still
# see the original soup unmodified.
body_copy = copy.copy(body)
for _noise in body_copy.find_all(
["script", "style", "noscript", "template", "nav", "header", "footer", "aside"]
):
_noise.extract()
body_text = re.sub(r"\s+", " ", body_copy.get_text(separator=" ", strip=True)).strip()
if len(body_text) > len(main_content):
main_content = body_text
result = {
"url": url,
"title": title_text,
"content": main_content,
"lists": _extract_lists(soup),
"tables": _extract_tables(soup),
"code_blocks": _extract_code_blocks(soup),
"meta_description": meta_info.get("description", ""),
"meta_keywords": meta_info.get("keywords", ""),
"og_image": og_image,
"js_rendered": js_rendered,
"js_message": js_message,
"success": True,
"error": "",
}
_cache_result(cache_file, cache_key, result, url)
return result
def _cache_result(cache_file, cache_key: str, result: dict, url: str):
"""Write a result to the content cache."""
try:
cache_data = {"timestamp": datetime.now().isoformat(), "data": result}
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache_data, f)
content_cache_index[cache_key] = datetime.now()
cleanup_cache(CONTENT_CACHE_DIR, content_cache_index, timedelta(hours=2))
except Exception as e:
logger.warning(f"Failed to write content cache for {url}: {e}")
# ----------------------------------------------------------------------
# Content summarization helpers
# ----------------------------------------------------------------------
def extract_key_points(text: str) -> List[str]:
"""Pull out bullet-style key points from a block of text."""
points: List[str] = []
bullet_pat = re.compile(r"^\s*[-*•]\s+(.*)")
numbered_pat = re.compile(r"^\s*\d+[\.\)]\s+(.*)")
for line in text.splitlines():
m = bullet_pat.match(line) or numbered_pat.match(line)
if m:
points.append(m.group(1).strip())
return points
def get_tldr(text: str, max_sentences: int = 3) -> str:
"""Produce a very short TL;DR by taking the first few sentences."""
sentences = re.split(r"(?<=[.!?])\s+", text)
selected = [s.strip() for s in sentences if s][:max_sentences]
return " ".join(selected)
def extract_quotes(text: str) -> List[str]:
"""Return quoted excerpts that are at least 15 characters long."""
# Backreference the opening quote so the closing quote must match it —
# otherwise `"text'` (open double, close single) is treated as a quote.
return [m.group(2).strip() for m in re.finditer(r'(["\'])([^"\']{15,}?)\1', text)]
def extract_statistics(text: str) -> List[str]:
"""Find numbers, percentages, dates and simple measurements."""
# Match a comma-grouped number (1,000,000) OR a plain digit run (50000) —
# the old `\d{1,3}(?:,\d{3})*` matched only the first 3 digits of a
# comma-less number, and the trailing `\b` dropped a closing `%`.
pattern = re.compile(
r"\b(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?\s*(%|percent|‰|per cent|[a-zA-Z]+)?",
re.IGNORECASE,
)
return [m.group(0).strip() for m in pattern.finditer(text)]
sys.modules[__name__] = _content

View File

@@ -1,141 +1,11 @@
"""Query enhancement, entity extraction, and cache duration helpers."""
"""Compatibility wrapper for the canonical services.search.query module.
import re
import logging
from datetime import timedelta
from typing import Dict, List, Optional, Tuple
``src.search.query`` stays importable for older agent/deep-research code, but the
implementation now lives in ``services.search.query`` so the two cannot drift.
"""
logger = logging.getLogger(__name__)
import sys
from services.search import query as _query
# ----------------------------------------------------------------------
# Query processing helpers
# ----------------------------------------------------------------------
def _detect_question_type(query: str) -> Optional[str]:
"""Return the leading question word if present (who, what, when, where, why, how)."""
if not isinstance(query, str):
return None
q = query.strip().lower()
for word in ("who", "what", "when", "where", "why", "how"):
# Require a whole-word match: a bare prefix mis-flags ordinary queries
# like "whatsapp pricing" (-> what) or "however ..." (-> how), which
# then get spurious boost terms OR-appended in enhance_query.
if q == word or q.startswith(word + " "):
return word
return None
def _extract_entities(query: str) -> Dict[str, List[str]]:
"""Lightweight entity extraction: capitalized words and date patterns."""
entities: Dict[str, List[str]] = {"names": [], "dates": []}
qtype = _detect_question_type(query)
cleaned = query
if qtype:
cleaned = re.sub(rf"^{qtype}\b", "", cleaned, flags=re.I).strip()
for token in re.findall(r"\b[A-Z][a-zA-Z]+\b", cleaned):
entities["names"].append(token)
for year in re.findall(r"\b(?:19|20)\d{2}\b", cleaned):
entities["dates"].append(year)
month_day_year = re.findall(
r"\b(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{1,2},?\s*\d{4}\b",
cleaned,
flags=re.I,
)
entities["dates"].extend(month_day_year)
return entities
def _split_multi_part(query: str) -> List[str]:
"""Split a query into sub-queries on common conjunctions."""
if not isinstance(query, str):
return []
parts = re.split(r"\s+and\s+|\s+or\s+|;", query, flags=re.I)
return [p.strip() for p in parts if p.strip()]
def _extract_site_filter(query: str) -> Tuple[str, Optional[str]]:
"""Detect a 'site:example.com' token. Returns (query_without_token, site_or_None)."""
if not isinstance(query, str):
return "", None
match = re.search(r"\bsite:([^\s]+)", query, flags=re.I)
if match:
site = match.group(1)
new_query = re.sub(r"\bsite:[^\s]+", "", query, flags=re.I).strip()
return new_query, site
return query, None
def _boost_entities_in_query(base_query: str, entities: Dict[str, List[str]]) -> str:
"""Append extracted entities to the query using OR to increase relevance."""
parts = [base_query]
if entities.get("names"):
parts.append(" OR ".join(f'"{n}"' for n in entities["names"]))
if entities.get("dates"):
parts.append(" OR ".join(f'"{d}"' for d in entities["dates"]))
return " ".join(parts)
def enhance_query(original_query: str) -> Tuple[str, Optional[str]]:
"""Process the original query: site filter, question type boosts, entity extraction."""
if not isinstance(original_query, str):
original_query = ""
query_without_site, site = _extract_site_filter(original_query)
sub_queries = _split_multi_part(query_without_site)
enhanced_subs: List[str] = []
for sub in sub_queries:
qtype = _detect_question_type(sub)
boost_keywords = []
if qtype == "who":
boost_keywords.append("person")
elif qtype == "when":
boost_keywords.append("date")
elif qtype == "where":
boost_keywords.append("location")
elif qtype == "why":
boost_keywords.append("reason")
elif qtype == "how":
boost_keywords.append("method")
entities = _extract_entities(sub)
boosted = _boost_entities_in_query(sub, entities)
if boost_keywords:
boosted = f'({boosted}) OR ({" OR ".join(boost_keywords)})'
enhanced_subs.append(boosted)
final_query = " AND ".join(f"({s})" for s in enhanced_subs)
if site:
final_query = f"{final_query} site:{site}"
return final_query, site
def build_enhanced_query(query: str, time_filter: str = None) -> str:
"""Build an enhanced search query with optional time filtering."""
enhanced_query, _ = enhance_query(query)
if time_filter:
time_map = {"day": "d", "week": "w", "month": "m", "year": "y"}
if time_filter in time_map:
enhanced_query = f"{enhanced_query} after:{time_map[time_filter]}"
logger.info(f"Added time filter '{time_filter}' to query")
logger.info(f"Enhanced query: '{query}' -> '{enhanced_query}'")
return enhanced_query
# ----------------------------------------------------------------------
# Cache duration helpers
# ----------------------------------------------------------------------
def _is_news_query(query: str) -> bool:
"""Lightweight heuristic to decide if a query is news-oriented."""
if not isinstance(query, str):
return False
news_terms = {"news", "latest", "breaking", "today", "today's", "current", "updates", "happening"}
tokens = set(re.findall(r"\b\w+\b", query.lower()))
return bool(tokens & news_terms)
def _cache_duration_for_query(query: str) -> timedelta:
"""News queries -> 30 minutes, reference queries -> 24 hours."""
if _is_news_query(query):
return timedelta(minutes=30)
return timedelta(hours=24)
sys.modules[__name__] = _query

View File

@@ -85,6 +85,11 @@ DEFAULT_SETTINGS = {
"research_search_provider": "",
"research_max_tokens": 16384,
"research_extraction_timeout_seconds": 90,
# Lightweight planning/query LLM calls happen before any search starts.
# Keep them separately tunable so slow local backends are not capped by
# the old 30s/60s per-call defaults.
"research_planning_timeout_seconds": 90,
"research_query_timeout_seconds": 90,
"research_extraction_concurrency": 3,
# Hard wall-clock cap on a single deep-research run. The previous 600s
# (10 min) default cut off slow local / edge LLMs mid-synthesis; 1800s
@@ -95,6 +100,7 @@ DEFAULT_SETTINGS = {
# Tune via Settings or by editing data/settings.json.
"research_run_timeout_seconds": 1800,
"agent_max_tool_calls": 0,
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
"agent_input_token_budget": 6000,
# Ceiling on the *auto-derived* input budget that #1230 introduced. Has
# no effect when `agent_input_token_budget` is explicitly set (the user's

View File

@@ -15,18 +15,33 @@ from __future__ import annotations
import re
_THINK_TAG_NAME = r"(?:think(?:ing)?|thought)"
# Closed reasoning blocks. Multi-pass loop in `strip_think` handles nested
# `<think><think>...</think></think>` patterns some models emit.
_THINK_CLOSED_RE = re.compile(r"<think(?:ing)?>[\s\S]*?</think(?:ing)?>\s*", re.IGNORECASE)
_THINK_CLOSED_RE = re.compile(rf"<{_THINK_TAG_NAME}(?:\s+[^>]*)?>[\s\S]*?</{_THINK_TAG_NAME}>\s*", re.IGNORECASE)
# Orphan opening or closing tags that survive after the closed-pass.
_THINK_TAG_RE = re.compile(r"</?think(?:ing)?[^>]*>\s*", re.IGNORECASE)
_THINK_TAG_RE = re.compile(rf"</?{_THINK_TAG_NAME}[^>]*>\s*", re.IGNORECASE)
# Dangling opener anywhere in the response with no closer — strip everything
# from `<think>` to the end of string.
_THINK_OPEN_RE = re.compile(r"<think(?:ing)?>[\s\S]*$", re.IGNORECASE)
_THINK_OPEN_RE = re.compile(rf"<{_THINK_TAG_NAME}(?:\s+[^>]*)?>[\s\S]*$", re.IGNORECASE)
# Streaming models occasionally emit `<thinking time="0.42">`-style attributes.
# Normalize to a plain `<think>` so the regexes above catch them.
_THINK_ATTR_RE = re.compile(r"<think(?:ing)?\s+[^>]*>", re.IGNORECASE)
_THINK_ATTR_CLOSE_RE = re.compile(r"</think(?:ing)?\s+[^>]*>", re.IGNORECASE)
_THINK_ATTR_RE = re.compile(rf"<{_THINK_TAG_NAME}\s+[^>]*>", re.IGNORECASE)
_THINK_ATTR_CLOSE_RE = re.compile(rf"</{_THINK_TAG_NAME}\s+[^>]*>", re.IGNORECASE)
_GEMMA_THOUGHT_OPEN_RE = re.compile(r"<\|channel>thought\s*\n?[\s\S]*$", re.IGNORECASE)
_GEMMA_RESPONSE_CHANNEL_RE = re.compile(
r"<\|channel>response\s*\n?([\s\S]*?)<channel\|>",
re.IGNORECASE,
)
_GEMMA_RESPONSE_OPEN_RE = re.compile(r"<\|channel>response\s*\n?", re.IGNORECASE)
_GEMMA_CHANNEL_CLOSE_RE = re.compile(r"<channel\|>", re.IGNORECASE)
_THOUGHT_TAG_OPEN_RE = re.compile(r"<thought(\s+[^>]*)?>", re.IGNORECASE)
_THOUGHT_TAG_CLOSE_RE = re.compile(r"</thought>", re.IGNORECASE)
_GEMMA_THOUGHT_CHANNEL_CAPTURE_RE = re.compile(
r"<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*",
re.IGNORECASE,
)
# Qwen and a few other models prefix the response with a "Thinking Process:"
# block before the real answer.
_QWEN_THINKING_RE = re.compile(
@@ -78,6 +93,30 @@ def _strip_reasoning_prose(text: str) -> str:
return "\n\n".join(keep).strip() if keep else text
def normalize_thinking_markup(text: str) -> str:
"""Canonicalize supported thinking wrappers to `<think>` markup.
The chat UI and persistence layer already understand `<think>...</think>`.
Gemma 4 may instead emit `<|channel>thought\n...<channel|>`, and some
gateways/models emit `<thought>...</thought>`. Normalize those shapes into
the existing representation and strip empty thought channels.
"""
if not text:
return text
out = _THOUGHT_TAG_OPEN_RE.sub(lambda m: "<think" + (m.group(1) or "") + ">", text)
out = _THOUGHT_TAG_CLOSE_RE.sub("</think>", out)
def _replace_gemma_thought(match: re.Match) -> str:
thought = match.group(1).strip()
return f"<think>{thought}</think>\n" if thought else ""
out = _GEMMA_THOUGHT_CHANNEL_CAPTURE_RE.sub(_replace_gemma_thought, out)
out = _GEMMA_RESPONSE_CHANNEL_RE.sub(lambda m: m.group(1), out)
out = _GEMMA_RESPONSE_OPEN_RE.sub("", out)
out = _GEMMA_CHANNEL_CLOSE_RE.sub("", out)
return out
def strip_think(text: str, *, prose: bool = False, prompt_echo: bool = True) -> str:
"""Strip `<think>` blocks from model output.
@@ -92,13 +131,21 @@ def strip_think(text: str, *, prose: bool = False, prompt_echo: bool = True) ->
"The user asks:" / "We need to" leaked prompt echoes.
Robust to:
* closed `<think>...</think>` (any depth, both `<think>` and `<thinking>`)
* dangling unclosed `<think>...`
* closed `<think>...</think>` (any depth, plus `<thinking>`/`<thought>`)
* dangling unclosed `<think>...` / `<thought>...`
* stray opener/closer tags
* `<think time="0.42">`-style attributes
* Gemma 4 `<|channel>thought...<channel|>` wrappers
"""
if not text:
return ""
# Gemma 4 thinking-capable models use channel control tokens rather than
# XML tags when the runtime does not split reasoning into a separate field.
# The thought channel can be empty in non-thinking mode; either way it is
# not user-facing content. A response channel, when present, is only a
# wrapper around the final answer.
text = normalize_thinking_markup(text)
text = _GEMMA_THOUGHT_OPEN_RE.sub("", text)
# Normalize attributes so the closed/open regexes can catch them.
text = _THINK_ATTR_RE.sub("<think>", text)
text = _THINK_ATTR_CLOSE_RE.sub("</think>", text)

View File

@@ -12,14 +12,127 @@ import collections
import json
import logging
import os
import pathlib
import sys
import time
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
from src.tool_security import is_public_blocked_tool, owner_is_admin_or_single_user
# Persistent working directory for agent subprocesses.
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
# (/app/data) and the local data directory for manual installs.
# Using this as cwd and HOME prevents the agent from silently creating files
# in ephemeral container layers that are lost on the next rebuild.
_AGENT_WORKDIR = str(pathlib.Path(__file__).parent.parent / "data")
MAX_OUTPUT_CHARS = 10_000
MAX_READ_CHARS = 20_000
MAX_DIFF_LINES = 400 # cap unified-diff size returned to the UI
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
"""Build a unified diff of a file write for display in the chat.
Returns {"text": <unified diff>, "added": N, "removed": M, "new_file": bool}
or None when there's no textual change. Truncates very large diffs.
"""
if old == new:
return None
import difflib
old_lines = old.splitlines()
new_lines = new.splitlines()
label = path or "file"
diff_lines = list(difflib.unified_diff(
old_lines, new_lines,
fromfile=f"a/{label}", tofile=f"b/{label}",
lineterm="",
))
added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
truncated = False
if len(diff_lines) > MAX_DIFF_LINES:
diff_lines = diff_lines[:MAX_DIFF_LINES]
truncated = True
text = "\n".join(diff_lines)
if truncated:
text += f"\n… diff truncated at {MAX_DIFF_LINES} lines"
return {
"text": text,
"added": added,
"removed": removed,
"new_file": old == "",
"file": os.path.basename(path) or (path or "file"),
}
async def _do_edit_file(content: str, workspace: Optional[str] = None) -> Dict[str, Any]:
"""Exact string-replacement edit of an on-disk file.
content is JSON: {"path", "old_string", "new_string", "replace_all"?}.
Fails if old_string is missing or non-unique (unless replace_all) so the
model can't silently edit the wrong place. Returns a unified diff for the UI.
Confined to the workspace when one is set (same policy as write_file).
"""
try:
args = json.loads(content) if content.strip().startswith("{") else {}
except (json.JSONDecodeError, TypeError):
args = {}
raw_path = (args.get("path") or "").strip()
old = args.get("old_string", "")
new = args.get("new_string", "")
replace_all = bool(args.get("replace_all", False))
if not raw_path:
return {"error": "edit_file: path required", "exit_code": 1}
# Confine to the workspace when set, else the same allowlist + sensitive-file
# policy as read/write_file.
try:
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
if workspace else _resolve_tool_path(raw_path))
except ValueError as e:
return {"error": f"edit_file: {e}", "exit_code": 1}
if old == "":
return {"error": "edit_file: old_string required (use write_file to create a file)", "exit_code": 1}
if old == new:
return {"error": "edit_file: old_string and new_string are identical", "exit_code": 1}
def _apply():
with open(path, "r", encoding="utf-8") as f:
original = f.read()
count = original.count(old)
if count == 0:
return original, None, "not_found"
if count > 1 and not replace_all:
return original, None, f"not_unique:{count}"
updated = original.replace(old, new) if replace_all else original.replace(old, new, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(updated)
return original, updated, "ok"
try:
original, updated, status = await asyncio.to_thread(_apply)
except FileNotFoundError:
return {"error": f"edit_file: {path}: not found (use write_file to create it)", "exit_code": 1}
except (IsADirectoryError, UnicodeDecodeError):
return {"error": f"edit_file: {path}: not an editable text file", "exit_code": 1}
except PermissionError:
return {"error": f"edit_file: {path}: permission denied", "exit_code": 1}
except OSError as e:
return {"error": f"edit_file: {path}: {e}", "exit_code": 1}
if status == "not_found":
return {"error": f"edit_file: old_string not found in {path}. Read the file and match it exactly.", "exit_code": 1}
if status.startswith("not_unique"):
n = status.split(":", 1)[1]
return {"error": f"edit_file: old_string is not unique in {path} ({n} matches). Add surrounding context or set replace_all=true.", "exit_code": 1}
n = original.count(old)
result = {"output": f"Edited {path} ({n} replacement{'s' if n != 1 else ''})", "exit_code": 0}
diff = _unified_diff(original, updated, path)
if diff:
result["diff"] = diff
return result
# ---------------------------------------------------------------------------
# Path confinement for read_file / write_file
@@ -158,6 +271,40 @@ def _resolve_tool_path(raw_path: str) -> str:
f"path '{raw_path}' is outside the allowed roots"
)
def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str:
"""Confine a model-supplied path to the active workspace.
Layered on top of upstream's path policy: the workspace is the allowed
root (relative paths resolve under it; paths that escape it are rejected),
and the sensitive-file deny list (.ssh, .gnupg, id_rsa, …) still applies
inside it. When no workspace is set, callers use _resolve_tool_path (the
default data/tmp allowlist) instead.
"""
if raw_path is None or not str(raw_path).strip():
raise ValueError("path is required")
base = os.path.realpath(workspace)
expanded = os.path.expanduser(str(raw_path).strip())
candidate = expanded if os.path.isabs(expanded) else os.path.join(base, expanded)
resolved = os.path.realpath(candidate)
if _is_sensitive_path(resolved):
raise ValueError(
f"path '{raw_path}' is inside a sensitive directory "
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
)
if resolved != base:
# normcase so containment holds on case-insensitive filesystems
# (Windows, default macOS): it lowercases on Windows and is a no-op on
# POSIX. commonpath raises ValueError across Windows drives (C: vs D:)
# or mixed abs/rel — both mean "outside", so the except rejects them.
nbase = os.path.normcase(base)
try:
if os.path.commonpath([os.path.normcase(resolved), nbase]) != nbase:
raise ValueError
except ValueError:
raise ValueError(f"path '{raw_path}' is outside the workspace ({workspace})")
return resolved
# Bash + python tools used to share a single 60s timeout. That's
# enough for one-shot commands but starves real workloads (pip
# install, ffmpeg conversions, etc.) — and worse, the agent saw the
@@ -186,6 +333,39 @@ def get_mcp_manager():
return agent_tools.get_mcp_manager()
# Directories ignored by the code-nav tools' Python fallbacks so results aren't
# polluted by VCS internals / dependency trees / build caches. ripgrep already
# honours .gitignore; this is the parity floor for the no-rg path (and the
# explicit excludes passed to rg so it skips them even without a .gitignore).
_CODENAV_SKIP_DIRS = frozenset({
".git", ".hg", ".svn", "node_modules", "venv", ".venv", "__pycache__",
".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build",
".next", ".cache", "site-packages", ".idea", ".tox",
})
# Per-tool result caps (keep tool output cheap + model-friendly).
_CODENAV_MAX_HITS = 200
_CODENAV_MAX_LINE = 400
def _resolve_search_root(raw_path: str, workspace: Optional[str] = None) -> str:
"""Resolve + confine a code-nav path (grep/glob/ls).
With a workspace set, the workspace folder is the root and supplied paths are
confined inside it (same policy as read_file). Without one, an empty path
defaults to the agent's primary root (project data dir) and a supplied path
is confined by the global allowlist + sensitive-file policy.
"""
raw = (raw_path or "").strip()
if workspace:
if not raw:
return os.path.realpath(workspace)
return _resolve_tool_path_in_workspace(workspace, raw)
if not raw:
roots = _tool_path_roots()
return roots[0] if roots else os.path.realpath(".")
return _resolve_tool_path(raw)
def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
if len(text) > limit:
return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
@@ -396,11 +576,12 @@ async def _call_mcp_tool(
tool: str,
content: str,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
workspace: Optional[str] = None,
) -> Dict:
"""Route a legacy tool call through the MCP manager, with direct fallbacks."""
mcp = get_mcp_manager()
if not mcp:
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
return await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
server_id, tool_name = _MCP_TOOL_MAP[tool]
qualified = f"mcp__{server_id}__{tool_name}"
@@ -409,7 +590,7 @@ async def _call_mcp_tool(
# If MCP server not connected, try direct fallback
if isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""):
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb)
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace)
if fallback:
return fallback
@@ -436,6 +617,7 @@ async def _direct_fallback(
tool: str,
content: str,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
workspace: Optional[str] = None,
) -> Optional[Dict]:
"""In-process execution path for the eight tools that used to live as
stdio MCP servers under mcp_servers/. Those servers were deleted in
@@ -461,6 +643,7 @@ async def _direct_fallback(
"TERM": "xterm-256color",
"COLUMNS": "120",
"LINES": "40",
"HOME": _AGENT_WORKDIR,
}
try:
@@ -470,6 +653,7 @@ async def _direct_fallback(
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=workspace or _AGENT_WORKDIR,
)
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc,
@@ -496,6 +680,7 @@ async def _direct_fallback(
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=workspace or _AGENT_WORKDIR,
)
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc,
@@ -512,14 +697,43 @@ async def _direct_fallback(
return {"output": output or "(no output)", "exit_code": rc or 0}
if tool == "read_file":
raw_path = content.split("\n", 1)[0].strip()
# Args: plain path on line 1 (back-compat) OR JSON
# {path, offset?, limit?} where offset/limit are a 1-based line range.
raw_path, offset, limit = content.split("\n", 1)[0].strip(), 0, 0
_stripped = content.strip()
if _stripped.startswith("{"):
try:
_a = _json.loads(_stripped)
raw_path = str(_a.get("path", "")).strip()
offset = int(_a.get("offset") or 0)
limit = int(_a.get("limit") or 0)
except (_json.JSONDecodeError, TypeError, ValueError):
pass
try:
path = _resolve_tool_path(raw_path)
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
if workspace else _resolve_tool_path(raw_path))
except ValueError as e:
return {"error": f"read_file: {e}", "exit_code": 1}
try:
# Run blocking read in a thread to keep the loop responsive
# Run blocking read in a thread to keep the loop responsive.
def _read():
if offset > 0 or limit > 0:
# Line-range read: slice [offset, offset+limit).
start = max(offset, 1)
out, n, budget = [], 0, MAX_READ_CHARS
with open(path, "r", encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f, 1):
if i < start:
continue
if limit > 0 and n >= limit:
break
out.append(line)
n += 1
budget -= len(line)
if budget <= 0:
out.append(f"\n... [truncated at {MAX_READ_CHARS} chars]")
break
return "".join(out)
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read(MAX_READ_CHARS + 1)
data = await asyncio.to_thread(_read)
@@ -527,10 +741,11 @@ async def _direct_fallback(
return {"error": f"read_file: {path}: not found", "exit_code": 1}
except PermissionError:
return {"error": f"read_file: {path}: permission denied", "exit_code": 1}
except IsADirectoryError:
return {"error": f"read_file: {path}: is a directory (use ls)", "exit_code": 1}
except OSError as e:
return {"error": f"read_file: {path}: {e}", "exit_code": 1}
truncated = len(data) > MAX_READ_CHARS
if truncated:
if not (offset > 0 or limit > 0) and len(data) > MAX_READ_CHARS:
data = data[:MAX_READ_CHARS] + f"\n... [truncated at {MAX_READ_CHARS} chars]"
return {"output": data, "exit_code": 0}
@@ -539,23 +754,226 @@ async def _direct_fallback(
raw_path = lines[0].strip()
body = lines[1] if len(lines) > 1 else ""
try:
path = _resolve_tool_path(raw_path)
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
if workspace else _resolve_tool_path(raw_path))
except ValueError as e:
return {"error": f"write_file: {e}", "exit_code": 1}
try:
def _write():
# Capture prior content (best-effort, text) so we can show a
# before/after diff. Missing/binary file → treat as empty.
old = ""
try:
with open(path, "r", encoding="utf-8") as f:
old = f.read()
except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError, OSError):
old = ""
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(body)
return len(body)
size = await asyncio.to_thread(_write)
return old, len(body)
old_content, size = await asyncio.to_thread(_write)
except PermissionError:
return {"error": f"write_file: {path}: permission denied", "exit_code": 1}
except OSError as e:
return {"error": f"write_file: {path}: {e}", "exit_code": 1}
return {"output": f"Wrote {size} bytes to {path}", "exit_code": 0}
diff = _unified_diff(old_content, body, path)
result = {"output": f"Wrote {size} bytes to {path}", "exit_code": 0}
if diff:
result["diff"] = diff
return result
if tool == "grep":
# Args (JSON): {pattern, path?, glob?, ignore_case?, max_results?}.
# Bare string → treated as the pattern.
args: Dict[str, Any] = {}
_s = (content or "").strip()
if _s.startswith("{"):
try:
args = _json.loads(_s)
except _json.JSONDecodeError:
args = {}
else:
args = {"pattern": _s}
pattern = str(args.get("pattern", "")).strip()
if not pattern:
return {"error": "grep: pattern is required", "exit_code": 1}
ignore_case = bool(args.get("ignore_case"))
glob_pat = str(args.get("glob", "") or "").strip()
try:
max_hits = int(args.get("max_results") or _CODENAV_MAX_HITS)
except (TypeError, ValueError):
max_hits = _CODENAV_MAX_HITS
max_hits = max(1, min(max_hits, _CODENAV_MAX_HITS))
try:
root = _resolve_search_root(str(args.get("path", "")), workspace)
except ValueError as e:
return {"error": f"grep: {e}", "exit_code": 1}
def _grep():
import re as _re
import shutil
rg = shutil.which("rg")
if rg:
cmd = [rg, "--line-number", "--no-heading", "--color=never",
"--max-count", str(max_hits)]
if ignore_case:
cmd.append("--ignore-case")
if glob_pat:
cmd += ["--glob", glob_pat]
# Exclude junk dirs even when the tree has no .gitignore, so
# results match the Python fallback's skip set.
for _d in _CODENAV_SKIP_DIRS:
cmd += ["--glob", f"!**/{_d}/**"]
cmd += ["--regexp", pattern, root]
try:
import subprocess
p = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
lines = [ln for ln in (p.stdout or "").splitlines() if ln][:max_hits]
return lines, None
except subprocess.TimeoutExpired:
return None, "grep: timed out"
except Exception as _e:
return None, f"grep: {_e}"
# Python fallback (no ripgrep): walk + regex.
try:
rx = _re.compile(pattern, _re.IGNORECASE if ignore_case else 0)
except _re.error as _e:
return None, f"grep: bad pattern: {_e}"
import fnmatch
hits = []
if os.path.isfile(root):
file_iter = [root]
else:
file_iter = []
for dp, dns, fns in os.walk(root):
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
for fn in fns:
if glob_pat and not fnmatch.fnmatch(fn, glob_pat):
continue
file_iter.append(os.path.join(dp, fn))
for fp in file_iter:
if len(hits) >= max_hits:
break
try:
with open(fp, "r", encoding="utf-8", errors="strict") as f:
for i, line in enumerate(f, 1):
if rx.search(line):
hits.append(f"{fp}:{i}:{line.rstrip()[:_CODENAV_MAX_LINE]}")
if len(hits) >= max_hits:
break
except (UnicodeDecodeError, OSError):
continue # skip binary / unreadable
return hits, None
lines, err = await asyncio.to_thread(_grep)
if err:
return {"error": err, "exit_code": 1}
if not lines:
return {"output": f"No matches for {pattern!r} under {root}", "exit_code": 0}
out = "\n".join(ln[:_CODENAV_MAX_LINE] for ln in lines)
if len(lines) >= max_hits:
out += f"\n... [capped at {max_hits} matches]"
return {"output": _truncate(out), "exit_code": 0}
if tool == "glob":
args = {}
_s = (content or "").strip()
if _s.startswith("{"):
try:
args = _json.loads(_s)
except _json.JSONDecodeError:
args = {}
else:
args = {"pattern": _s}
pattern = str(args.get("pattern", "")).strip()
if not pattern:
return {"error": "glob: pattern is required", "exit_code": 1}
try:
root = _resolve_search_root(str(args.get("path", "")), workspace)
except ValueError as e:
return {"error": f"glob: {e}", "exit_code": 1}
def _glob():
from pathlib import Path
base = Path(root)
if not base.is_dir():
return None, f"glob: {root}: not a directory"
matched = []
try:
for p in base.rglob(pattern):
if set(p.relative_to(base).parts) & _CODENAV_SKIP_DIRS:
continue
try:
mtime = p.stat().st_mtime
except OSError:
mtime = 0
matched.append((mtime, str(p)))
if len(matched) > _CODENAV_MAX_HITS * 5:
break
except (OSError, ValueError) as _e:
return None, f"glob: {_e}"
matched.sort(key=lambda t: t[0], reverse=True) # newest first
return [pth for _, pth in matched[:_CODENAV_MAX_HITS]], None
paths, err = await asyncio.to_thread(_glob)
if err:
return {"error": err, "exit_code": 1}
if not paths:
return {"output": f"No files matching {pattern!r} under {root}", "exit_code": 0}
out = "\n".join(paths)
if len(paths) >= _CODENAV_MAX_HITS:
out += f"\n... [capped at {_CODENAV_MAX_HITS} files]"
return {"output": _truncate(out), "exit_code": 0}
if tool == "ls":
raw_path = ""
_s = (content or "").strip()
if _s.startswith("{"):
try:
raw_path = str(_json.loads(_s).get("path", "")).strip()
except _json.JSONDecodeError:
raw_path = ""
else:
raw_path = _s.split("\n", 1)[0].strip()
try:
root = _resolve_search_root(raw_path, workspace)
except ValueError as e:
return {"error": f"ls: {e}", "exit_code": 1}
def _ls():
if not os.path.isdir(root):
return None, f"ls: {root}: not a directory"
rows = []
try:
with os.scandir(root) as it:
for entry in it:
if entry.name.startswith("."):
continue
try:
is_dir = entry.is_dir(follow_symlinks=False)
size = entry.stat(follow_symlinks=False).st_size if not is_dir else 0
except OSError:
continue
rows.append((is_dir, entry.name, size))
except (PermissionError, OSError) as _e:
return None, f"ls: {_e}"
rows.sort(key=lambda r: (not r[0], r[1].lower())) # dirs first, then name
lines = [f"{root}:"]
for is_dir, name, size in rows[:_CODENAV_MAX_HITS]:
lines.append(f" {name}/" if is_dir else f" {name} ({size} B)")
if len(rows) > _CODENAV_MAX_HITS:
lines.append(f" ... [{len(rows) - _CODENAV_MAX_HITS} more]")
if not rows:
lines.append(" (empty)")
return "\n".join(lines), None
out, err = await asyncio.to_thread(_ls)
if err:
return {"error": err, "exit_code": 1}
return {"output": _truncate(out), "exit_code": 0}
if tool == "web_search":
from src.search import comprehensive_web_search
@@ -685,6 +1103,7 @@ async def execute_tool_block(
disabled_tools: Optional[set] = None,
owner: Optional[str] = None,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
workspace: Optional[str] = None,
) -> Tuple[str, Dict]:
"""Execute a single tool block. Returns (description, result_dict).
@@ -773,7 +1192,7 @@ async def execute_tool_block(
_is_bg, _bg_cmd = _split_bg_marker(content)
if _is_bg and _bg_cmd:
from src import bg_jobs
rec = bg_jobs.launch(_bg_cmd, session_id=session_id)
rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=workspace or _AGENT_WORKDIR)
short = _bg_cmd.strip().split(chr(10))[0][:80]
desc = f"bash (background): {short}"
result = {
@@ -795,7 +1214,14 @@ async def execute_tool_block(
if tool in _MCP_TOOL_MAP:
first_line = content.split(chr(10))[0][:80]
desc = f"{tool}: {first_line}"
result = await _call_mcp_tool(tool, content, progress_cb=progress_cb)
result = await _call_mcp_tool(tool, content, progress_cb=progress_cb, workspace=workspace)
elif tool in ("grep", "glob", "ls"):
# Code-navigation tools — no MCP server; run the direct implementation.
# Confined to the workspace when one is set (same policy as read_file).
first_line = content.split(chr(10))[0][:80]
desc = f"{tool}: {first_line}"
result = await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace) \
or {"error": f"{tool}: execution failed", "exit_code": 1}
elif tool == "create_document":
title = content.split("\n")[0].strip()[:60]
desc = f"create_document: {title}"
@@ -898,6 +1324,9 @@ async def execute_tool_block(
elif tool == "edit_image":
desc = "edit_image"
result = await do_edit_image(content, owner=owner)
elif tool == "edit_file":
result = await _do_edit_file(content, workspace=workspace)
desc = result.get("output") or result.get("error") or "edit_file"
elif tool == "trigger_research":
desc = "trigger_research"
result = await do_trigger_research(content, owner=owner)

View File

@@ -22,7 +22,13 @@ logger = logging.getLogger(__name__)
# Tools that are ALWAYS included regardless of retrieval results.
# These are the most commonly needed and should never be missing.
ALWAYS_AVAILABLE = frozenset({
"bash", "python", "web_search", "web_fetch", "read_file",
"bash", "python", "web_search", "web_fetch",
# File tools: read AND write/edit. An agent with disk access should always
# be able to change files, not just read them — otherwise a bare "edit X"
# request can miss write_file/edit_file (RAG-only) and the model wrongly
# falls back to edit_document (editor panel). All admin-gated by tool_security.
"read_file", "write_file", "edit_file",
"grep", "glob", "ls", # code-navigation tools (admin-gated by tool_security)
"api_call", # For configured integrations (Miniflux, Gitea, Linkding, etc.)
# The two genuinely AMBIENT cookbook tools — "what's running" and
# "kill it" can be asked any time without prior cookbook context,
@@ -71,8 +77,12 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"python": "Execute Python code for computation, data processing, math, scripting, parsing, API calls. Not for writing code for the user.",
"web_search": "Quick single web lookup for a fact, current event, or doc mid-task. NOT for 'research X' / 'do research on X' requests — those are deep-research jobs (use trigger_research). web_search = one query; trigger_research = a full researched report in the sidebar.",
"web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.",
"read_file": "Read a file from disk and return its contents. View source code, config files, logs.",
"write_file": "Write content to a file on disk. Create new files, save output, update configs.",
"read_file": "Read a file from disk and return its contents. View source code, config files, logs. Supports an optional line range (offset/limit) for large files.",
"grep": "Search file CONTENTS for a regex across a directory tree (ripgrep-backed, honours .gitignore). Returns file:line:match. Use to find where code/symbols/strings live — prefer over bash grep.",
"glob": "Find FILES by glob pattern (e.g. '**/*.py'), newest first. Use to locate files by name/extension — prefer over bash find/ls.",
"ls": "List a directory's entries (folders then files with sizes). Use to see what's in a folder — prefer over bash ls.",
"write_file": "Write/create or fully rewrite a file ON DISK (source code, configs, project files). Use for new files or full rewrites — NOT create_document (editor panel) and NOT a bash heredoc.",
"edit_file": "Edit an existing file ON DISK by exact string replacement (fix a bug, change a function). Shows a diff. The tool for changing files on disk — NOT edit_document (editor panel) and NOT bash sed/heredoc.",
"create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines, unless an already-open document/email draft is the obvious target. If an email compose draft is open, edit that draft instead of creating another document.",
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",

View File

@@ -82,16 +82,65 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file from disk",
"description": "Read a file from disk. Optionally read a line range with offset/limit for large files.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to read"}
"path": {"type": "string", "description": "File path to read"},
"offset": {"type": "integer", "description": "1-based line to start reading from (optional)"},
"limit": {"type": "integer", "description": "Max number of lines to read from offset (optional)"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "grep",
"description": "Search file contents for a regular expression across a directory tree (uses ripgrep when available, respecting .gitignore). Returns file:line:match. PREFER this over `bash grep/rg` for code search — confined to the allowed roots, structured output.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regular expression to search for"},
"path": {"type": "string", "description": "Directory or file to search (optional; defaults to the project root)"},
"glob": {"type": "string", "description": "Only search files matching this glob, e.g. '*.py' (optional)"},
"ignore_case": {"type": "boolean", "description": "Case-insensitive match (optional)"},
"max_results": {"type": "integer", "description": "Max matches to return (optional)"}
},
"required": ["pattern"]
}
}
},
{
"type": "function",
"function": {
"name": "glob",
"description": "Find files by glob pattern (recursive), newest first. e.g. '**/*.py'. PREFER this over `bash find/ls` for locating files — confined to the allowed roots.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.ts' or 'src/**/test_*.py'"},
"path": {"type": "string", "description": "Base directory (optional; defaults to the project root)"}
},
"required": ["pattern"]
}
}
},
{
"type": "function",
"function": {
"name": "ls",
"description": "List the entries of a directory (folders first, then files with sizes). PREFER this over `bash ls` — confined to the allowed roots.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory to list (optional; defaults to the project root)"}
},
"required": []
}
}
},
{
"type": "function",
"function": {
@@ -107,6 +156,23 @@ FUNCTION_TOOL_SCHEMAS = [
}
}
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "Edit a file ON DISK by exact string replacement (home folder, project files, any real path like ~/sweden.txt or /path/to/file). This is the right tool for files on disk — NOT edit_document (that's for editor-panel documents). PREFER this over bash (sed/echo) — it shows a diff. old_string must match the file exactly and be unique (or set replace_all). Use write_file to create a new file.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to edit"},
"old_string": {"type": "string", "description": "Exact text to replace (must match the file, including indentation)"},
"new_string": {"type": "string", "description": "Replacement text"},
"replace_all": {"type": "boolean", "description": "Replace all occurrences instead of requiring a unique match"}
},
"required": ["path", "old_string", "new_string"]
}
}
},
{
"type": "function",
"function": {
@@ -127,7 +193,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "edit_document",
"description": "PREFERRED way to change an existing document. Targeted find-and-replace with multiple FIND/REPLACE pairs per call. Use this for any edit smaller than a full rewrite: adding a function, fixing a bug, tweaking a section, renaming things. Do NOT send the whole file back via update_document for small edits — it wastes tokens and is hard to review.",
"description": "Edit a document OPEN IN THE EDITOR PANEL (created via create_document) — NOT a file on disk. For files on disk (home folder, project files, anything with a path like ~/x.txt or /path/to/file) use edit_file instead. Targeted find-and-replace with multiple FIND/REPLACE pairs per call; use for any edit smaller than a full rewrite. Do NOT send the whole file back via update_document for small edits.",
"parameters": {
"type": "object",
"properties": {
@@ -1126,9 +1192,17 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
else:
content = args.get("query", "")
elif tool_type == "read_file":
content = args.get("path", "")
# Plain path (back-compat) unless a line range is requested → JSON.
if args.get("offset") or args.get("limit"):
content = json.dumps(args)
else:
content = args.get("path", "")
elif tool_type in ("grep", "glob", "ls"):
content = json.dumps(args) if args else "{}"
elif tool_type == "write_file":
content = args.get("path", "") + "\n" + args.get("content", "")
elif tool_type == "edit_file":
content = json.dumps(args)
elif tool_type == "create_document":
parts = [args.get("title", "Untitled")]
if args.get("language"):

View File

@@ -16,6 +16,10 @@ NON_ADMIN_BLOCKED_TOOLS = {
"python",
"read_file",
"write_file",
"edit_file",
"grep",
"glob",
"ls",
"search_chats",
"manage_memory",
"manage_skills",

View File

@@ -4,6 +4,7 @@
// ============================================
import Storage from './js/storage.js';
import uiModule from './js/ui.js';
import workspaceModule from './js/workspace.js';
import fileHandlerModule from './js/fileHandler.js';
import modelsModule from './js/models.js';
import ragModule from './js/rag.js';
@@ -13,6 +14,7 @@ import chatModule from './js/chat.js';
import compareModule from './js/compare/index.js';
import documentModule from './js/document.js';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js';
import sessionModule from './js/sessions.js';
@@ -1686,6 +1688,7 @@ function initializeEventListeners() {
}
setupToggle('web-toggle-btn', 'web-toggle', 'web');
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
try { workspaceModule.initWorkspace(); } catch (_) {}
// Document editor toggle (special: uses module panel, not a checkbox)
const overflowDocBtn = el('overflow-doc-btn');
@@ -2683,82 +2686,38 @@ function initializeEventListeners() {
// Apply saved visibility on load
applyUIVis(loadUIVis());
// Generic draggable for all .modal elements
const _sharedDragModalIds = new Set(['settings-modal']);
try { document.querySelectorAll('.modal').forEach(m => {
if (_sharedDragModalIds.has(m.id)) return;
const content = m.querySelector('.modal-content');
const header = m.querySelector('.modal-header');
if (!content || !header) return;
let dragX, dragY, startLeft, startTop, dragging = false;
// Reset to flex-centered position each time modal opens
new MutationObserver(() => {
if (!m.classList.contains('hidden')) {
content.style.position = '';
content.style.left = '';
content.style.top = '';
content.style.right = '';
content.style.bottom = '';
content.style.margin = '';
}
}).observe(m, { attributes: true, attributeFilter: ['class'] });
function startDrag(clientX, clientY) {
dragging = true;
const rect = content.getBoundingClientRect();
dragX = clientX; dragY = clientY;
startLeft = rect.left; startTop = rect.top;
// Switch to fixed so it can be freely positioned
content.style.position = 'fixed';
content.style.left = startLeft + 'px';
content.style.top = startTop + 'px';
content.style.margin = '0';
}
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.close-btn')) return;
e.preventDefault();
startDrag(e.clientX, e.clientY);
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
});
function onDrag(e) {
if (!dragging) return;
content.style.left = (startLeft + e.clientX - dragX) + 'px';
content.style.top = (startTop + e.clientY - dragY) + 'px';
}
function stopDrag() {
dragging = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
}
// Touch drag is desktop-only — on mobile, modals are bottom sheets and
// ui.js handles swipe-down-to-dismiss. Attaching this listener fights
// the swipe-dismiss gesture.
if (window.innerWidth > 768) {
header.addEventListener('touchstart', (e) => {
if (e.target.closest('.close-btn')) return;
const t = e.touches[0];
startDrag(t.clientX, t.clientY);
document.addEventListener('touchmove', onTouchDrag, { passive: false });
document.addEventListener('touchend', stopTouchDrag);
// The only two modals without a per-module makeWindowDraggable call. Wire
// them onto the shared helper, drag-only, to match their old behavior.
try {
['custom-preset-modal', 'rename-session-modal'].forEach((id) => {
const m = document.getElementById(id);
if (!m) return;
const content = m.querySelector('.modal-content');
const header = m.querySelector('.modal-header');
if (!content || !header) return;
makeWindowDraggable(m, {
content, header,
skipSelector: '.close-btn',
enableDock: false,
enableResize: false,
});
}
function onTouchDrag(e) {
if (!dragging) return;
e.preventDefault();
const t = e.touches[0];
content.style.left = (startLeft + t.clientX - dragX) + 'px';
content.style.top = (startTop + t.clientY - dragY) + 'px';
}
function stopTouchDrag() {
dragging = false;
document.removeEventListener('touchmove', onTouchDrag);
document.removeEventListener('touchend', stopTouchDrag);
}
}); } catch(e) { console.error('Modal drag init error:', e); }
// Re-center on open (these persist in the DOM). Guard on the
// hidden→visible edge so it never fires mid-drag.
let wasHidden = m.classList.contains('hidden');
new MutationObserver(() => {
const isHidden = m.classList.contains('hidden');
if (wasHidden && !isHidden) {
content.style.position = '';
content.style.left = '';
content.style.top = '';
content.style.right = '';
content.style.bottom = '';
content.style.margin = '';
}
wasHidden = isHidden;
}).observe(m, { attributes: true, attributeFilter: ['class'] });
});
} catch (e) { console.error('Dialog drag init error:', e); }
})();
// ── Modal minimize → dock ──

View File

@@ -1031,6 +1031,13 @@
<span>RAG</span>
<span class="overflow-active-dot"></span>
</button>
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
<span>Workspace</span>
<span class="overflow-active-dot"></span>
</button>
<!-- Inline "deep research mode" toggle removed (superseded by the
Deep Research sidebar / trigger_research). The hidden
#research-toggle checkbox is kept inert so existing JS refs
@@ -1062,6 +1069,12 @@
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</button>
<!-- Workspace indicator (hidden until a folder is set) -->
<button type="button" class="input-icon-btn tool-indicator" title="Workspace — click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></span>
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
<!-- RAG toolbar indicator (hidden until active) -->
<button type="button" class="input-icon-btn tool-indicator" title="RAG active — click to deactivate" id="rag-indicator-btn" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -1478,6 +1491,10 @@
<label class="settings-label">Tool call limit</label>
<input id="set-agentMaxTools" type="text" inputmode="numeric" placeholder="0 = unlimited" class="settings-select" style="width:120px;">
</div>
<div class="settings-row">
<label class="settings-label">Max steps per message</label>
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
</div>
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
@@ -2264,7 +2281,7 @@
<script type="module" src="/static/js/chatRenderer.js"></script>
<script type="module" src="/static/js/codeRunner.js"></script>
<script type="module" src="/static/js/chatStream.js"></script>
<script type="module" src="/static/js/chat.js?v=20260520m"></script>
<script type="module" src="/static/js/chat.js?v=20260604s"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
<script type="module" src="/static/js/compare/index.js"></script>

View File

@@ -912,6 +912,78 @@ function initEndpointForm() {
btn.disabled = false; btn.textContent = 'Add';
});
// GitHub Copilot — device-flow login. Starts the flow, shows the user a
// code + verification link, and polls until they authorise (or it expires).
const copilotBtn = el('adm-copilotConnectBtn');
if (copilotBtn) {
let copilotPolling = false;
copilotBtn.addEventListener('click', async () => {
if (copilotPolling) return;
const status = el('adm-copilotStatus');
const reset = () => { copilotBtn.disabled = false; copilotBtn.textContent = 'Connect GitHub Copilot'; copilotPolling = false; };
status.textContent = ''; status.className = 'adm-ep-inline-msg';
copilotBtn.disabled = true; copilotBtn.textContent = 'Starting...';
copilotPolling = true;
let start;
try {
const res = await fetch('/api/copilot/device/start', { method: 'POST', body: new FormData(), credentials: 'same-origin' });
start = await res.json();
if (!res.ok) { status.textContent = start.detail || 'Failed to start login'; status.className = 'admin-error'; reset(); return; }
} catch (e) { status.textContent = 'Request failed'; status.className = 'admin-error'; reset(); return; }
const { poll_id, user_code, verification_uri, verification_uri_complete, interval, expires_in } = start;
// Prefer the "complete" URL — it embeds the code so the user only has to
// click "Authorize" (no manual code entry).
const authUrl = verification_uri_complete || verification_uri || '';
const esc = (s) => String(s || '').replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
copilotBtn.textContent = 'Waiting…';
// Cohesive waiting panel: spinner + status line, the device code as a
// copyable chip, and a primary "Authorize on GitHub" action.
status.className = '';
status.innerHTML =
'<div class="adm-copilot-panel">' +
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
'<span>Waiting for GitHub authorization…</span></div>' +
'<div class="adm-copilot-coderow">' +
'<span class="adm-copilot-code-label">Code</span>' +
'<code class="adm-copilot-code">' + esc(user_code) + '</code>' +
'<button type="button" class="admin-btn-sm adm-copilot-copy">Copy</button>' +
'</div>' +
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl) + '" target="_blank" rel="noopener">Authorize on GitHub ↗</a>' +
'<div class="adm-copilot-hint">A new tab opened on GitHub — approve there to finish. Didn\'t open? Use the button above.</div>' +
'</div>';
const copyBtn = status.querySelector('.adm-copilot-copy');
if (copyBtn) copyBtn.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(user_code || ''); copyBtn.textContent = 'Copied'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); } catch (e) {}
});
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
const deadline = Date.now() + (expires_in || 900) * 1000;
const stepMs = Math.max((interval || 5), 2) * 1000;
const done = (cls, text) => { status.className = cls; status.textContent = text; reset(); };
const poll = async () => {
if (Date.now() > deadline) { done('admin-error', 'Authorization expired — try again.'); return; }
try {
const fd = new FormData(); fd.append('poll_id', poll_id);
const r = await fetch('/api/copilot/device/poll', { method: 'POST', body: fd, credentials: 'same-origin' });
const d = await r.json();
if (d.status === 'authorized') {
const n = ((d.endpoint && d.endpoint.models) || []).length;
done('admin-success', '✓ Connected — ' + n + ' Copilot model' + (n !== 1 ? 's' : '') + ' available.');
if (d.endpoint && d.endpoint.id) _recentlyAddedEpId = String(d.endpoint.id);
await loadEndpoints();
await _selectAddedModelInChat(d.endpoint || {});
return;
}
if (d.status === 'failed') { done('admin-error', 'Authorization failed (' + (d.error || 'denied') + ').'); return; }
} catch (e) { /* transient — keep polling */ }
setTimeout(poll, stepMs);
};
setTimeout(poll, stepMs);
});
}
// Local "Add" button — sibling form for self-hosted base URLs.
const localAddBtn = el('adm-epLocalAddBtn');
const localTestBtn = el('adm-epLocalTestBtn');
@@ -1133,11 +1205,11 @@ const _GOOGLE_OAUTH_HELP = `To get Google OAuth credentials:
const MCP_PRESETS = [
{ name: "Gmail", command: "npx", args: ["-y", "@gongrzhe/server-gmail-autoauth-mcp"], env: { GOOGLE_CLIENT_ID: "", GOOGLE_CLIENT_SECRET: "" },
oauthFile: { dir: "~/.gmail-mcp", filename: "gcp-oauth.keys.json" },
oauthFile: { dir: "gmail", filename: "gcp-oauth.keys.json" },
oauth: {
provider: "google",
keys_file: "~/.gmail-mcp/gcp-oauth.keys.json",
token_file: "~/.gmail-mcp/credentials.json",
keys_file: "gmail/gcp-oauth.keys.json",
token_file: "gmail/credentials.json",
scopes: ["https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.settings.basic"],
},
help: `Setup:

View File

@@ -82,13 +82,15 @@ import createResearchSynapse from './researchSynapse.js';
// Background streaming support
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock)
let _streamSessionId = null; // Session ID for the currently active reader loop
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
let _webLockRelease = null; // Function to release the Web Lock held during streaming
/** Check if an SSE reader is still actively connected for a session. */
function hasActiveStream(sessionId) {
return _streamSessionId === sessionId || _backgroundStreams.has(sessionId);
return _streamSessionId === sessionId || _backgroundStreams.has(sessionId) ||
_resumingStreams.has(sessionId);
}
// Sources box builder and toggleSources are now in chatRenderer.js
@@ -779,6 +781,10 @@ import createResearchSynapse from './researchSynapse.js';
if (incognitoChk && incognitoChk.checked) {
fd.append('incognito', 'true');
}
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
if (_ws) {
fd.append('workspace', _ws);
}
if (presetsModule.getSelectedPreset()) {
fd.append('preset_id', presetsModule.getSelectedPreset());
}
@@ -842,7 +848,7 @@ import createResearchSynapse from './researchSynapse.js';
var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
if (_charNameInit) roleLabel = _charNameInit;
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
holder.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
holder.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
_applyModelColor(holder.querySelector('.role'), modelName);
holder.style.position = 'relative';
@@ -1118,7 +1124,7 @@ import createResearchSynapse from './researchSynapse.js';
let _measureDiv = null;
function _replyAfterClosedThinking(text) {
const closeRe = /<\/think(?:ing)?>/gi;
const closeRe = /<\/(?:think(?:ing)?|thought)>|<channel\|>/gi;
let match = null;
let last = null;
while ((match = closeRe.exec(text || '')) !== null) last = match;
@@ -1145,7 +1151,7 @@ import createResearchSynapse from './researchSynapse.js';
replyTrimmed = (replyText || '').trim();
} else {
// Non-tag: check for garbled <think> (reasoning\n<think>reply)
const _gm = dt.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
const _gm = dt.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
if (_gm && _gm[1].trim()) {
replyTrimmed = _gm[1].trim();
} else {
@@ -1186,8 +1192,11 @@ import createResearchSynapse from './researchSynapse.js';
const prevLen = contentEl._prevTextLen || 0;
// If thinking is still streaming (unclosed <think>), show indicator instead of raw text
if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) {
const thinkStart = dt.search(/<think(?:ing)?>/i);
const thinkContent = dt.substring(thinkStart).replace(/<think(?:ing)?>/i, '').trim();
const thinkStart = dt.search(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i);
const thinkContent = dt.substring(Math.max(thinkStart, 0))
.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought\s*\n?/i, '')
.replace(/<channel\|>/gi, '')
.trim();
const lines = thinkContent.split('\n').length;
// Don't show beforeThink text during streaming — it'll appear in the final render
// This prevents the "split into two" duplication
@@ -1447,7 +1456,7 @@ import createResearchSynapse from './researchSynapse.js';
// Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
// These patterns don't use <think> tags, so we simulate unclosed thinking during streaming
const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
if (!hasUnclosedThink && !roundText.includes('<think')) {
if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(roundText)) {
const _trimmedRT = roundText.trimStart();
const _isReasoning = markdownModule.startsWithReasoningPrefix(_trimmedRT);
if (_isReasoning) {
@@ -1473,10 +1482,10 @@ import createResearchSynapse from './researchSynapse.js';
}
}
}
if (!hasUnclosedThink && /^<think(?:ing)?>\s*<\/think(?:ing)?>/i.test(roundText)) {
if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(roundText)) {
// Empty <think></think> — the model likely put thinking outside the tags
const afterEmpty = roundText.replace(/^<think(?:ing)?>\s*<\/think(?:ing)?>/i, '').trim();
const closeTags = (afterEmpty.match(/<\/think(?:ing)?>/gi) || []).length;
const afterEmpty = roundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
const closeTags = (afterEmpty.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length;
if (closeTags === 0 && afterEmpty.length > 0) {
hasUnclosedThink = true; // still waiting for real closing tag
}
@@ -1485,13 +1494,13 @@ import createResearchSynapse from './researchSynapse.js';
// Only applies when there's a second </think> later (model leaked thinking outside tags)
// Do NOT trigger if the text after </think> contains tool calls (that's real content)
if (!hasUnclosedThink && isThinking) {
const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
const _thinkMatch = roundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0;
if (_thinkLen < 20) {
const _afterClose = roundText.replace(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i, '').trim();
const _afterClose = roundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
// Only keep waiting if there's trailing text that looks like thinking (not tool calls)
const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose);
const _hasOrphanClose = /<\/think(?:ing)?>/i.test(_afterClose);
const _hasOrphanClose = /<\/(?:think(?:ing)?|thought)>/i.test(_afterClose);
if (!_hasToolCall && (_hasOrphanClose || (Date.now() - thinkingStartTime) < 500)) {
hasUnclosedThink = true; // keep waiting for real </think>
}
@@ -1548,8 +1557,12 @@ import createResearchSynapse from './researchSynapse.js';
}
} else if (hasUnclosedThink && isThinking) {
if (_liveThinkInner) {
// Extract raw thinking text (strip all <think>/<thinking> open/close tags and prefixes)
var thinkText = roundText.replace(/<\/?think(?:ing)?>/gi, '');
// Extract raw thinking text (strip known thinking wrappers and prefixes)
var thinkText = roundText
.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
.replace(/<\|channel>thought\s*\n?/gi, '')
.replace(/<\|channel>response\s*\n?/gi, '')
.replace(/<channel\|>/gi, '');
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
// Keep thinking box scrolled to bottom
@@ -1827,6 +1840,44 @@ import createResearchSynapse from './researchSynapse.js';
}
}
}
} else if (json.type === 'rounds_exhausted') {
// The agent hit the per-turn step limit while still working.
// Offer a Continue button instead of stalling silently.
// NOTE: append to the chat-history container (bottom), NOT the
// message body — the body innerHTML is re-rendered at stream
// finalize, which would wipe a note placed inside it.
const _chatBox = document.getElementById('chat-history');
if (!_isBg && _chatBox) {
// Drop any prior box so repeated cap-hits each get a fresh
// Continue at the bottom (multiple continues in a row).
const _old = _chatBox.querySelector('.rounds-exhausted');
if (_old) _old.remove();
const note = document.createElement('div');
note.className = 'stopped-indicator rounds-exhausted';
const label = document.createElement('span');
label.className = 'rounds-exhausted-label';
label.textContent = `Reached the ${json.rounds || ''}-step limit — not finished.`;
note.appendChild(label);
const contBtn = document.createElement('button');
contBtn.className = 'continue-btn';
contBtn.title = 'Continue the task';
contBtn.textContent = 'Continue ▸';
const _holder = currentHolder;
contBtn.addEventListener('click', () => {
note.remove();
_hideUserBubble = true;
_pendingContinue = _holder;
const msgInput = uiModule.el('message');
if (msgInput) {
msgInput.value = 'You hit the step limit before finishing — the task is not complete. Continue from exactly where you left off and keep going until it is done. Do NOT repeat work already done.';
const sb = document.querySelector('.send-btn');
if (sb) sb.click();
}
});
note.appendChild(contBtn);
_chatBox.appendChild(note);
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
}
} else if (json.type === 'attachments') {
if (_isBg) continue;
// Update user bubble — replace file chips with image previews
@@ -1993,7 +2044,7 @@ import createResearchSynapse from './researchSynapse.js';
const node = document.createElement('div')
node.className = 'agent-thread-node running';
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
// Expand/collapse via delegated click handler (init at module bottom).
threadWrap.appendChild(node);
currentToolBubble = node;
@@ -2072,7 +2123,33 @@ import createResearchSynapse from './researchSynapse.js';
if (json.output && json.output.trim()) {
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(json.output)}</pre></details>`;
}
const cmdHtml2 = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
// File-write diff (write_file): show a before/after unified diff.
let diffHtml = '';
if (json.diff && json.diff.text) {
const d = json.diff;
// Collapsed summary: filename + +adds (green) / dels (red).
const stat = [
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
d.removed ? `<span class="diff-stat-del">${d.removed}</span>` : '',
].filter(Boolean).join(' ');
const rows = d.text.split('\n').map(line => {
let cls = 'diff-ctx', text = line;
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
else if (line.startsWith('@@')) cls = 'diff-hunk';
// Drop the leading diff marker (+/-/space) — the row colour
// already encodes add/del, and keeping it doubles up with
// markdown "- " bullets (reads as "+-"/"--").
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
else if (line.startsWith(' ')) { text = line.slice(1); }
return `<span class="${cls}">${esc(text) || '&nbsp;'}</span>`;
}).join(''); // spans are display:block — a literal \n here would double-space the diff
diffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
}
// For file edits the "command" is the raw JSON args — redundant
// next to the diff, so hide it when we have a diff to show.
const cmdHtml2 = (cmd && !(json.diff && json.diff.text)) ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
// Preserve the user's .open choice across the innerHTML
// rewrite \u2014 otherwise expanding a running tool collapses
// it as soon as the result lands, forcing the user to
@@ -2080,7 +2157,7 @@ import createResearchSynapse from './researchSynapse.js';
// bottom of file) so no per-node listener needed.
const _wasOpen = currentToolBubble.classList.contains('open');
currentToolBubble.className = 'agent-thread-node' + (ok ? '' : ' error') + (_wasOpen ? ' open' : '');
currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}</div>`;
currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}${diffHtml}</div>`;
// Reset so thinking spinner between tools says "Thinking" not the old tool's label
_lastToolName = '';
uiModule.scrollHistory();
@@ -2097,10 +2174,19 @@ import createResearchSynapse from './researchSynapse.js';
if (json.screenshot && currentToolBubble) {
const contentEl = currentToolBubble.querySelector('.agent-thread-content');
if (contentEl) {
const details = document.createElement('details');
details.className = 'agent-tool-output';
details.innerHTML = `<summary>Screenshot</summary><img src="${json.screenshot}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" />`;
contentEl.appendChild(details);
const screenshotSrc = chatRenderer.safeToolScreenshotSrc(json.screenshot);
if (screenshotSrc) {
const details = document.createElement('details');
details.className = 'agent-tool-output';
const summary = document.createElement('summary');
summary.textContent = 'Screenshot';
const img = document.createElement('img');
img.src = screenshotSrc;
img.style.cssText = 'max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)';
details.appendChild(summary);
details.appendChild(img);
contentEl.appendChild(details);
}
}
}
// --- Reload sessions after manage_session tool (delete, rename, etc.) ---
@@ -2374,8 +2460,8 @@ import createResearchSynapse from './researchSynapse.js';
_finalReply = (_extracted.content || '').trim();
} else {
// Non-tag thinking: extract reply from raw text
// Handle garbled <think> tag: "Thinking: reasoning\n<think>reply"
const _garbledMatch = finalDisplay.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
// Handle garbled thinking tag: "Thinking: reasoning\n<think>reply"
const _garbledMatch = finalDisplay.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
if (_garbledMatch && _garbledMatch[1].trim()) {
_finalReply = _garbledMatch[1].trim();
} else {
@@ -2424,8 +2510,8 @@ import createResearchSynapse from './researchSynapse.js';
_body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
} else if (roundHolder !== holder) {
// Check if there's thinking content worth showing
const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
if (_thinkMatch && _thinkMatch[1].trim()) {
const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText);
if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) {
// Show thinking in a collapsed section even if no visible reply text
const _body4c = roundHolder.querySelector('.body');
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText);
@@ -3045,6 +3131,152 @@ import createResearchSynapse from './researchSynapse.js';
var _notifyStreamComplete = chatStream.notifyStreamComplete;
var _insertStreamDoneToast = chatStream.insertStreamDoneToast;
/**
* Live-resume a chat run still streaming detached on the server (#2539).
*
* On session re-entry, GET /api/chat/resume/{id} replays the run's buffer then
* streams live; reply tokens render as they arrive. On completion a plain text
* reply is finalized in place (canonical bubble via chatRenderer.addMessage, no
* reload); a "rich" reply (tool calls, sources, doc streaming, multi-round) is
* reloaded from the DB so its full render stays faithful. Returns true if it
* attached, false to let the caller fall back to spinner+poll.
*/
export async function resumeStream(sessionId) {
if (!sessionId) return false;
if (hasActiveStream(sessionId)) return false;
let res;
try {
res = await fetch(`${API_BASE}/api/chat/resume/${sessionId}`);
} catch (e) {
return false;
}
if (!res.ok || !res.body) return false;
const box = document.getElementById('chat-history');
if (!box) return false;
// Block duplicate re-attach attempts while this reader is live. A dedicated
// set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
// for a same-tab POST stream and spawn its own spinner+poll on re-entry.
_resumingStreams.add(sessionId);
const holder = document.createElement('div');
holder.className = 'msg msg-ai';
const meta = sessionModule.getSessions().find(s => s.id === sessionId);
const roleLabel = _shortModel(meta && meta.model);
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) +
' <span class="role-timestamp">' + roleTs + '</span></div>' +
'<div class="body"><div class="stream-content"></div></div>';
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
const contentDiv = holder.querySelector('.stream-content');
box.appendChild(holder);
const spinner = spinnerModule.create('Generating response...', 'right');
holder.querySelector('.body').appendChild(spinner.createElement());
spinner.start();
uiModule.scrollHistory();
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let roundText = '';
let gotDelta = false;
let leftSession = false;
let metricsData = null;
// "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
// full canonical render, which is rebuilt from the saved DB record on reload.
// Plain text replies can be finalized in place without a reload.
let rich = false;
const cleanup = () => {
try { spinner.destroy(); } catch (_) {}
_resumingStreams.delete(sessionId);
};
const renderDelta = () => {
const dt = stripToolBlocks(roundText);
contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
uiModule.scrollHistory();
};
try {
readLoop:
while (true) {
// User left this session: stop rendering, the run continues server-side.
if (sessionModule.getCurrentSessionId &&
sessionModule.getCurrentSessionId() !== sessionId) {
leftSession = true;
try { await reader.cancel(); } catch (_) {}
break;
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop();
for (const part of parts) {
const line = part.split('\n').find(l => l.startsWith('data: '));
if (!line) continue;
const payload = line.slice(6);
if (payload === '[DONE]') {
try { await reader.cancel(); } catch (_) {}
break readLoop;
}
let json;
try { json = JSON.parse(payload); } catch (_) { continue; }
if (json.delta) {
roundText += json.delta;
if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} }
renderDelta();
} else if (json.type === 'doc_stream_open') {
rich = true;
if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || '');
} else if (json.type === 'doc_stream_delta') {
rich = true;
if (documentModule && json.delta) documentModule.streamDocDelta(json.delta);
} else if (json.type === 'metrics') {
metricsData = json.data || metricsData;
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
json.type === 'tool_progress' || json.type === 'agent_step' ||
json.type === 'web_sources' || json.type === 'rag_sources' ||
json.type === 'research_progress' || json.type === 'research_sources' ||
json.type === 'research_findings' || json.type === 'research_done') {
rich = true;
}
}
}
} catch (e) {
// Network drop or parse failure: fall through to the reload below.
}
cleanup();
if (leftSession) { if (holder.parentNode) holder.remove(); return true; }
const onThisSession = sessionModule.getCurrentSessionId &&
sessionModule.getCurrentSessionId() === sessionId;
// Plain text reply: finalize in place. Replace the live bubble with a
// canonical single message (markdown + footer actions + metrics) using the
// same renderer history does. No history refetch, no end-of-stream flicker.
if (onThisSession && !rich && roundText.trim()) {
if (holder.parentNode) holder.remove();
const model = meta && meta.model;
const meta_ = metricsData ? Object.assign({ model }, metricsData) : { model };
chatRenderer.addMessage('assistant', roundText, model, meta_);
uiModule.scrollHistory();
return true;
}
// Rich response (tools, sources, docs, multi-round) or user moved on:
// reload from the DB for the full canonical render.
if (holder.parentNode) holder.remove();
if (onThisSession) sessionModule.selectSession(sessionId);
else sessionModule.loadSessions();
return true;
}
/**
* Check for background streams when switching to a session.
* Called after history loads on session switch.
@@ -3090,7 +3322,7 @@ import createResearchSynapse from './researchSynapse.js';
var meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
var roleLabel = _shortModel(meta && meta.model);
var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
holder.innerHTML = '<div class="role">' + roleLabel + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
var bodyDiv = holder.querySelector('.body');
@@ -3892,7 +4124,7 @@ import createResearchSynapse from './researchSynapse.js';
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
const agentModelLabel = _shortModel(agentMeta?.model);
holder.innerHTML = `<div class="role">${agentModelLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
holder.innerHTML = `<div class="role">${uiModule.esc(agentModelLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
_applyModelColor(holder.querySelector('.role'), agentMeta?.model);
box.appendChild(holder);
@@ -4360,9 +4592,10 @@ import createResearchSynapse from './researchSynapse.js';
// never closes (so it would otherwise hide the whole answer). Peel all of
// those off so what's left is just the rewritten text.
const _stripThink = (t) => {
t = t.replace(/<think>[\s\S]*?<\/think>/gi, ''); // complete blocks
if (/<\/think>/i.test(t)) t = t.replace(/^[\s\S]*?<\/think>/i, ''); // reasoning w/o opener
return t.replace(/<\/?think>/gi, '').trim(); // any orphan tag
t = markdownModule.normalizeThinkingMarkup(t || '');
t = t.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>[\s\S]*?<\/(?:think(?:ing)?|thought)>/gi, ''); // complete blocks
if (/<\/(?:think(?:ing)?|thought)>/i.test(t)) t = t.replace(/^[\s\S]*?<\/(?:think(?:ing)?|thought)>/i, ''); // reasoning w/o opener
return t.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '').trim(); // any orphan tag
};
newText = _stripThink(newText);
@@ -4528,6 +4761,7 @@ import createResearchSynapse from './researchSynapse.js';
abortCurrentRequest,
detachCurrentStream,
checkBackgroundStream,
resumeStream,
hideWelcomeScreen: chatRenderer.hideWelcomeScreen,
showWelcomeScreen: chatRenderer.showWelcomeScreen,
checkPendingResearch,

View File

@@ -4,7 +4,7 @@
import uiModule from './ui.js';
import markdownModule from './markdown.js';
import { addAITTSButton } from './tts-ai.js';
import { providerLogo } from './providers.js';
import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js';
import spinnerModule from './spinner.js';
import { bindMenuDismiss } from './escMenuStack.js';
@@ -26,6 +26,29 @@ function _safeHref(url) {
return '#';
}
export function safeToolScreenshotSrc(raw) {
const src = String(raw || '').trim();
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
return src;
}
return '';
}
export function safeDisplayImageSrc(raw) {
const src = String(raw || '').trim();
if (!src) return '';
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
return src;
}
try {
const parsed = new URL(src, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (_) {}
return '';
}
function _makeActionBtn(className, title, text, handler) {
const btn = document.createElement('button');
btn.className = className;
@@ -577,6 +600,12 @@ export function applyModelColor(roleEl, modelName) {
if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>';
html += short + '</div>';
html += '<div><span class="ctx-label">Model</span> ' + modelName.split('/').pop() + '</div>';
// Provider = the serving endpoint, distinct from the model vendor/logo
// (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct).
const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
? window.sessionModule.getCurrentEndpointUrl() : null;
const _provLabel = providerLabel(_epUrl);
if (_provLabel) html += '<div><span class="ctx-label">Provider</span> ' + uiModule.esc(_provLabel) + '</div>';
// Show static context initially, then fetch real from server
const _realCtx = window._realContextLengths && window._realContextLengths[modelName];
if (_realCtx) {
@@ -1052,12 +1081,19 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
const body = document.createElement('div');
body.className = 'body';
const safeImageUrl = safeDisplayImageSrc(imageUrl);
if (!safeImageUrl) {
body.textContent = '[Image unavailable]';
wrap.appendChild(body);
return wrap;
}
const img = document.createElement('img');
img.className = 'generated-image';
img.alt = prompt || 'Generated image';
img.title = prompt || 'Generated image';
img.src = imageUrl;
img.addEventListener('click', () => { window.open(img.src, '_blank'); });
img.src = safeImageUrl;
img.addEventListener('click', () => { window.open(safeImageUrl, '_blank', 'noopener,noreferrer'); });
body.appendChild(img);
if (prompt) {
@@ -1947,13 +1983,37 @@ export function addMessage(role, content, modelName, metadata) {
if (ev.output && ev.output.trim()) {
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`;
}
if (ev.screenshot) {
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(ev.screenshot)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
const screenshotSrc = safeToolScreenshotSrc(ev.screenshot);
if (screenshotSrc) {
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(screenshotSrc)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
}
// File-write/edit diff (persisted in the tool event) \u2014 re-render it
// so it survives reload, matching the live stream.
let evDiffHtml = '';
if (ev.diff && ev.diff.text) {
const d = ev.diff;
const stat = [
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
d.removed ? `<span class="diff-stat-del">\u2212${d.removed}</span>` : '',
].filter(Boolean).join(' ');
const rows = d.text.split('\n').map(line => {
let cls = 'diff-ctx', text = line;
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
else if (line.startsWith('@@')) cls = 'diff-hunk';
// Drop the leading diff marker (+/-/space) — colour encodes add/del.
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
else if (line.startsWith(' ')) { text = line.slice(1); }
return `<span class="${cls}">${esc(text) || '&nbsp;'}</span>`;
}).join(''); // spans are display:block \u2014 a literal \n would double-space
evDiffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
}
const node = document.createElement('div');
node.className = 'agent-thread-node' + (ok ? '' : ' error');
const evCmdHtml = ev.command ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}</div>`;
// Hide the raw JSON command when a diff says it better (same as live).
const evCmdHtml = (ev.command && !(ev.diff && ev.diff.text)) ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}${evDiffHtml}</div>`;
// Click handling is delegated globally \u2014 see chat.js init.
threadWrap.appendChild(node);
}
@@ -2279,6 +2339,8 @@ const chatRenderer = {
updateSessionCostUI,
roleTimestamp,
stripToolBlocks,
safeToolScreenshotSrc,
safeDisplayImageSrc,
buildSourcesBox,
buildFindingsBox,
appendReportButton,

View File

@@ -362,6 +362,7 @@ export function runHTML(code, panel) {
addCloseBtn(panel);
return;
}
try { win.opener = null; } catch (_) {}
win.document.open();
win.document.write(code);
win.document.close();

View File

@@ -1090,6 +1090,7 @@ function _exportPrint() {
// the system print dialog — user can pick "Save as PDF" from there.
const w = window.open('', '_blank');
if (!w) return;
try { w.opener = null; } catch (_) {}
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const html = '<!doctype html><meta charset="utf-8"><title>Compare export</title>' +
'<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:780px;margin:32px auto;padding:0 24px;line-height:1.55;color:#222}' +

View File

@@ -1195,7 +1195,7 @@ async function showModelSelector() {
const row = document.createElement('div');
row.className = 'compare-probe-row';
row.dataset.idx = 'p' + i;
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${p.label || p.id}</span><span class="compare-probe-status"></span>`;
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${escapeHtml(p.label || p.id)}</span><span class="compare-probe-status"></span>`;
const waveEl = row.querySelector('.compare-probe-spinner');
const waveFrames = WAVE_FRAMES;
let wIdx = 0;

View File

@@ -1,7 +1,7 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { addFinishBadge } from './vote.js';
import { getModelCost } from '../chatRenderer.js';
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
import markdownModule from '../markdown.js';
import spinnerModule from '../spinner.js';
import uiModule from '../ui.js';
@@ -11,6 +11,16 @@ var escapeHtml = uiModule.esc;
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
function _safeHttpHref(raw) {
try {
const parsed = new URL(String(raw || '').trim(), window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (_) {}
return '';
}
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
let _rerollPane = null;
let _autoPreviewHtml = null;
@@ -36,9 +46,12 @@ function _renderSearchResults(data) {
const card = document.createElement('div');
card.className = 'compare-search-result';
const titleLink = document.createElement('a');
titleLink.href = r.url || '#';
titleLink.target = '_blank';
titleLink.rel = 'noopener';
const safeUrl = _safeHttpHref(r.url);
if (safeUrl) {
titleLink.href = safeUrl;
titleLink.target = '_blank';
titleLink.rel = 'noopener noreferrer';
}
titleLink.className = 'search-result-title';
titleLink.textContent = r.title || 'Untitled';
card.appendChild(titleLink);
@@ -344,7 +357,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
const node = document.createElement('div');
node.className = 'agent-thread-node running';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${escapeHtml(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
// Animate wave
const waveEl = node.querySelector('.agent-thread-wave');
@@ -363,28 +376,33 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
if (json.image_url) {
// Stop image spinner and render generated image in pane
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
const safeImageUrl = safeDisplayImageSrc(json.image_url);
aiBody.innerHTML = '';
const img = document.createElement('img');
img.className = 'compare-gen-image';
img.src = json.image_url;
img.alt = json.image_prompt || '';
img.title = json.image_prompt || '';
img.addEventListener('click', () => window.open(img.src, '_blank'));
aiBody.appendChild(img);
if (json.image_prompt) {
const caption = document.createElement('div');
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
caption.textContent = json.image_prompt;
aiBody.appendChild(caption);
if (!safeImageUrl) {
aiBody.textContent = '[Image unavailable]';
} else {
const img = document.createElement('img');
img.className = 'compare-gen-image';
img.src = safeImageUrl;
img.alt = json.image_prompt || '';
img.title = json.image_prompt || '';
img.addEventListener('click', () => window.open(safeImageUrl, '_blank', 'noopener,noreferrer'));
aiBody.appendChild(img);
if (json.image_prompt) {
const caption = document.createElement('div');
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
caption.textContent = json.image_prompt;
aiBody.appendChild(caption);
}
// Show model name below image (hidden in blind mode until vote)
if (json.image_model && !state._blindMode) {
const modelLabel = document.createElement('div');
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
modelLabel.textContent = json.image_model;
aiBody.appendChild(modelLabel);
}
aiMsgEl._imageData = { url: safeImageUrl, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
}
// Show model name below image (hidden in blind mode until vote)
if (json.image_model && !state._blindMode) {
const modelLabel = document.createElement('div');
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
modelLabel.textContent = json.image_model;
aiBody.appendChild(modelLabel);
}
aiMsgEl._imageData = { url: json.image_url, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
} else if (currentToolBlock) {
// Stop wave animation
if (currentToolBlock._waveInterval) { clearInterval(currentToolBlock._waveInterval); currentToolBlock._waveInterval = null; }

View File

@@ -2246,7 +2246,9 @@ import * as Modals from './modalManager.js';
// WYSIWYG body — use it verbatim. (Checking a leading '<' isn't enough: a
// rich body often starts with plain text, e.g. "Hi <b>there</b>".)
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) return t;
try { return markdownModule.mdToHtml(text); }
// Email body: keep author-typed `:shortcode:` text literal. Issue #345
// (shortcode → emoji) is scoped to chat; do not rewrite colons in mail.
try { return markdownModule.mdToHtml(text, { shortcodes: false }); }
catch (_) {
const d = document.createElement('div'); d.textContent = text;
return d.innerHTML.replace(/\n/g, '<br>');
@@ -8386,7 +8388,7 @@ import * as Modals from './modalManager.js';
const text = textarea.value || '';
let body;
if (lang === 'markdown' && markdownModule?.mdToHtml) {
body = markdownModule.mdToHtml(text);
body = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal
} else {
body = '<pre style="white-space:pre-wrap;font-size:12px;font-family:monospace;">' +
text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
@@ -8417,7 +8419,7 @@ import * as Modals from './modalManager.js';
// Render content as HTML for PDF
let html;
if (lang === 'markdown' && markdownModule?.mdToHtml) {
html = markdownModule.mdToHtml(text);
html = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal
} else {
html = '<pre style="white-space:pre-wrap;font-size:11px;font-family:monospace;color:#000;background:#fff;">' +
text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
@@ -8547,7 +8549,7 @@ import * as Modals from './modalManager.js';
if (active) {
const md = textarea.value || '';
if (markdownModule && markdownModule.mdToHtml) {
preview.innerHTML = markdownModule.mdToHtml(md);
preview.innerHTML = markdownModule.mdToHtml(md, { shortcodes: false }); // doc preview: keep :shortcodes: literal
} else {
preview.innerHTML = md.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g, '<br>');
}

View File

@@ -76,6 +76,15 @@ function _hlSearch(text) {
'<mark class="doclib-search-hl">$1</mark>');
} catch { return esc; }
}
function _safeResearchHref(raw) {
try {
const parsed = new URL(String(raw || '').trim(), window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
} catch {}
return '';
}
let _libraryEscHandler = null;
let _librarySelectMode = false;
let _librarySelectedIds = new Set();
@@ -2649,7 +2658,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const data = await res.json();
_researchItems = data.research || data || [];
} catch (e) {
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${e.message}</div>`;
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${_esc(e.message)}</div>`;
return;
}
_renderResearchGrid();
@@ -2691,9 +2700,9 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const sources = Array.isArray(detail.sources) ? detail.sources : [];
const sourcesList = sources.slice(0, 12).map((src, i) => {
const title = _esc(src.title || src.url || `Source ${i + 1}`);
const url = src.url || '';
const url = _safeResearchHref(src.url);
return url
? `<li><a href="${_esc(url)}" target="_blank" rel="noopener">${title}</a></li>`
? `<li><a href="${url}" target="_blank" rel="noopener">${title}</a></li>`
: `<li>${title}</li>`;
}).join('');
const sourcesHtml = sources.length

View File

@@ -30,6 +30,28 @@ export function _esc(text) {
return div.innerHTML;
}
function _attrEsc(text) {
return String(text ?? '')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/`/g, '&#96;');
}
function _compactUrlSchemeValue(value) {
return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
}
function _isDangerousUrl(value) {
const compact = _compactUrlSchemeValue(value);
return compact.startsWith('javascript:') || compact.startsWith('vbscript:') || compact.startsWith('data:');
}
function _isDangerousSrcset(value) {
return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
}
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
export function _escLinkify(text) {
const escaped = _esc(text);
@@ -39,9 +61,9 @@ export function _escLinkify(text) {
return escaped
.replace(urlRe, (m) => {
const href = m.startsWith('www.') ? `https://${m}` : m;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${m}</a>`;
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
})
.replace(mailRe, (m) => `<a href="mailto:${m}">${m}</a>`);
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
}
// Pull display name out of "Name <email@x>"; fallback to local-part of
@@ -133,19 +155,14 @@ export function _initials(s) {
// `data:` URLs on every known URL attribute, scrubs inline colour/font/
// position styles so the theme can take over, and wraps highlight-bearing
// inline tags in <mark> so they render legibly across themes.
export function _sanitizeHtml(html) {
function _sanitizeHtmlOnce(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll(
'script, iframe, object, embed, form, style, link, ' +
'svg, math, base, meta, noscript, frame, frameset, applet, portal'
).forEach(el => el.remove());
const URL_ATTRS = ['href', 'src', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
const isDangerousUrl = (val) => {
if (!val) return false;
const v = val.trim().toLowerCase();
return v.startsWith('javascript:') || v.startsWith('vbscript:') || v.startsWith('data:');
};
const URL_ATTRS = ['href', 'src', 'xlink:href', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
const STRIP_CSS_PROPS = ['color', 'background', 'background-color',
'font-family', 'font', '-webkit-text-fill-color',
@@ -160,7 +177,7 @@ export function _sanitizeHtml(html) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) { el.removeAttribute(attr.name); continue; }
if (name === 'srcdoc') { el.removeAttribute(attr.name); continue; }
if (URL_ATTRS.includes(name) && isDangerousUrl(attr.value)) {
if (URL_ATTRS.includes(name) && (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value))) {
el.removeAttribute(attr.name);
continue;
}
@@ -177,8 +194,8 @@ export function _sanitizeHtml(html) {
if (style) {
const kept = style.split(';').map(s => s.trim()).filter(decl => {
if (!decl) return false;
const lower = decl.toLowerCase();
if (lower.includes('javascript:') || lower.includes('expression(')) return false;
const lower = _compactUrlSchemeValue(decl);
if (lower.includes('javascript:') || lower.includes('vbscript:') || lower.includes('data:') || lower.includes('expression(')) return false;
const prop = decl.split(':', 1)[0].trim().toLowerCase();
return !STRIP_CSS_PROPS.includes(prop);
});
@@ -200,3 +217,13 @@ export function _sanitizeHtml(html) {
return doc.body.innerHTML;
}
export function _sanitizeHtml(html) {
let out = String(html ?? '');
for (let i = 0; i < 4; i++) {
const next = _sanitizeHtmlOnce(out);
if (next === out) break;
out = next;
}
return out;
}

View File

@@ -0,0 +1,458 @@
// static/js/emojiShortcodes.js
//
// Emoji shortcode → Unicode conversion (issue #345).
//
// Chat models frequently emit GitHub/Slack-style `:shortcode:` text — e.g.
// `:blush:`, `:fire:`, `:microphone:` — instead of the actual emoji character.
// Nothing in the render pipeline used to translate these, so they showed up as
// literal `:blush:` text in the chat bubble.
//
// This module turns the common shortcode set into the real Unicode emoji. The
// chat renderer (markdown.js → svgifyEmoji) runs this BEFORE its existing
// Unicode-emoji → monochrome-SVG pass, so a converted `:blush:` renders as the
// same theme-tinted single-color line icon as any other emoji (project rule:
// never colorful emoji), not as a colored system glyph.
//
// Pure and browser-free on purpose: no DOM, no imports, so it can be unit
// tested with plain `node` (see tests/test_emoji_shortcodes_js.py).
// Canonical map of common shortcode → Unicode emoji. Names follow the GitHub
// convention (lowercase, underscore-separated). A handful of well-known aliases
// (`+1`, `thumbsup`, `grinning_face`, …) point at the same glyph so the most
// frequent model spellings all resolve.
export const EMOJI_SHORTCODES = {
// ── Smileys & emotion ──
grinning: '😀', grinning_face: '😀',
smiley: '😃', smiley_face: '😃',
smile: '😄',
grin: '😁',
laughing: '😆', satisfied: '😆',
sweat_smile: '😅',
rofl: '🤣', rolling_on_the_floor_laughing: '🤣',
joy: '😂',
slightly_smiling_face: '🙂', slight_smile: '🙂',
upside_down_face: '🙃', upside_down: '🙃',
wink: '😉', winking_face: '😉',
blush: '😊', smiling_face_with_smiling_eyes: '😊',
innocent: '😇',
smiling_face_with_three_hearts: '🥰',
heart_eyes: '😍', heart_eyes_face: '😍',
star_struck: '🤩',
kissing_heart: '😘',
kissing: '😗',
kissing_closed_eyes: '😚',
kissing_smiling_eyes: '😙',
yum: '😋',
stuck_out_tongue: '😛',
stuck_out_tongue_winking_eye: '😜',
zany_face: '🤪',
stuck_out_tongue_closed_eyes: '😝',
money_mouth_face: '🤑',
hugs: '🤗', hugging_face: '🤗',
hand_over_mouth: '🤭',
shushing_face: '🤫',
thinking: '🤔', thinking_face: '🤔',
zipper_mouth_face: '🤐',
raised_eyebrow: '🤨',
neutral_face: '😐',
expressionless: '😑',
no_mouth: '😶',
smirk: '😏', smirk_face: '😏',
unamused: '😒',
roll_eyes: '🙄', face_with_rolling_eyes: '🙄',
grimacing: '😬',
lying_face: '🤥',
relieved: '😌',
pensive: '😔',
sleepy: '😪',
drooling_face: '🤤',
sleeping: '😴',
mask: '😷',
face_with_thermometer: '🤒',
face_with_head_bandage: '🤕',
nauseated_face: '🤢',
vomiting_face: '🤮',
sneezing_face: '🤧',
hot_face: '🥵',
cold_face: '🥶',
woozy_face: '🥴',
dizzy_face: '😵',
exploding_head: '🤯',
cowboy_hat_face: '🤠',
partying_face: '🥳',
sunglasses: '😎',
nerd_face: '🤓',
monocle_face: '🧐',
confused: '😕',
worried: '😟',
slightly_frowning_face: '🙁',
frowning_face: '☹️',
open_mouth: '😮',
hushed: '😯',
astonished: '😲',
flushed: '😳',
pleading_face: '🥺',
frowning: '😦',
anguished: '😧',
fearful: '😨',
cold_sweat: '😰',
disappointed_relieved: '😥',
cry: '😢',
sob: '😭',
scream: '😱',
confounded: '😖',
persevere: '😣',
disappointed: '😞',
sweat: '😓',
weary: '😩',
tired_face: '😫',
yawning_face: '🥱',
triumph: '😤',
rage: '😡', pout: '😡', pouting_face: '😡',
angry: '😠',
cursing_face: '🤬',
smiling_imp: '😈',
imp: '👿',
skull: '💀',
skull_and_crossbones: '☠️',
hankey: '💩', poop: '💩', shit: '💩',
clown_face: '🤡',
japanese_ogre: '👹',
japanese_goblin: '👺',
ghost: '👻',
alien: '👽',
space_invader: '👾',
robot: '🤖', robot_face: '🤖',
// ── Cats ──
smiley_cat: '😺',
smile_cat: '😸',
joy_cat: '😹',
heart_eyes_cat: '😻',
smirk_cat: '😼',
kissing_cat: '😽',
scream_cat: '🙀',
crying_cat_face: '😿',
pouting_cat: '😾',
see_no_evil: '🙈',
hear_no_evil: '🙉',
speak_no_evil: '🙊',
// ── Hands & body ──
wave: '👋', wave_hand: '👋',
raised_back_of_hand: '🤚',
raised_hand_with_fingers_splayed: '🖐️',
hand: '✋', raised_hand: '✋',
vulcan_salute: '🖖',
ok_hand: '👌',
pinched_fingers: '🤌',
pinching_hand: '🤏',
v: '✌️', victory_hand: '✌️',
crossed_fingers: '🤞',
love_you_gesture: '🤟',
metal: '🤘',
call_me_hand: '🤙',
point_left: '👈',
point_right: '👉',
point_up_2: '👆',
middle_finger: '🖕', fu: '🖕',
point_down: '👇',
point_up: '☝️',
'+1': '👍', thumbsup: '👍', thumbup: '👍', thumbs_up: '👍',
'-1': '👎', thumbsdown: '👎', thumbdown: '👎', thumbs_down: '👎',
fist_raised: '✊', fist: '✊',
fist_oncoming: '👊', facepunch: '👊', punch: '👊',
fist_left: '🤛',
fist_right: '🤜',
clap: '👏', clapping_hands: '👏',
raised_hands: '🙌',
open_hands: '👐',
palms_up_together: '🤲',
handshake: '🤝',
pray: '🙏', folded_hands: '🙏',
writing_hand: '✍️',
nail_care: '💅',
selfie: '🤳',
muscle: '💪', flexed_biceps: '💪',
// ── Hearts & symbols of feeling ──
heart: '❤️', red_heart: '❤️',
orange_heart: '🧡',
yellow_heart: '💛',
green_heart: '💚',
blue_heart: '💙',
purple_heart: '💜',
black_heart: '🖤',
white_heart: '🤍',
brown_heart: '🤎',
broken_heart: '💔',
heart_on_fire: '❤️‍🔥',
two_hearts: '💕',
revolving_hearts: '💞',
heartbeat: '💓',
heartpulse: '💗',
sparkling_heart: '💖',
cupid: '💘',
gift_heart: '💝',
heart_decoration: '💟',
heavy_heart_exclamation: '❣️',
// ── Celebration & misc objects ──
fire: '🔥', flame: '🔥',
'100': '💯', hundred: '💯',
sparkles: '✨',
star: '⭐',
star2: '🌟', glowing_star: '🌟',
dizzy: '💫',
boom: '💥', collision: '💥',
anger: '💢',
sweat_drops: '💦',
dash: '💨',
zzz: '💤',
tada: '🎉', party_popper: '🎉',
confetti_ball: '🎊',
balloon: '🎈',
gift: '🎁',
trophy: '🏆',
'1st_place_medal': '🥇',
'2nd_place_medal': '🥈',
'3rd_place_medal': '🥉',
medal_sports: '🏅',
zap: '⚡', lightning: '⚡',
bulb: '💡', light_bulb: '💡',
key: '🔑',
lock: '🔒',
unlock: '🔓',
bell: '🔔',
no_bell: '🔕',
loudspeaker: '📢',
mega: '📣', megaphone: '📣',
speech_balloon: '💬',
thought_balloon: '💭',
white_check_mark: '✅',
heavy_check_mark: '✔️', check_mark: '✔️',
ballot_box_with_check: '☑️',
x: '❌', cross_mark: '❌',
negative_squared_cross_mark: '❎',
question: '❓',
grey_question: '❔',
exclamation: '❗', heavy_exclamation_mark: '❗',
grey_exclamation: '❕',
warning: '⚠️',
no_entry: '⛔',
no_entry_sign: '🚫',
red_circle: '🔴',
green_circle: '🟢',
large_blue_circle: '🔵',
yellow_circle: '🟡',
white_circle: '⚪',
black_circle: '⚫',
orange_circle: '🟠',
purple_circle: '🟣',
brown_circle: '🟤',
// ── Tech, work, study ──
rocket: '🚀',
eyes: '👀',
eye: '👁️',
brain: '🧠',
books: '📚',
book: '📖', open_book: '📖',
memo: '📝', pencil: '📝',
pencil2: '✏️',
page_facing_up: '📄',
paperclip: '📎',
pushpin: '📌',
round_pushpin: '📍',
link: '🔗',
bar_chart: '📊',
chart_with_upwards_trend: '📈',
chart_with_downwards_trend: '📉',
mag: '🔍',
mag_right: '🔎',
globe_with_meridians: '🌐',
earth_africa: '🌍',
earth_americas: '🌎',
earth_asia: '🌏',
alarm_clock: '⏰',
hourglass_flowing_sand: '⏳',
hourglass: '⌛',
microphone: '🎤', mic: '🎤',
musical_note: '🎵',
notes: '🎶', musical_notes: '🎶',
headphones: '🎧',
camera: '📷',
camera_flash: '📸',
clapper: '🎬',
tv: '📺',
computer: '💻', laptop: '💻',
desktop_computer: '🖥️',
iphone: '📱', mobile_phone: '📱',
telephone: '☎️',
wrench: '🔧',
hammer: '🔨',
gear: '⚙️',
nut_and_bolt: '🔩',
magnet: '🧲',
test_tube: '🧪',
microscope: '🔬',
dart: '🎯', bullseye: '🎯',
game_die: '🎲',
jigsaw: '🧩',
// ── Food & drink ──
pizza: '🍕',
hamburger: '🍔',
fries: '🍟',
taco: '🌮',
sushi: '🍣',
doughnut: '🍩', donut: '🍩',
coffee: '☕',
beer: '🍺',
wine_glass: '🍷',
// ── Animals & nature ──
dog: '🐶',
cat: '🐱',
mouse: '🐭',
hamster: '🐹',
rabbit: '🐰',
fox_face: '🦊',
bear: '🐻',
panda_face: '🐼',
koala: '🐨',
tiger: '🐯',
lion: '🦁',
cow: '🐮',
pig: '🐷',
frog: '🐸',
monkey_face: '🐵',
chicken: '🐔',
penguin: '🐧',
bird: '🐦',
eagle: '🦅',
duck: '🦆',
owl: '🦉',
wolf: '🐺',
horse: '🐴',
unicorn: '🦄',
bee: '🐝', honeybee: '🐝',
bug: '🐛',
butterfly: '🦋',
snail: '🐌',
lady_beetle: '🐞',
snake: '🐍',
turtle: '🐢',
octopus: '🐙',
crab: '🦀',
tropical_fish: '🐠',
whale: '🐳',
shark: '🦈',
cherry_blossom: '🌸',
rose: '🌹',
sunflower: '🌻',
hibiscus: '🌺',
tulip: '🌷',
seedling: '🌱',
evergreen_tree: '🌲',
deciduous_tree: '🌳',
four_leaf_clover: '🍀',
apple: '🍎',
green_apple: '🍏',
pear: '🍐',
tangerine: '🍊',
lemon: '🍋',
banana: '🍌',
watermelon: '🍉',
grapes: '🍇',
strawberry: '🍓',
blueberries: '🫐',
peach: '🍑',
rainbow: '🌈',
sunny: '☀️', sun: '☀️',
partly_sunny: '⛅',
cloud: '☁️',
snowflake: '❄️',
ocean: '🌊',
// ── Arrows & signs ──
arrow_right: '➡️',
arrow_left: '⬅️',
arrow_up: '⬆️',
arrow_down: '⬇️',
arrow_upper_right: '↗️',
arrow_lower_right: '↘️',
arrow_lower_left: '↙️',
arrow_upper_left: '↖️',
leftwards_arrow_with_hook: '↩️',
arrow_right_hook: '↪️',
arrows_counterclockwise: '🔄',
arrows_clockwise: '🔃',
heavy_plus_sign: '',
heavy_minus_sign: '',
heavy_division_sign: '➗',
heavy_multiplication_x: '✖️',
infinity: '♾️',
copyright: '©️',
registered: '®️',
tm: '™️',
recycle: '♻️',
checkered_flag: '🏁',
triangular_flag_on_post: '🚩',
white_flag: '🏳️',
black_flag: '🏴',
// ── People & wearables ──
baby: '👶',
boy: '👦',
girl: '👧',
man: '👨',
woman: '👩',
older_man: '👴',
older_woman: '👵',
crown: '👑',
gem: '💎',
graduation_cap: '🎓', mortar_board: '🎓',
};
// `:name:` where name is letters/digits/`_`/`+`/`-`. Length ≥1 so `:+1:` and
// `:-1:` match. Global + case-insensitive for replace; a separate non-global
// literal is used for the cheap presence check so there's no shared lastIndex
// state to reset.
const SHORTCODE_RE = /:([a-z0-9_+-]{1,40}):/gi;
/**
* Cheap test for whether `text` could contain any emoji shortcode at all.
* Lets callers skip the replace pass entirely on the common no-shortcode path.
*/
export function hasEmojiShortcode(text) {
return !!text && text.indexOf(':') !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(text);
}
// A shortcode must stand on its own — flanked by whitespace, punctuation, a
// string edge, or markup, never glued to an ASCII word character. Without this
// guard, real `:name:` shortcodes that happen to sit inside a longer run of
// digits/letters get converted by mistake and mangle perfectly literal text:
// "1:100:2" → the `:100:` would become 💯 ("1💯2")
// "host:fire:port", URL authorities, `key:value:` pairs, etc.
// Chat models always emit shortcodes delimited by spaces/punctuation (":fire:",
// "**:microphone:**", "nice :tada:!"), so requiring a boundary keeps every real
// shortcode working while leaving embedded colon runs untouched. `_` counts as a
// word char too (identifier-like), but `+`/`-` do not, so "C++ :fire:" still works.
const _WORDISH = /[A-Za-z0-9_]/;
function _boundedOnBothSides(str, start, end) {
const before = start > 0 ? str[start - 1] : '';
const after = end < str.length ? str[end] : '';
return !_WORDISH.test(before) && !_WORDISH.test(after);
}
/**
* Replace every known `:shortcode:` in `text` with its Unicode emoji. Unknown
* shortcodes (`:definitely_not_emoji:`), colon runs that don't form a shortcode
* (`10:30:45`, `16:9`), and known shortcodes embedded mid-token (`1:100:2`) are
* all left exactly as-is.
*/
export function replaceEmojiShortcodes(text) {
if (!text || text.indexOf(':') === -1) return text;
return text.replace(SHORTCODE_RE, (whole, name, offset, str) => {
const key = name.toLowerCase();
if (!Object.prototype.hasOwnProperty.call(EMOJI_SHORTCODES, key)) return whole;
// Only convert when the `:shortcode:` is a standalone token, not glued to a
// surrounding word/number (which would mean it's literal text, not an emoji).
if (!_boundedOnBothSides(str, offset, offset + whole.length)) return whole;
return EMOJI_SHORTCODES[key];
});
}
export default { EMOJI_SHORTCODES, replaceEmojiShortcodes, hasEmojiShortcode };

View File

@@ -676,7 +676,7 @@ function _createGroupBubble(model, box) {
// Role label — use character name if assigned, otherwise model name
const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid));
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
wrap.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
wrap.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid);
// Spinner — identical to chat.js line 3062
@@ -860,11 +860,14 @@ async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
}
// Generated image
else if (json.type === 'generated_image' && json.url) {
const img = document.createElement('img');
img.src = json.url;
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
img.loading = 'lazy';
bodyEl.appendChild(img);
const safeImageUrl = chatRenderer.safeDisplayImageSrc(json.url);
if (safeImageUrl) {
const img = document.createElement('img');
img.src = safeImageUrl;
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
img.loading = 'lazy';
bodyEl.appendChild(img);
}
}
// Error
else if (json.error) {

View File

@@ -6,6 +6,7 @@
import uiModule from './ui.js';
import { splitTableRow } from './markdown/tableRow.js';
import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js';
var escapeHtml = uiModule.esc;
@@ -60,9 +61,21 @@ const _ALLOWED_HTML_BAD_TAGS = new Set([
'SVG', 'MATH',
]);
const _ALLOWED_HTML_URL_ATTRS = new Set([
'href', 'src', 'xlink:href', 'action', 'formaction', 'background', 'poster',
'href', 'src', 'srcset', 'xlink:href', 'action', 'formaction', 'background', 'poster',
]);
function _compactUrlSchemeValue(value) {
return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
}
function _isDangerousUrl(value) {
return /^(javascript|vbscript|data):/.test(_compactUrlSchemeValue(value));
}
function _isDangerousSrcset(value) {
return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
}
function _cleanAllowedHtmlOnce(htmlString) {
const tpl = document.createElement('template');
tpl.innerHTML = htmlString;
@@ -82,11 +95,17 @@ function _cleanAllowedHtmlOnce(htmlString) {
el.removeAttribute(attr.name);
continue;
}
if (name === 'style') {
const value = _compactUrlSchemeValue(attr.value);
if (/javascript:|vbscript:|data:|expression\(/.test(value)) {
el.removeAttribute(attr.name);
}
continue;
}
// Neutralize javascript:/vbscript:/data: in URL-bearing attributes.
// Strip control/space chars first so e.g. "java\tscript:" can't slip by.
if (_ALLOWED_HTML_URL_ATTRS.has(name)) {
const value = (attr.value || '').replace(/[\x00-\x20]+/g, '').toLowerCase();
if (/^(javascript|vbscript|data):/.test(value)) {
if (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value)) {
el.removeAttribute(attr.name);
}
}
@@ -116,8 +135,13 @@ function sanitizeAllowedHtml(html) {
* Check if text has unclosed think tag
*/
export function hasUnclosedThinkTag(text) {
const openCount = (text.match(/<think(?:ing)?>/gi) || []).length;
const closeCount = (text.match(/<\/think(?:ing)?>/gi) || []).length;
text = text || '';
const openCount =
(text.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi) || []).length
+ (text.match(/<\|channel>thought/gi) || []).length;
const closeCount =
(text.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length
+ (text.match(/<channel\|>/gi) || []).length;
return openCount > closeCount;
}
@@ -125,8 +149,25 @@ export function startsWithReasoningPrefix(text) {
return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || '');
}
export function normalizeThinkingMarkup(text) {
if (!text) return text;
let normalized = text;
normalized = normalized.replace(/<thought(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
normalized = normalized.replace(/<\/thought>/gi, '</think>');
normalized = normalized.replace(/<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*/gi, (_m, content = '') => {
const thought = String(content || '').trim();
return thought ? `<think>${thought}</think>\n` : '';
});
normalized = normalized.replace(/<\|channel>response\s*\n?([\s\S]*?)<channel\|>/gi, (_m, content = '') => content || '');
normalized = normalized.replace(/<\|channel>response\s*\n?/gi, '');
normalized = normalized.replace(/<channel\|>/gi, '');
return normalized;
}
function normalizePlainThinking(text) {
if (!text || /<think/i.test(text)) return text;
if (!text) return text;
text = normalizeThinkingMarkup(text);
if (/<think/i.test(text)) return text;
const trimmed = text.trimStart();
if (!startsWithReasoningPrefix(trimmed)) return text;
@@ -220,11 +261,21 @@ export function extractThinkingBlocks(text) {
// (b) Cut-off mid-generation — there's already real reply text before the
// opener. Drop from the tag onward as before (it's truncated thinking).
if (hasUnclosedThinkTag(normalized)) {
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
if (strayOpener) {
cleanContent = strayOpener[1];
const gemmaThoughtStart = cleanContent.search(/<\|channel>thought/i);
if (gemmaThoughtStart >= 0) {
const leakedThought = cleanContent
.slice(gemmaThoughtStart)
.replace(/^<\|channel>thought\s*\n?/i, '')
.trim();
if (gemmaThoughtStart === 0 && leakedThought) thinkingBlocks.push(leakedThought);
cleanContent = cleanContent.slice(0, gemmaThoughtStart);
} else {
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
if (strayOpener) {
cleanContent = strayOpener[1];
} else {
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
}
}
}
@@ -316,8 +367,19 @@ function _useSvgEmoji() {
return typeof document === 'undefined' || !document.body?.classList.contains('text-emojis');
}
export function svgifyEmoji(html) {
if (!_useSvgEmoji() || !html || !_EMOJI_RE.test(html)) return html;
// `opts.shortcodes` (default true) controls the issue-#345 `:name:` → emoji
// expansion. Chat passes it through as true; document/email body renderers pass
// false so author-typed `:shortcode:` text stays literal (see mdToHtml callers).
// The Unicode-emoji → monochrome-SVG pass always runs regardless, so a real 😀
// in a document still renders as the themed line icon as it always has.
export function svgifyEmoji(html, opts) {
if (!_useSvgEmoji() || !html) return html;
const allowShortcodes = !opts || opts.shortcodes !== false;
// Two reasons to walk the HTML: real Unicode emoji to turn into SVG icons,
// or `:shortcode:` text the model emitted instead of an emoji (issue #345).
const hasUnicode = _EMOJI_RE.test(html);
const hasShortcode = allowShortcodes && hasEmojiShortcode(html);
if (!hasUnicode && !hasShortcode) return html;
const parts = html.split(/(<[^>]*>)/); // odd indices = tags
let codeDepth = 0;
for (let i = 0; i < parts.length; i++) {
@@ -327,7 +389,13 @@ export function svgifyEmoji(html) {
else if (/^<\/(pre|code)\s*>/.test(t)) codeDepth = Math.max(0, codeDepth - 1);
continue;
}
if (codeDepth === 0 && _EMOJI_RE.test(parts[i])) parts[i] = _svgifyText(parts[i]);
if (codeDepth !== 0) continue;
let seg = parts[i];
// Expand shortcodes to Unicode first, then both they and any pre-existing
// Unicode emoji get rendered as the same monochrome line icons below.
if (hasShortcode) seg = replaceEmojiShortcodes(seg);
if (_EMOJI_RE.test(seg)) seg = _svgifyText(seg);
parts[i] = seg;
}
return parts.join('');
}
@@ -371,7 +439,7 @@ export function processWithThinking(text) {
/**
* Convert markdown to HTML
*/
export function mdToHtml(src) {
export function mdToHtml(src, opts) {
const allowedHtmlBlocks = [];
const codeBlocks = [];
const mermaidBlocks = [];
@@ -456,9 +524,11 @@ export function mdToHtml(src) {
// allowlist keeps it from matching file names / versions ("package.json",
// "node.js", "v1.2.3"); the required start/[\s(<] prefix means domains
// already inside an http link (preceded by "//") or an email ("@") are
// skipped. Trailing sentence punctuation is kept outside the link.
// skipped. Require the TLD to end at a real domain boundary so dotted code
// identifiers like `sklearn.metrics` do not link `sklearn.me` and leave
// placeholder fragments in the remaining text.
s = s.replace(
/(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?:\/[^\s<>"'`\])]*)?)/gi,
/(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?=$|[\/\s<>"'`\]).,;:!?])(?:\/[^\s<>"'`\])]*)?)/gi,
(match, prefix, domain) => {
const trail = (domain.match(/[.,;:!?)]+$/) || [''])[0];
const core = trail ? domain.slice(0, -trail.length) : domain;
@@ -628,7 +698,7 @@ export function mdToHtml(src) {
s = s.replace(`___CODE_BLOCK_${index}___`, block);
});
return _useSvgEmoji() ? svgifyEmoji(s) : s;
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
}
/**
@@ -686,6 +756,7 @@ const markdownModule = {
createCollapsible,
hasUnclosedThinkTag,
extractThinkingBlocks,
normalizeThinkingMarkup,
startsWithReasoningPrefix,
renderMermaid
};

View File

@@ -438,13 +438,22 @@ async function _patchNote(id, patch) {
// ---- Helpers ----
function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// Image src guard — reject anything that isn't a relative path or http(s)/data URL
// so an AI-saved note can't slip a `javascript:` URL into the rendered <img>.
function _attrEsc(s) {
return String(s || '')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/`/g, '&#96;');
}
// Image src guard — reject anything that isn't a relative path, http(s), or
// raster data URL so an AI-saved note can't slip script-capable media into the
// rendered <img>.
function _safeImgSrc(s) {
const v = (s || '').trim();
if (!v) return '';
if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v;
if (/^https?:\/\//i.test(v) || /^data:image\//i.test(v)) return v;
if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v;
return '';
}
@@ -461,7 +470,7 @@ function _linkify(s) {
url = url.slice(0, -1);
}
const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${href}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
});
}
function _uid() { return Math.random().toString(36).slice(2, 10); }
@@ -2779,7 +2788,7 @@ function _buildForm(note = null) {
form.className = 'note-form';
if (color && !_isBgImage(color)) form.classList.add('note-color-' + color);
if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color));
let currentImageUrl = note?.image_url || '';
let currentImageUrl = _safeImgSrc(note?.image_url || '');
form.innerHTML = `
<div class="note-form-header">
<input type="text" class="note-form-title" placeholder="Title" value="${_esc(note?.title || '')}" />
@@ -2861,7 +2870,7 @@ function _buildForm(note = null) {
let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null;
// Drawing also stashes the saved image URL so it survives Note↔Draw flips.
let _stashedDrawUrl = (type === 'draw') ? (note?.image_url || null) : null;
let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null;
const _refreshFormLayout = () => {
const body = form.closest('.notes-pane-body');
if (!body) return;
@@ -2913,7 +2922,7 @@ function _buildForm(note = null) {
// toggled to Draw, paint that photo onto the canvas so they can draw
// on top of it. _stashedDrawUrl wins if they were drawing earlier in
// the same edit session.
_wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || note?.image_url || null);
_wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null);
} else {
const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '')
? _stashedNoteText
@@ -3003,7 +3012,7 @@ function _buildForm(note = null) {
if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body'));
if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body'));
if (currentType === 'draw') {
_wireCanvas(form.querySelector('.note-form-body'), note?.image_url || null);
_wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null);
// Same hides we apply on type-switch — keep them consistent on initial open.
const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none';
const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none';
@@ -3894,11 +3903,12 @@ function _wireCanvas(container, initialImageUrl) {
ctx.lineJoin = 'round';
// Load prior drawing as starting point so consecutive edits compose.
if (initialImageUrl) {
const safeInitialImageUrl = _safeImgSrc(initialImageUrl);
if (safeInitialImageUrl) {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} };
img.src = initialImageUrl;
img.src = safeInitialImageUrl;
// Float an X over the canvas so the user can blank it out and go back to
// a clean draw surface. Removes itself once clicked.
const wrap = container.querySelector('.note-form-draw-wrap');

View File

@@ -90,4 +90,51 @@ export function providerLogo(modelId) {
return null;
}
export default { providerLogo };
// Host suffix → friendly provider label. The model-info card shows this so the
// SAME model name served by DIFFERENT routes is distinguishable (e.g.
// `claude-haiku` via OpenRouter vs GitHub Copilot vs Anthropic direct); the logo
// only reflects the model vendor, not the actual endpoint. Patterns are anchored
// to the end of the hostname (^|.)domain$ so a host like `max.airlines.com`
// doesn't match `x.ai`.
const _ENDPOINT_LABELS = [
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
[/(^|\.)anthropic\.com$/i, "Anthropic"],
[/(^|\.)openai\.com$/i, "OpenAI"],
[/(^|\.)(generativelanguage|aiplatform)\.googleapis\.com$/i, "Google"],
[/(^|\.)bedrock[\w.-]*\.amazonaws\.com$/i, "AWS Bedrock"],
[/(^|\.)deepseek\.com$/i, "DeepSeek"],
[/(^|\.)mistral\.ai$/i, "Mistral"],
[/(^|\.)groq\.com$/i, "Groq"],
[/(^|\.)together\.(ai|xyz)$/i, "Together"],
[/(^|\.)fireworks\.ai$/i, "Fireworks"],
[/(^|\.)perplexity\.ai$/i, "Perplexity"],
[/(^|\.)x\.ai$/i, "xAI"],
];
/**
* Friendly label for the endpoint that served a model, from its URL.
* Returns "Local" for loopback/LAN hosts, a known provider name when matched,
* else the bare host. Null when no URL is available.
*/
export function providerLabel(endpointUrl) {
if (!endpointUrl || typeof endpointUrl !== "string") return null;
let host;
try {
host = new URL(endpointUrl).hostname;
} catch (_) {
// Not a full URL (e.g. bare host[:port]) — strip scheme/path/port best-effort.
host = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0];
}
if (!host) return null;
if (/^(localhost|127\.|0\.0\.0\.0|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
return "Local";
}
for (const [re, label] of _ENDPOINT_LABELS) {
if (re.test(host)) return label;
}
// Unknown host → drop a leading "api." for a cleaner readout.
return host.replace(/^api\./i, "");
}
export default { providerLogo, providerLabel };

View File

@@ -1103,8 +1103,10 @@ function _renderResult(job) {
html += '<div class="research-job-sources">';
for (const s of job.sources.slice(0, 10)) {
const title = _esc(s.title || s.url || '');
const url = _esc(s.url || '');
html += `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`;
const url = _safeSourceHref(s.url);
html += url
? `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`
: `<span class="research-source-link">${title}</span>`;
}
if (job.sources.length > 10) html += `<span class="research-source-more">+${job.sources.length - 10} more</span>`;
html += '</div>';
@@ -1231,3 +1233,11 @@ function _esc(s) {
d.textContent = s || '';
return d.innerHTML;
}
function _safeSourceHref(raw) {
try {
const parsed = new URL(String(raw || '').trim(), window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
} catch {}
return '';
}

View File

@@ -2157,7 +2157,14 @@ async function _checkServerStream(sessionId) {
// Skip if this is a research stream — research has its own progress UI
if (info.mode === 'research' || info.is_research) return;
// Server is still streaming — show spinner and poll
// Live-resume the detached run: replay its buffer then stream live tokens
// (#2539). Falls back to the spinner+poll path below if unavailable.
if (window.chatModule && window.chatModule.resumeStream) {
const attached = await window.chatModule.resumeStream(sessionId);
if (attached) return;
}
// Fallback: server is still streaming, show spinner and poll.
const box = document.getElementById('chat-history');
if (!box) return;

View File

@@ -13,6 +13,10 @@ let modalEl = null;
function el(id) { return document.getElementById(id); }
function esc(s) { return uiModule.esc(s); }
function safeRasterDataUrl(raw) {
const value = String(raw || '').trim();
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
}
/* ── Tab switching ── */
const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']);
@@ -1554,6 +1558,7 @@ async function initResearchSearchSettings() {
/* ── Agent Settings (AI tab) ── */
async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds');
var msg = el('set-agentMsg');
if (!toolsInput) return;
@@ -1561,23 +1566,41 @@ async function initAgentSettings() {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
} catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
// when blank/non-numeric. Mirrors the server-side validation.
function clampInt(raw, lo, hi, dflt) {
var n = parseInt(raw, 10);
if (isNaN(n)) return dflt;
return Math.max(lo, Math.min(n, hi));
}
async function save() {
var val = parseInt(toolsInput.value, 10) || 0;
var tools = clampInt(toolsInput.value, 0, 1000, 0);
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
toolsInput.value = tools; // reflect the clamped value
if (roundsInput) roundsInput.value = rounds;
var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds;
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent_max_tool_calls: val })
body: JSON.stringify(payload)
});
msg.textContent = val > 0 ? 'Limit: ' + val + ' tool calls per message' : 'Unlimited';
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
(rounds != null ? ' · ' + rounds + ' steps/message' : '');
msg.style.color = 'var(--fg)';
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0;
msg.textContent = cur > 0 ? 'Limit: ' + cur + ' tool calls per message' : 'Unlimited';
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
(curR != null ? ' · ' + curR + ' steps/message' : '');
}
/* ═══════════════════════════════════════════
@@ -2069,15 +2092,16 @@ function initAccount() {
const r = await fetch('/api/auth/2fa/setup', { method: 'POST', credentials: 'same-origin' });
if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); }
const setup = await r.json();
const qrCode = safeRasterDataUrl(setup.qr_code);
// Show QR code + manual secret + verify input
tfaContent.innerHTML = `
<div style="text-align:center;margin-bottom:12px;">
<img src="${setup.qr_code}" alt="QR Code" style="border-radius:8px;max-width:200px;">
${qrCode ? `<img src="${esc(qrCode)}" alt="QR Code" style="border-radius:8px;max-width:200px;">` : ''}
</div>
<div style="font-size:11px;opacity:0.5;text-align:center;margin-bottom:8px;">
Scan with your authenticator app, or enter manually:
</div>
<div style="font-family:monospace;font-size:12px;text-align:center;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin-bottom:12px;word-break:break-all;user-select:all;cursor:text;">${setup.secret}</div>
<div style="font-family:monospace;font-size:12px;text-align:center;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin-bottom:12px;word-break:break-all;user-select:all;cursor:text;">${esc(setup.secret)}</div>
<input id="tfa-verify-code" type="text" placeholder="Enter 6-digit code to verify" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:13px;box-sizing:border-box;text-align:center;letter-spacing:3px;margin-bottom:6px;">
<div class="settings-row" style="justify-content:flex-end;">
<span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
@@ -4424,6 +4448,68 @@ async function initUnifiedIntegrations() {
// ── MCP form — full management view ──
async function showMcpForm(editId) {
// Toggle an in-flight loading state on a button (disabled + dimmed + label).
function _setBtnLoading(btn, loading, label) {
if (!btn) return;
btn.disabled = loading;
btn.style.opacity = loading ? '0.6' : '';
btn.style.cursor = loading ? 'progress' : '';
if (label != null) btn.textContent = label;
}
function _showMcpPasteback(id) {
const msg = el('uf-mcp-msg'); if (!msg) return;
if (el('uf-mcp-pasteback')) return; // already shown
msg.innerHTML =
'Authorize in the opened tab. If the redirect fails (remote access), paste the resulting URL here: ' +
'<input id="uf-mcp-pasteback" class="settings-input" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." style="margin-top:4px">' +
'<button class="admin-btn-sm" id="uf-mcp-paste-go" style="margin-top:4px">Submit</button>';
const pasteGo = el('uf-mcp-paste-go');
if (pasteGo) pasteGo.addEventListener('click', async () => {
const cb = el('uf-mcp-pasteback').value.trim();
if (!cb) return;
const pf = new FormData(); pf.append('callback_url', cb);
_setBtnLoading(pasteGo, true, 'Submitting…');
try {
await fetch(`/api/mcp/oauth/exchange/${id}`, { method: 'POST', credentials: 'same-origin', body: pf });
} finally {
_setBtnLoading(pasteGo, false, 'Submit');
}
});
}
// Drives the OAuth flow: waits for the auth_url (discovery+DCR may lag),
// opens it once, then resolves on connected/error.
async function _handleMcpAuth(id, initialAuthUrl, tries = 90) {
let opened = false;
const openAuth = (u) => { if (!opened && u) { opened = true; window.open(u, '_blank', 'noopener'); _showMcpPasteback(id); } };
openAuth(initialAuthUrl);
const msg = el('uf-mcp-msg');
let fails = 0;
for (let i = 0; i < tries; i++) {
await new Promise(res => setTimeout(res, 2000));
try {
const r = await fetch('/api/mcp/servers', { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const list = await r.json();
fails = 0;
const s = Array.isArray(list) ? list.find(x => x.id === id) : null;
if (!s) continue;
if (s.auth_url) openAuth(s.auth_url);
if (s.status === 'connected') {
if (msg) msg.textContent = `Connected (${s.tool_count || 0} tools)`;
await renderList(); return;
}
if (s.status === 'error') {
if (msg) msg.textContent = `Failed: ${s.error || 'unknown'}`; return;
}
} catch (e) {
// Tolerate a single blip, but surface persistent failures instead of
// silently polling until timeout.
if (++fails >= 5 && msg) msg.textContent = `Status check failing (${e.message || 'network error'}) — still retrying…`;
}
}
if (msg) msg.textContent = 'Authorization timed out. Reconnect from the server list to retry.';
}
if (editId && editId !== 'new') {
// Show management view for existing server
formEl.innerHTML = '<div class="admin-card" style="margin-top:8px"><span style="opacity:0.5;font-size:11px">Loading...</span></div>';
@@ -4501,7 +4587,7 @@ async function initUnifiedIntegrations() {
<h2 style="font-size:13px">Add MCP Server</h2>
<div class="settings-col">
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-mcp-name" class="settings-input" placeholder="Server name"></div>
<div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option></select></div>
<div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option><option value="http">Streamable HTTP</option></select></div>
<div id="uf-mcp-stdio-fields" style="display:flex;flex-direction:column;gap:6px;">
<div class="settings-row"><label class="settings-label">Command</label><input id="uf-mcp-cmd" class="settings-input" placeholder="npx"></div>
<div class="settings-row"><label class="settings-label">Args</label><input id="uf-mcp-args" class="settings-input" placeholder='["-y", "@modelcontextprotocol/server-filesystem"]'></div>
@@ -4514,9 +4600,12 @@ async function initUnifiedIntegrations() {
</div>
</div>`;
el('uf-mcp-transport').addEventListener('change', () => {
const sse = el('uf-mcp-transport').value === 'sse';
el('uf-mcp-stdio-fields').style.display = sse ? 'none' : 'flex';
el('uf-mcp-sse-fields').style.display = sse ? 'flex' : 'none';
const v = el('uf-mcp-transport').value;
const isUrl = (v === 'sse' || v === 'http');
el('uf-mcp-stdio-fields').style.display = isUrl ? 'none' : 'flex';
el('uf-mcp-sse-fields').style.display = isUrl ? 'flex' : 'none';
const urlInput = el('uf-mcp-url');
if (urlInput) urlInput.placeholder = (v === 'http') ? 'https://mcp.example.com/mcp' : 'http://localhost:3001/sse';
});
el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
el('uf-mcp-save').addEventListener('click', async () => {
@@ -4534,14 +4623,25 @@ async function initUnifiedIntegrations() {
} else {
fd.append('url', el('uf-mcp-url').value);
}
const saveBtn = el('uf-mcp-save'), cancelBtn = el('uf-mcp-cancel');
const _origLabel = saveBtn.textContent;
_setBtnLoading(saveBtn, true, 'Saving…'); if (cancelBtn) cancelBtn.disabled = true;
try {
const r = await fetch('/api/mcp/servers', { method: 'POST', credentials: 'same-origin', body: fd });
if (r.ok) {
const data = await r.json().catch(() => ({}));
if (r.ok && data.needs_auth) {
el('uf-mcp-msg').textContent = 'Preparing authorization…';
_handleMcpAuth(data.id, data.auth_url);
} else if (r.ok && (data.connected || data.status === 'connected')) {
el('uf-mcp-msg').textContent = `Connected (${data.tool_count || 0} tools)`;
formEl.style.display = 'none'; await renderList();
} else if (r.ok) {
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
} else {
el('uf-mcp-msg').textContent = `Failed (${r.status})`;
}
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
});
}
}

View File

@@ -14,6 +14,20 @@
const API_BASE = window.location.origin;
function _esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function _safeSignatureDataUrl(raw) {
const value = String(raw || '').trim();
return /^data:image\/(?:png|jpe?g);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
}
// Last signature the user picked or created in this session. Lets the export
// modal pre-fill subsequent signature fields with the same one — sign once,
// applies everywhere.
@@ -446,13 +460,17 @@ export function capture(opts = {}) {
export function pick(opts = {}) {
return new Promise(async (resolve) => {
const sigs = await _listSignatures();
const tiles = sigs.map((s) => `
<div class="sig-tile" data-id="${s.id}">
<img src="${s.data_url}"/>
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${(s.name || '').replace(/[<>&]/g, '')}</div>
<button class="sig-tile-del" data-id="${s.id}" title="Delete">×</button>
const tiles = sigs.map((s) => {
const dataUrl = _safeSignatureDataUrl(s.data_url);
if (!dataUrl) return '';
return `
<div class="sig-tile" data-id="${_esc(s.id)}">
<img src="${_esc(dataUrl)}"/>
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(s.name || '')}</div>
<button class="sig-tile-del" data-id="${_esc(s.id)}" title="Delete">×</button>
</div>
`).join('');
`;
}).join('');
const overlay = _modal(`
<div class="modal-content" style="width:min(560px,94vw);">
@@ -477,7 +495,9 @@ export function pick(opts = {}) {
const id = tile.dataset.id;
const s = sigs.find((x) => x.id === id);
if (s) {
const out = { id: s.id, dataUrl: s.data_url, width: s.width, height: s.height, name: s.name };
const dataUrl = _safeSignatureDataUrl(s.data_url);
if (!dataUrl) return;
const out = { id: s.id, dataUrl, width: s.width, height: s.height, name: s.name };
setLastUsed(out);
close(out);
}

View File

@@ -17,6 +17,7 @@ import chatRenderer from './chatRenderer.js';
import spinnerModule from './spinner.js';
import themeModule from './theme.js';
import documentModule from './document.js';
import workspaceModule from './workspace.js';
import settingsModule from './settings.js';
import cookbookModule from './cookbook.js';
import { EVAL_PROMPTS } from './compare/index.js';
@@ -1141,6 +1142,35 @@ async function _cmdToggleDoc(args, ctx) {
return true;
}
// Workspace: confine the agent's file/shell tools to a folder. Not a boolean —
// show / set <path> / clear / pick (open the directory browser).
async function _cmdWorkspace(args, ctx) {
const sub = (args[0] || '').toLowerCase();
const rest = args.slice(1).join(' ').trim();
const cur = workspaceModule.getWorkspace();
if (!sub || sub === 'show' || sub === 'status' || sub === 'info') {
slashReply(cur ? `Workspace: <code>${uiModule.esc(cur)}</code>` : 'No workspace set. <code>/workspace pick</code> or <code>/workspace set /path</code>.');
return true;
}
if (sub === 'set' || sub === 'cd' || sub === 'use') {
if (!rest) { slashReply('Usage: <code>/workspace set /absolute/path</code>'); return true; }
workspaceModule.setWorkspace(rest);
slashReply(`Workspace set: <code>${uiModule.esc(rest)}</code>`);
return true;
}
if (sub === 'clear' || sub === 'off' || sub === 'none' || sub === 'unset') {
workspaceModule.clearWorkspace();
slashReply('Workspace cleared.');
return true;
}
if (sub === 'pick' || sub === 'browse' || sub === 'open') {
workspaceModule.openWorkspaceBrowser();
return true;
}
slashReply('Usage: <code>/workspace</code> · <code>set /path</code> · <code>clear</code> · <code>pick</code>');
return true;
}
async function _cmdToggleShow(args, ctx) {
const name = (args[0] || '').toLowerCase();
const val = (args[1] || '').toLowerCase();
@@ -4735,11 +4765,47 @@ function _clearSetupCommandInput() {
}
}
// GitHub Copilot device-flow sign-in, driven from chat (mirrors the Settings
// "Connect GitHub Copilot" button). Replies via the setup guide messages.
async function _setupCopilot() {
_clearSetupGuideMessages();
await _setupReply('Starting GitHub Copilot sign-in…');
let start;
try {
const r = await fetch(`${API_BASE}/api/copilot/device/start`, { method: 'POST', body: new FormData(), credentials: 'same-origin' });
start = await r.json();
if (!r.ok) { await _setupReply(start.detail || 'Failed to start Copilot sign-in.'); return; }
} catch (e) { await _setupReply('Request failed.'); return; }
const authUrl = start.verification_uri_complete || start.verification_uri || '';
await _setupReply(`Opening GitHub — approve the request (code ${start.user_code}). Waiting…`);
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
const deadline = Date.now() + (start.expires_in || 900) * 1000;
const stepMs = Math.max((start.interval || 5), 2) * 1000;
const poll = async () => {
if (Date.now() > deadline) { await _setupReply('Copilot sign-in expired — run /setup copilot again.'); return; }
try {
const fd = new FormData(); fd.append('poll_id', start.poll_id);
const r = await fetch(`${API_BASE}/api/copilot/device/poll`, { method: 'POST', body: fd, credentials: 'same-origin' });
const d = await r.json();
if (d.status === 'authorized') {
const n = ((d.endpoint && d.endpoint.models) || []).length;
await _setupReply(`Connected — ${n} Copilot model${n !== 1 ? 's' : ''} available.`);
if (modelsModule) modelsModule.refreshModels(true);
return;
}
if (d.status === 'failed') { await _setupReply('Copilot sign-in failed (' + (d.error || 'denied') + ').'); return; }
} catch (e) { /* transient — keep polling */ }
setTimeout(poll, stepMs);
};
setTimeout(poll, stepMs);
}
async function _cmdSetup(args, ctx) {
_hideWelcomeScreen();
_clearSetupCommandInput();
const topic = (args[0] || '').trim().toLowerCase();
const topicArgs = args.slice(1);
if (topic === 'copilot' || topic === 'github') { await _setupCopilot(); return true; }
const provider = _setupProviderFromInput(topic);
if (provider) {
_clearSetupGuideMessages();
@@ -5419,6 +5485,14 @@ const COMMANDS = {
'_show': { handler: _cmdToggleShow, alias: [], help: 'Show all toggle states', usage: '/toggle' }
}
},
workspace: {
alias: ['ws'],
category: 'Agent',
help: 'Set the folder the agent works in',
handler: _cmdWorkspace,
noUserBubble: true,
usage: '/workspace [set <path> | clear | pick]',
},
memory: {
alias: ['m'],
category: 'Memory',
@@ -5464,7 +5538,7 @@ const COMMANDS = {
category: 'Getting started',
help: 'Add local or API model endpoints',
handler: _cmdSetup,
usage: '/setup local URL · /setup groq KEY · /setup endpoint'
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup endpoint'
},
demo: {
alias: ['tour'],
@@ -5844,7 +5918,9 @@ async function handleSlashCommand(input) {
let args = parts.slice(1);
const ctx = _makeCtx();
let _userShown = false;
function _showUser() { if (!_userShown) { _userShown = true; _addMessage('user', input); _persistMsg('user', input); } }
// Tag the echoed command with source:'slash' so it renders in the transcript
// but is excluded from LLM context (get_context_messages), like the replies.
function _showUser() { if (!_userShown) { _userShown = true; _addMessage('user', input); _persistMsg('user', input, { source: 'slash' }); } }
try {
// --- Check for --help / -h on any command ---

View File

@@ -23,7 +23,8 @@ export const KEYS = {
MCP_ACTIVE: 'odysseus-mcp-active',
SECTION_ORDER: 'sidebar-section-order',
ADMIN_LAST_TAB: 'admin-last-tab',
DENSITY: 'odysseus-density'
DENSITY: 'odysseus-density',
WORKSPACE: 'odysseus-workspace'
};
/**

View File

@@ -149,6 +149,13 @@ export function makeWindowDraggable(modal, options = {}) {
const _startDrag = (cx, cy) => {
dragging = true;
if (modal) modal.classList.add('modal-dragging');
// Cancel any in-flight open animation so we don't pin a mid-animation
// rect and then jump once the animation settles.
try {
content.getAnimations()
.filter(a => a.playState !== 'finished')
.forEach(a => a.cancel());
} catch (_) {}
const rect = content.getBoundingClientRect();
if (onDragStart) {
try { onDragStart({ rect, cx, cy }); } catch (_) {}

160
static/js/workspace.js Normal file
View File

@@ -0,0 +1,160 @@
// static/js/workspace.js
//
// Workspace picker: browse server directories in a draggable modal, choose a
// folder, and show it as a removable pill in the chat input bar. While set, the
// chat request sends `workspace` so the agent's file/shell tools are confined
// to that folder (see routes/chat_routes.py + src/tool_execution.py).
import Storage, { KEYS } from './storage.js';
import uiModule from './ui.js';
import { makeWindowDraggable } from './windowDrag.js';
const API_BASE = window.location.origin;
// Same folder glyph as the overflow menu item + pill (not an emoji).
const _FOLDER_SVG = '<svg class="workspace-row-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
let _modal = null;
let _curPath = '';
export function getWorkspace() {
return Storage.get(KEYS.WORKSPACE, '') || '';
}
function _basename(p) {
if (!p) return '';
// Handle both POSIX (/) and Windows (\) separators.
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
return parts[parts.length - 1] || p;
}
export function syncWorkspaceIndicator(path) {
const pill = document.getElementById('workspace-indicator-btn');
const name = document.getElementById('workspace-indicator-name');
const overflow = document.getElementById('overflow-workspace-btn');
if (pill) {
pill.style.display = path ? '' : 'none';
pill.classList.toggle('active', !!path);
if (path) pill.title = `Workspace: ${path} — click to clear`;
}
if (name) name.textContent = path ? _basename(path) : '';
if (overflow) overflow.classList.toggle('active', !!path);
// Recompute the "+" overflow dot (app.js owns updatePlusDot via this event).
try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {}
}
export function setWorkspace(path) {
if (path) Storage.set(KEYS.WORKSPACE, path);
else Storage.remove(KEYS.WORKSPACE);
syncWorkspaceIndicator(path || '');
}
export function clearWorkspace() {
setWorkspace('');
if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared');
}
async function _load(path) {
const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`;
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`browse failed: ${res.status}`);
return res.json();
}
function _render(data) {
_curPath = data.path;
const body = _modal.querySelector('#workspace-body');
const pathEl = _modal.querySelector('#workspace-cur-path');
if (pathEl) {
// Reflect the resolved (realpath) location back into the editable field.
pathEl.value = data.path;
pathEl.title = data.path;
}
let rows = '';
if (data.parent) {
rows += `<div class="workspace-row workspace-up" data-path="${encodeURIComponent(data.parent)}">↑ ..</div>`;
}
for (const d of data.dirs) {
// Backend supplies the full child path (os.path.join → cross-platform).
rows += `<div class="workspace-row" data-path="${encodeURIComponent(d.path)}">${_FOLDER_SVG}<span>${uiModule.esc(d.name)}</span></div>`;
}
if (!data.dirs.length && !data.parent) rows = '<div class="workspace-empty">No subfolders</div>';
body.innerHTML = rows || '<div class="workspace-empty">No subfolders</div>';
body.querySelectorAll('.workspace-row').forEach((row) => {
row.addEventListener('click', () => _navigate(decodeURIComponent(row.dataset.path)));
});
}
async function _navigate(path) {
try {
_render(await _load(path));
} catch (e) {
if (uiModule && uiModule.showError) uiModule.showError('Could not open folder');
}
}
function _getModal() {
if (_modal) return _modal;
_modal = document.createElement('div');
_modal.id = 'workspace-modal';
_modal.className = 'modal';
_modal.style.display = 'none';
_modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Select workspace</h4>
<button class="close-btn" id="workspace-close" aria-label="Close">✖</button>
</div>
<input type="text" class="styled-prompt-input workspace-cur" id="workspace-cur-path"
spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
placeholder="Type or paste a folder path, then press Enter" />
<div class="modal-body workspace-body" id="workspace-body"></div>
<div class="modal-footer workspace-footer">
<button type="button" class="confirm-btn confirm-btn-secondary" id="workspace-cancel">Cancel</button>
<button type="button" class="confirm-btn confirm-btn-primary" id="workspace-use">Use this folder</button>
</div>
</div>`;
document.body.appendChild(_modal);
_modal.querySelector('#workspace-close').addEventListener('click', closeWorkspaceBrowser);
_modal.querySelector('#workspace-cancel').addEventListener('click', closeWorkspaceBrowser);
// Editable path bar: Enter navigates to a typed/pasted folder.
_modal.querySelector('#workspace-cur-path').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const v = e.target.value.trim();
if (v) _navigate(v);
}
});
_modal.querySelector('#workspace-use').addEventListener('click', () => {
setWorkspace(_curPath);
if (uiModule && uiModule.showToast) uiModule.showToast(`Workspace set: ${_basename(_curPath)}`);
closeWorkspaceBrowser();
});
const content = _modal.querySelector('.modal-content');
const header = _modal.querySelector('.modal-header');
if (content && header) makeWindowDraggable(_modal, { content, header });
return _modal;
}
export async function openWorkspaceBrowser() {
const modal = _getModal();
modal.style.display = 'flex';
try {
_render(await _load(getWorkspace() || ''));
} catch (e) {
if (uiModule && uiModule.showError) uiModule.showError('Could not browse folders');
}
}
export function closeWorkspaceBrowser() {
if (_modal) _modal.style.display = 'none';
}
export function initWorkspace() {
// Restore persisted workspace into the pill on load.
syncWorkspaceIndicator(getWorkspace());
const overflow = document.getElementById('overflow-workspace-btn');
if (overflow) overflow.addEventListener('click', openWorkspaceBrowser);
const pill = document.getElementById('workspace-indicator-btn');
if (pill) pill.addEventListener('click', clearWorkspace);
}
export default { initWorkspace, openWorkspaceBrowser, getWorkspace, setWorkspace, clearWorkspace, syncWorkspaceIndicator };

View File

@@ -1,824 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Odysseus — a self-hosted AI workspace: chat, agents, tools, model serving, email, research, and more. Your models, your hardware, your data.">
<title>Odysseus — A Self-Hosted AI Workspace</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
<style>
:root {
/* Odysseus default theme — exact app tokens */
--bg: #282c34;
--bg2: #1e2228; /* app code/hl background */
--panel: #111; /* app panel surface */
--panel2: #1e2228;
--fg: #9cdef2; /* signature cyan text */
--heading: #9cdef2;
--muted: #6b8a94; /* app subheader */
--border: #355a66; /* teal border */
--accent: #e06c75; /* app accent (the send-button coral) */
--accent2: #f0989e; /* lighter coral for gradients */
--green: #50fa7b;
--gold: #f0ad4e; /* app --warn */
--red: #e06c75;
--radius: 8px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-snap-type: y mandatory; scroll-padding-top: 60px; }
/* Each section is a full-viewport "page" with its content centered, so only
one shows at a time and the snap is obvious. */
.hero, section {
scroll-snap-align: start; min-height: 100vh;
display: flex; flex-direction: column; justify-content: center;
}
/* Alternate the page backgrounds: slate (the body) ↔ black, to make each
page boundary obvious. */
section:nth-of-type(odd) { background: #111111; }
section:nth-of-type(even) { background: var(--bg); }
/* Domino reveal — each section fades/slides up as it scrolls into view. */
.hero, section { opacity: 0; transform: translateY(24px); transition: opacity .6s cubic-bezier(.2,.7,.2,1), transform .6s cubic-bezier(.2,.7,.2,1); }
.hero.in, section.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
html { scroll-snap-type: none; }
.hero, section { opacity: 1 !important; transform: none !important; transition: none; }
}
/* Capabilities cards cascade in like the app's domino expand. */
#features .feature { opacity: 0; transform: translateY(16px); }
#features.in .feature { animation: domino-in .5s cubic-bezier(.2,.7,.2,1) forwards; }
#features.in .feature:nth-child(1) { animation-delay: .04s; }
#features.in .feature:nth-child(2) { animation-delay: .09s; }
#features.in .feature:nth-child(3) { animation-delay: .14s; }
#features.in .feature:nth-child(4) { animation-delay: .19s; }
#features.in .feature:nth-child(5) { animation-delay: .24s; }
#features.in .feature:nth-child(6) { animation-delay: .29s; }
#features.in .feature:nth-child(7) { animation-delay: .34s; }
#features.in .feature:nth-child(8) { animation-delay: .39s; }
#features.in .feature:nth-child(9) { animation-delay: .44s; }
@keyframes domino-in { to { opacity: 1; transform: none; } }
body {
margin: 0;
background:
radial-gradient(1100px 520px at 82% -10%, rgba(224,108,117,0.12), transparent 60%),
radial-gradient(900px 520px at 0% 0%, rgba(53,90,102,0.30), transparent 55%),
var(--bg);
color: var(--fg);
font-family: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 22px; }
/* Nav */
nav {
position: sticky; top: 0; z-index: 50;
backdrop-filter: blur(10px);
background: rgba(17,17,17,0.88);
border-bottom: 1px solid #9cdef2;
}
nav .wrap { display: flex; align-items: center; justify-content: space-between; height: 60px; }
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 17px; letter-spacing: 0.2px; color: var(--heading); }
.brand .boat { color: var(--accent); flex-shrink: 0; }
.nav-links { display: flex; align-items: center; gap: 22px; }
.nav-links a { color: var(--muted); font-size: 14px; font-weight: 500; }
.nav-links a:hover { color: var(--fg); }
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 9px 16px; border-radius: 10px; font-weight: 600; font-size: 14px;
border: 1px solid var(--border); color: var(--fg); background: var(--panel);
transition: transform .12s ease, border-color .12s ease, background .12s ease;
}
.btn:hover { transform: translateY(-1px); border-color: var(--accent); }
.btn.primary {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff; border: none;
}
.btn.primary:hover { filter: brightness(1.07); }
.nav-links-hamburger {
display: none;
position: relative;
}
.hamburger-btn {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--fg);
cursor: pointer;
}
.hamburger-btn:hover {
border-color: var(--accent);
}
.hamburger-menu {
position: absolute;
top: calc(100% + 10px);
right: 0;
min-width: 220px;
display: none;
flex-direction: column;
padding: 12px;
background: rgba(17,17,17,0.98);
border: 1px solid var(--border);
border-radius: var(--radius);
backdrop-filter: blur(10px);
box-shadow: 0 12px 40px rgba(0,0,0,0.4);
}
.hamburger-menu a {
color: var(--muted);
padding: 8px 10px;
border-radius: 6px;
}
.hamburger-menu a:hover {
color: var(--fg);
}
.nav-links-hamburger.open .hamburger-menu {
display: flex;
}
/* Hero */
.hero { padding: 86px 0 40px; text-align: center; }
.badge {
display: inline-flex; align-items: center; gap: 7px;
font-size: 12.5px; color: var(--muted); border: 1px solid var(--border);
background: var(--panel); padding: 5px 12px; border-radius: 999px; margin-bottom: 22px;
}
.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); }
.hero-logo { display: flex; align-items: center; justify-content: center; gap: 14px; color: var(--accent); margin-bottom: 4px; }
.hero-logo svg { filter: drop-shadow(0 4px 18px rgba(224,108,117,0.35)); }
.hero-logo .wordmark { font-size: clamp(30px, 6vw, 44px); font-weight: 700; color: var(--heading); letter-spacing: -0.01em; line-height: 1; }
.hero h1 {
font-size: clamp(32px, 5.4vw, 52px); line-height: 1.12; margin: 0 0 18px;
letter-spacing: -0.01em; font-weight: 700; color: var(--heading);
}
.hero h1 .grad {
background: linear-gradient(120deg, var(--accent), var(--accent2));
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
}
.hero .slogan { font-style: italic; color: var(--accent); font-size: 12px; margin: 0 0 24px; letter-spacing: 0.3px; opacity: 0.9; }
.hero p.lede { font-size: clamp(16px, 2.4vw, 20px); color: var(--muted); max-width: 680px; margin: 0 auto 30px; }
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
/* terminal origin card */
.term-intro { color: var(--fg); font-size: clamp(13px, 1.8vw, 15px); margin: 34px auto 0; max-width: 560px; }
.term {
max-width: 620px; margin: 12px auto 0; text-align: left;
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; box-shadow: 0 24px 60px rgba(0,0,0,0.4);
}
.term-bar { display: flex; align-items: center; justify-content: space-between; padding: 5px 6px 5px 12px; border-bottom: 1px solid var(--border); background: #20242c; }
.term-bar .ttl { color: var(--muted); font-size: 12px; font-family: 'Fira Code', ui-monospace, monospace; }
.term-bar .winbtns { display: flex; gap: 1px; }
.term-bar .winbtns span { cursor: pointer; }
.term { transition: opacity .18s ease, transform .18s ease; }
/* Minimized = a rounded "pill", like the app's tab-down dock chip. */
.term.term-min { max-width: max-content; border-radius: 999px; box-shadow: 0 6px 22px rgba(0,0,0,0.4); }
.term.term-min .term-bar { border-bottom: none; border-radius: 999px; padding: 7px 10px 7px 16px; gap: 12px; background: var(--panel); }
.term.term-min pre { display: none; }
.term.term-closed { opacity: 0; transform: scale(0.96); pointer-events: none; height: 0; margin: 0 auto; border: 0; overflow: hidden; }
.term-reopen {
display: none; margin: 14px auto 0; font-family: 'Fira Code', monospace; font-size: 12px;
color: var(--muted); background: none; border: 1px dashed var(--border); border-radius: 6px;
padding: 5px 12px; cursor: pointer;
}
.term-reopen:hover { color: var(--accent); border-color: var(--accent); }
.term-reopen.show { display: inline-block; }
.term-bar .winbtns span {
width: 28px; height: 20px; display: inline-flex; align-items: center; justify-content: center;
border-radius: 4px; color: var(--muted); font-size: 12px; line-height: 1;
}
.term-bar .winbtns span:hover { background: rgba(156,222,242,0.12); color: var(--fg); }
.term-bar .winbtns span.x:hover { background: #c0392b; color: #fff; }
.term pre {
margin: 0; padding: 18px 16px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13.5px; color: var(--fg); line-height: 1.7; white-space: pre-wrap;
}
.term .cs { color: var(--green); } .term .cm { color: #828997; }
.term-cursor { display: inline-block; color: var(--fg); font-weight: 400; animation: term-blink 1.05s steps(1) infinite; }
@keyframes term-blink { 50% { opacity: 0; } }
/* Sections */
section { padding: 60px 0; }
.eyebrow { color: var(--accent); font-weight: 700; font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
h2.h { font-size: clamp(24px, 3.6vw, 32px); margin: 8px 0 12px; letter-spacing: -0.01em; color: var(--heading); font-weight: 700; }
.sub { color: var(--muted); max-width: 620px; }
.center { text-align: center; }
.center .sub { margin: 0 auto; }
/* Testimonial gag — single featured testimonial, click/swipe to cycle (all sizes) */
.tcarousel-wrap { position: relative; max-width: 820px; margin: 36px auto 0; }
.tarrow {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 4;
width: 38px; height: 38px; border-radius: 50%;
background: rgba(17,17,17,0.85); border: 1px solid var(--border); color: var(--fg);
font-size: 20px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: border-color .12s ease, color .12s ease;
}
.tarrow:hover { border-color: var(--accent); color: var(--accent); }
.tarrow.prev { left: 0; }
.tarrow.next { right: 0; }
.tgrid {
display: block; position: relative; overflow: hidden; cursor: pointer;
margin: 0 auto; max-width: 740px;
}
.tgrid .tcard {
display: none;
flex-direction: row-reverse; align-items: center; gap: 24px; text-align: left;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px;
}
.tgrid .tcard.active { display: flex; animation: tslide .25s ease both; }
.tgrid .tcard.active.shake { animation: tshake .5s ease-in-out 2 both; }
.tcard .av {
width: 84px; height: 84px; border-radius: 50%; overflow: hidden;
border: 1px solid var(--border); background: var(--panel2); flex: 0 0 auto;
}
.tcard .av img, .tcard .av svg { width: 100%; height: 100%; object-fit: cover; display: block; }
.tcard .tmeta { flex: 1 1 auto; }
.tcard .q { font-size: 18px; color: var(--fg); margin: 0 0 12px; }
.tcard .stars { font-size: 15px; letter-spacing: 3px; margin: 0 0 8px; color: var(--gold); }
.tcard .stars.zero { color: var(--muted); opacity: 0.5; }
.tcard .nm { font-weight: 700; font-size: 14.5px; }
.tcard .rl { color: var(--muted); font-size: 12.5px; }
.tcard.cyclops { border-color: rgba(255,90,90,0.45); background: linear-gradient(180deg, rgba(255,80,80,0.06), var(--panel)); }
.tcard.cyclops .q { color: #ff8a8a; font-weight: 700; letter-spacing: 0.4px; word-break: break-word; }
.tnav { display: block; text-align: center; margin-top: 18px; }
.tdot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #39414d; margin: 0 4px; cursor: pointer; }
.tdot.on { background: var(--accent); }
.thint { font-size: 12px; color: var(--muted); margin-top: 8px; }
@keyframes tshake {
0%,100% { transform: translateX(0) rotate(0); }
10% { transform: translateX(-9px) rotate(-1.5deg); }
20% { transform: translateX(9px) rotate(1.5deg); }
35% { transform: translateX(-7px) rotate(-1deg); }
50% { transform: translateX(7px) rotate(1deg); }
65% { transform: translateX(-5px); } 80% { transform: translateX(4px); } 92% { transform: translateX(-2px); }
}
@keyframes tslide { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 36px; }
.feature {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 22px; transition: transform .14s ease, border-color .14s ease;
}
.feature:hover { transform: translateY(-3px); border-color: var(--accent); }
.feature .ico {
width: 40px; height: 40px; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, rgba(224,108,117,0.18), rgba(53,90,102,0.28));
border: 1px solid var(--border); color: var(--accent); margin-bottom: 14px;
}
.feature h3 { margin: 0 0 6px; font-size: 16.5px; }
.feature p { margin: 0; color: var(--muted); font-size: 14px; }
/* Screenshot strip */
.shotrow { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 16px; margin-top: 8px; }
.shot {
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
background: linear-gradient(180deg, var(--panel), var(--panel2));
aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 13px; position: relative;
}
.shot .ph { display: flex; flex-direction: column; align-items: center; gap: 8px; opacity: 0.7; }
.shot .frame-dots { position: absolute; top: 10px; left: 12px; display: flex; gap: 5px; }
.shot .frame-dots i { width: 8px; height: 8px; border-radius: 50%; background: #39414d; display: inline-block; }
/* Previews — expanding hover carousel that plays a video on hover */
.previews { display: flex; align-items: center; gap: 12px; height: 480px; max-width: 1000px; margin: 36px auto 0; }
.preview-panel {
position: relative; flex: 1 1 0; min-width: 0; height: 360px; overflow: hidden;
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer;
background: linear-gradient(180deg, var(--panel), var(--panel2));
transition: flex-grow .5s cubic-bezier(.2,.7,.2,1), height .5s cubic-bezier(.2,.7,.2,1), border-color .25s ease;
}
.previews:hover .preview-panel { flex-grow: 0.55; height: 300px; }
.preview-panel:hover, .preview-panel:focus-visible { flex-grow: 3.4 !important; height: 480px !important; border-color: var(--accent); }
.preview-panel .ph {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px;
color: var(--muted); font-size: 12.5px; opacity: 0.7; text-align: center; padding: 8px;
}
.preview-panel video {
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
z-index: 1; opacity: 0; transition: opacity .3s ease; background: transparent;
}
.preview-panel.has-video video { opacity: 1; }
.preview-panel .label {
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; padding: 14px 16px;
background: linear-gradient(0deg, rgba(0,0,0,0.8), transparent);
color: var(--heading); font-weight: 700; font-size: 14px;
display: flex; align-items: center; gap: 8px; white-space: nowrap;
}
.preview-panel .label .ico { color: var(--accent); flex-shrink: 0; }
@media (max-width: 760px) {
.previews { flex-direction: column; height: auto; }
.preview-panel { height: 200px; flex: none; }
.previews:hover .preview-panel, .preview-panel:hover { flex: none !important; }
}
/* Get started */
.start {
background: linear-gradient(180deg, var(--panel), var(--bg2));
border: 1px solid var(--border); border-radius: 18px; padding: 40px; text-align: center;
}
.codeblock {
display: inline-flex; align-items: center; gap: 14px; margin: 18px auto 8px;
background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 16px; font-family: ui-monospace, monospace; font-size: 14px; color: var(--fg);
}
.codeblock .prompt { color: var(--accent); }
.pill-row { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 18px; }
.pill { font-size: 12.5px; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 5px 12px; background: var(--panel); }
footer { border-top: 1px solid var(--border); padding: 30px 0; color: var(--muted); font-size: 13px; scroll-snap-align: end; }
footer .wrap { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
@media (max-width: 820px) {
.grid { grid-template-columns: repeat(2, 1fr); }
.shotrow { grid-template-columns: 1fr; }
.nav-links { display: none; }
.nav-links-hamburger { display: block; }
}
@media (max-width: 520px) {
.grid { grid-template-columns: 1fr; }
.tgrid .tcard { padding: 20px; gap: 16px; }
.tcard .av { width: 64px; height: 64px; }
.tcard .q { font-size: 15px; }
}
</style>
</head>
<body>
<nav>
<div class="wrap">
<div class="brand">
<svg class="boat" viewBox="0 0 32 32" width="24" height="24" aria-hidden="true"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
Odysseus
</div>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#testimonials">Testimonials</a>
<a href="#how">How it started</a>
<a href="#start">Get started</a>
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
GitHub
</a>
</div>
<div class="nav-links-hamburger">
<button class="hamburger-btn" aria-label="Open menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.5,7 C3.22385763,7 3,6.77614237 3,6.5 C3,6.22385763 3.22385763,6 3.5,6 L20.5,6 C20.7761424,6 21,6.22385763 21,6.5 C21,6.77614237 20.7761424,7 20.5,7 L3.5,7 Z M3.5,12 C3.22385763,12 3,11.7761424 3,11.5 C3,11.2238576 3.22385763,11 3.5,11 L20.5,11 C20.7761424,11 21,11.2238576 21,11.5 C21,11.7761424 20.7761424,12 20.5,12 L3.5,12 Z M3.5,17 C3.22385763,17 3,16.7761424 3,16.5 C3,16.2238576 3.22385763,16 3.5,16 L20.5,16 C20.7761424,16 21,16.2238576 21,16.5 C21,16.7761424 20.7761424,17 20.5,17 L3.5,17 Z"/>
</svg>
</button>
<div class="hamburger-menu">
<a href="#features">Features</a>
<a href="#testimonials">Testimonials</a>
<a href="#how">How it started</a>
<a href="#start">Get started</a>
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/>
</svg>
GitHub
</a>
</div>
</div>
</div>
</nav>
<!-- HERO -->
<header class="hero">
<div class="wrap">
<div class="hero-logo">
<svg viewBox="0 0 32 32" width="48" height="48" aria-hidden="true"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
<span class="wordmark">Odysseus</span>
</div>
<p class="slogan">Yours for the voyage.</p>
<h1>Your own <span class="grad">AI workspace</span>,<br>running on your hardware.</h1>
<p class="lede">
Odysseus is a self-hosted interface for talking to language models &mdash; chat,
autonomous agents, tools, model serving, email, research, and more. Local-first,
privacy-first, and no telemetry. Just you and your models.
</p>
<p style="font-size:11.5px; color:var(--muted); opacity:0.7; max-width:560px; margin:-18px auto 30px;">
(if you want to add an API that's cool too &mdash; I'm not here to tell you how to live your life&hellip;)
</p>
<div class="hero-cta">
<a class="btn primary" href="#start">Get started</a>
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">View on GitHub</a>
</div>
</div>
</header>
<!-- TESTIMONIALS (gag) -->
<section id="testimonials" style="padding-top:30px;">
<div class="wrap">
<div class="center">
<div class="eyebrow">Loved by enterprises</div>
<h2 class="h">What our customers are saying</h2>
</div>
<div class="tcarousel-wrap">
<button class="tarrow prev" type="button" aria-label="Previous testimonial">&#8249;</button>
<div class="tgrid" id="tcarousel">
<!-- Coder guy -->
<figure class="tcard">
<span class="av"><img src="https://cdn.prod.website-files.com/66708f90d7e407423093fa76/66708f91d7e407423093fd21_john-carter-testimonial-image-dentistry-x-webflow-template.png" alt="Generic Coder Guy" loading="lazy"></span>
<div class="tmeta">
<p class="q">"Odysseus helped us ship more ships while shipping ships. Truly best-in-class shipping."</p>
<div class="stars">&#9733;&#9733;&#9733;&#9733;&#9733;</div>
<div class="nm">Generic Coder Guy</div>
<div class="rl">Sr. Engineer, ShipShip Inc.</div>
</div>
</figure>
<!-- Woman -->
<figure class="tcard">
<span class="av"><img src="https://images.pexels.com/photos/5876695/pexels-photo-5876695.jpeg?auto=compress&amp;cs=tinysrgb&amp;w=160&amp;h=160&amp;fit=crop" alt="A real woman" loading="lazy"></span>
<div class="tmeta">
<p class="q">"I'm a real person. This is a real testimonial. By a real woman."</p>
<div class="stars">&#9733;&#9733;&#9733;&#9733;&#9733;</div>
<div class="nm">Generic Corporate Woman</div>
<div class="rl">VP of Verticals, Things LLC</div>
</div>
</figure>
<!-- Cyclops -->
<figure class="tcard cyclops" data-shake="1">
<span class="av" style="border-color:rgba(255,90,90,0.6);">
<svg viewBox="0 0 72 72" width="54" height="54" fill="none" stroke="#cbd5e1" stroke-width="2">
<rect x="0" y="0" width="72" height="72" fill="#16241a"/>
<circle cx="36" cy="32" r="18" fill="#7fae7f" stroke="#5a7a5a"/>
<line x1="29" y1="22" x2="43" y2="34" stroke="#ff5a5a" stroke-width="3"/>
<line x1="43" y1="22" x2="29" y2="34" stroke="#ff5a5a" stroke-width="3"/>
<ellipse cx="36" cy="45" rx="7" ry="9" fill="#3a0a0a" stroke="#200"/>
<path d="M31 51 l-1 4" stroke="#fff" stroke-width="2"/><path d="M41 51 l1 4" stroke="#fff" stroke-width="2"/>
</svg>
</span>
<div class="tmeta">
<p class="q">"AHHHHHHHHHHHHHHHHHHHHHHHHHHHHH"</p>
<div class="stars zero">&#9734;&#9734;&#9734;&#9734;&#9734;</div>
<div class="nm">Polyphemus</div>
<div class="rl">Cyclops, Cave Solutions (on leave)</div>
</div>
</figure>
<!-- Corporate -->
<figure class="tcard">
<span class="av">
<svg viewBox="0 0 80 80" aria-hidden="true">
<rect width="80" height="80" rx="18" fill="#111827"/>
<circle cx="40" cy="29" r="14" fill="#d1d5db"/>
<path d="M18 70c4-18 15-27 22-27s18 9 22 27" fill="#374151"/>
<path d="M28 58h24l-5 12H33z" fill="#e06c75"/>
<path d="M32 14h16l6 11H26z" fill="#f8fafc"/>
</svg>
</span>
<div class="tmeta">
<p class="q">"Anyway, as I was saying &mdash; best-in-class."</p>
<div class="stars">&#9733;&#9733;&#9733;&#9733;&#9733;</div>
<div class="nm">Chad Corporate</div>
<div class="rl">Chief Executive Officer</div>
</div>
</figure>
</div>
<button class="tarrow next" type="button" aria-label="Next testimonial">&#8250;</button>
</div>
<div class="tnav" id="tnav"></div>
</div>
</section>
<!-- FEATURES -->
<section id="features">
<div class="wrap">
<div class="center">
<div class="eyebrow">Everything, self-hosted</div>
<h2 class="h">One app, a lot of capabilities</h2>
<p class="sub">Started as an AI chat. Became a workspace. Each piece runs locally against
whatever endpoints you point it at.</p>
</div>
<div class="grid">
<div class="feature">
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
<h3>Chat &amp; Agents</h3>
<p>Multi-turn chat plus autonomous agents that plan, call tools, and work through tasks.</p>
</div>
<div class="feature">
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.8-3.8a6 6 0 0 1-7.9 7.9l-6.9 6.9a2.1 2.1 0 0 1-3-3l6.9-6.9a6 6 0 0 1 7.9-7.9z"/></svg></span>
<h3>Tools &amp; MCP</h3>
<p>Built-in tools (bash, files, web, memory) plus any MCP server you connect. Toggle per tool.</p>
</div>
<div class="feature">
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></span>
<h3>Cookbook</h3>
<p>Hardware-aware model recommendations and one-click serving across 270+ catalogued models.</p>
</div>
<div class="feature">
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg></span>
<h3>Email Assistant</h3>
<p>AI summaries, style-matched draft replies, auto-tagging and spam triage over IMAP/SMTP.</p>
</div>
<div class="feature">
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></span>
<h3>Deep Research</h3>
<p>Multi-step research runs that gather, read, and synthesize sources into a written report.</p>
</div>
<div class="feature">
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg></span>
<h3>Compare</h3>
<p>Send one prompt to several models at once and compare their answers side-by-side.</p>
</div>
<div class="feature">
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/></svg></span>
<h3>Memory</h3>
<p>Persistent memory the assistant builds up and recalls across all your conversations.</p>
</div>
<div class="feature">
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/></svg></span>
<h3>Skills <span style="font-size:10.5px;font-weight:700;color:var(--accent);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:4px;vertical-align:middle;">self-evolving</span></h3>
<p>The assistant writes, refines, and reuses its own skills &mdash; getting more capable over time.</p>
</div>
<div class="feature">
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<h3>Private by default</h3>
<p>Runs on your machine against your own endpoints. No telemetry, with optional external integrations when you choose them.</p>
</div>
</div>
</div>
</section>
<!-- The one-shot prompt it started from (gag) -->
<section style="padding-top:0;">
<div class="wrap" style="text-align:center;">
<p class="term-intro">Odysseus was created by a carefully crafted one-shot AI prompt:</p>
<div class="term">
<div class="term-bar">
<span class="ttl">user@odysseus: ~</span>
<span class="winbtns"><span data-term="min" title="Minimize">&#8211;</span><span class="x" data-term="close" title="Close">&#10005;</span></span>
</div>
<pre id="term-pre"><span class="cs">&gt;</span> idk what to make can you write it for me?
actually make an ai chat, but make it good
and also make it better</pre>
</div>
<button class="term-reopen" type="button">&#10005; reopen terminal</button>
</div>
</section>
<!-- PREVIEWS — hover to expand + play -->
<section id="previews">
<div class="wrap">
<div class="center">
<div class="eyebrow">See it in action</div>
<h2 class="h">Hover to take a closer look</h2>
<p class="sub center">Each panel expands and plays its preview when you hover it.</p>
</div>
<div class="previews">
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span>[ Chat &amp; Agents ]</span></div>
<video muted loop playsinline preload="none"><source src="chat.webm" type="video/webm"><source src="chat.mp4" type="video/mp4"></video>
<div class="label"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chat &amp; Agents</div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg><span>[ Cookbook ]</span></div>
<div class="label"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>Cookbook</div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg><span>[ Email Assistant ]</span></div>
<video muted loop playsinline preload="none"><source src="email.webm" type="video/webm"><source src="email.mp4" type="video/mp4"></video>
<div class="label"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg>Email Assistant</div>
</div>
</div>
</div>
</section>
<!-- HOW IT STARTED -->
<section id="how">
<div class="wrap">
<div class="eyebrow">How it actually started</div>
<h2 class="h">Odysseus is everything I hate, just making it tolerable.</h2>
<p class="sub" style="max-width:760px;">
I started working on the Odysseus project because running local AI felt fun &mdash; a step into the future.
But the options to actually engage with LLMs felt like taking steps back. Where were
features like Memory, Deep Research, Agents, and just basic integrations?!
</p>
<p class="sub" style="max-width:760px; margin-top:14px;">
So I started building my own, for fun &mdash; and eventually figured it might be fun to
share what I built for myself with others. Doesn't work for you? Well&hellip; it runs
great on my hardware.
</p>
</div>
</section>
<!-- GET STARTED -->
<section id="start">
<div class="wrap">
<div class="start">
<div class="eyebrow">Get started</div>
<h2 class="h" style="margin-bottom:6px;">Clone it and run</h2>
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request &mdash; just clone the repo.</p>
<div class="codeblock"><span class="prompt">$</span> git clone https://github.com/pewdiepie-archdaemon/odysseus.git &amp;&amp; cd odysseus</div>
<div>
<a class="btn primary" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
</div>
<div class="pill-row">
<span class="pill">Self-hosted</span>
<span class="pill">Bring your own models</span>
<span class="pill">Local-first</span>
<span class="pill">MCP-ready</span>
<span class="pill">No telemetry</span>
</div>
</div>
</div>
</section>
<footer>
<div class="wrap">
<div>&copy; 2026 Odysseus &middot; Built from one prompt that refused to stop.</div>
<div>No cyclopes were harmed in production.<sup>*</sup></div>
</div>
</footer>
<script>
// Typewriter for the origin terminal: type line 1, pause 2s, line 2, pause
// 2s, line 3, hold 4s, then reset and loop. Blinking "|" cursor throughout.
(function () {
var pre = document.getElementById('term-pre');
if (!pre) return;
var lines = [
{ p: '<span class="cs">&gt;</span> ', t: 'idk what to make can you write it for me?' },
{ p: ' ', t: 'actually make an ai chat, but make it good' },
{ p: ' ', t: 'and also make it better' }
];
var CURSOR = '<span class="term-cursor">|</span>';
var TYPE_MS = 40;
var done = [], li = 0, timer = null;
function render(partial) {
pre.innerHTML = done.join('\n') + (done.length ? '\n' : '') + partial + CURSOR;
}
function typeLine() {
var ln = lines[li], i = 0;
(function step() {
if (i <= ln.t.length) {
render(ln.p + ln.t.slice(0, i));
i++; timer = setTimeout(step, TYPE_MS);
} else {
done.push(ln.p + ln.t);
li++;
if (li >= lines.length) timer = setTimeout(reset, 4000); // hold last line 4s
else timer = setTimeout(typeLine, 2000); // pause 2s before next
}
})();
}
function reset() { clearTimeout(timer); done = []; li = 0; typeLine(); }
// Start typing only when the terminal scrolls into view (and replay each
// time you return to it).
if ('IntersectionObserver' in window) {
var io2 = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) reset(); });
}, { threshold: 0.45 });
io2.observe(pre);
} else {
reset();
}
})();
// Previews: hovering a panel expands it (CSS) and plays its video; the
// video only becomes visible once it actually starts playing, so missing
// files just leave the labeled placeholder.
(function () {
document.querySelectorAll('.preview-panel').forEach(function (p) {
var v = p.querySelector('video');
if (!v) return;
v.addEventListener('playing', function () { p.classList.add('has-video'); });
v.addEventListener('pause', function () { /* keep last frame */ });
var play = function () { var pr = v.play(); if (pr && pr.catch) pr.catch(function () {}); };
p.addEventListener('mouseenter', play);
p.addEventListener('focus', play);
p.addEventListener('mouseleave', function () { v.pause(); });
p.addEventListener('blur', function () { v.pause(); });
p.addEventListener('click', function () { if (v.paused) play(); else v.pause(); });
});
})();
// Domino reveal: fade/slide each section in as it scrolls into view.
(function () {
var els = document.querySelectorAll('.hero, section');
if (!('IntersectionObserver' in window)) {
els.forEach(function (e) { e.classList.add('in'); });
return;
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
});
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
els.forEach(function (e) { io.observe(e); });
})();
// Fake terminal window buttons — minimize, maximize, close (and reopen).
(function () {
var term = document.querySelector('.term');
var reopen = document.querySelector('.term-reopen');
if (!term) return;
term.querySelectorAll('.winbtns [data-term]').forEach(function (b) {
b.addEventListener('click', function () {
var act = b.getAttribute('data-term');
if (act === 'min') term.classList.toggle('term-min');
else if (act === 'close') {
term.classList.add('term-closed');
if (reopen) reopen.classList.add('show');
}
});
});
if (reopen) reopen.addEventListener('click', function () {
term.classList.remove('term-closed', 'term-min');
reopen.classList.remove('show');
});
})();
// Mobile testimonial carousel: tap or swipe to advance; Polyphemus shakes ~1s.
(function () {
var carousel = document.getElementById('tcarousel');
var nav = document.getElementById('tnav');
if (!carousel || !nav) return;
var cards = [].slice.call(carousel.querySelectorAll('.tcard'));
if (!cards.length) return;
var idx = 0;
var dots = cards.map(function (_, k) {
var d = document.createElement('span');
d.className = 'tdot';
d.addEventListener('click', function (e) { e.stopPropagation(); show(k); });
nav.appendChild(d);
return d;
});
var hint = document.createElement('div');
hint.className = 'thint';
hint.textContent = 'tap or swipe for the next satisfied customer →';
nav.appendChild(hint);
function show(i) {
idx = (i + cards.length) % cards.length;
cards.forEach(function (c, k) { c.classList.toggle('active', k === idx); c.classList.remove('shake'); });
dots.forEach(function (d, k) { d.classList.toggle('on', k === idx); });
var cur = cards[idx];
if (cur.getAttribute('data-shake') === '1') {
void cur.offsetWidth;
cur.classList.add('shake');
setTimeout(function () { cur.classList.remove('shake'); }, 1000);
}
}
carousel.addEventListener('click', function () { show(idx + 1); });
var _prev = document.querySelector('.tarrow.prev');
var _next = document.querySelector('.tarrow.next');
if (_prev) _prev.addEventListener('click', function (e) { e.stopPropagation(); show(idx - 1); });
if (_next) _next.addEventListener('click', function (e) { e.stopPropagation(); show(idx + 1); });
var sx = null;
carousel.addEventListener('touchstart', function (e) { sx = e.touches[0].clientX; }, { passive: true });
carousel.addEventListener('touchend', function (e) {
if (sx === null) return;
var dx = e.changedTouches[0].clientX - sx;
if (Math.abs(dx) > 30) { show(idx + (dx < 0 ? 1 : -1)); }
sx = null;
});
show(0);
})();
// Mobile navigation: open/close hamburger menu
(function () {
var container = document.querySelector('.nav-links-hamburger');
if (!container) return;
var button = container.querySelector('.hamburger-btn');
button.addEventListener('click', function (e) {
e.stopPropagation();
container.classList.toggle('open');
});
document.addEventListener('click', function () {
container.classList.remove('open');
});
})();
</script>
</body>
</html>

View File

@@ -3478,6 +3478,38 @@ body.bg-pattern-sparkles {
.continue-btn:hover {
opacity:0.8;
}
/* Round-cap "Continue" affordance a cohesive centered pill at the chat
bottom (not the bare red in-message stopped style). */
.rounds-exhausted {
justify-content:center;
gap:12px;
width:fit-content;
max-width:90%;
margin:14px auto 4px;
padding:7px 8px 7px 16px;
border:1px solid var(--border);
border-radius:999px;
background:color-mix(in srgb, var(--fg) 4%, transparent);
opacity:1;
}
.rounds-exhausted .rounds-exhausted-label {
color:color-mix(in srgb, var(--fg) 60%, transparent);
font-size:0.95em;
}
.rounds-exhausted .continue-btn {
font-size:0.9em;
font-weight:600;
opacity:1;
color:var(--bg);
background:var(--accent, var(--red));
border-radius:999px;
padding:4px 14px;
line-height:1.3;
}
.rounds-exhausted .continue-btn:hover {
opacity:0.88;
}
.ctx-indicator {
display:inline-flex; align-items:center; gap:1px;
font-size:0.75rem;
@@ -8835,6 +8867,57 @@ body.hide-thinking .thinking-section { display: none !important; }
list-style: none;
}
.agent-tool-output summary::-webkit-details-marker { display: none; }
/* File-write diff — neutral chrome (not the red error tint) + colored lines */
.agent-tool-diff {
background: color-mix(in srgb, var(--fg) 4%, transparent);
border-color: color-mix(in srgb, var(--fg) 18%, transparent);
}
.agent-tool-diff summary {
color: var(--fg);
background: color-mix(in srgb, var(--fg) 7%, transparent);
border-bottom-color: color-mix(in srgb, var(--fg) 12%, transparent);
}
.agent-tool-diff .diff-stat {
font-weight: 600;
opacity: 0.7;
font-family: var(--mono, monospace);
}
/* Collapsed diff summary: filename + +adds/dels (theme green/red). */
.agent-tool-diff summary {
display: flex;
align-items: center;
gap: 8px;
}
.agent-tool-diff .diff-file {
font-family: var(--mono, monospace);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-tool-diff .diff-summary-stats {
margin-left: auto;
font-family: var(--mono, monospace);
font-weight: 600;
flex-shrink: 0;
}
.agent-tool-diff .diff-summary-stats .diff-stat-add { color: var(--green, #2ecc71); }
.agent-tool-diff .diff-summary-stats .diff-stat-del { color: var(--red, #e74c3c); }
.agent-tool-diff .diff-summary-stats .diff-stat-new { color: var(--accent, var(--red)); opacity: 0.85; }
.diff-pre {
margin: 0;
padding: 8px 10px;
overflow-x: auto;
font-family: var(--mono, monospace);
font-size: 0.82em;
line-height: 1.45;
}
.diff-pre span { display: block; white-space: pre; }
.diff-pre .diff-add { background: color-mix(in srgb, #2ecc71 22%, transparent); }
.diff-pre .diff-del { background: color-mix(in srgb, #e74c3c 22%, transparent); }
.diff-pre .diff-hunk { color: var(--accent); opacity: 0.85; }
.diff-pre .diff-meta { opacity: 0.55; }
.diff-pre .diff-ctx { opacity: 0.8; }
/* Suppress the global `summary::before { content: '▶' }` left arrow this
section uses a right-side chevron instead. */
.agent-tool-output summary::before { content: none; }
@@ -35736,3 +35819,109 @@ body.theme-frosted .modal {
is already 16px and never zoomed leave it so we don't shrink it. */
.doc-email-richbody.doc-font-m { font-size: 16px !important; }
}
/* GitHub Copilot device-flow connect block (model endpoints → API) */
.adm-copilot-connect {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.adm-copilot-connect #adm-copilotStatus { flex-basis: 100%; margin-top: 0; }
.adm-copilot-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
.adm-copilot-wait {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: color-mix(in srgb, var(--fg) 70%, transparent);
}
.adm-copilot-coderow {
display: flex;
align-items: center;
gap: 8px;
}
.adm-copilot-code-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--fg) 45%, transparent);
}
.adm-copilot-code {
font-family: var(--mono, ui-monospace, monospace);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.12em;
padding: 4px 10px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--fg);
user-select: all;
}
.adm-copilot-copy { margin-left: auto; }
.adm-copilot-auth {
text-align: center;
text-decoration: none;
padding: 7px 12px;
font-size: 12px;
}
.adm-copilot-hint {
font-size: 11px;
line-height: 1.4;
color: color-mix(in srgb, var(--fg) 45%, transparent);
}
/* ── Workspace picker ───────────────────────────────────────────── */
/* Layout (width/flex column/max-height) inherited from base .modal-content. */
/* Editable path/address bar: reuses .styled-prompt-input for border/bg/radius/
focus ring (set in the element's class list). Overrides only the deltas:
mono font, and full-bleed via flex stretch with no horizontal margin (the
modal-content's 10px padding is the gutter) instead of the base width:100%,
which overflowed against the overflow:auto scrollbar. */
.workspace-cur {
align-self: stretch;
width: auto;
min-width: 0;
margin: 4px 0 8px;
font-family: var(--mono, monospace);
font-size: 12px;
}
/* flex/overflow inherited from base .modal-body; only the padding differs. */
.workspace-body { padding: 6px 0; }
.workspace-row {
padding: 7px 18px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.workspace-row > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-row-icon { flex-shrink: 0; opacity: 0.75; }
.workspace-row:hover {
background: color-mix(in srgb, var(--border) 20%, transparent);
}
.workspace-up { opacity: 0.7; }
.workspace-empty { padding: 14px 18px; opacity: 0.5; font-size: 13px; }
.workspace-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 18px;
border-top: 1px solid var(--border);
}

View File

@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v326';
const CACHE_NAME = 'odysseus-v327';
// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the <script type="module"> tags

View File

View File

@@ -0,0 +1,25 @@
"""Shared loader for CLI scripts under scripts/."""
import importlib.machinery
import importlib.util
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "scripts"
def load_script(script_name):
"""Load a script from scripts/ by name and return it as a module.
The module name is derived from the script name (hyphens become underscores,
with a _cli suffix) giving each script a stable, unique import identity.
Any sys.modules stubs the script needs at import time must be injected via
monkeypatch before calling this function.
"""
module_name = script_name.replace("-", "_") + "_cli"
path = _SCRIPTS_DIR / script_name
loader = importlib.machinery.SourceFileLoader(module_name, str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module

20
tests/helpers/db_stubs.py Normal file
View File

@@ -0,0 +1,20 @@
"""Shared database stub helpers for CLI and unit tests."""
import sys
import types
from unittest.mock import MagicMock
def make_core_db_stub(monkeypatch, models=()):
"""Create a core.database stub and inject it via monkeypatch.
Always sets SessionLocal. Pass model class names via `models` to set
each as a MagicMock attribute on the stub.
Returns the stub module for optional further configuration.
"""
db = types.ModuleType("core.database")
db.SessionLocal = MagicMock()
for name in models:
setattr(db, name, MagicMock())
monkeypatch.setitem(sys.modules, "core.database", db)
return db

View File

@@ -16,6 +16,10 @@ src = src.replace(
/import \{ splitTableRow \} from '\.\/markdown\/tableRow\.js';/,
'const splitTableRow = (row) => row.split("|").filter((cell) => cell.trim() !== "");'
);
src = src.replace(
/import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from '\.\/emojiShortcodes\.js';/,
'const hasEmojiShortcode = (t) => !!t && t.indexOf(":") !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(t); const replaceEmojiShortcodes = (t) => t;'
);
src = src.replace(/export function /g, 'function ');
src = src.replace(/export const /g, 'const ');
src = src.replace(/export default markdownModule;?/g, '');

View File

@@ -38,6 +38,7 @@ try:
_detect_admin_intent,
_compute_final_metrics,
_append_tool_results,
_MCP_KEYWORDS,
)
_IMPORTED_AGENT_LOOP = sys.modules.get("src.agent_loop")
finally:
@@ -57,6 +58,10 @@ def test_import_stubs_do_not_leak_into_later_tests():
assert sys.modules.get("src.agent_loop") is not _IMPORTED_AGENT_LOOP
def test_mcp_keyword_gate_matches_literal_mcp_requests():
assert "mcp" in _MCP_KEYWORDS
# ---------------------------------------------------------------------------
# _detect_admin_intent
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,70 @@
"""Regression: stream_agent_loop emits `rounds_exhausted` only when the round
cap is hit while still working, and NOT on a normal finish.
The decision is a `for/else` in the loop: the `else` runs only if no `break`
fired (break = done / budget / error). A refactor that adds a stray break or
return, or moves the done-break, could silently flip this. See PR #1999 / #1997.
"""
import asyncio
import json
import src.agent_loop as al
def _collect(gen):
async def _run():
return [c async for c in gen]
return asyncio.run(_run())
def _types(chunks):
out = []
for c in chunks:
if c.startswith("data: ") and not c.startswith("data: [DONE]"):
try:
out.append(json.loads(c[6:]))
except Exception:
pass
return out
def _patch_common(monkeypatch):
# Skip RAG/tool-index, MCP, and settings lookups; keep the real loop body,
# _resolve_tool_blocks, and parse_tool_blocks.
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
async def _fake_exec(block, *a, **k):
return ("bash", {"output": "ok", "exit_code": 0})
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
def _run_loop(monkeypatch, round_text, max_rounds=2):
async def _fake_stream(_candidates, messages, **kwargs):
yield f'data: {json.dumps({"delta": round_text})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
gen = al.stream_agent_loop(
"http://x/v1", "m",
[{"role": "user", "content": "do a long multi-step task"}],
max_rounds=max_rounds,
relevant_tools={"bash"},
)
return _types(_collect(gen))
def test_emits_rounds_exhausted_when_cap_hit_mid_task(monkeypatch):
_patch_common(monkeypatch)
# Every round returns a tool block -> never "done" -> loop exhausts the cap.
events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=2)
assert any(e.get("type") == "rounds_exhausted" for e in events), events
def test_no_rounds_exhausted_on_normal_finish(monkeypatch):
_patch_common(monkeypatch)
# A plain answer (no tool block) -> done-break on round 1 -> no event.
events = _run_loop(monkeypatch, "All done, here is your answer.", max_rounds=2)
assert not any(e.get("type") == "rounds_exhausted" for e in events), events

View File

@@ -29,16 +29,61 @@ _REPO = Path(__file__).resolve().parent.parent
# caches routes.session_routes after the first import, so stubbing auth_helpers /
# session_manager here would poison the shared module for the sibling session
# tests (whichever file is collected first wins). Matching their stub set keeps
# the cached module identical regardless of collection order.
# the cached module identical regardless of collection order. We restore both
# sys.modules AND the parent `routes` package attribute so the stub-bound module
# never leaks into sibling modules via `import routes.session_routes as X`.
_ABSENT = object()
_TEMP_STUBS = ("core.database", "core.models", "src.request_models")
def _save_module_and_parent_attr(dotted_name):
"""Capture a module's sys.modules entry *and* its parent-package attribute.
Importing ``routes.session_routes`` also sets ``session_routes`` on the
parent ``routes`` package object, and ``import routes.session_routes as X``
resolves ``X`` through that parent attribute — so restoring sys.modules
alone leaves the stale stub-bound module reachable. Returns a (module, attr)
pair to hand back to _restore_module_and_parent_attr.
"""
saved_module = sys.modules.get(dotted_name, _ABSENT)
pkg_name, _, attr = dotted_name.rpartition(".")
pkg = sys.modules.get(pkg_name)
saved_attr = getattr(pkg, attr, _ABSENT) if pkg is not None else _ABSENT
return saved_module, saved_attr
def _restore_module_and_parent_attr(dotted_name, saved_module, saved_attr):
"""Restore (or remove) both the sys.modules entry and the parent attribute.
Passing _ABSENT for both clears the cache, which is how we drop any stale
entry before the stubbed import.
"""
if saved_module is _ABSENT:
sys.modules.pop(dotted_name, None)
else:
sys.modules[dotted_name] = saved_module
pkg_name, _, attr = dotted_name.rpartition(".")
pkg = sys.modules.get(pkg_name)
if pkg is None:
return
if saved_attr is _ABSENT:
if hasattr(pkg, attr):
delattr(pkg, attr)
else:
setattr(pkg, attr, saved_attr)
_TEMP_STUBS = ("core.database", "core.models")
_saved = {name: sys.modules.get(name, _ABSENT) for name in _TEMP_STUBS}
_saved["core.session_manager"] = sys.modules.get("core.session_manager", _ABSENT)
_sr_saved = _save_module_and_parent_attr("routes.session_routes")
try:
for _name in _TEMP_STUBS:
sys.modules[_name] = MagicMock(name=_name)
if isinstance(sys.modules.get("core.session_manager"), MagicMock):
del sys.modules["core.session_manager"]
# Clear the sys.modules entry AND the parent `routes` attribute so the
# stubbed import below produces a fresh module with no stale binding behind it.
_restore_module_and_parent_attr("routes.session_routes", _ABSENT, _ABSENT)
importlib.import_module("core.session_manager")
import routes.session_routes as SR # noqa: E402
finally:
@@ -47,6 +92,7 @@ finally:
sys.modules.pop(_name, None)
else:
sys.modules[_name] = _val
_restore_module_and_parent_attr("routes.session_routes", *_sr_saved)
# ── backend: GET /api/sessions model redaction ─────────────────────────────

View File

@@ -50,8 +50,9 @@ def test_pdf_body_marker_stripped_without_eating_text(monkeypatch, tmp_path):
)
body = content[0]["text"] if isinstance(content, list) else content
# The leading page text must survive intact.
assert "[Page 1 text]:" in body
assert "to the board, the agenda is set" in body
# The old lstrip(chars) corruption ate "[P" then "to" -> "age 1 text]: the board".
assert "age 1 text" not in body
body_lines = body.splitlines()
# The leading page marker and page text must survive intact.
assert "[Page 1 text]:" in body_lines
assert "to the board, the agenda is set" in body_lines
# The old lstrip(chars) corruption produced a line like "age 1 text]:" (missing "[P").
assert "age 1 text]:" not in body_lines

View File

@@ -0,0 +1,101 @@
"""CalDAV sync must not prune locally-created events (#2704).
The prune step in `_sync_blocking` deletes events in the synced calendar+window
whose UID the server didn't just return, to propagate upstream deletions. But
`CalendarEvent` had no way to distinguish a server-pulled row from a locally
created one (agent / email triage / a UI event whose write-back failed), so it
also deleted events that were never on the server — silent data loss.
The fix adds an `origin` column and gates the prune on `origin == "caldav"`.
This test replicates the exact prune query against an in-memory DB (the prune is
pure DB logic; `_sync_blocking` itself needs a live CalDAV client) and asserts a
local-origin event survives while a server-origin one with a vanished UID does
not.
"""
import tempfile
from datetime import datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
import core.database as cdb
from core.database import CalendarEvent, CalendarCal
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_ENGINE = create_engine(
f"sqlite:///{_TMPDB.name}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(_ENGINE)
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
_NOW = datetime(2026, 6, 4, 12, 0)
_START = _NOW - timedelta(days=90)
_END = _NOW + timedelta(days=365)
def _prune(db, calendar_id, seen_uids):
"""The exact prune filter from src/caldav_sync.py (post-fix)."""
stale = db.query(CalendarEvent).filter(
CalendarEvent.calendar_id == calendar_id,
CalendarEvent.origin == "caldav",
CalendarEvent.dtstart >= _START,
CalendarEvent.dtstart <= _END,
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
).all()
for ev in stale:
db.delete(ev)
db.commit()
return len(stale)
def _seed():
db = _TS()
try:
db.query(CalendarEvent).delete()
db.query(CalendarCal).delete()
db.add(CalendarCal(id="cal1", owner="alice", name="Work", source="caldav"))
# A server-synced event whose UID is NO LONGER returned (deleted upstream).
db.add(CalendarEvent(
uid="server-gone@svc", calendar_id="cal1", summary="Old server event",
dtstart=_NOW + timedelta(days=1), dtend=_NOW + timedelta(days=1, hours=1),
origin="caldav",
))
# A locally-created event (agent / triage / failed write-back) — origin NULL.
db.add(CalendarEvent(
uid="local-uuid", calendar_id="cal1", summary="Dentist",
dtstart=_NOW + timedelta(days=2), dtend=_NOW + timedelta(days=2, hours=1),
origin=None,
))
db.commit()
finally:
db.close()
def test_local_event_survives_prune():
_seed()
db = _TS()
try:
# Server returned nothing (both UIDs absent from seen_uids).
deleted = _prune(db, "cal1", seen_uids={"some-other-uid"})
# Only the server-origin, now-vanished event is pruned.
assert deleted == 1
assert db.query(CalendarEvent).filter_by(uid="local-uuid").first() is not None
assert db.query(CalendarEvent).filter_by(uid="server-gone@svc").first() is None
finally:
db.close()
def test_synced_event_still_returned_is_kept():
_seed()
db = _TS()
try:
# The server still returns the synced event → it must be kept.
deleted = _prune(db, "cal1", seen_uids={"server-gone@svc"})
assert deleted == 0
assert db.query(CalendarEvent).filter_by(uid="server-gone@svc").first() is not None
assert db.query(CalendarEvent).filter_by(uid="local-uuid").first() is not None
finally:
db.close()

View File

@@ -1,31 +1,12 @@
import importlib.machinery
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
ROOT = Path(__file__).resolve().parents[1]
def _load_cli(monkeypatch):
db = types.ModuleType("core.database")
db.SessionLocal = MagicMock()
db.CalendarCal = MagicMock()
db.CalendarEvent = MagicMock()
monkeypatch.setitem(sys.modules, "core.database", db)
path = ROOT / "scripts" / "odysseus-calendar"
loader = importlib.machinery.SourceFileLoader("odysseus_calendar_cli", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_calendar_name_handles_missing_relation(monkeypatch):
cli = _load_cli(monkeypatch)
make_core_db_stub(monkeypatch, models=["CalendarCal", "CalendarEvent"])
cli = load_script("odysseus-calendar")
assert cli._calendar_name(SimpleNamespace(calendar=None)) == ""
assert cli._calendar_name(SimpleNamespace(calendar=SimpleNamespace(name=123))) == ""

View File

@@ -1,5 +1,5 @@
import pytest
from routes.chat_helpers import needs_auto_name
from routes.chat_helpers import clean_thinking_for_save, needs_auto_name
@pytest.mark.parametrize("name,expected", [
@@ -27,3 +27,44 @@ from routes.chat_helpers import needs_auto_name
])
def test_needs_auto_name(name, expected):
assert needs_auto_name(name) == expected, f"needs_auto_name({name!r}) should be {expected}"
def test_clean_thinking_for_save_extracts_gemma4_thought_channel():
content, metadata = clean_thinking_for_save(
"<|channel>thought\ninternal reasoning<channel|>Final answer.",
{"model": "google/gemma-4-31B-it"},
)
assert content == "Final answer."
assert metadata["thinking"] == "internal reasoning"
assert metadata["model"] == "google/gemma-4-31B-it"
def test_clean_thinking_for_save_strips_empty_gemma4_thought_channel():
content, metadata = clean_thinking_for_save(
"<|channel>thought\n<channel|>Final answer.",
{"model": "google/gemma-4-31B-it"},
)
assert content == "Final answer."
assert "thinking" not in metadata
def test_clean_thinking_for_save_unwraps_gemma4_response_channel():
content, metadata = clean_thinking_for_save(
"<|channel>thought\ninternal reasoning<channel|><|channel>response\nFinal answer.<channel|>",
{"model": "google/gemma-4-31B-it"},
)
assert content == "Final answer."
assert metadata["thinking"] == "internal reasoning"
def test_clean_thinking_for_save_extracts_thought_tag():
content, metadata = clean_thinking_for_save(
"<thought>internal reasoning</thought>Final answer.",
{},
)
assert content == "Final answer."
assert metadata["thinking"] == "internal reasoning"

View File

@@ -0,0 +1,83 @@
"""Regression guards for agent-tool screenshot DOM sinks."""
from pathlib import Path
_REPO = Path(__file__).resolve().parent.parent
def test_live_tool_screenshot_does_not_template_raw_sse_value():
chat = (_REPO / "static" / "js" / "chat.js").read_text(encoding="utf-8")
assert "safeToolScreenshotSrc(json.screenshot)" in chat
assert 'img.src = screenshotSrc' in chat
assert 'details.innerHTML = `<summary>Screenshot</summary><img src="${json.screenshot}"' not in chat
def test_restored_tool_screenshot_uses_raster_data_url_whitelist():
renderer = (_REPO / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
assert "export function safeToolScreenshotSrc(raw)" in renderer
assert "(?:png|jpe?g|gif|webp)" in renderer
assert "safeToolScreenshotSrc(ev.screenshot)" in renderer
assert 'src="${esc(ev.screenshot)}"' not in renderer
def test_streaming_tool_labels_are_escaped_before_inner_html():
chat = (_REPO / "static" / "js" / "chat.js").read_text(encoding="utf-8")
compare = (_REPO / "static" / "js" / "compare" / "stream.js").read_text(encoding="utf-8")
assert '<span class="agent-thread-tool">${esc(toolLabel)}</span>' in chat
assert '<span class="agent-thread-tool">${toolLabel}</span>' not in chat
assert '<span class="agent-thread-tool">${escapeHtml(toolLabel)}</span>' in compare
assert '<span class="agent-thread-tool">${toolLabel}</span>' not in compare
def test_generated_image_urls_are_vetted_before_assignment_or_open():
renderer = (_REPO / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
compare = (_REPO / "static" / "js" / "compare" / "stream.js").read_text(encoding="utf-8")
group = (_REPO / "static" / "js" / "group.js").read_text(encoding="utf-8")
assert "export function safeDisplayImageSrc(raw)" in renderer
assert "safeDisplayImageSrc(imageUrl)" in renderer
assert "img.src = safeImageUrl" in renderer
assert "window.open(safeImageUrl, '_blank', 'noopener,noreferrer')" in renderer
assert "safeDisplayImageSrc," in renderer
assert "safeDisplayImageSrc(json.image_url)" in compare
assert "img.src = json.image_url" not in compare
assert "chatRenderer.safeDisplayImageSrc(json.url)" in group
assert "img.src = json.url" not in group
def test_group_chat_role_labels_are_escaped_before_inner_html():
group = (_REPO / "static" / "js" / "group.js").read_text(encoding="utf-8")
assert '<div class="role">${uiModule.esc(roleLabel)}' in group
assert '<div class="role">${roleLabel}' not in group
def test_main_chat_role_labels_are_escaped_before_inner_html():
chat = (_REPO / "static" / "js" / "chat.js").read_text(encoding="utf-8")
assert '<div class="role">${uiModule.esc(roleLabel)}' in chat
assert "'<div class=\"role\">' + uiModule.esc(roleLabel)" in chat
assert '<div class="role">${uiModule.esc(agentModelLabel)}' in chat
assert '<div class="role">${roleLabel}' not in chat
assert "'<div class=\"role\">' + roleLabel" not in chat
assert '<div class="role">${agentModelLabel}' not in chat
def test_compare_search_result_links_are_http_only():
compare = (_REPO / "static" / "js" / "compare" / "stream.js").read_text(encoding="utf-8")
assert "function _safeHttpHref(raw)" in compare
assert "const safeUrl = _safeHttpHref(r.url);" in compare
assert "titleLink.href = safeUrl;" in compare
assert "titleLink.href = r.url || '#';" not in compare
def test_compare_probe_provider_labels_are_escaped():
selector = (_REPO / "static" / "js" / "compare" / "selector.js").read_text(encoding="utf-8")
assert "${escapeHtml(p.label || p.id)}" in selector
assert "${p.label || p.id}" not in selector

View File

@@ -0,0 +1,140 @@
"""Tests for the code-navigation tools (grep, glob, ls) + read_file line range."""
import os
import shutil
import asyncio
import tempfile
import pytest
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/test_code_nav.db")
from src.tool_execution import _direct_fallback
def _run(tool, content):
return asyncio.run(_direct_fallback(tool, content))
@pytest.fixture
def repo():
# Built under /tmp, which is on the default tool-path allowlist.
root = tempfile.mkdtemp(dir="/tmp", prefix="codenav_")
try:
with open(os.path.join(root, "a.py"), "w") as f:
f.write("import os\n# needle here\nprint('x')\n")
os.mkdir(os.path.join(root, "sub"))
with open(os.path.join(root, "sub", "b.txt"), "w") as f:
f.write("nothing\nNEEDLE upper\n")
os.mkdir(os.path.join(root, "node_modules"))
with open(os.path.join(root, "node_modules", "dep.py"), "w") as f:
f.write("needle in dep\n")
g = os.path.join(root, ".git")
os.mkdir(g)
with open(os.path.join(g, "config"), "w") as f:
f.write("needle in git\n")
yield root
finally:
shutil.rmtree(root, ignore_errors=True)
# ── grep ──────────────────────────────────────────────────────────────────
def test_grep_finds_match(repo):
r = _run("grep", f'{{"pattern": "needle", "path": "{repo}"}}')
assert r["exit_code"] == 0
assert "a.py:2:" in r["output"]
def test_grep_skips_junk_dirs(repo):
r = _run("grep", f'{{"pattern": "needle", "path": "{repo}"}}')
assert "node_modules" not in r["output"]
assert ".git/config" not in r["output"]
def test_grep_ignore_case(repo):
r = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "path": "{repo}"}}')
assert "b.txt:2:" in r["output"]
def test_grep_glob_filter(repo):
r = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
assert "a.py" in r["output"]
assert "b.txt" not in r["output"]
def test_grep_no_match(repo):
r = _run("grep", f'{{"pattern": "zzzznotfound", "path": "{repo}"}}')
assert r["exit_code"] == 0
assert "No matches" in r["output"]
def test_grep_requires_pattern(repo):
r = _run("grep", "{}")
assert r["exit_code"] == 1
assert "pattern is required" in r["error"]
def test_grep_path_outside_roots_rejected(repo):
r = _run("grep", '{"pattern": "x", "path": "/etc"}')
assert r["exit_code"] == 1
assert "outside the allowed roots" in r["error"]
def test_grep_python_fallback_when_no_rg(repo, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda name: None)
r = _run("grep", f'{{"pattern": "needle", "path": "{repo}"}}')
assert r["exit_code"] == 0
assert "a.py:2:" in r["output"]
assert "node_modules" not in r["output"]
assert ".git/config" not in r["output"]
# ── glob ──────────────────────────────────────────────────────────────────
def test_glob_py(repo):
r = _run("glob", f'{{"pattern": "*.py", "path": "{repo}"}}')
assert r["exit_code"] == 0
assert "a.py" in r["output"]
def test_glob_recursive_skips_junk(repo):
r = _run("glob", f'{{"pattern": "**/*.py", "path": "{repo}"}}')
assert "a.py" in r["output"]
assert "node_modules" not in r["output"]
def test_glob_requires_pattern(repo):
r = _run("glob", "{}")
assert r["exit_code"] == 1
# ── ls ────────────────────────────────────────────────────────────────────
def test_ls_lists_entries(repo):
r = _run("ls", f'{{"path": "{repo}"}}')
assert r["exit_code"] == 0
assert "a.py" in r["output"]
assert "sub/" in r["output"]
assert ".git" not in r["output"] # hidden skipped
def test_ls_path_outside_rejected(repo):
r = _run("ls", '{"path": "/etc"}')
assert r["exit_code"] == 1
assert "outside the allowed roots" in r["error"]
# ── read_file line range ───────────────────────────────────────────────────
def test_read_file_offset_limit(repo):
p = os.path.join(repo, "lines.txt")
with open(p, "w") as f:
f.write("\n".join(f"line{i}" for i in range(1, 11)) + "\n")
r = _run("read_file", f'{{"path": "{p}", "offset": 3, "limit": 2}}')
assert r["exit_code"] == 0
assert r["output"] == "line3\nline4\n"
def test_read_file_plain_path_backcompat(repo):
r = _run("read_file", os.path.join(repo, "a.py"))
assert r["exit_code"] == 0
assert "needle" in r["output"]

View File

@@ -10,27 +10,10 @@ Mirrors the session `_owned_endpoint` and research `_owned_enabled_endpoint`
fixes.
"""
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
# Stub core.database so importing routes.compare_routes (which drags in
# core.session_manager) is cheap under the sqlalchemy MagicMock stubs. The
# helper resolves ModelEndpoint at call time; we swap in a fake declarative
# class below. owner_filter stays REAL.
if "core.database" not in sys.modules:
sys.modules["core.database"] = types.ModuleType("core.database")
_cd = sys.modules["core.database"]
_cd.Base = MagicMock()
for _name in (
"Session", "ChatMessage", "Document", "DocumentVersion", "GalleryImage",
"GalleryAlbum", "SessionLocal", "Comparison", "ModelEndpoint",
):
if not hasattr(_cd, _name):
setattr(_cd, _name, MagicMock())
from routes.compare_routes import _owned_endpoint_by_url # noqa: E402
import core.database
from routes.compare_routes import _owned_endpoint_by_url
class _Predicate:
@@ -82,40 +65,40 @@ def _ep(base_url, owner):
return SimpleNamespace(base_url=base_url, owner=owner, api_key="sk-secret")
def _resolve(rows, base_url, owner):
sys.modules["core.database"].ModelEndpoint = _ModelEndpoint
def _resolve(monkeypatch, rows, base_url, owner):
monkeypatch.setattr(core.database, "ModelEndpoint", _ModelEndpoint)
return _owned_endpoint_by_url(_DB(rows), base_url, owner)
URL = "https://api.example.com/v1"
def test_rejects_another_owners_private_endpoint():
def test_rejects_another_owners_private_endpoint(monkeypatch):
# bob owns the only endpoint at URL; alice supplying that URL gets None
# → no headers, no key copied into her comparison session.
rows = [_ep(URL, "bob")]
assert _resolve(rows, URL, "alice") is None
assert _resolve(monkeypatch, rows, URL, "alice") is None
def test_returns_callers_own_endpoint():
def test_returns_callers_own_endpoint(monkeypatch):
rows = [_ep(URL, "bob"), _ep(URL, "alice")]
ep = _resolve(rows, URL, "alice")
ep = _resolve(monkeypatch, rows, URL, "alice")
assert ep is not None and ep.owner == "alice"
def test_allows_legacy_null_owner_shared_row():
def test_allows_legacy_null_owner_shared_row(monkeypatch):
rows = [_ep(URL, None)]
ep = _resolve(rows, URL, "alice")
ep = _resolve(monkeypatch, rows, URL, "alice")
assert ep is not None and ep.owner is None
def test_no_match_returns_none():
def test_no_match_returns_none(monkeypatch):
rows = [_ep("https://other.example/v1", "alice")]
assert _resolve(rows, URL, "alice") is None
assert _resolve(monkeypatch, rows, URL, "alice") is None
def test_null_owner_is_legacy_single_user_noop():
def test_null_owner_is_legacy_single_user_noop(monkeypatch):
# Single-user / unresolved owner: owner_filter no-op, exact URL match wins.
rows = [_ep(URL, "bob")]
ep = _resolve(rows, URL, None)
ep = _resolve(monkeypatch, rows, URL, None)
assert ep is not None and ep.owner == "bob"

View File

@@ -0,0 +1,39 @@
"""Regression for #2603 — model context-window cache must be keyed per endpoint.
`get_context_length()` cached by model id alone, so two different remote endpoints
serving the same model id (e.g. a capped proxy at 8k vs. the full provider at 200k)
collided: whichever resolved first won process-wide and the other was served the
wrong window. The fix keys the cache on (endpoint_url, model).
"""
import src.model_context as mc
def _setup(monkeypatch, windows):
"""windows: {endpoint_url: context_length}. Force the remote path."""
monkeypatch.setattr(mc, "_is_local_endpoint", lambda url: False)
monkeypatch.setattr(mc, "_configured_endpoint_kind", lambda url: "api")
monkeypatch.setattr(mc, "_query_context_length", lambda url, model: windows[url])
mc._context_cache.clear()
def test_same_model_two_remote_endpoints_get_their_own_window(monkeypatch):
a, b = "https://proxy-a.example/v1", "https://provider-b.example/v1"
_setup(monkeypatch, {a: 8000, b: 200000})
assert mc.get_context_length(a, "shared-model") == 8000
# Same model id, different endpoint: must NOT return endpoint A's cached 8000.
assert mc.get_context_length(b, "shared-model") == 200000
def test_cache_hit_still_works_per_endpoint(monkeypatch):
a, b = "https://proxy-a.example/v1", "https://provider-b.example/v1"
_setup(monkeypatch, {a: 8000, b: 200000})
mc.get_context_length(a, "shared-model")
mc.get_context_length(b, "shared-model")
# Both endpoints are now cached under their own key; flip the underlying
# query to prove subsequent reads come from the per-endpoint cache, not a re-query.
monkeypatch.setattr(mc, "_query_context_length", lambda url, model: 999)
assert mc.get_context_length(a, "shared-model") == 8000
assert mc.get_context_length(b, "shared-model") == 200000

View File

@@ -1,25 +1,12 @@
import importlib.machinery
import importlib.util
import io
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
def _load_cli():
path = ROOT / "scripts" / "odysseus-cookbook"
loader = importlib.machinery.SourceFileLoader("odysseus_cookbook_cli", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
from tests.helpers.cli_loader import load_script
def test_state_set_rejects_non_object_json(tmp_path, monkeypatch, capsys):
cli = _load_cli()
cli = load_script("odysseus-cookbook")
cli._STATE_PATH = tmp_path / "cookbook_state.json"
monkeypatch.setattr(cli.sys, "stdin", io.StringIO("[]"))

View File

@@ -125,15 +125,15 @@ def test_pip_install_fallback_chain_propagates_failure_in_venv():
reported success even though nothing was installed. The negated
`{ ! venv_check && user }` shape propagates the failure correctly.
"""
import shlex
py = shlex.quote(sys.executable)
# Use the venv python so venv_check detects we're in a venv.
# Simulate "inside a venv" deterministically: the venv check exits 0.
# Base install fails, venv_check exits 0, negated to 1,
# && skips user, group exits 1.
# && skips user, group exits 1. This avoids depending on whether the
# test runner's own interpreter happens to be inside a venv (which
# differs between local and CI environments).
script = (
f"{py} -c 'import sys; sys.exit(1)' || "
f"{{ ! {py} -c \"import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)\" "
f"&& echo user_attempt; }}"
"false || "
"{ ! true " # venv_check=0 (in venv) → negated to 1 → user skipped
"&& echo user_attempt; }"
)
result = subprocess.run(
["bash", "-c", script],

170
tests/test_copilot.py Normal file
View File

@@ -0,0 +1,170 @@
"""Tests for the GitHub Copilot provider integration (src/copilot.py + wiring)."""
import types
import pytest
from src import copilot
# ── Provider detection ─────────────────────────────────────────────────────
@pytest.mark.parametrize("url,expected", [
("https://api.githubcopilot.com", True),
("https://api.githubcopilot.com/chat/completions", True),
("https://copilot-api.acme.ghe.com", True),
("https://sub.githubcopilot.com", True),
("https://api.openai.com/v1", False),
("https://githubcopilot.com.evil.test", False), # lookalike host
("", False),
(None, False),
])
def test_is_copilot_base(url, expected):
assert copilot.is_copilot_base(url) is expected
def test_detect_provider_copilot():
from src.llm_core import _detect_provider
assert _detect_provider("https://api.githubcopilot.com") == "copilot"
assert _detect_provider("https://copilot-api.acme.ghe.com") == "copilot"
# lookalike must not be classified as copilot
assert _detect_provider("https://githubcopilot.com.evil.test") == "openai"
def test_enterprise_base():
assert copilot.enterprise_base(None) == "https://api.githubcopilot.com"
assert copilot.enterprise_base("https://acme.ghe.com/") == "https://copilot-api.acme.ghe.com"
assert copilot.enterprise_base("acme.ghe.com") == "https://copilot-api.acme.ghe.com"
# ── Headers ────────────────────────────────────────────────────────────────
def test_copilot_headers_core():
h = copilot.copilot_headers("TOK")
assert h["Authorization"] == "Bearer TOK"
assert h["X-GitHub-Api-Version"] == copilot.COPILOT_API_VERSION
assert h["Openai-Intent"] == "conversation-edits"
assert h["Copilot-Integration-Id"]
assert h["x-initiator"] == "user"
assert "Copilot-Vision-Request" not in h
def test_copilot_headers_agent_vision():
h = copilot.copilot_headers("TOK", agent=True, vision=True)
assert h["x-initiator"] == "agent"
assert h["Copilot-Vision-Request"] == "true"
def test_copilot_headers_no_token():
h = copilot.copilot_headers(None)
assert "Authorization" not in h
assert h["X-GitHub-Api-Version"] == copilot.COPILOT_API_VERSION
def test_build_headers_dispatches_to_copilot():
from src.endpoint_resolver import build_headers
h = build_headers("TOK", "https://api.githubcopilot.com")
assert h["Authorization"] == "Bearer TOK"
assert h["X-GitHub-Api-Version"] == copilot.COPILOT_API_VERSION
# OpenAI base must stay plain bearer (no copilot headers)
ho = build_headers("TOK", "https://api.openai.com/v1")
assert "X-GitHub-Api-Version" not in ho
# ── Per-request flags ──────────────────────────────────────────────────────
def test_request_flags_user():
assert copilot.request_flags([{"role": "user", "content": "hi"}]) == (False, False)
def test_request_flags_agent_when_tool_last():
msgs = [{"role": "user", "content": "hi"}, {"role": "tool", "content": "x"}]
assert copilot.request_flags(msgs) == (True, False)
def test_request_flags_vision():
msgs = [{"role": "user", "content": [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "data:..."}},
]}]
agent, vision = copilot.request_flags(msgs)
assert vision is True
def test_apply_request_headers_mutates():
h = {"X-GitHub-Api-Version": "v"}
copilot.apply_request_headers(h, [{"role": "tool", "content": "x"}])
assert h["x-initiator"] == "agent"
# ── Model discovery ────────────────────────────────────────────────────────
def _fake_response(payload):
r = types.SimpleNamespace()
r.json = lambda: payload
r.raise_for_status = lambda: None
return r
def test_fetch_models_filters_picker(monkeypatch):
payload = {"data": [
{"id": "gpt-4o", "model_picker_enabled": True,
"capabilities": {"supports": {"tool_calls": True, "vision": True}}},
{"id": "internal-embed", "model_picker_enabled": False,
"capabilities": {"supports": {"tool_calls": False}}},
{"id": "claude-3.5", "model_picker_enabled": True,
"capabilities": {"supports": {"tool_calls": True}}},
]}
monkeypatch.setattr(copilot.httpx, "get", lambda *a, **k: _fake_response(payload))
models = copilot.fetch_models("https://api.githubcopilot.com", "TOK")
ids = {m["id"] for m in models}
assert ids == {"gpt-4o", "claude-3.5"}
gpt = next(m for m in models if m["id"] == "gpt-4o")
assert gpt["tool_calls"] is True and gpt["vision"] is True
def test_fetch_models_fallback_when_no_picker(monkeypatch):
payload = {"data": [
{"id": "m1", "capabilities": {"supports": {}}},
{"id": "m2", "capabilities": {"supports": {}}},
]}
monkeypatch.setattr(copilot.httpx, "get", lambda *a, **k: _fake_response(payload))
models = copilot.fetch_models("https://api.githubcopilot.com", "TOK")
assert {m["id"] for m in models} == {"m1", "m2"}
# ── Device flow ────────────────────────────────────────────────────────────
def test_request_device_code(monkeypatch):
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["url"] = url
captured["json"] = json
return _fake_response({"device_code": "DC", "user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"interval": 5, "expires_in": 900})
monkeypatch.setattr(copilot.httpx, "post", fake_post)
data = copilot.request_device_code()
assert data["device_code"] == "DC"
assert captured["url"] == "https://github.com/login/device/code"
assert captured["json"]["client_id"] == copilot.COPILOT_CLIENT_ID
assert captured["json"]["scope"] == "read:user"
def test_poll_access_token(monkeypatch):
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["json"] = json
return _fake_response({"access_token": "GHTOKEN"})
monkeypatch.setattr(copilot.httpx, "post", fake_post)
data = copilot.poll_access_token("github.com", "DC")
assert data["access_token"] == "GHTOKEN"
assert captured["json"]["grant_type"] == "urn:ietf:params:oauth:grant-type:device_code"
assert captured["json"]["device_code"] == "DC"
def test_agent_loop_host_allowlisted():
from src.agent_loop import _API_HOSTS
assert "api.githubcopilot.com" in _API_HOSTS

View File

@@ -0,0 +1,80 @@
"""DB-backed tests for Copilot endpoint provisioning (routes/copilot_routes.py)."""
import json
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, ModelEndpoint
import routes.copilot_routes as cr
def _mem_db(monkeypatch):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestSessionLocal = sessionmaker(bind=engine)
monkeypatch.setattr(cr, "SessionLocal", TestSessionLocal)
return TestSessionLocal
def test_provision_creates_owner_scoped_endpoint(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(
cr.copilot, "fetch_models",
lambda base, token: [
{"id": "gpt-4o", "tool_calls": True, "vision": True},
{"id": "claude-3.5", "tool_calls": True, "vision": False},
],
)
res = cr._provision_endpoint("GHTOK", "https://api.githubcopilot.com", "alice")
assert res["base_url"] == "https://api.githubcopilot.com"
assert res["models"] == ["gpt-4o", "claude-3.5"]
db = TestSessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == res["id"]).first()
assert ep is not None
assert ep.owner == "alice"
assert ep.is_enabled is True
assert ep.supports_tools is True
assert ep.api_key == "GHTOK" # round-trips through EncryptedText
assert json.loads(ep.cached_models) == ["gpt-4o", "claude-3.5"]
finally:
db.close()
def test_provision_refreshes_existing_token(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(cr.copilot, "fetch_models", lambda base, token: [{"id": "gpt-4o", "tool_calls": True}])
first = cr._provision_endpoint("OLD", "https://api.githubcopilot.com", "bob")
second = cr._provision_endpoint("NEW", "https://api.githubcopilot.com", "bob")
# Same row reused (no duplicate), token refreshed.
assert first["id"] == second["id"]
db = TestSessionLocal()
try:
rows = db.query(ModelEndpoint).filter(ModelEndpoint.owner == "bob").all()
assert len(rows) == 1
assert rows[0].api_key == "NEW"
finally:
db.close()
def test_provision_handles_model_fetch_failure(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
def boom(base, token):
raise RuntimeError("network down")
monkeypatch.setattr(cr.copilot, "fetch_models", boom)
# Should still create the endpoint (login succeeded) with an empty model list.
res = cr._provision_endpoint("GHTOK", "https://api.githubcopilot.com", "carol")
assert res["models"] == []
db = TestSessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == res["id"]).first()
assert ep is not None and ep.api_key == "GHTOK"
finally:
db.close()

View File

@@ -96,3 +96,33 @@ def test_extraction_timeout_allows_long_local_model_runs():
)
assert researcher.extraction_timeout == 1800
@pytest.mark.asyncio
async def test_planning_and_query_generation_use_configured_timeouts():
researcher = DeepResearcher(
llm_endpoint="http://local.test/v1/chat/completions",
llm_model="local-model",
planning_timeout=234,
query_timeout=345,
)
captured = []
async def fake_llm(messages, temperature=0.3, max_tokens=4096, timeout=60):
captured.append(timeout)
if max_tokens == 1024:
return json.dumps({
"sub_questions": ["one"],
"key_topics": ["topic"],
"success_criteria": "complete",
})
return json.dumps(["query one", "query two"])
researcher._llm = fake_llm
plan = await researcher._create_plan("question")
queries = await researcher._generate_queries("question", "", 1)
assert "Sub-questions: one" in plan
assert queries == ["query one", "query two"]
assert captured == [234, 345]

View File

@@ -1,30 +1,10 @@
import importlib.machinery
import importlib.util
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
ROOT = Path(__file__).resolve().parents[1]
def _load_cli(monkeypatch):
db = types.ModuleType("core.database")
db.SessionLocal = MagicMock()
db.Document = MagicMock()
db.DocumentVersion = MagicMock()
monkeypatch.setitem(sys.modules, "core.database", db)
path = ROOT / "scripts" / "odysseus-docs"
loader = importlib.machinery.SourceFileLoader("odysseus_docs_cli", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
def test_text_len_ignores_non_string_values(monkeypatch):
cli = _load_cli(monkeypatch)
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion"])
cli = load_script("odysseus-docs")
assert cli._text_len("hello") == 5
assert cli._text_len(None) == 0

94
tests/test_edit_file.py Normal file
View File

@@ -0,0 +1,94 @@
"""edit_file: filesystem-write permission policy + behavior."""
import json
import os
import tempfile
import pytest
from src import tool_security
from src.tool_security import (
NON_ADMIN_BLOCKED_TOOLS,
is_public_blocked_tool,
blocked_tools_for_owner,
)
from src.tool_execution import _do_edit_file
from src.agent_tools import ToolBlock
# ── Permission policy ─────────────────────────────────────────────────────
def test_edit_file_is_sensitive_write_tool():
# Must be blocked for non-admins exactly like write_file.
assert "edit_file" in NON_ADMIN_BLOCKED_TOOLS
assert is_public_blocked_tool("edit_file") is True
def test_blocked_tools_for_owner_includes_edit_file_for_non_admin(monkeypatch):
monkeypatch.setattr(tool_security, "owner_is_admin_or_single_user", lambda owner: False)
blocked = blocked_tools_for_owner("bob")
assert "edit_file" in blocked and "write_file" in blocked
# Admin / single-user gets nothing blocked.
monkeypatch.setattr(tool_security, "owner_is_admin_or_single_user", lambda owner: True)
assert blocked_tools_for_owner("admin") == set()
@pytest.mark.asyncio
async def test_edit_file_blocked_at_execution_for_non_admin(monkeypatch):
# Execution-level gate: a non-admin owner must be refused even if the tool
# reaches execute_tool_block. edit_file stays admin-gated by tool_security
# after #2684 (ALWAYS_AVAILABLE only changed advertisement, not execution).
#
# Resolve execute_tool_block from the live module object (te) rather than a
# top-level import: other test modules pop src.tool_execution from
# sys.modules and re-import it, so a stale top-level reference would call a
# different module's function than the one monkeypatch targets — silently
# bypassing the admin gate.
import src.tool_execution as te
monkeypatch.setattr(te, "_owner_is_admin", lambda owner: False)
ws = tempfile.mkdtemp()
p = os.path.join("/tmp", "ef_block.txt")
open(p, "w").write("a\n")
_desc, result = await te.execute_tool_block(
ToolBlock("edit_file", json.dumps({"path": p, "old_string": "a", "new_string": "b"})),
owner="bob",
)
assert result.get("exit_code") == 1 and "admin" in result.get("error", "").lower()
os.unlink(p)
# ── Behavior ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_edit_file_success():
p = os.path.join("/tmp", "ef_ok.py")
open(p, "w").write("def f():\n return 1\n")
res = await _do_edit_file(json.dumps({"path": p, "old_string": "return 1", "new_string": "return 2"}))
assert res["exit_code"] == 0
assert open(p).read() == "def f():\n return 2\n"
assert res["diff"]["added"] == 1 and res["diff"]["removed"] == 1 and res["diff"]["file"] == "ef_ok.py"
os.unlink(p)
@pytest.mark.asyncio
async def test_edit_file_not_found():
p = os.path.join("/tmp", "ef_nf.txt")
open(p, "w").write("hello\n")
res = await _do_edit_file(json.dumps({"path": p, "old_string": "nope", "new_string": "x"}))
assert res["exit_code"] == 1 and "not found" in res["error"]
os.unlink(p)
@pytest.mark.asyncio
async def test_edit_file_non_unique():
p = os.path.join("/tmp", "ef_dup.txt")
open(p, "w").write("x\nx\n")
res = await _do_edit_file(json.dumps({"path": p, "old_string": "x", "new_string": "y"}))
assert res["exit_code"] == 1 and "not unique" in res["error"]
# replace_all resolves it
res = await _do_edit_file(json.dumps({"path": p, "old_string": "x", "new_string": "y", "replace_all": True}))
assert res["exit_code"] == 0 and open(p).read() == "y\ny\n"
os.unlink(p)
@pytest.mark.asyncio
async def test_edit_file_outside_allowed_roots():
res = await _do_edit_file(json.dumps({"path": "/etc/hosts", "old_string": "x", "new_string": "y"}))
assert res["exit_code"] == 1 and ("outside the allowed roots" in res["error"] or "sensitive" in res["error"])

View File

@@ -0,0 +1,102 @@
"""DOM-XSS regressions for email plain-text linkification helpers."""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_HELPER = _REPO / "static" / "js" / "emailLibrary" / "utils.js"
_HAS_NODE = shutil.which("node") is not None
def _run(js: str) -> str:
proc = subprocess.run(
["node", "--input-type=module"],
input=js,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip()
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_plain_text_linkify_escapes_href_attribute_without_double_escaping():
js = textwrap.dedent(
f"""
globalThis.document = {{
createElement() {{
return {{
set textContent(v) {{
this._t = String(v ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}},
get innerHTML() {{ return this._t || ''; }}
}};
}}
}};
const {{ _escLinkify }} = await import('{_HELPER.as_posix()}');
const out = _escLinkify('See https://example.test/path?a=1&b=2 and www.example.test/a`b');
console.log(JSON.stringify(out));
"""
)
html = json.loads(_run(js))
assert 'href="https://example.test/path?a=1&amp;b=2"' in html
assert "amp;amp" not in html
assert 'href="https://www.example.test/a&#96;b"' in html
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_email_url_scheme_checks_strip_embedded_controls():
js = textwrap.dedent(
f"""
import fs from 'node:fs';
let source = fs.readFileSync('{_HELPER.as_posix()}', 'utf8');
source = source
.replace('function _compactUrlSchemeValue', 'export function _compactUrlSchemeValue')
.replace('function _isDangerousUrl', 'export function _isDangerousUrl')
.replace('function _isDangerousSrcset', 'export function _isDangerousSrcset');
const mod = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64'));
const checks = {{
compact: mod._compactUrlSchemeValue('java\\n script:\\talert(1)'),
jsUrl: mod._isDangerousUrl('java\\n script:\\talert(1)'),
vbUrl: mod._isDangerousUrl('vb\\rscript:msgbox(1)'),
dataUrl: mod._isDangerousUrl(' data:text/html,<script>alert(1)</script>'),
httpUrl: mod._isDangerousUrl('https://example.test/?q=javascript:alert(1)'),
srcset: mod._isDangerousSrcset('https://safe.test/a.png 1x, java\\nscript:alert(1) 2x'),
}};
console.log(JSON.stringify(checks));
"""
)
checks = json.loads(_run(js))
assert checks["compact"] == "javascript:alert(1)"
assert checks["jsUrl"] is True
assert checks["vbUrl"] is True
assert checks["dataUrl"] is True
assert checks["httpUrl"] is False
assert checks["srcset"] is True
def test_email_html_sanitizer_runs_to_fixpoint():
source = _HELPER.read_text(encoding="utf-8")
assert "function _sanitizeHtmlOnce(html)" in source
assert "for (let i = 0; i < 4; i++)" in source
assert "const next = _sanitizeHtmlOnce(out);" in source
assert "if (next === out) break;" in source

Some files were not shown because too many files have changed in this diff Show More